From f1919d44ca88783570641e6d62993f19d8a840fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Mon, 19 Oct 2026 02:28:52 +0700 Subject: [PATCH 1/2] feat(linux): add the GTK app for Linux (#3538) Signed-off-by: Dat Ngo Quoc Co-authored-by: Sergii Lapin Co-authored-by: Trung Nguyen Co-authored-by: Trung Nguyen Co-authored-by: Cursor --- .claude/rules/changelog.md | 12 +- .claude/rules/ci.md | 9 +- .../fix-issue/references/platform-linux.md | 54 + .../fix-issue/references/research-linux.md | 47 + .../references/verification-linux.md | 43 + .../fix-issue/scripts/lib/platform-linux.sh | 481 + .gitattributes | 16 +- .github/workflows/build-linux.yml | 110 +- .github/workflows/linux-packages.yml | 245 + .github/workflows/release-linux.yml | 245 + .github/workflows/repo-hygiene.yml | 1 + .gitignore | 1 + AGENTS.md | 5 +- CONTRIBUTING.md | 2 +- README.ko.md | 4 +- README.md | 4 +- README.vi.md | 2 +- README.zh.md | 2 +- RELEASING.md | 2 +- linux/.config/nextest.toml | 72 + linux/.editorconfig | 25 + linux/.gitignore | 5 + linux/AGENTS.md | 54 + linux/ARCHITECTURE.md | 326 + linux/CHANGELOG.md | 10 + linux/CLAUDE.md | 1 + linux/CONTRIBUTING.md | 156 + linux/Cargo.lock | 6046 +++++++++++++ linux/Cargo.toml | 125 + linux/LICENSE | 661 ++ linux/README.md | 269 + linux/ROADMAP.md | 203 + linux/about.toml | 157 + .../build-aux/about/THIRD-PARTY-NOTICE.md.hbs | 27 + linux/build-aux/about/fallbacks.hbs | 7 + linux/build-aux/about/third-party.json.hbs | 21 + linux/build-aux/debian/README.source | 33 + linux/build-aux/debian/clean | 1 + linux/build-aux/debian/control | 50 + linux/build-aux/debian/copyright | 5791 ++++++++++++ linux/build-aux/debian/rules | 48 + linux/build-aux/debian/source/format | 1 + .../build-aux/debian/source/lintian-overrides | 3 + .../flatpak/app.tablepro.TablePro.Devel.json | 69 + linux/build-aux/flatpak/krb5.conf | 7 + linux/build-aux/flatpak/lint-exceptions.json | 6 + linux/build-aux/meson.build | 42 + .../packaging/add-rust-toolchain-ppa.sh | 38 + linux/build-aux/packaging/build-deb.sh | 103 + linux/build-aux/packaging/build-packages.sh | 68 + linux/build-aux/packaging/build-rpm.sh | 46 + linux/build-aux/packaging/build-source.sh | 98 + linux/build-aux/packaging/check_packaging.py | 255 + .../packaging/fetch-release-sources.sh | 48 + linux/build-aux/packaging/license_metadata.py | 398 + linux/build-aux/packaging/make-sources.sh | 67 + .../packaging/rust-toolchain-staging.asc | 29 + linux/build-aux/packaging/smoke-test.sh | 66 + linux/build-aux/rpm/copr-sources.sh | 30 + linux/build-aux/rpm/tablepro.spec | 157 + linux/build-aux/tests/test_check_packaging.py | 373 + linux/build-aux/tests/test_copr_sources.py | 39 + .../build-aux/tests/test_license_metadata.py | 405 + .../build-aux/tests/test_regression_names.py | 112 + .../build-aux/update-third-party-licenses.sh | 52 + linux/changelog.d/.gitkeep | 0 linux/changelog.d/3413.added.md | 1 + linux/changelog.d/3464.added.1.md | 1 + linux/changelog.d/3464.added.10.md | 1 + linux/changelog.d/3464.added.11.md | 1 + linux/changelog.d/3464.added.2.md | 1 + linux/changelog.d/3464.added.3.md | 1 + linux/changelog.d/3464.added.4.md | 1 + linux/changelog.d/3464.added.5.md | 1 + linux/changelog.d/3464.added.6.md | 1 + linux/changelog.d/3464.added.7.md | 1 + linux/changelog.d/3464.added.8.md | 1 + linux/changelog.d/3464.added.9.md | 1 + linux/changelog.d/3464.added.md | 1 + linux/clippy.toml | 17 + linux/crates/app/Cargo.toml | 112 + linux/crates/app/build.rs | 90 + linux/crates/app/clippy.toml | 63 + linux/crates/app/meson.build | 103 + .../app/src/application/about_dialog.rs | 199 + .../crates/app/src/application/accelerator.rs | 9 + .../app/src/application/accelerator_scope.rs | 22 + .../app/src/application/accelerators.rs | 735 ++ .../crates/app/src/application/app_actions.rs | 76 + .../app/src/application/app_services.rs | 255 + .../app/src/application/app_window_host.rs | 34 + .../app/src/application/crash_notice.rs | 62 + .../crates/app/src/application/debug_info.rs | 103 + linux/crates/app/src/application/imp.rs | 170 + .../app/src/application/legal_section.rs | 49 + .../app/src/application/metainfo_release.rs | 69 + linux/crates/app/src/application/mod.rs | 217 + .../crates/app/src/application/open_files.rs | 74 + .../app/src/application/quit_sequence.rs | 119 + .../app/src/application/runtime_versions.rs | 47 + .../app/src/application/session_signals.rs | 100 + .../app/src/application/shortcut_listing.rs | 7 + .../app/src/application/shortcut_owner.rs | 22 + .../app/src/application/shortcut_section.rs | 10 + linux/crates/app/src/application/tests.rs | 851 ++ .../src/application/third_party_licenses.rs | 294 + .../crates/app/src/application/window_host.rs | 13 + .../app/src/application/window_launch.rs | 13 + .../app/src/application/window_registry.rs | 64 + linux/crates/app/src/config.rs | 113 + linux/crates/app/src/crash_report.rs | 186 + linux/crates/app/src/i18n.rs | 158 + linux/crates/app/src/lib.rs | 120 + linux/crates/app/src/logging.rs | 267 + linux/crates/app/src/main.rs | 17 + .../app/src/persistence/column_width_store.rs | 170 + .../src/persistence/column_widths_document.rs | 24 + .../persistence/filter_settings_document.rs | 23 + .../src/persistence/filter_settings_store.rs | 178 + linux/crates/app/src/persistence/mod.rs | 16 + .../crates/app/src/persistence/state_file.rs | 266 + linux/crates/app/src/runtime/app_runtime.rs | 80 + linux/crates/app/src/runtime/clock_sample.rs | 10 + linux/crates/app/src/runtime/clock_source.rs | 5 + linux/crates/app/src/runtime/deliver.rs | 85 + linux/crates/app/src/runtime/deliver_input.rs | 85 + linux/crates/app/src/runtime/detect_resume.rs | 48 + .../crates/app/src/runtime/failed_requests.rs | 66 + .../app/src/runtime/gio_process_hold.rs | 68 + .../app/src/runtime/gio_reachability.rs | 38 + linux/crates/app/src/runtime/input_queue.rs | 177 + linux/crates/app/src/runtime/mod.rs | 39 + linux/crates/app/src/runtime/network_watch.rs | 116 + .../app/src/runtime/reachability_fanout.rs | 222 + .../app/src/runtime/reachability_probe.rs | 12 + .../app/src/runtime/request_sequencer.rs | 183 + linux/crates/app/src/runtime/request_stamp.rs | 10 + .../crates/app/src/runtime/request_ticket.rs | 11 + .../crates/app/src/runtime/resume_detector.rs | 204 + linux/crates/app/src/runtime/stamped.rs | 8 + linux/crates/app/src/runtime/system_clocks.rs | 41 + .../crates/app/src/services/active_session.rs | 50 + linux/crates/app/src/services/cell_overlay.rs | 41 + .../crates/app/src/services/change_origins.rs | 49 + .../crates/app/src/services/change_tracker.rs | 2189 +++++ .../src/services/change_tracker_registry.rs | 61 + .../app/src/services/column_relayout.rs | 215 + .../app/src/services/connection_deletion.rs | 534 ++ .../crates/app/src/services/deletion_error.rs | 57 + .../app/src/services/deletion_report.rs | 105 + .../crates/app/src/services/duplicate_name.rs | 100 + .../crates/app/src/services/file_location.rs | 340 + .../app/src/services/file_location_kind.rs | 52 + .../app/src/services/file_resolution.rs | 173 + .../app/src/services/history_availability.rs | 98 + .../app/src/services/history_preferences.rs | 65 + .../app/src/services/history_service.rs | 403 + linux/crates/app/src/services/mod.rs | 39 + .../app/src/services/pending_structure.rs | 508 ++ linux/crates/app/src/services/save_batch.rs | 23 + .../src/services/saved_connection_editor.rs | 735 ++ .../src/services/saved_connection_error.rs | 18 + .../app/src/services/saved_connection_list.rs | 323 + .../src/services/saved_connection_object.rs | 115 + .../services/saved_connection_object_imp.rs | 65 + .../src/services/saved_connection_order.rs | 163 + .../crates/app/src/services/secret_change.rs | 13 + .../crates/app/src/services/secret_changes.rs | 105 + .../crates/app/src/services/secret_labels.rs | 144 + .../app/src/services/secret_save_report.rs | 39 + .../app/src/services/session_context.rs | 123 + .../app/src/services/ssh_environment.rs | 25 + .../app/src/services/structure_tracker.rs | 175 + .../src/services/structure_tracker_handle.rs | 45 + .../services/structure_tracker_registry.rs | 53 + linux/crates/app/src/services/tab_registry.rs | 34 + .../app/src/services/tab_tracker_handle.rs | 372 + linux/crates/app/src/services/touch.rs | 12 + .../crates/app/src/services/tracker_delta.rs | 14 + linux/crates/app/src/services/tracker_edit.rs | 46 + linux/crates/app/src/services/undo_op.rs | 134 + linux/crates/app/src/storage.rs | 101 + .../app/src/test_support/accessible_label.rs | 51 + .../app/src/test_support/alert_response.rs | 30 + .../app/src/test_support/application_end.rs | 25 + .../app/src/test_support/fake_clocks.rs | 25 + .../app/src/test_support/fake_reachability.rs | 86 + .../app/src/test_support/fake_session.rs | 59 + .../app/src/test_support/focus_thief.rs | 51 + .../app/src/test_support/icon_buttons.rs | 110 + .../src/test_support/key_injection_error.rs | 14 + .../app/src/test_support/key_injector.rs | 119 + .../crates/app/src/test_support/main_loop.rs | 62 + .../app/src/test_support/memory_settings.rs | 30 + linux/crates/app/src/test_support/mod.rs | 71 + .../app/src/test_support/owned_window.rs | 48 + .../app/src/test_support/shortcut_keys.rs | 124 + .../crates/app/src/test_support/signal_log.rs | 47 + linux/crates/app/src/test_support/tests.rs | 131 + .../app/src/test_support/unlabelled_widget.rs | 15 + .../app/src/test_support/wait_timed_out.rs | 9 + .../app/src/test_support/widget_lookup.rs | 42 + linux/crates/app/src/ui/a11y/announce.rs | 59 + linux/crates/app/src/ui/a11y/focus.rs | 40 + linux/crates/app/src/ui/a11y/icon_button.rs | 58 + linux/crates/app/src/ui/a11y/invalid.rs | 62 + linux/crates/app/src/ui/a11y/key_shortcuts.rs | 79 + linux/crates/app/src/ui/a11y/mod.rs | 17 + linux/crates/app/src/ui/a11y/tests.rs | 282 + .../app/src/ui/browse_tab/browse_actions.rs | 140 + .../app/src/ui/browse_tab/browse_command.rs | 27 + .../app/src/ui/browse_tab/browse_pager.rs | 340 + .../src/ui/browse_tab/browse_request_kind.rs | 13 + .../app/src/ui/browse_tab/browse_tab_input.rs | 75 + .../src/ui/browse_tab/browse_tab_widgets.rs | 28 + .../src/ui/browse_tab/clipboard_handlers.rs | 155 + .../app/src/ui/browse_tab/clipboard_input.rs | 19 + .../app/src/ui/browse_tab/column_recheck.rs | 165 + .../app/src/ui/browse_tab/columns_fetch.rs | 41 + .../app/src/ui/browse_tab/command_handlers.rs | 152 + .../src/ui/browse_tab/confirming_delete.rs | 16 + .../src/ui/browse_tab/count_failure_text.rs | 86 + .../app/src/ui/browse_tab/count_policy.rs | 66 + .../app/src/ui/browse_tab/data_handlers.rs | 331 + .../app/src/ui/browse_tab/data_input.rs | 18 + .../app/src/ui/browse_tab/edit_handlers.rs | 382 + .../app/src/ui/browse_tab/edit_input.rs | 38 + linux/crates/app/src/ui/browse_tab/fetch.rs | 214 + linux/crates/app/src/ui/browse_tab/imp.rs | 128 + linux/crates/app/src/ui/browse_tab/layout.rs | 221 + linux/crates/app/src/ui/browse_tab/mod.rs | 671 ++ .../app/src/ui/browse_tab/page_label.rs | 80 + .../app/src/ui/browse_tab/page_reply.rs | 46 + .../app/src/ui/browse_tab/page_state.rs | 16 + .../crates/app/src/ui/browse_tab/pager_bar.rs | 542 ++ .../app/src/ui/browse_tab/paging_handlers.rs | 140 + .../app/src/ui/browse_tab/paging_input.rs | 29 + .../src/ui/browse_tab/pending_changes_bar.rs | 293 + .../crates/app/src/ui/browse_tab/row_count.rs | 154 + .../app/src/ui/browse_tab/row_deletion.rs | 102 + .../crates/app/src/ui/browse_tab/row_total.rs | 147 + .../app/src/ui/browse_tab/save_handlers.rs | 158 + .../app/src/ui/browse_tab/save_input.rs | 28 + .../src/ui/browse_tab/selection_handlers.rs | 134 + .../app/src/ui/browse_tab/selection_input.rs | 7 + .../src/ui/browse_tab/selection_snapshot.rs | 155 + .../app/src/ui/browse_tab/tests/load.rs | 575 ++ .../crates/app/src/ui/browse_tab/tests/mod.rs | 3166 +++++++ .../app/src/ui/browse_tab/tests/total.rs | 404 + .../app/src/ui/clipboard/clipboard_copy.rs | 185 + .../app/src/ui/clipboard/clipboard_format.rs | 40 + .../app/src/ui/clipboard/clipboard_payload.rs | 23 + .../app/src/ui/clipboard/clipboard_writer.rs | 78 + .../app/src/ui/clipboard/copy_renderer.rs | 227 + linux/crates/app/src/ui/clipboard/mod.rs | 19 + linux/crates/app/src/ui/clipboard/tests.rs | 373 + linux/crates/app/src/ui/combo_value.rs | 98 + .../ui/connect_dialog/connect_dialog_mode.rs | 22 + .../app/src/ui/connect_dialog/connect_form.rs | 1116 +++ .../ui/connect_dialog/connect_submission.rs | 10 + .../app/src/ui/connect_dialog/form_errors.rs | 57 + .../app/src/ui/connect_dialog/form_field.rs | 38 + linux/crates/app/src/ui/connect_dialog/imp.rs | 135 + linux/crates/app/src/ui/connect_dialog/mod.rs | 868 ++ .../src/ui/connect_dialog/pem_validation.rs | 134 + .../src/ui/connect_dialog/read_only_row.rs | 32 + .../app/src/ui/connect_dialog/row_problem.rs | 21 + .../src/ui/connect_dialog/sqlite_file_row.rs | 236 + .../crates/app/src/ui/connect_dialog/tests.rs | 966 ++ .../app/src/ui/connect_dialog/tls_section.rs | 416 + .../app/src/ui/connection_list_banner.rs | 90 + .../app/src/ui/credential_prompt/mod.rs | 15 + .../app/src/ui/credential_prompt/presenter.rs | 237 + .../src/ui/credential_prompt/prompt_form.rs | 271 + .../src/ui/credential_prompt/prompt_text.rs | 219 + .../app/src/ui/credential_prompt/prompter.rs | 119 + .../app/src/ui/delete_connection_dialog.rs | 190 + linux/crates/app/src/ui/discard_alert.rs | 32 + linux/crates/app/src/ui/editor/buffer_edit.rs | 114 + .../app/src/ui/editor/catalog_change.rs | 110 + .../src/ui/editor/completion/catalog_read.rs | 197 + .../ui/editor/completion/completion_scope.rs | 654 ++ .../app/src/ui/editor/completion/fuzzy.rs | 151 + .../app/src/ui/editor/completion/mod.rs | 14 + .../src/ui/editor/completion/proposal_kind.rs | 88 + .../completion/sql_completion_provider.rs | 261 + .../completion/sql_completion_provider_imp.rs | 98 + .../src/ui/editor/completion/sql_proposal.rs | 111 + .../ui/editor/completion/sql_proposal_imp.rs | 27 + .../app/src/ui/editor/completion/tests.rs | 565 ++ .../app/src/ui/editor/editor_actions.rs | 113 + linux/crates/app/src/ui/editor/editor_file.rs | 197 + .../app/src/ui/editor/editor_session_slot.rs | 325 + linux/crates/app/src/ui/editor/find_bar.rs | 506 ++ linux/crates/app/src/ui/editor/find_state.rs | 109 + linux/crates/app/src/ui/editor/format_plan.rs | 219 + linux/crates/app/src/ui/editor/imp.rs | 125 + linux/crates/app/src/ui/editor/mod.rs | 967 ++ linux/crates/app/src/ui/editor/result_item.rs | 51 + .../app/src/ui/editor/result_widgets.rs | 197 + .../crates/app/src/ui/editor/results_pane.rs | 496 ++ linux/crates/app/src/ui/editor/run_history.rs | 108 + linux/crates/app/src/ui/editor/run_log.rs | 807 ++ linux/crates/app/src/ui/editor/run_scope.rs | 63 + .../crates/app/src/ui/editor/script_runner.rs | 797 ++ .../app/src/ui/editor/sidebar_policy.rs | 74 + .../app/src/ui/editor/significant_tokens.rs | 194 + linux/crates/app/src/ui/editor/sql_file.rs | 85 + .../app/src/ui/editor/statement_cursor.rs | 125 + linux/crates/app/src/ui/editor/tests.rs | 1352 +++ .../app/src/ui/editor/transaction_dirty.rs | 65 + .../crates/app/src/ui/editor_font_provider.rs | 146 + linux/crates/app/src/ui/error_text/ddl.rs | 128 + linux/crates/app/src/ui/error_text/driver.rs | 279 + linux/crates/app/src/ui/error_text/edit.rs | 162 + linux/crates/app/src/ui/error_text/mod.rs | 27 + .../crates/app/src/ui/error_text/operation.rs | 102 + .../app/src/ui/error_text/saved_connection.rs | 47 + linux/crates/app/src/ui/error_text/script.rs | 28 + linux/crates/app/src/ui/error_text/secret.rs | 24 + linux/crates/app/src/ui/error_text/ssh.rs | 97 + linux/crates/app/src/ui/error_text/tests.rs | 454 + linux/crates/app/src/ui/error_text/tls.rs | 27 + .../app/src/ui/error_text/tls_remedy.rs | 94 + linux/crates/app/src/ui/export/dialog.rs | 705 ++ .../crates/app/src/ui/export/document_sink.rs | 233 + .../crates/app/src/ui/export/encoding_sink.rs | 261 + .../app/src/ui/export/export_failure_text.rs | 382 + .../crates/app/src/ui/export/export_format.rs | 101 + linux/crates/app/src/ui/export/export_job.rs | 42 + .../app/src/ui/export/export_job_item.rs | 64 + .../app/src/ui/export/export_ledger/mod.rs | 438 + .../app/src/ui/export/export_ledger/tests.rs | 782 ++ .../app/src/ui/export/export_message.rs | 42 + .../src/ui/export/export_operations_button.rs | 153 + .../app/src/ui/export/export_outcome.rs | 60 + .../app/src/ui/export/export_producer.rs | 126 + .../crates/app/src/ui/export/export_query.rs | 37 + .../crates/app/src/ui/export/export_scope.rs | 313 + .../app/src/ui/export/export_sink_parts.rs | 76 + .../crates/app/src/ui/export/export_source.rs | 33 + linux/crates/app/src/ui/export/mod.rs | 40 + .../src/ui/export/replace_file_writer/mod.rs | 284 + .../ui/export/replace_file_writer/tests.rs | 301 + linux/crates/app/src/ui/file_read.rs | 128 + .../ui/file_reveal/file_launcher_revealer.rs | 15 + .../app/src/ui/file_reveal/file_revealer.rs | 9 + linux/crates/app/src/ui/file_reveal/mod.rs | 13 + .../src/ui/file_reveal/recording_revealer.rs | 49 + .../ui/file_reveal/reveal_failure_toast.rs | 35 + linux/crates/app/src/ui/file_write.rs | 81 + .../src/ui/filter_strip/filter_rule_object.rs | 55 + linux/crates/app/src/ui/filter_strip/imp.rs | 97 + linux/crates/app/src/ui/filter_strip/mod.rs | 234 + .../app/src/ui/filter_strip/rule_operators.rs | 140 + .../app/src/ui/filter_strip/rule_row.rs | 288 + .../src/ui/filter_strip/rule_value_editor.rs | 200 + linux/crates/app/src/ui/filter_strip/tests.rs | 492 + linux/crates/app/src/ui/format/mod.rs | 13 + linux/crates/app/src/ui/format/thousands.rs | 100 + .../crates/app/src/ui/grid/calendar_editor.rs | 201 + .../app/src/ui/grid/cell_accessibility.rs | 94 + linux/crates/app/src/ui/grid/cell_binding.rs | 39 + linux/crates/app/src/ui/grid/cell_edit.rs | 149 + .../app/src/ui/grid/cell_editability.rs | 156 + .../app/src/ui/grid/cell_editor_kind.rs | 303 + linux/crates/app/src/ui/grid/cell_focus.rs | 513 ++ linux/crates/app/src/ui/grid/cell_lock.rs | 15 + linux/crates/app/src/ui/grid/cell_preset.rs | 95 + linux/crates/app/src/ui/grid/cell_tooltip.rs | 61 + .../crates/app/src/ui/grid/column_drawing.rs | 147 + .../crates/app/src/ui/grid/column_factory.rs | 1084 +++ .../crates/app/src/ui/grid/column_figures.rs | 418 + .../app/src/ui/grid/column_width_binding.rs | 36 + linux/crates/app/src/ui/grid/context_menu.rs | 298 + .../app/src/ui/grid/delete_availability.rs | 91 + linux/crates/app/src/ui/grid/display_text.rs | 334 + linux/crates/app/src/ui/grid/edit_commit.rs | 281 + .../crates/app/src/ui/grid/edit_navigation.rs | 89 + linux/crates/app/src/ui/grid/edit_snapshot.rs | 13 + .../crates/app/src/ui/grid/grid_attributes.rs | 74 + linux/crates/app/src/ui/grid/grid_cell.rs | 615 ++ linux/crates/app/src/ui/grid/grid_columns.rs | 107 + linux/crates/app/src/ui/grid/grid_content.rs | 13 + linux/crates/app/src/ui/grid/grid_keyboard.rs | 498 ++ linux/crates/app/src/ui/grid/grid_menus.rs | 583 ++ linux/crates/app/src/ui/grid/grid_msg.rs | 49 + linux/crates/app/src/ui/grid/grid_row_id.rs | 61 + .../crates/app/src/ui/grid/grid_row_index.rs | 111 + linux/crates/app/src/ui/grid/inline_editor.rs | 260 + linux/crates/app/src/ui/grid/menu_rows.rs | 80 + linux/crates/app/src/ui/grid/mod.rs | 73 + .../app/src/ui/grid/multiline_editor.rs | 109 + linux/crates/app/src/ui/grid/result_grid.rs | 369 + .../crates/app/src/ui/grid/result_sorters.rs | 63 + linux/crates/app/src/ui/grid/row_factory.rs | 117 + .../crates/app/src/ui/grid/row_json_dialog.rs | 397 + .../crates/app/src/ui/grid/row_key_builder.rs | 232 + linux/crates/app/src/ui/grid/row_object.rs | 145 + linux/crates/app/src/ui/grid/row_snapshot.rs | 119 + .../app/src/ui/grid/row_state_column.rs | 263 + .../crates/app/src/ui/grid/row_state_icon.rs | 90 + linux/crates/app/src/ui/grid/row_watches.rs | 90 + linux/crates/app/src/ui/grid/selection.rs | 47 + .../app/src/ui/grid/tab_grid_context.rs | 251 + linux/crates/app/src/ui/grid/test_grid.rs | 311 + linux/crates/app/src/ui/grid/toggle_cell.rs | 153 + linux/crates/app/src/ui/grid/value_order.rs | 519 ++ linux/crates/app/src/ui/handler_guard.rs | 66 + .../ui/history_dialog/connection_filter.rs | 158 + .../src/ui/history_dialog/history_actions.rs | 46 + .../ui/history_dialog/history_entry_object.rs | 37 + .../history_entry_object_imp.rs | 18 + .../src/ui/history_dialog/history_export.rs | 160 + .../ui/history_dialog/history_filter_state.rs | 71 + .../src/ui/history_dialog/history_filters.rs | 201 + .../ui/history_dialog/history_formatting.rs | 166 + .../src/ui/history_dialog/history_paging.rs | 181 + .../app/src/ui/history_dialog/history_row.rs | 126 + linux/crates/app/src/ui/history_dialog/imp.rs | 102 + linux/crates/app/src/ui/history_dialog/mod.rs | 1110 +++ .../crates/app/src/ui/history_dialog/tests.rs | 912 ++ linux/crates/app/src/ui/host_key_failure.rs | 158 + linux/crates/app/src/ui/host_key_review.rs | 365 + linux/crates/app/src/ui/icons.rs | 135 + linux/crates/app/src/ui/import_dialog.rs | 606 ++ linux/crates/app/src/ui/mod.rs | 47 + linux/crates/app/src/ui/name_entry.rs | 39 + .../app/src/ui/picker/connection_menu.rs | 242 + .../app/src/ui/picker/connection_row.rs | 198 + linux/crates/app/src/ui/picker/mod.rs | 11 + .../app/src/ui/picker/new_group_dialog.rs | 81 + .../app/src/ui/picker/picker_actions.rs | 147 + linux/crates/app/src/ui/picker/tests.rs | 439 + linux/crates/app/src/ui/plain_toast.rs | 64 + .../preferences_dialog/data_browser_group.rs | 133 + .../src/ui/preferences_dialog/editor_page.rs | 174 + .../src/ui/preferences_dialog/general_page.rs | 174 + .../ui/preferences_dialog/history_group.rs | 404 + .../app/src/ui/preferences_dialog/imp.rs | 44 + .../app/src/ui/preferences_dialog/mod.rs | 339 + linux/crates/app/src/ui/result_pane/mod.rs | 70 + .../src/ui/result_pane/result_limit_banner.rs | 37 + .../app/src/ui/result_pane/result_set_view.rs | 33 + .../app/src/ui/result_pane/result_summary.rs | 221 + .../crates/app/src/ui/saved_queries_dialog.rs | 837 ++ .../application_notification_sink.rs | 40 + .../ui/session_window/connect_failed_page.rs | 208 + .../ui/session_window/connect_failure_view.rs | 106 + .../app/src/ui/session_window/connect_flow.rs | 587 ++ .../src/ui/session_window/connecting_page.rs | 101 + .../app/src/ui/session_window/connections.rs | 1099 +++ .../app/src/ui/session_window/crash_notice.rs | 159 + linux/crates/app/src/ui/session_window/imp.rs | 112 + .../app/src/ui/session_window/locate_file.rs | 396 + linux/crates/app/src/ui/session_window/mod.rs | 1216 +++ .../ui/session_window/notification_request.rs | 132 + .../ui/session_window/notification_sink.rs | 8 + .../src/ui/session_window/notifications.rs | 116 + .../app/src/ui/session_window/picker_page.rs | 80 + .../session_window/resolve_file_identity.rs | 106 + .../app/src/ui/session_window/secret_retry.rs | 488 + .../app/src/ui/session_window/session_page.rs | 67 + .../src/ui/session_window/session_restore.rs | 327 + .../src/ui/session_window/session_teardown.rs | 216 + .../session_window/session_window_command.rs | 69 + .../ui/session_window/session_window_input.rs | 116 + .../session_window/sqlite_location_dialog.rs | 369 + .../app/src/ui/session_window/tests/mod.rs | 1105 +++ .../ui/session_window/tests/notification.rs | 637 ++ .../src/ui/session_window/tests/open_file.rs | 80 + .../src/ui/session_window/window_actions.rs | 185 + .../src/ui/session_window/work_notifier.rs | 187 + linux/crates/app/src/ui/shell/mod.rs | 5 + .../app/src/ui/shell/primary_menu_button.rs | 47 + linux/crates/app/src/ui/ssh_inputs.rs | 444 + linux/crates/app/src/ui/ssh_section.rs | 329 + .../src/ui/structure_tab/column_handlers.rs | 80 + .../app/src/ui/structure_tab/columns.rs | 359 + .../app/src/ui/structure_tab/columns_input.rs | 24 + .../app/src/ui/structure_tab/context.rs | 148 + .../ui/structure_tab/draft_column_object.rs | 82 + .../structure_tab/draft_column_object_imp.rs | 64 + linux/crates/app/src/ui/structure_tab/fks.rs | 65 + .../ui/structure_tab/foreign_key_dialog.rs | 792 ++ .../src/ui/structure_tab/foreign_key_draft.rs | 222 + .../ui/structure_tab/foreign_key_handlers.rs | 19 + .../ui/structure_tab/foreign_key_object.rs | 53 + .../ui/structure_tab/foreign_keys_input.rs | 10 + .../app/src/ui/structure_tab/form_dialog.rs | 237 + linux/crates/app/src/ui/structure_tab/imp.rs | 137 + .../app/src/ui/structure_tab/index_dialog.rs | 495 ++ .../app/src/ui/structure_tab/index_draft.rs | 200 + .../src/ui/structure_tab/index_handlers.rs | 19 + .../app/src/ui/structure_tab/index_object.rs | 51 + .../app/src/ui/structure_tab/indexes.rs | 150 + .../app/src/ui/structure_tab/indexes_input.rs | 10 + .../crates/app/src/ui/structure_tab/layout.rs | 296 + .../app/src/ui/structure_tab/load_handlers.rs | 170 + .../app/src/ui/structure_tab/load_input.rs | 19 + linux/crates/app/src/ui/structure_tab/mod.rs | 601 ++ .../structure_tab/referential_action_label.rs | 64 + .../app/src/ui/structure_tab/review_dialog.rs | 296 + .../app/src/ui/structure_tab/save_gate.rs | 134 + .../app/src/ui/structure_tab/save_handlers.rs | 218 + .../app/src/ui/structure_tab/save_input.rs | 25 + .../app/src/ui/structure_tab/sql_preview.rs | 147 + .../src/ui/structure_tab/structure_actions.rs | 25 + .../src/ui/structure_tab/structure_command.rs | 28 + .../src/ui/structure_tab/structure_fetch.rs | 105 + .../ui/structure_tab/structure_tab_input.rs | 63 + .../ui/structure_tab/structure_tab_widgets.rs | 36 + .../ui/structure_tab/table_choice_object.rs | 128 + .../src/ui/structure_tab/table_handlers.rs | 42 + .../app/src/ui/structure_tab/table_input.rs | 6 + .../crates/app/src/ui/structure_tab/tests.rs | 1559 ++++ linux/crates/app/src/ui/style_scheme_grid.rs | 48 + .../app/src/ui/style_scheme_resolver.rs | 57 + .../app/src/ui/tab_load/content_load.rs | 227 + .../crates/app/src/ui/tab_load/first_load.rs | 127 + .../app/src/ui/tab_load/load_failure.rs | 88 + .../app/src/ui/tab_load/load_presentation.rs | 18 + linux/crates/app/src/ui/tab_load/mod.rs | 16 + .../app/src/ui/tab_load/stale_banner.rs | 41 + linux/crates/app/src/ui/welcome_view.rs | 278 + linux/crates/app/src/ui/window_geometry.rs | 76 + .../crates/app/src/ui/workspace/close_flow.rs | 272 + .../app/src/ui/workspace/editor_label.rs | 35 + .../app/src/ui/workspace/finished_work.rs | 101 + .../app/src/ui/workspace/health_banner.rs | 782 ++ linux/crates/app/src/ui/workspace/imp.rs | 130 + linux/crates/app/src/ui/workspace/import.rs | 723 ++ .../app/src/ui/workspace/import_ledger.rs | 263 + linux/crates/app/src/ui/workspace/mod.rs | 1058 +++ linux/crates/app/src/ui/workspace/row_ops.rs | 9 + linux/crates/app/src/ui/workspace/saves.rs | 144 + .../app/src/ui/workspace/session_watches.rs | 53 + linux/crates/app/src/ui/workspace/shell.rs | 513 ++ .../src/ui/workspace/sidebar/catalog_watch.rs | 197 + .../app/src/ui/workspace/sidebar/mod.rs | 265 + .../src/ui/workspace/sidebar/object_key.rs | 61 + .../workspace/sidebar/sidebar_affordances.rs | 74 + .../src/ui/workspace/sidebar/sidebar_event.rs | 15 + .../sidebar/sidebar_header_factory.rs | 71 + .../src/ui/workspace/sidebar/sidebar_menu.rs | 162 + .../src/ui/workspace/sidebar/sidebar_model.rs | 298 + .../ui/workspace/sidebar/sidebar_object.rs | 160 + .../workspace/sidebar/sidebar_object_imp.rs | 73 + .../workspace/sidebar/sidebar_row_factory.rs | 91 + .../src/ui/workspace/sidebar/sidebar_view.rs | 465 + .../app/src/ui/workspace/sidebar/tests.rs | 730 ++ .../app/src/ui/workspace/status_pages.rs | 354 + .../crates/app/src/ui/workspace/structure.rs | 696 ++ .../app/src/ui/workspace/structure_target.rs | 26 + .../app/src/ui/workspace/tab_factory.rs | 429 + .../app/src/ui/workspace/tab_identity.rs | 12 + .../crates/app/src/ui/workspace/tab_index.rs | 74 + linux/crates/app/src/ui/workspace/tab_spec.rs | 45 + .../crates/app/src/ui/workspace/tab_title.rs | 178 + .../app/src/ui/workspace/table_naming.rs | 44 + .../app/src/ui/workspace/tests/close_tab.rs | 254 + .../crates/app/src/ui/workspace/tests/mod.rs | 2 + .../app/src/ui/workspace/tests/tab_title.rs | 297 + .../app/src/ui/workspace/unsaved_icon.rs | 13 + .../app/src/ui/workspace/window_tabs.rs | 68 + .../app/src/ui/workspace/window_title.rs | 88 + .../app/src/ui/workspace/workspace_actions.rs | 125 + .../src/ui/workspace/workspace_persistence.rs | 455 + .../src/ui/workspace/workspace_tab_view.rs | 98 + .../app/src/ui/workspace/workspace_tabs.rs | 818 ++ .../crates/app/src/workspace/close_blocker.rs | 21 + .../app/src/workspace/close_blocker_dialog.rs | 21 + .../app/src/workspace/close_decision.rs | 7 + linux/crates/app/src/workspace/close_guard.rs | 463 + .../crates/app/src/workspace/close_guards.rs | 469 + .../crates/app/src/workspace/close_reason.rs | 11 + linux/crates/app/src/workspace/close_scope.rs | 19 + linux/crates/app/src/workspace/close_step.rs | 127 + .../app/src/workspace/completed_work.rs | 11 + .../app/src/workspace/dialog_response.rs | 109 + linux/crates/app/src/workspace/dirty_kind.rs | 24 + linux/crates/app/src/workspace/dirty_tab.rs | 23 + .../crates/app/src/workspace/draft_writer.rs | 407 + .../app/src/workspace/editor_file_record.rs | 11 + .../app/src/workspace/engine_profile.rs | 53 + .../app/src/workspace/inhibit_reason.rs | 42 + .../app/src/workspace/legacy_tab_file.rs | 55 + linux/crates/app/src/workspace/mod.rs | 125 + .../crates/app/src/workspace/open_sessions.rs | 315 + .../app/src/workspace/open_sessions_load.rs | 9 + .../app/src/workspace/open_sessions_record.rs | 164 + .../src/workspace/open_transaction_dialog.rs | 27 + .../crates/app/src/workspace/pending_state.rs | 12 + .../app/src/workspace/persist_debouncer.rs | 130 + .../app/src/workspace/ran_statements.rs | 160 + .../app/src/workspace/restored_drafts.rs | 81 + .../app/src/workspace/rollback_cause.rs | 64 + .../app/src/workspace/save_changes_dialog.rs | 77 + linux/crates/app/src/workspace/save_id.rs | 9 + linux/crates/app/src/workspace/save_ledger.rs | 283 + .../crates/app/src/workspace/save_outcome.rs | 176 + .../app/src/workspace/save_rejection.rs | 121 + .../crates/app/src/workspace/save_settled.rs | 22 + .../crates/app/src/workspace/save_snapshot.rs | 13 + linux/crates/app/src/workspace/save_start.rs | 11 + linux/crates/app/src/workspace/save_ticket.rs | 11 + .../crates/app/src/workspace/schema_write.rs | 44 + .../app/src/workspace/session_key_record.rs | 44 + linux/crates/app/src/workspace/sort_record.rs | 8 + .../crates/app/src/workspace/tab_directory.rs | 18 + linux/crates/app/src/workspace/tab_entry.rs | 13 + linux/crates/app/src/workspace/tab_record.rs | 115 + .../crates/app/src/workspace/tab_snapshot.rs | 11 + .../app/src/workspace/test_workspace.rs | 307 + linux/crates/app/src/workspace/tests/mod.rs | 1 + .../src/workspace/tests/structure_review.rs | 202 + .../crates/app/src/workspace/unknown_cause.rs | 60 + .../src/workspace/unknown_outcome_dialog.rs | 86 + .../app/src/workspace/waiting_dialog.rs | 64 + .../app/src/workspace/window_inhibitor.rs | 240 + linux/crates/app/src/workspace/work_kind.rs | 8 + .../crates/app/src/workspace/work_outcome.rs | 50 + .../app/src/workspace/workspace_event.rs | 23 + .../app/src/workspace/workspace_file_name.rs | 76 + .../app/src/workspace/workspace_handle.rs | 861 ++ .../app/src/workspace/workspace_load.rs | 11 + .../app/src/workspace/workspace_load_error.rs | 39 + .../app/src/workspace/workspace_record.rs | 206 + .../src/workspace/workspace_record_builder.rs | 85 + .../app/src/workspace/workspace_save_error.rs | 16 + .../app/src/workspace/workspace_store.rs | 903 ++ linux/crates/app/src/workspace/write_job.rs | 32 + .../app/src/workspace/writes_in_flight.rs | 116 + linux/crates/app/tests/accessibility/audit.rs | 246 + linux/crates/app/tests/accessibility/main.rs | 515 ++ linux/crates/app/tests/accessibility/world.rs | 144 + .../crates/app/tests/application_lifecycle.rs | 493 + .../app/tests/application_session_restore.rs | 368 + .../app/tests/application_uniqueness.rs | 277 + linux/crates/app/tests/crash_report.rs | 144 + linux/crates/app/tests/desktop_entry.rs | 72 + linux/crates/app/tests/fixtures/i18n/en_US.po | 28 + .../app/tests/fixtures/workspace-v1.json | 39 + linux/crates/app/tests/i18n_catalog.rs | 152 + linux/crates/app/tests/logging.rs | 124 + linux/crates/app/tests/open_files.rs | 297 + linux/crates/app/tests/secret_save_flow.rs | 274 + linux/crates/app/tests/startup.rs | 218 + .../app/tests/support/settings_schema.rs | 30 + .../crates/app/tests/tracker_save_postgres.rs | 140 + linux/crates/core/Cargo.toml | 31 + linux/crates/core/src/auth_mode.rs | 33 + linux/crates/core/src/browse/browse_page.rs | 35 + .../core/src/browse/browse_sql_error.rs | 30 + linux/crates/core/src/browse/build_count.rs | 32 + linux/crates/core/src/browse/build_export.rs | 45 + linux/crates/core/src/browse/build_page.rs | 271 + .../crates/core/src/browse/export_request.rs | 16 + linux/crates/core/src/browse/mod.rs | 35 + linux/crates/core/src/browse/page_layout.rs | 27 + linux/crates/core/src/browse/page_request.rs | 19 + linux/crates/core/src/browse/page_spec.rs | 22 + linux/crates/core/src/browse/paging_mode.rs | 85 + linux/crates/core/src/browse/read_page.rs | 416 + .../crates/core/src/browse/row_key_layout.rs | 32 + linux/crates/core/src/browse/select_parts.rs | 109 + linux/crates/core/src/browse/sort_key.rs | 7 + linux/crates/core/src/browse/split_page.rs | 224 + linux/crates/core/src/browse/tests.rs | 584 ++ linux/crates/core/src/call_options.rs | 28 + linux/crates/core/src/capabilities.rs | 130 + .../core/src/certificate_file_problem.rs | 28 + linux/crates/core/src/client_identity.rs | 9 + linux/crates/core/src/column/catalog_type.rs | 12 + linux/crates/core/src/column/classify.rs | 540 ++ linux/crates/core/src/column/column_info.rs | 110 + linux/crates/core/src/column/column_type.rs | 67 + linux/crates/core/src/column/default.rs | 34 + linux/crates/core/src/column/integer_kind.rs | 67 + linux/crates/core/src/column/kind.rs | 263 + linux/crates/core/src/column/kind_parts.rs | 69 + linux/crates/core/src/column/mod.rs | 23 + linux/crates/core/src/column/read_form.rs | 21 + linux/crates/core/src/column/result_column.rs | 22 + linux/crates/core/src/column/sql_type_expr.rs | 47 + linux/crates/core/src/commit.rs | 39 + linux/crates/core/src/config_error.rs | 56 + linux/crates/core/src/connect_environment.rs | 21 + linux/crates/core/src/connect_options.rs | 17 + linux/crates/core/src/connect_target.rs | 9 + linux/crates/core/src/connection.rs | 127 + .../src/credentials/connect_credentials.rs | 55 + .../src/credentials/credential_interaction.rs | 19 + .../core/src/credentials/credential_prompt.rs | 9 + .../src/credentials/credential_prompter.rs | 7 + linux/crates/core/src/credentials/mod.rs | 90 + .../core/src/credentials/prompt_field.rs | 5 + .../core/src/credentials/prompt_purpose.rs | 29 + .../core/src/credentials/prompt_reason.rs | 7 + .../core/src/credentials/prompt_reply.rs | 7 + .../core/src/credentials/secret_error.rs | 31 + .../core/src/credentials/secret_kind.rs | 29 + .../core/src/credentials/secret_lookup.rs | 24 + .../core/src/credentials/secret_save.rs | 11 + .../core/src/credentials/secret_vault.rs | 26 + linux/crates/core/src/ddl/build_ddl_error.rs | 42 + linux/crates/core/src/ddl/change_review.rs | 111 + .../crates/core/src/ddl/changed_attributes.rs | 44 + linux/crates/core/src/ddl/collation_name.rs | 19 + .../crates/core/src/ddl/column_alteration.rs | 10 + .../crates/core/src/ddl/column_attributes.rs | 50 + .../core/src/ddl/column_capabilities.rs | 116 + .../crates/core/src/ddl/column_definition.rs | 19 + linux/crates/core/src/ddl/column_key_roles.rs | 10 + linux/crates/core/src/ddl/column_rename.rs | 6 + linux/crates/core/src/ddl/column_storage.rs | 23 + .../crates/core/src/ddl/column_visibility.rs | 11 + linux/crates/core/src/ddl/ddl_capabilities.rs | 29 + linux/crates/core/src/ddl/ddl_dialect.rs | 77 + linux/crates/core/src/ddl/ddl_operation.rs | 57 + linux/crates/core/src/ddl/ddl_plan.rs | 152 + linux/crates/core/src/ddl/ddl_precondition.rs | 12 + linux/crates/core/src/ddl/ddl_sql.rs | 143 + linux/crates/core/src/ddl/default_text.rs | 49 + .../crates/core/src/ddl/destructive_change.rs | 30 + linux/crates/core/src/ddl/diff_table.rs | 112 + linux/crates/core/src/ddl/draft_column.rs | 55 + linux/crates/core/src/ddl/draft_problem.rs | 31 + linux/crates/core/src/ddl/draft_type.rs | 69 + .../crates/core/src/ddl/drop_table_options.rs | 8 + linux/crates/core/src/ddl/generated_column.rs | 19 + linux/crates/core/src/ddl/identity_style.rs | 53 + .../core/src/ddl/inbound_foreign_key.rs | 16 + linux/crates/core/src/ddl/mod.rs | 89 + linux/crates/core/src/ddl/order_renames.rs | 260 + linux/crates/core/src/ddl/permission.rs | 38 + .../crates/core/src/ddl/primary_key_change.rs | 11 + .../core/src/ddl/primary_key_constraint.rs | 9 + .../crates/core/src/ddl/referential_action.rs | 51 + linux/crates/core/src/ddl/refusal_reason.rs | 66 + .../core/src/ddl/schema_object_source.rs | 22 + linux/crates/core/src/ddl/table_changes.rs | 207 + linux/crates/core/src/ddl/table_definition.rs | 88 + linux/crates/core/src/ddl/table_draft.rs | 157 + linux/crates/core/src/ddl/table_rebuild.rs | 23 + linux/crates/core/src/ddl/table_source.rs | 10 + linux/crates/core/src/ddl/tests.rs | 893 ++ .../core/src/ddl/unmodeled_attribute.rs | 15 + linux/crates/core/src/dialect/bind_error.rs | 25 + linux/crates/core/src/dialect/bind_target.rs | 16 + .../core/src/dialect/dialect_capabilities.rs | 8 + .../core/src/dialect/insert_batch_limits.rs | 24 + .../core/src/dialect/keyset_direction.rs | 36 + linux/crates/core/src/dialect/like_form.rs | 46 + .../crates/core/src/dialect/literal_error.rs | 13 + linux/crates/core/src/dialect/mod.rs | 26 + linux/crates/core/src/dialect/placeholder.rs | 29 + linux/crates/core/src/dialect/sql_dialect.rs | 168 + linux/crates/core/src/dialect/test_dialect.rs | 329 + linux/crates/core/src/dml/build_delete.rs | 22 + linux/crates/core/src/dml/build_insert.rs | 104 + linux/crates/core/src/dml/build_key_probe.rs | 24 + linux/crates/core/src/dml/build_sql_error.rs | 24 + linux/crates/core/src/dml/build_update.rs | 40 + linux/crates/core/src/dml/key_component.rs | 128 + linux/crates/core/src/dml/mod.rs | 22 + linux/crates/core/src/dml/predicate.rs | 47 + linux/crates/core/src/dml/tests.rs | 383 + linux/crates/core/src/driver.rs | 29 + linux/crates/core/src/edit/cell_input.rs | 41 + linux/crates/core/src/edit/edit_context.rs | 10 + .../crates/core/src/edit/edit_parse_error.rs | 67 + linux/crates/core/src/edit/edit_text.rs | 127 + .../core/src/edit/empty_input_meaning.rs | 32 + linux/crates/core/src/edit/is_unchanged.rs | 66 + linux/crates/core/src/edit/mod.rs | 22 + linux/crates/core/src/edit/parse_edit_text.rs | 136 + .../core/src/edit/parse_literal_text.rs | 883 ++ linux/crates/core/src/editor_session.rs | 75 + linux/crates/core/src/engine_stop.rs | 57 + linux/crates/core/src/error.rs | 277 + linux/crates/core/src/error_category.rs | 16 + linux/crates/core/src/export/csv.rs | 517 ++ linux/crates/core/src/export/csv_options.rs | 78 + .../core/src/export/encoder/csv_encoder.rs | 55 + .../core/src/export/encoder/encode_error.rs | 14 + .../core/src/export/encoder/html_encoder.rs | 42 + .../src/export/encoder/json_array_encoder.rs | 83 + .../src/export/encoder/markdown_encoder.rs | 40 + linux/crates/core/src/export/encoder/mod.rs | 113 + .../core/src/export/encoder/row_encoder.rs | 17 + .../src/export/encoder/sql_insert_encoder.rs | 53 + linux/crates/core/src/export/encoder/tests.rs | 384 + .../core/src/export/encoder/tsv_encoder.rs | 52 + .../core/src/export/encoder/xml_encoder.rs | 36 + linux/crates/core/src/export/html.rs | 123 + linux/crates/core/src/export/in_clause.rs | 109 + linux/crates/core/src/export/json.rs | 350 + .../core/src/export/json_field_names.rs | 52 + linux/crates/core/src/export/markdown.rs | 47 + linux/crates/core/src/export/mod.rs | 129 + .../core/src/export/render_insert_error.rs | 13 + linux/crates/core/src/export/sql.rs | 336 + linux/crates/core/src/export/tsv.rs | 77 + linux/crates/core/src/export/value_text.rs | 561 ++ linux/crates/core/src/export/xlsx.rs | 584 ++ linux/crates/core/src/export/xml.rs | 168 + linux/crates/core/src/file_endpoint.rs | 73 + linux/crates/core/src/file_open_mode.rs | 5 + linux/crates/core/src/filter/build_filter.rs | 448 + .../core/src/filter/build_filter_error.rs | 34 + linux/crates/core/src/filter/filter_op.rs | 31 + linux/crates/core/src/filter/filter_rule.rs | 23 + linux/crates/core/src/filter/filter_set.rs | 62 + linux/crates/core/src/filter/mod.rs | 22 + linux/crates/core/src/filter/operators_for.rs | 102 + .../core/src/filter/validate_raw_predicate.rs | 66 + linux/crates/core/src/foreign_key_check.rs | 55 + linux/crates/core/src/foreign_key_info.rs | 60 + linux/crates/core/src/foreign_key_mode.rs | 64 + linux/crates/core/src/hex.rs | 22 + linux/crates/core/src/import/csv_import.rs | 538 ++ linux/crates/core/src/import/import_column.rs | 69 + linux/crates/core/src/import/import_plan.rs | 303 + linux/crates/core/src/import/mod.rs | 13 + linux/crates/core/src/lib.rs | 143 + linux/crates/core/src/link_watch.rs | 81 + linux/crates/core/src/liveness_policy.rs | 66 + linux/crates/core/src/loss_phase.rs | 25 + linux/crates/core/src/meta/index_info.rs | 107 + linux/crates/core/src/meta/mod.rs | 18 + linux/crates/core/src/meta/object_info.rs | 66 + linux/crates/core/src/meta/page.rs | 77 + linux/crates/core/src/meta/row_identity.rs | 166 + linux/crates/core/src/meta/schema_info.rs | 19 + linux/crates/core/src/meta/table_estimate.rs | 44 + linux/crates/core/src/meta/table_ref.rs | 36 + linux/crates/core/src/network_endpoint.rs | 85 + linux/crates/core/src/network_target.rs | 101 + linux/crates/core/src/owned_calls.rs | 901 ++ linux/crates/core/src/query_result.rs | 102 + linux/crates/core/src/read_only_refusal.rs | 29 + linux/crates/core/src/read_statement.rs | 13 + linux/crates/core/src/registry.rs | 35 + linux/crates/core/src/result_event.rs | 84 + linux/crates/core/src/result_sink.rs | 22 + linux/crates/core/src/row_limit.rs | 39 + linux/crates/core/src/run_options.rs | 31 + linux/crates/core/src/save/change_set.rs | 86 + linux/crates/core/src/save/mod.rs | 9 + linux/crates/core/src/save/plan_save.rs | 298 + linux/crates/core/src/save/save_plan.rs | 21 + linux/crates/core/src/server_diagnostics.rs | 119 + linux/crates/core/src/server_info.rs | 57 + linux/crates/core/src/server_name_override.rs | 6 + linux/crates/core/src/session_fate.rs | 16 + .../core/src/sql_syntax/credential_rules.rs | 225 + linux/crates/core/src/sql_syntax/errors.rs | 33 + linux/crates/core/src/sql_syntax/grammar.rs | 41 + .../core/src/sql_syntax/history_recording.rs | 413 + linux/crates/core/src/sql_syntax/keywords.rs | 184 + .../core/src/sql_syntax/literal_token.rs | 13 + linux/crates/core/src/sql_syntax/mod.rs | 19 + .../core/src/sql_syntax/parse_expression.rs | 165 + .../crates/core/src/sql_syntax/parse_type.rs | 249 + .../core/src/sql_syntax/reserved_words.rs | 197 + .../sql_syntax/script/batch_error_policy.rs | 5 + .../src/sql_syntax/script/compound_tracker.rs | 191 + .../src/sql_syntax/script/directive_scan.rs | 121 + .../core/src/sql_syntax/script/lexer.rs | 357 + .../src/sql_syntax/script/lexical_settings.rs | 16 + .../crates/core/src/sql_syntax/script/mod.rs | 24 + .../src/sql_syntax/script/open_construct.rs | 7 + .../src/sql_syntax/script/script_batch.rs | 9 + .../sql_syntax/script/script_diagnostic.rs | 6 + .../core/src/sql_syntax/script/script_plan.rs | 214 + .../src/sql_syntax/script/script_rules.rs | 200 + .../src/sql_syntax/script/script_statement.rs | 13 + .../src/sql_syntax/script/script_token.rs | 9 + .../sql_syntax/script/script_token_kind.rs | 23 + .../src/sql_syntax/script/text_offsets.rs | 91 + .../core/src/sql_syntax/statement_tokens.rs | 258 + linux/crates/core/src/ssh_failure.rs | 222 + .../crates/core/src/statement/bound_param.rs | 74 + linux/crates/core/src/statement/mod.rs | 20 + linux/crates/core/src/statement/param_type.rs | 193 + linux/crates/core/src/statement/row_guard.rs | 104 + .../core/src/statement/sql_build_error.rs | 31 + linux/crates/core/src/statement/statement.rs | 43 + .../core/src/statement/statement_builder.rs | 249 + linux/crates/core/src/stop_request.rs | 97 + linux/crates/core/src/stream_summary.rs | 11 + linux/crates/core/src/timeout_phase.rs | 26 + linux/crates/core/src/tls_capabilities.rs | 9 + linux/crates/core/src/tls_config.rs | 404 + linux/crates/core/src/tls_failure.rs | 14 + linux/crates/core/src/tls_mode.rs | 9 + linux/crates/core/src/transaction_state.rs | 14 + linux/crates/core/src/transport.rs | 40 + linux/crates/core/src/transport_class.rs | 16 + linux/crates/core/src/transport_error.rs | 84 + linux/crates/core/src/transport_kind.rs | 30 + linux/crates/core/src/transport_route.rs | 9 + linux/crates/core/src/user_sql.rs | 40 + linux/crates/core/src/value/bit_string.rs | 116 + linux/crates/core/src/value/decimal.rs | 479 + linux/crates/core/src/value/identity.rs | 120 + linux/crates/core/src/value/interval.rs | 192 + linux/crates/core/src/value/json_text.rs | 39 + linux/crates/core/src/value/mod.rs | 118 + .../crates/core/src/value/offset_timestamp.rs | 54 + linux/crates/core/src/value/other.rs | 5 + linux/crates/core/src/value/temporal.rs | 15 + linux/crates/core/src/value/time.rs | 198 + .../crates/core/src/value/time_with_offset.rs | 9 + linux/crates/core/src/value/undecodable.rs | 12 + linux/crates/core/src/value/wide_integer.rs | 212 + linux/crates/core/src/workspace_settings.rs | 44 + linux/crates/core/src/write_batch.rs | 46 + linux/crates/core/src/write_ledger.rs | 591 ++ linux/crates/core/src/write_report.rs | 42 + linux/crates/core/tests/decimal_allocation.rs | 51 + linux/crates/core/tests/script_corpus.rs | 531 ++ linux/crates/drivers/clickhouse/Cargo.toml | 38 + .../drivers/clickhouse/src/call_client.rs | 155 + linux/crates/drivers/clickhouse/src/cancel.rs | 194 + .../drivers/clickhouse/src/capabilities.rs | 61 + .../src/catalog/clickhouse_column_row.rs | 226 + .../drivers/clickhouse/src/catalog/mod.rs | 125 + .../src/catalog/table_definition.rs | 58 + .../drivers/clickhouse/src/cell_seed.rs | 439 + .../drivers/clickhouse/src/clickhouse_type.rs | 318 + .../drivers/clickhouse/src/client_factory.rs | 178 + .../clickhouse/src/commit_resolution.rs | 536 ++ .../drivers/clickhouse/src/connection.rs | 457 + linux/crates/drivers/clickhouse/src/ddl.rs | 482 + .../crates/drivers/clickhouse/src/dialect.rs | 673 ++ linux/crates/drivers/clickhouse/src/driver.rs | 74 + .../drivers/clickhouse/src/editor_run.rs | 584 ++ .../drivers/clickhouse/src/editor_session.rs | 270 + .../crates/drivers/clickhouse/src/endpoint.rs | 184 + linux/crates/drivers/clickhouse/src/errors.rs | 299 + .../drivers/clickhouse/src/http_call.rs | 30 + .../drivers/clickhouse/src/key_expression.rs | 135 + linux/crates/drivers/clickhouse/src/lib.rs | 42 + .../drivers/clickhouse/src/line_reader.rs | 118 + .../drivers/clickhouse/src/lingering_query.rs | 30 + .../crates/drivers/clickhouse/src/metadata.rs | 95 + .../drivers/clickhouse/src/param_value.rs | 101 + .../drivers/clickhouse/src/result_reader.rs | 302 + .../crates/drivers/clickhouse/src/row_seed.rs | 160 + .../drivers/clickhouse/src/session_status.rs | 78 + .../drivers/clickhouse/src/stream_read.rs | 79 + .../clickhouse/src/transport_connector.rs | 127 + .../drivers/clickhouse/src/transport_io.rs | 59 + .../drivers/clickhouse/src/write_batch.rs | 224 + .../clickhouse/src/write_error_class.rs | 81 + .../tests/integration/conformance_engine.rs | 184 + .../tests/integration/csv_import.rs | 53 + .../clickhouse/tests/integration/ddl.rs | 70 + .../tests/integration/keys_and_decimals.rs | 237 + .../clickhouse/tests/integration/main.rs | 17 + .../clickhouse/tests/integration/metadata.rs | 299 + .../clickhouse/tests/integration/sessions.rs | 260 + .../clickhouse/tests/integration/support.rs | 274 + .../clickhouse/tests/integration/tls.rs | 203 + .../clickhouse/tests/integration/writes.rs | 655 ++ linux/crates/drivers/mssql/Cargo.toml | 32 + linux/crates/drivers/mssql/src/cancel.rs | 138 + .../crates/drivers/mssql/src/capabilities.rs | 74 + .../crates/drivers/mssql/src/catalog/cells.rs | 81 + linux/crates/drivers/mssql/src/catalog/mod.rs | 287 + .../mssql/src/catalog/mssql_column_row.rs | 173 + .../mssql/src/catalog/sql_server_type.rs | 566 ++ .../mssql/src/catalog/table_definition.rs | 113 + linux/crates/drivers/mssql/src/connection.rs | 500 ++ linux/crates/drivers/mssql/src/ddl.rs | 1096 +++ linux/crates/drivers/mssql/src/decode.rs | 369 + linux/crates/drivers/mssql/src/dialect.rs | 730 ++ linux/crates/drivers/mssql/src/driver.rs | 112 + linux/crates/drivers/mssql/src/editor_run.rs | 334 + .../drivers/mssql/src/editor_session.rs | 292 + linux/crates/drivers/mssql/src/encode.rs | 300 + linux/crates/drivers/mssql/src/endpoint.rs | 130 + linux/crates/drivers/mssql/src/errors.rs | 238 + .../drivers/mssql/src/in_band_request.rs | 422 + linux/crates/drivers/mssql/src/lib.rs | 41 + linux/crates/drivers/mssql/src/login.rs | 415 + linux/crates/drivers/mssql/src/metadata.rs | 409 + .../crates/drivers/mssql/src/mssql_manager.rs | 87 + .../crates/drivers/mssql/src/mssql_pooled.rs | 121 + linux/crates/drivers/mssql/src/physical.rs | 92 + .../drivers/mssql/src/read_only_guard.rs | 133 + linux/crates/drivers/mssql/src/standalone.rs | 55 + linux/crates/drivers/mssql/src/stream_read.rs | 126 + linux/crates/drivers/mssql/src/tds_message.rs | 46 + linux/crates/drivers/mssql/src/tls_config.rs | 332 + .../crates/drivers/mssql/src/transport_io.rs | 105 + .../drivers/mssql/src/workspace_call.rs | 182 + linux/crates/drivers/mssql/src/write_batch.rs | 273 + .../mssql/tests/integration/cancellation.rs | 161 + .../tests/integration/conformance_engine.rs | 180 + .../mssql/tests/integration/csv_import.rs | 117 + .../drivers/mssql/tests/integration/ddl.rs | 402 + .../drivers/mssql/tests/integration/decode.rs | 184 + .../drivers/mssql/tests/integration/filter.rs | 47 + .../drivers/mssql/tests/integration/main.rs | 21 + .../mssql/tests/integration/metadata.rs | 311 + .../mssql/tests/integration/read_only.rs | 79 + .../mssql/tests/integration/sessions.rs | 111 + .../mssql/tests/integration/support.rs | 285 + .../drivers/mssql/tests/integration/tls.rs | 159 + .../mssql/tests/integration/write_batch.rs | 242 + linux/crates/drivers/mysql/Cargo.toml | 30 + linux/crates/drivers/mysql/src/cancel.rs | 209 + .../crates/drivers/mysql/src/capabilities.rs | 64 + .../mysql/src/catalog/catalog_cells.rs | 88 + .../src/catalog/column_type_scan_error.rs | 13 + .../mysql/src/catalog/escaped_expression.rs | 44 + linux/crates/drivers/mysql/src/catalog/mod.rs | 618 ++ .../mysql/src/catalog/mysql_column_row.rs | 74 + .../mysql/src/catalog/mysql_column_type.rs | 134 + .../drivers/mysql/src/catalog/mysql_extra.rs | 135 + .../mysql/src/catalog/table_definition.rs | 302 + linux/crates/drivers/mysql/src/connection.rs | 514 ++ linux/crates/drivers/mysql/src/ddl/mod.rs | 958 ++ .../mysql/src/ddl/mysql_column_definition.rs | 250 + linux/crates/drivers/mysql/src/decode.rs | 236 + linux/crates/drivers/mysql/src/dialect.rs | 749 ++ linux/crates/drivers/mysql/src/driver.rs | 91 + linux/crates/drivers/mysql/src/editor_run.rs | 211 + .../drivers/mysql/src/editor_session.rs | 318 + linux/crates/drivers/mysql/src/encode.rs | 64 + linux/crates/drivers/mysql/src/errors.rs | 343 + linux/crates/drivers/mysql/src/flavour.rs | 63 + linux/crates/drivers/mysql/src/lib.rs | 42 + linux/crates/drivers/mysql/src/metadata.rs | 619 ++ linux/crates/drivers/mysql/src/mysql_call.rs | 116 + .../crates/drivers/mysql/src/mysql_manager.rs | 98 + .../drivers/mysql/src/mysql_temporal.rs | 273 + .../drivers/mysql/src/mysql_timestamp_mode.rs | 10 + .../drivers/mysql/src/mysql_wire_format.rs | 10 + linux/crates/drivers/mysql/src/physical.rs | 410 + linux/crates/drivers/mysql/src/server_zone.rs | 239 + .../drivers/mysql/src/session_statements.rs | 137 + linux/crates/drivers/mysql/src/standalone.rs | 70 + linux/crates/drivers/mysql/src/stream_read.rs | 216 + .../crates/drivers/mysql/src/table_engine.rs | 92 + .../drivers/mysql/src/transaction_probe.rs | 85 + .../drivers/mysql/src/workspace_call.rs | 124 + .../drivers/mysql/src/workspace_session.rs | 37 + linux/crates/drivers/mysql/src/write_batch.rs | 420 + .../tests/integration/conformance_engine.rs | 249 + .../mysql/tests/integration/csv_import.rs | 109 + .../drivers/mysql/tests/integration/ddl.rs | 529 ++ .../drivers/mysql/tests/integration/decode.rs | 319 + .../mysql/tests/integration/editor_session.rs | 130 + .../mysql/tests/integration/flavour_suite.rs | 477 + .../mysql/tests/integration/lenient_server.rs | 84 + .../drivers/mysql/tests/integration/main.rs | 22 + .../mysql/tests/integration/metadata.rs | 343 + .../tests/integration/non_transactional.rs | 238 + .../mysql/tests/integration/row_limit.rs | 189 + .../tests/integration/server_time_zone.rs | 232 + .../mysql/tests/integration/support.rs | 331 + .../drivers/mysql/tests/integration/tls.rs | 362 + .../mysql/tests/integration/write_batch.rs | 49 + .../drivers/mysql/tests/statement_logging.rs | 106 + .../drivers/mysql/tests/system_trust.rs | 146 + linux/crates/drivers/postgres/Cargo.toml | 34 + linux/crates/drivers/postgres/src/cancel.rs | 131 + .../drivers/postgres/src/capabilities.rs | 64 + .../drivers/postgres/src/catalog/mod.rs | 218 + .../postgres/src/catalog/pg_column_row.rs | 155 + .../drivers/postgres/src/catalog/pg_typmod.rs | 57 + .../postgres/src/catalog/table_definition.rs | 48 + .../postgres/src/catalog/type_facts.rs | 276 + .../postgres/src/codec/array_decode.rs | 183 + .../drivers/postgres/src/codec/decode.rs | 463 + .../postgres/src/codec/interval_wire.rs | 46 + .../crates/drivers/postgres/src/codec/mod.rs | 17 + .../postgres/src/codec/network_text.rs | 79 + .../postgres/src/codec/numeric_wire.rs | 168 + .../crates/drivers/postgres/src/codec/oid.rs | 135 + .../drivers/postgres/src/codec/pg_param.rs | 289 + .../drivers/postgres/src/codec/raw_cell.rs | 47 + .../postgres/src/codec/temporal_wire.rs | 89 + .../drivers/postgres/src/codec/wire_error.rs | 20 + .../drivers/postgres/src/commit_resolution.rs | 51 + .../crates/drivers/postgres/src/connection.rs | 448 + linux/crates/drivers/postgres/src/ddl.rs | 1255 +++ linux/crates/drivers/postgres/src/dialect.rs | 734 ++ linux/crates/drivers/postgres/src/driver.rs | 79 + .../drivers/postgres/src/editor/actor.rs | 77 + .../postgres/src/editor/cursor_flight.rs | 93 + .../postgres/src/editor/editor_dispatch.rs | 162 + .../postgres/src/editor/editor_session.rs | 140 + .../crates/drivers/postgres/src/editor/mod.rs | 16 + .../crates/drivers/postgres/src/editor/run.rs | 455 + .../postgres/src/editor/session_core.rs | 124 + .../postgres/src/editor/session_status.rs | 93 + .../postgres/src/editor/statement_class.rs | 206 + .../drivers/postgres/src/editor/stop_watch.rs | 133 + .../src/editor/transaction_tracker.rs | 204 + linux/crates/drivers/postgres/src/errors.rs | 291 + linux/crates/drivers/postgres/src/lib.rs | 33 + .../drivers/postgres/src/metadata/estimate.rs | 62 + .../postgres/src/metadata/foreign_keys.rs | 71 + .../drivers/postgres/src/metadata/identity.rs | 101 + .../drivers/postgres/src/metadata/indexes.rs | 52 + .../drivers/postgres/src/metadata/mod.rs | 111 + .../drivers/postgres/src/metadata/objects.rs | 105 + linux/crates/drivers/postgres/src/pg_call.rs | 92 + linux/crates/drivers/postgres/src/physical.rs | 182 + linux/crates/drivers/postgres/src/pool.rs | 170 + .../crates/drivers/postgres/src/standalone.rs | 100 + .../drivers/postgres/src/stream_read.rs | 218 + linux/crates/drivers/postgres/src/tls.rs | 162 + .../drivers/postgres/src/transport_io.rs | 142 + .../drivers/postgres/src/workspace_call.rs | 341 + .../drivers/postgres/src/write_batch.rs | 247 + .../tests/integration/cancellation.rs | 173 + .../tests/integration/conformance_engine.rs | 194 + .../postgres/tests/integration/csv_import.rs | 124 + .../drivers/postgres/tests/integration/ddl.rs | 546 ++ .../postgres/tests/integration/decode.rs | 281 + .../tests/integration/editor_session.rs | 528 ++ .../postgres/tests/integration/filter.rs | 77 + .../postgres/tests/integration/flight.rs | 192 + .../postgres/tests/integration/liveness.rs | 59 + .../postgres/tests/integration/main.rs | 25 + .../postgres/tests/integration/metadata.rs | 547 ++ .../postgres/tests/integration/paging.rs | 195 + .../postgres/tests/integration/pgbouncer.rs | 245 + .../postgres/tests/integration/read_only.rs | 197 + .../postgres/tests/integration/slow_route.rs | 109 + .../postgres/tests/integration/support.rs | 249 + .../drivers/postgres/tests/integration/tls.rs | 257 + .../postgres/tests/integration/write_batch.rs | 218 + .../drivers/postgres/tests/tls_provider.rs | 9 + linux/crates/drivers/sqlite/Cargo.toml | 30 + linux/crates/drivers/sqlite/src/autocommit.rs | 18 + .../crates/drivers/sqlite/src/capabilities.rs | 23 + .../drivers/sqlite/src/catalog/dependents.rs | 205 + .../crates/drivers/sqlite/src/catalog/mod.rs | 409 + .../sqlite/src/catalog/runtime_version.rs | 79 + .../sqlite/src/catalog/sqlite_column_row.rs | 219 + .../sqlite/src/catalog/table_definition.rs | 212 + linux/crates/drivers/sqlite/src/connection.rs | 474 + .../crates/drivers/sqlite/src/create_file.rs | 133 + .../src/ddl/column_constraint_segment.rs | 36 + .../sqlite/src/ddl/create_table_source.rs | 1182 +++ .../drivers/sqlite/src/ddl/declared_shape.rs | 78 + .../sqlite/src/ddl/expression_columns.rs | 395 + .../drivers/sqlite/src/ddl/listed_columns.rs | 306 + linux/crates/drivers/sqlite/src/ddl/mod.rs | 376 + .../sqlite/src/ddl/native_change_rules.rs | 203 + .../drivers/sqlite/src/ddl/rebuild_plan.rs | 1156 +++ .../drivers/sqlite/src/ddl/source_element.rs | 72 + .../sqlite/src/ddl/source_read_error.rs | 64 + linux/crates/drivers/sqlite/src/ddl/tests.rs | 1443 +++ linux/crates/drivers/sqlite/src/deadline.rs | 17 + linux/crates/drivers/sqlite/src/decode.rs | 59 + linux/crates/drivers/sqlite/src/dialect.rs | 467 + linux/crates/drivers/sqlite/src/driver.rs | 58 + .../drivers/sqlite/src/editor_session.rs | 308 + linux/crates/drivers/sqlite/src/encode.rs | 62 + linux/crates/drivers/sqlite/src/errors.rs | 159 + .../sqlite/src/foreign_key_verification.rs | 299 + linux/crates/drivers/sqlite/src/index_sql.rs | 302 + linux/crates/drivers/sqlite/src/interrupt.rs | 104 + linux/crates/drivers/sqlite/src/lib.rs | 30 + linux/crates/drivers/sqlite/src/lock_wait.rs | 146 + linux/crates/drivers/sqlite/src/metadata.rs | 573 ++ linux/crates/drivers/sqlite/src/open.rs | 59 + .../drivers/sqlite/src/result_stream.rs | 350 + .../drivers/sqlite/src/sqlite_pooled.rs | 57 + .../drivers/sqlite/src/sqlite_standalone.rs | 36 + .../crates/drivers/sqlite/src/write_batch.rs | 384 + .../crates/drivers/sqlite/tests/csv_import.rs | 341 + .../sqlite/tests/integration/catalog.rs | 185 + .../tests/integration/conformance_engine.rs | 150 + .../drivers/sqlite/tests/integration/ddl.rs | 1530 ++++ .../sqlite/tests/integration/decode.rs | 184 + .../sqlite/tests/integration/dialect.rs | 89 + .../sqlite/tests/integration/lock_wait.rs | 104 + .../drivers/sqlite/tests/integration/main.rs | 28 + .../sqlite/tests/integration/metadata.rs | 393 + .../tests/integration/open_semantics.rs | 197 + .../sqlite/tests/integration/sessions.rs | 407 + .../sqlite/tests/integration/support.rs | 153 + .../integration/write_batch_foreign_keys.rs | 192 + .../script_plan_matches_sqlite3_complete.rs | 48 + .../drivers/sqlite/tests/statement_logging.rs | 101 + linux/crates/drivers/sqlite/tests/values.rs | 158 + linux/crates/net/Cargo.toml | 32 + linux/crates/net/src/dial.rs | 170 + linux/crates/net/src/direct_tcp.rs | 84 + linux/crates/net/src/lib.rs | 6 + linux/crates/net/src/socket_options.rs | 43 + linux/crates/net/src/tls/classify.rs | 112 + linux/crates/net/src/tls/client_config.rs | 41 + linux/crates/net/src/tls/mod.rs | 15 + linux/crates/net/src/tls/mysql_bundle.rs | 58 + linux/crates/net/src/tls/off_runtime.rs | 13 + linux/crates/net/src/tls/pem_check.rs | 122 + linux/crates/net/src/tls/setup_error.rs | 18 + linux/crates/net/src/tls/unverified.rs | 52 + linux/crates/net/tests/empty_trust_store.rs | 23 + linux/crates/net/tests/tls_handshake.rs | 389 + linux/crates/session/Cargo.toml | 35 + linux/crates/session/clippy.toml | 36 + .../session/src/connect/attempt_generation.rs | 23 + .../session/src/connect/attempt_outcome.rs | 9 + .../session/src/connect/attempt_ticket.rs | 32 + .../session/src/connect/connect_attempts.rs | 98 + .../session/src/connect/connect_error.rs | 87 + .../session/src/connect/connect_intent.rs | 12 + .../session/src/connect/connect_request.rs | 37 + .../session/src/connect/connect_services.rs | 11 + .../session/src/connect/connect_source.rs | 12 + .../src/connect/connection_metadata.rs | 9 + .../session/src/connect/connection_writes.rs | 50 + linux/crates/session/src/connect/connector.rs | 18 + .../session/src/connect/credential_source.rs | 49 + .../session/src/connect/driver_connector.rs | 438 + .../session/src/connect/keyring_source.rs | 100 + linux/crates/session/src/connect/mod.rs | 45 + .../src/connect/no_stored_credentials.rs | 21 + .../session/src/connect/pending_save.rs | 15 + .../session/src/connect/pending_session.rs | 96 + .../src/connect/persist_pending_save.rs | 294 + .../session/src/connect/request_target.rs | 38 + .../session/src/connect/resolve_saved.rs | 343 + .../session/src/fakes/fake_connector.rs | 244 + .../src/fakes/fake_credential_source.rs | 82 + linux/crates/session/src/fakes/mod.rs | 9 + .../session/src/health/failure_cause.rs | 100 + .../session/src/health/health_monitor.rs | 781 ++ .../session/src/health/health_policy.rs | 479 + .../session/src/health/link_generation.rs | 44 + linux/crates/session/src/health/mod.rs | 38 + .../session/src/health/monitor_action.rs | 12 + .../session/src/health/monitor_mailbox.rs | 108 + .../session/src/health/monitor_signal.rs | 32 + .../session/src/health/pending_signals.rs | 97 + .../session/src/health/probe_outcome.rs | 94 + .../crates/session/src/health/reachability.rs | 17 + .../session/src/health/reachability_target.rs | 164 + .../session/src/health/reconnect_plan.rs | 41 + .../session/src/health/reconnect_result.rs | 12 + .../session/src/health/reviewed_host_key.rs | 7 + .../session/src/health/session_health.rs | 30 + .../session/src/health/trusted_host_key.rs | 135 + linux/crates/session/src/lib.rs | 30 + linux/crates/session/src/link/close_report.rs | 12 + .../session/src/link/connection_lease.rs | 58 + .../session/src/link/connection_link.rs | 210 + .../src/link/established_connection.rs | 24 + linux/crates/session/src/link/mod.rs | 14 + .../session/src/link/retired_connection.rs | 44 + .../session/src/operation/deadline_policy.rs | 9 + .../src/operation/default_deadlines.rs | 60 + linux/crates/session/src/operation/mod.rs | 17 + .../session/src/operation/operation_error.rs | 51 + .../session/src/operation/operation_kind.rs | 25 + .../session/src/operation/operation_runner.rs | 437 + .../session/src/operation/replay_rule.rs | 100 + .../session/src/registry/claim_error.rs | 8 + .../session/src/registry/file_identity.rs | 21 + linux/crates/session/src/registry/mod.rs | 17 + .../session/src/registry/registry_event.rs | 19 + .../src/registry/registry_subscription.rs | 19 + .../session/src/registry/session_claim.rs | 39 + .../session/src/registry/session_key.rs | 155 + .../session/src/registry/session_registry.rs | 288 + .../crates/session/src/runtime/catch_panic.rs | 38 + .../session/src/runtime/latest_wins_writer.rs | 202 + linux/crates/session/src/runtime/mod.rs | 17 + .../session/src/runtime/process_hold.rs | 88 + .../session/src/runtime/task_failure.rs | 22 + .../crates/session/src/runtime/task_panic.rs | 69 + .../crates/session/src/runtime/task_result.rs | 98 + linux/crates/session/src/runtime/tasks.rs | 94 + .../session/src/runtime/watch_forwarder.rs | 103 + .../src/session/editor_session_shutdown.rs | 73 + linux/crates/session/src/session/mod.rs | 15 + .../src/session/object_list_snapshot.rs | 30 + .../session/src/session/schema_catalog.rs | 735 ++ linux/crates/session/src/session/session.rs | 463 + .../session/src/session/session_deps.rs | 28 + linux/crates/session/src/test_support.rs | 13 + .../crates/session/tests/session_postgres.rs | 419 + linux/crates/ssh/Cargo.toml | 29 + linux/crates/ssh/src/argv.rs | 438 + linux/crates/ssh/src/askpass_bridge.rs | 383 + linux/crates/ssh/src/bin/tablepro-askpass.rs | 61 + linux/crates/ssh/src/config.rs | 25 + linux/crates/ssh/src/destination.rs | 236 + linux/crates/ssh/src/forward.rs | 157 + linux/crates/ssh/src/hop_config.rs | 233 + linux/crates/ssh/src/host_key_checking.rs | 52 + linux/crates/ssh/src/known_hosts.rs | 357 + linux/crates/ssh/src/lib.rs | 35 + linux/crates/ssh/src/password_owner.rs | 62 + linux/crates/ssh/src/prompt.rs | 262 + linux/crates/ssh/src/route.rs | 359 + linux/crates/ssh/src/runtime.rs | 259 + linux/crates/ssh/src/runtime_cell.rs | 58 + linux/crates/ssh/src/services.rs | 152 + linux/crates/ssh/src/session.rs | 602 ++ linux/crates/ssh/src/stderr_classify.rs | 396 + linux/crates/ssh/src/supervisor.rs | 118 + linux/crates/ssh/src/sweep.rs | 148 + linux/crates/ssh/tests/askpass_helper.rs | 81 + .../tests/fixtures/stderr/auth_rejected.txt | 2 + .../stderr/config_rejected_option.txt | 1 + .../fixtures/stderr/control_path_too_long.txt | 1 + .../fixtures/stderr/host_key_changed.txt | 15 + .../fixtures/stderr/host_key_revoked.txt | 2 + .../stderr/host_key_unknown_strict.txt | 2 + .../tests/fixtures/stderr/name_resolution.txt | 1 + .../ssh/tests/fixtures/stderr/refused.txt | 1 + .../ssh/tests/fixtures/stderr/timeout.txt | 1 + linux/crates/ssh/tests/openssh_session.rs | 770 ++ linux/crates/storage/Cargo.toml | 27 + .../20260914000000_create_history.sql | 38 + .../20260918000000_create_saved_queries.sql | 12 + ...0261014000000_history_substring_search.sql | 39 + linux/crates/storage/src/connection_color.rs | 83 + .../storage/src/connection_group_name.rs | 130 + linux/crates/storage/src/connections.rs | 1354 +++ .../src/credentials/credential_demand.rs | 15 + .../src/credentials/credential_error.rs | 38 + .../src/credentials/keyring_credentials.rs | 717 ++ linux/crates/storage/src/credentials/mod.rs | 11 + .../src/credentials/resolved_secret.rs | 24 + .../storage/src/credentials/secret_labeler.rs | 16 + linux/crates/storage/src/document.rs | 347 + linux/crates/storage/src/document_problem.rs | 44 + linux/crates/storage/src/drafts/draft_id.rs | 38 + .../crates/storage/src/drafts/draft_scope.rs | 93 + .../crates/storage/src/drafts/draft_store.rs | 291 + linux/crates/storage/src/drafts/mod.rs | 13 + linux/crates/storage/src/error.rs | 67 + linux/crates/storage/src/fs.rs | 403 + linux/crates/storage/src/group_name_error.rs | 11 + linux/crates/storage/src/lib.rs | 36 + linux/crates/storage/src/paths.rs | 99 + .../crates/storage/src/query_history/entry.rs | 19 + .../storage/src/query_history/export.rs | 158 + .../src/query_history/history_connection.rs | 9 + .../src/query_history/history_cursor.rs | 26 + .../storage/src/query_history/history_page.rs | 25 + .../src/query_history/history_query.rs | 83 + linux/crates/storage/src/query_history/mod.rs | 1081 +++ .../storage/src/query_history/new_entry.rs | 17 + .../storage/src/query_history/outcome.rs | 6 + .../storage/src/query_history/prune_report.rs | 18 + .../storage/src/query_history/retention.rs | 38 + .../storage/src/query_history/schema.rs | 49 + .../storage/src/query_history/search_fold.rs | 16 + .../src/query_history/status_filter.rs | 22 + linux/crates/storage/src/saved_queries/mod.rs | 473 + .../src/saved_queries/new_saved_query.rs | 10 + .../storage/src/saved_queries/saved_query.rs | 58 + .../storage/src/secrets/keyring_error.rs | 102 + linux/crates/storage/src/secrets/mod.rs | 5 + .../storage/src/secrets/secret_attributes.rs | 77 + .../storage/src/secrets/secret_store.rs | 137 + .../storage/src/settings/editor_font.rs | 20 + linux/crates/storage/src/settings/error.rs | 21 + linux/crates/storage/src/settings/keys.rs | 34 + linux/crates/storage/src/settings/mod.rs | 286 + .../storage/src/settings/window_geometry.rs | 6 + linux/crates/storage/src/unix_time.rs | 40 + .../tests/fixtures/connections-v1.json | 175 + linux/crates/storage/tests/isolation.rs | 169 + .../crates/storage/tests/runtime_fallback.rs | 25 + linux/crates/storage/tests/secret_service.rs | 86 + linux/crates/storage/tests/settings.rs | 211 + linux/crates/storage/tests/stored_formats.rs | 239 + linux/crates/test-fixtures/Cargo.toml | 25 + .../test-fixtures/src/clickhouse_fixture.rs | 433 + .../test-fixtures/src/container_file.rs | 75 + linux/crates/test-fixtures/src/exec_output.rs | 38 + .../test-fixtures/src/fixture_credentials.rs | 31 + .../crates/test-fixtures/src/fixture_error.rs | 27 + .../crates/test-fixtures/src/fixture_image.rs | 50 + linux/crates/test-fixtures/src/hba_mode.rs | 65 + .../test-fixtures/src/host_certificate.rs | 49 + .../crates/test-fixtures/src/host_command.rs | 42 + .../test-fixtures/src/host_key_revocation.rs | 35 + linux/crates/test-fixtures/src/lib.rs | 54 + .../crates/test-fixtures/src/mssql_fixture.rs | 90 + .../crates/test-fixtures/src/mysql_fixture.rs | 234 + .../crates/test-fixtures/src/mysql_flavour.rs | 45 + .../crates/test-fixtures/src/mysql_options.rs | 48 + .../test-fixtures/src/openssh_fixture.rs | 207 + .../test-fixtures/src/pgbouncer_fixture.rs | 150 + .../crates/test-fixtures/src/pki_algorithm.rs | 32 + linux/crates/test-fixtures/src/pki_file.rs | 35 + linux/crates/test-fixtures/src/pool_mode.rs | 16 + .../test-fixtures/src/postgres_fixture.rs | 202 + .../test-fixtures/src/postgres_version.rs | 48 + .../test-fixtures/src/scripted_askpass.rs | 50 + linux/crates/test-fixtures/src/ssh_agent.rs | 135 + .../test-fixtures/src/ssh_auth_variant.rs | 15 + .../crates/test-fixtures/src/ssh_key_pair.rs | 50 + .../crates/test-fixtures/src/test_network.rs | 28 + linux/crates/test-fixtures/src/test_pki.rs | 249 + linux/crates/test-fixtures/tests/fixtures.rs | 573 ++ linux/crates/test-support/Cargo.toml | 23 + .../crates/test-support/src/call_counters.rs | 37 + .../test-support/src/close_behaviour.rs | 19 + linux/crates/test-support/src/close_race.rs | 46 + .../src/conformance/bounded_ddl_engine.rs | 13 + .../src/conformance/cases/cancellation.rs | 241 + .../src/conformance/cases/close.rs | 32 + .../conformance/cases/commit_resolution.rs | 34 + .../test-support/src/conformance/cases/ddl.rs | 807 ++ .../src/conformance/cases/deadlines.rs | 109 + .../src/conformance/cases/liveness.rs | 51 + .../src/conformance/cases/metadata.rs | 69 + .../test-support/src/conformance/cases/mod.rs | 20 + .../src/conformance/cases/pool.rs | 187 + .../src/conformance/cases/read_only.rs | 52 + .../src/conformance/cases/row_guards.rs | 186 + .../src/conformance/cases/row_limit.rs | 51 + .../conformance/cases/statement_timeout.rs | 53 + .../src/conformance/cases/streaming.rs | 225 + .../src/conformance/cases/suite_flags.rs | 77 + .../test-support/src/conformance/cases/tls.rs | 180 + .../src/conformance/cases/type_fallback.rs | 51 + .../src/conformance/cases/writes.rs | 139 + .../src/conformance/client_identity_engine.rs | 12 + .../src/conformance/conformance_engine.rs | 41 + .../src/conformance/conformance_failure.rs | 43 + .../src/conformance/connection_suite_flags.rs | 11 + .../src/conformance/connection_suite_macro.rs | 385 + .../src/conformance/ddl_conformance_engine.rs | 30 + .../src/conformance/ddl_suite_macro.rs | 36 + .../conformance/editor_transaction_engine.rs | 9 + .../src/conformance/fake_engine.rs | 90 + .../test-support/src/conformance/mod.rs | 51 + .../conformance/network_conformance_engine.rs | 24 + .../conformance/pooled_conformance_engine.rs | 9 + .../test-support/src/conformance/run_case.rs | 64 + .../src/conformance/suite_endpoint.rs | 6 + .../test-support/src/conformance/support.rs | 274 + .../src/conformance/test_liveness.rs | 16 + .../src/conformance/tls_conformance_engine.rs | 43 + .../src/conformance/tls_suite_macro.rs | 145 + .../conformance/typed_conformance_engine.rs | 6 + .../src/conformance/unsupported_type_case.rs | 13 + .../test-support/src/connection_script.rs | 70 + linux/crates/test-support/src/csv_import.rs | 49 + linux/crates/test-support/src/executable.rs | 52 + .../test-support/src/fake_connection.rs | 685 ++ linux/crates/test-support/src/fake_dialect.rs | 157 + linux/crates/test-support/src/fake_driver.rs | 214 + .../test-support/src/fake_editor_session.rs | 347 + .../crates/test-support/src/fake_prompter.rs | 84 + linux/crates/test-support/src/fake_reply.rs | 187 + linux/crates/test-support/src/fake_run.rs | 84 + .../test-support/src/fake_secret_vault.rs | 204 + .../test-support/src/fault/fault_state.rs | 50 + .../test-support/src/fault/fault_stream.rs | 87 + .../test-support/src/fault/fault_transport.rs | 243 + linux/crates/test-support/src/fault/mod.rs | 6 + .../test-support/src/fault/route_proxy.rs | 74 + .../crates/test-support/src/history_cases.rs | 46 + linux/crates/test-support/src/lib.rs | 49 + linux/crates/test-support/src/replies.rs | 94 + linux/crates/test-support/src/stream_end.rs | 15 + .../data/app.tablepro.TablePro.desktop.in.in | 20 + .../data/app.tablepro.TablePro.gschema.xml.in | 159 + .../app.tablepro.TablePro.metainfo.xml.in.in | 83 + linux/data/app.tablepro.TablePro.service.in | 3 + .../apps/app.tablepro.TablePro.Devel.svg | 19 + .../scalable/apps/app.tablepro.TablePro.svg | 19 + .../apps/app.tablepro.TablePro-symbolic.svg | 6 + linux/data/licenses/THIRD-PARTY-NOTICE.md | 7899 +++++++++++++++++ linux/data/licenses/third-party.json | 1815 ++++ linux/data/meson.build | 120 + linux/data/resources/gtk/menus.ui | 66 + .../actions/document-modified-symbolic.svg | 4 + .../actions/statement-pending-symbolic.svg | 9 + .../actions/table-relation-symbolic.svg | 7 + linux/data/resources/resources.gresource.xml | 43 + linux/data/resources/shortcuts-dialog.ui | 370 + linux/data/resources/style.css | 130 + linux/data/resources/ui/browse-tab.ui | 210 + linux/data/resources/ui/connect-dialog.ui | 139 + .../data/resources/ui/connect-failed-page.ui | 81 + linux/data/resources/ui/connecting-page.ui | 39 + .../resources/ui/export-operations-button.ui | 46 + linux/data/resources/ui/filter-rule-row.ui | 39 + linux/data/resources/ui/filter-strip.ui | 185 + linux/data/resources/ui/find-bar.ui | 118 + linux/data/resources/ui/history-dialog.ui | 182 + linux/data/resources/ui/history-row.ui | 84 + linux/data/resources/ui/pager-bar.ui | 77 + .../data/resources/ui/pending-changes-bar.ui | 57 + linux/data/resources/ui/picker-page.ui | 38 + .../ui/preferences-data-browser-group.ui | 27 + linux/data/resources/ui/preferences-dialog.ui | 15 + .../resources/ui/preferences-editor-page.ui | 66 + .../resources/ui/preferences-general-page.ui | 26 + .../resources/ui/preferences-history-group.ui | 60 + linux/data/resources/ui/results-pane.ui | 103 + linux/data/resources/ui/session-window.ui | 14 + linux/data/resources/ui/sql-editor.ui | 144 + linux/data/resources/ui/sqlite-file-row.ui | 24 + linux/data/resources/ui/ssh-section.ui | 79 + linux/data/resources/ui/structure-tab.ui | 376 + linux/data/resources/ui/tls-section.ui | 106 + linux/data/resources/ui/welcome-view.ui | 51 + linux/data/resources/ui/workspace.ui | 201 + linux/data/screenshots/01-table.png | Bin 0 -> 136822 bytes linux/data/screenshots/02-editor.png | Bin 0 -> 83397 bytes linux/data/screenshots/03-structure.png | Bin 0 -> 77046 bytes linux/data/screenshots/04-connect.png | Bin 0 -> 67117 bytes linux/data/tablepro.1 | 87 + linux/deny.toml | 63 + linux/docs/README.md | 31 + linux/docs/adding-drivers.md | 381 + linux/docs/decisions/0001-no-plugin-system.md | 70 + .../decisions/0002-rust-gtk4-libadwaita.md | 73 + .../docs/decisions/0003-relm4-architecture.md | 67 + .../0004-libsecret-secret-storage.md | 107 + .../decisions/0005-supply-chain-policy.md | 58 + .../docs/decisions/0006-meson-cargo-build.md | 60 + .../decisions/0007-gsettings-preferences.md | 58 + .../0008-flatpak-sandbox-permissions.md | 45 + .../0009-crypto-provider-and-sources.md | 34 + .../0010-ddl-plans-and-table-definitions.md | 46 + .../docs/decisions/0012-window-per-session.md | 40 + .../0013-openssh-client-transport.md | 34 + .../0014-lossless-values-and-dialects.md | 50 + .../decisions/0015-gnome-50-platform-floor.md | 58 + .../docs/decisions/0016-driver-contract-v2.md | 57 + .../decisions/0017-distribution-channels.md | 112 + .../0018-ppa-copr-and-flathub-later.md | 166 + linux/docs/decisions/README.md | 71 + linux/docs/drivers/clickhouse.md | 107 + linux/docs/drivers/mysql.md | 125 + linux/docs/drivers/postgresql.md | 147 + linux/docs/drivers/sql-server.md | 106 + linux/docs/drivers/sqlite.md | 109 + linux/docs/error-handling.md | 169 + linux/docs/regression-tests.md | 77 + linux/docs/releasing.md | 63 + linux/docs/state-management.md | 180 + linux/docs/storage.md | 318 + linux/docs/testing.md | 381 + linux/docs/troubleshooting.md | 105 + linux/meson.build | 91 + linux/meson.options | 21 + linux/po/LINGUAS | 2 + linux/po/POTFILES.in | 581 ++ linux/po/README.md | 92 + linux/po/meson.build | 36 + linux/po/tablepro.pot | 5478 ++++++++++++ linux/rust-toolchain.toml | 3 + linux/rustfmt.toml | 2 + linux/towncrier.toml | 27 + scripts/check-banned-words.sh | 25 +- scripts/check-doc-symbols.sh | 63 +- scripts/ci/check-changelog.py | 154 +- scripts/ci/detect-changed-paths.sh | 9 +- scripts/ci/test_check_banned_words.py | 92 + scripts/ci/test_check_changelog.py | 122 +- scripts/ci/test_detect_changed_paths.py | 22 +- 1574 files changed, 269915 insertions(+), 150 deletions(-) create mode 100644 .claude/skills/fix-issue/references/platform-linux.md create mode 100644 .claude/skills/fix-issue/references/research-linux.md create mode 100644 .claude/skills/fix-issue/references/verification-linux.md create mode 100644 .claude/skills/fix-issue/scripts/lib/platform-linux.sh create mode 100644 .github/workflows/linux-packages.yml create mode 100644 .github/workflows/release-linux.yml create mode 100644 linux/.config/nextest.toml create mode 100644 linux/.editorconfig create mode 100644 linux/.gitignore create mode 100644 linux/AGENTS.md create mode 100644 linux/ARCHITECTURE.md create mode 100644 linux/CHANGELOG.md create mode 100644 linux/CLAUDE.md create mode 100644 linux/CONTRIBUTING.md create mode 100644 linux/Cargo.lock create mode 100644 linux/Cargo.toml create mode 100644 linux/LICENSE create mode 100644 linux/README.md create mode 100644 linux/ROADMAP.md create mode 100644 linux/about.toml create mode 100644 linux/build-aux/about/THIRD-PARTY-NOTICE.md.hbs create mode 100644 linux/build-aux/about/fallbacks.hbs create mode 100644 linux/build-aux/about/third-party.json.hbs create mode 100644 linux/build-aux/debian/README.source create mode 100644 linux/build-aux/debian/clean create mode 100644 linux/build-aux/debian/control create mode 100644 linux/build-aux/debian/copyright create mode 100755 linux/build-aux/debian/rules create mode 100644 linux/build-aux/debian/source/format create mode 100644 linux/build-aux/debian/source/lintian-overrides create mode 100644 linux/build-aux/flatpak/app.tablepro.TablePro.Devel.json create mode 100644 linux/build-aux/flatpak/krb5.conf create mode 100644 linux/build-aux/flatpak/lint-exceptions.json create mode 100644 linux/build-aux/meson.build create mode 100755 linux/build-aux/packaging/add-rust-toolchain-ppa.sh create mode 100755 linux/build-aux/packaging/build-deb.sh create mode 100755 linux/build-aux/packaging/build-packages.sh create mode 100755 linux/build-aux/packaging/build-rpm.sh create mode 100755 linux/build-aux/packaging/build-source.sh create mode 100644 linux/build-aux/packaging/check_packaging.py create mode 100755 linux/build-aux/packaging/fetch-release-sources.sh create mode 100644 linux/build-aux/packaging/license_metadata.py create mode 100755 linux/build-aux/packaging/make-sources.sh create mode 100644 linux/build-aux/packaging/rust-toolchain-staging.asc create mode 100755 linux/build-aux/packaging/smoke-test.sh create mode 100755 linux/build-aux/rpm/copr-sources.sh create mode 100644 linux/build-aux/rpm/tablepro.spec create mode 100644 linux/build-aux/tests/test_check_packaging.py create mode 100644 linux/build-aux/tests/test_copr_sources.py create mode 100644 linux/build-aux/tests/test_license_metadata.py create mode 100644 linux/build-aux/tests/test_regression_names.py create mode 100755 linux/build-aux/update-third-party-licenses.sh create mode 100644 linux/changelog.d/.gitkeep create mode 100644 linux/changelog.d/3413.added.md create mode 100644 linux/changelog.d/3464.added.1.md create mode 100644 linux/changelog.d/3464.added.10.md create mode 100644 linux/changelog.d/3464.added.11.md create mode 100644 linux/changelog.d/3464.added.2.md create mode 100644 linux/changelog.d/3464.added.3.md create mode 100644 linux/changelog.d/3464.added.4.md create mode 100644 linux/changelog.d/3464.added.5.md create mode 100644 linux/changelog.d/3464.added.6.md create mode 100644 linux/changelog.d/3464.added.7.md create mode 100644 linux/changelog.d/3464.added.8.md create mode 100644 linux/changelog.d/3464.added.9.md create mode 100644 linux/changelog.d/3464.added.md create mode 100644 linux/clippy.toml create mode 100644 linux/crates/app/Cargo.toml create mode 100644 linux/crates/app/build.rs create mode 100644 linux/crates/app/clippy.toml create mode 100644 linux/crates/app/meson.build create mode 100644 linux/crates/app/src/application/about_dialog.rs create mode 100644 linux/crates/app/src/application/accelerator.rs create mode 100644 linux/crates/app/src/application/accelerator_scope.rs create mode 100644 linux/crates/app/src/application/accelerators.rs create mode 100644 linux/crates/app/src/application/app_actions.rs create mode 100644 linux/crates/app/src/application/app_services.rs create mode 100644 linux/crates/app/src/application/app_window_host.rs create mode 100644 linux/crates/app/src/application/crash_notice.rs create mode 100644 linux/crates/app/src/application/debug_info.rs create mode 100644 linux/crates/app/src/application/imp.rs create mode 100644 linux/crates/app/src/application/legal_section.rs create mode 100644 linux/crates/app/src/application/metainfo_release.rs create mode 100644 linux/crates/app/src/application/mod.rs create mode 100644 linux/crates/app/src/application/open_files.rs create mode 100644 linux/crates/app/src/application/quit_sequence.rs create mode 100644 linux/crates/app/src/application/runtime_versions.rs create mode 100644 linux/crates/app/src/application/session_signals.rs create mode 100644 linux/crates/app/src/application/shortcut_listing.rs create mode 100644 linux/crates/app/src/application/shortcut_owner.rs create mode 100644 linux/crates/app/src/application/shortcut_section.rs create mode 100644 linux/crates/app/src/application/tests.rs create mode 100644 linux/crates/app/src/application/third_party_licenses.rs create mode 100644 linux/crates/app/src/application/window_host.rs create mode 100644 linux/crates/app/src/application/window_launch.rs create mode 100644 linux/crates/app/src/application/window_registry.rs create mode 100644 linux/crates/app/src/config.rs create mode 100644 linux/crates/app/src/crash_report.rs create mode 100644 linux/crates/app/src/i18n.rs create mode 100644 linux/crates/app/src/lib.rs create mode 100644 linux/crates/app/src/logging.rs create mode 100644 linux/crates/app/src/main.rs create mode 100644 linux/crates/app/src/persistence/column_width_store.rs create mode 100644 linux/crates/app/src/persistence/column_widths_document.rs create mode 100644 linux/crates/app/src/persistence/filter_settings_document.rs create mode 100644 linux/crates/app/src/persistence/filter_settings_store.rs create mode 100644 linux/crates/app/src/persistence/mod.rs create mode 100644 linux/crates/app/src/persistence/state_file.rs create mode 100644 linux/crates/app/src/runtime/app_runtime.rs create mode 100644 linux/crates/app/src/runtime/clock_sample.rs create mode 100644 linux/crates/app/src/runtime/clock_source.rs create mode 100644 linux/crates/app/src/runtime/deliver.rs create mode 100644 linux/crates/app/src/runtime/deliver_input.rs create mode 100644 linux/crates/app/src/runtime/detect_resume.rs create mode 100644 linux/crates/app/src/runtime/failed_requests.rs create mode 100644 linux/crates/app/src/runtime/gio_process_hold.rs create mode 100644 linux/crates/app/src/runtime/gio_reachability.rs create mode 100644 linux/crates/app/src/runtime/input_queue.rs create mode 100644 linux/crates/app/src/runtime/mod.rs create mode 100644 linux/crates/app/src/runtime/network_watch.rs create mode 100644 linux/crates/app/src/runtime/reachability_fanout.rs create mode 100644 linux/crates/app/src/runtime/reachability_probe.rs create mode 100644 linux/crates/app/src/runtime/request_sequencer.rs create mode 100644 linux/crates/app/src/runtime/request_stamp.rs create mode 100644 linux/crates/app/src/runtime/request_ticket.rs create mode 100644 linux/crates/app/src/runtime/resume_detector.rs create mode 100644 linux/crates/app/src/runtime/stamped.rs create mode 100644 linux/crates/app/src/runtime/system_clocks.rs create mode 100644 linux/crates/app/src/services/active_session.rs create mode 100644 linux/crates/app/src/services/cell_overlay.rs create mode 100644 linux/crates/app/src/services/change_origins.rs create mode 100644 linux/crates/app/src/services/change_tracker.rs create mode 100644 linux/crates/app/src/services/change_tracker_registry.rs create mode 100644 linux/crates/app/src/services/column_relayout.rs create mode 100644 linux/crates/app/src/services/connection_deletion.rs create mode 100644 linux/crates/app/src/services/deletion_error.rs create mode 100644 linux/crates/app/src/services/deletion_report.rs create mode 100644 linux/crates/app/src/services/duplicate_name.rs create mode 100644 linux/crates/app/src/services/file_location.rs create mode 100644 linux/crates/app/src/services/file_location_kind.rs create mode 100644 linux/crates/app/src/services/file_resolution.rs create mode 100644 linux/crates/app/src/services/history_availability.rs create mode 100644 linux/crates/app/src/services/history_preferences.rs create mode 100644 linux/crates/app/src/services/history_service.rs create mode 100644 linux/crates/app/src/services/mod.rs create mode 100644 linux/crates/app/src/services/pending_structure.rs create mode 100644 linux/crates/app/src/services/save_batch.rs create mode 100644 linux/crates/app/src/services/saved_connection_editor.rs create mode 100644 linux/crates/app/src/services/saved_connection_error.rs create mode 100644 linux/crates/app/src/services/saved_connection_list.rs create mode 100644 linux/crates/app/src/services/saved_connection_object.rs create mode 100644 linux/crates/app/src/services/saved_connection_object_imp.rs create mode 100644 linux/crates/app/src/services/saved_connection_order.rs create mode 100644 linux/crates/app/src/services/secret_change.rs create mode 100644 linux/crates/app/src/services/secret_changes.rs create mode 100644 linux/crates/app/src/services/secret_labels.rs create mode 100644 linux/crates/app/src/services/secret_save_report.rs create mode 100644 linux/crates/app/src/services/session_context.rs create mode 100644 linux/crates/app/src/services/ssh_environment.rs create mode 100644 linux/crates/app/src/services/structure_tracker.rs create mode 100644 linux/crates/app/src/services/structure_tracker_handle.rs create mode 100644 linux/crates/app/src/services/structure_tracker_registry.rs create mode 100644 linux/crates/app/src/services/tab_registry.rs create mode 100644 linux/crates/app/src/services/tab_tracker_handle.rs create mode 100644 linux/crates/app/src/services/touch.rs create mode 100644 linux/crates/app/src/services/tracker_delta.rs create mode 100644 linux/crates/app/src/services/tracker_edit.rs create mode 100644 linux/crates/app/src/services/undo_op.rs create mode 100644 linux/crates/app/src/storage.rs create mode 100644 linux/crates/app/src/test_support/accessible_label.rs create mode 100644 linux/crates/app/src/test_support/alert_response.rs create mode 100644 linux/crates/app/src/test_support/application_end.rs create mode 100644 linux/crates/app/src/test_support/fake_clocks.rs create mode 100644 linux/crates/app/src/test_support/fake_reachability.rs create mode 100644 linux/crates/app/src/test_support/fake_session.rs create mode 100644 linux/crates/app/src/test_support/focus_thief.rs create mode 100644 linux/crates/app/src/test_support/icon_buttons.rs create mode 100644 linux/crates/app/src/test_support/key_injection_error.rs create mode 100644 linux/crates/app/src/test_support/key_injector.rs create mode 100644 linux/crates/app/src/test_support/main_loop.rs create mode 100644 linux/crates/app/src/test_support/memory_settings.rs create mode 100644 linux/crates/app/src/test_support/mod.rs create mode 100644 linux/crates/app/src/test_support/owned_window.rs create mode 100644 linux/crates/app/src/test_support/shortcut_keys.rs create mode 100644 linux/crates/app/src/test_support/signal_log.rs create mode 100644 linux/crates/app/src/test_support/tests.rs create mode 100644 linux/crates/app/src/test_support/unlabelled_widget.rs create mode 100644 linux/crates/app/src/test_support/wait_timed_out.rs create mode 100644 linux/crates/app/src/test_support/widget_lookup.rs create mode 100644 linux/crates/app/src/ui/a11y/announce.rs create mode 100644 linux/crates/app/src/ui/a11y/focus.rs create mode 100644 linux/crates/app/src/ui/a11y/icon_button.rs create mode 100644 linux/crates/app/src/ui/a11y/invalid.rs create mode 100644 linux/crates/app/src/ui/a11y/key_shortcuts.rs create mode 100644 linux/crates/app/src/ui/a11y/mod.rs create mode 100644 linux/crates/app/src/ui/a11y/tests.rs create mode 100644 linux/crates/app/src/ui/browse_tab/browse_actions.rs create mode 100644 linux/crates/app/src/ui/browse_tab/browse_command.rs create mode 100644 linux/crates/app/src/ui/browse_tab/browse_pager.rs create mode 100644 linux/crates/app/src/ui/browse_tab/browse_request_kind.rs create mode 100644 linux/crates/app/src/ui/browse_tab/browse_tab_input.rs create mode 100644 linux/crates/app/src/ui/browse_tab/browse_tab_widgets.rs create mode 100644 linux/crates/app/src/ui/browse_tab/clipboard_handlers.rs create mode 100644 linux/crates/app/src/ui/browse_tab/clipboard_input.rs create mode 100644 linux/crates/app/src/ui/browse_tab/column_recheck.rs create mode 100644 linux/crates/app/src/ui/browse_tab/columns_fetch.rs create mode 100644 linux/crates/app/src/ui/browse_tab/command_handlers.rs create mode 100644 linux/crates/app/src/ui/browse_tab/confirming_delete.rs create mode 100644 linux/crates/app/src/ui/browse_tab/count_failure_text.rs create mode 100644 linux/crates/app/src/ui/browse_tab/count_policy.rs create mode 100644 linux/crates/app/src/ui/browse_tab/data_handlers.rs create mode 100644 linux/crates/app/src/ui/browse_tab/data_input.rs create mode 100644 linux/crates/app/src/ui/browse_tab/edit_handlers.rs create mode 100644 linux/crates/app/src/ui/browse_tab/edit_input.rs create mode 100644 linux/crates/app/src/ui/browse_tab/fetch.rs create mode 100644 linux/crates/app/src/ui/browse_tab/imp.rs create mode 100644 linux/crates/app/src/ui/browse_tab/layout.rs create mode 100644 linux/crates/app/src/ui/browse_tab/mod.rs create mode 100644 linux/crates/app/src/ui/browse_tab/page_label.rs create mode 100644 linux/crates/app/src/ui/browse_tab/page_reply.rs create mode 100644 linux/crates/app/src/ui/browse_tab/page_state.rs create mode 100644 linux/crates/app/src/ui/browse_tab/pager_bar.rs create mode 100644 linux/crates/app/src/ui/browse_tab/paging_handlers.rs create mode 100644 linux/crates/app/src/ui/browse_tab/paging_input.rs create mode 100644 linux/crates/app/src/ui/browse_tab/pending_changes_bar.rs create mode 100644 linux/crates/app/src/ui/browse_tab/row_count.rs create mode 100644 linux/crates/app/src/ui/browse_tab/row_deletion.rs create mode 100644 linux/crates/app/src/ui/browse_tab/row_total.rs create mode 100644 linux/crates/app/src/ui/browse_tab/save_handlers.rs create mode 100644 linux/crates/app/src/ui/browse_tab/save_input.rs create mode 100644 linux/crates/app/src/ui/browse_tab/selection_handlers.rs create mode 100644 linux/crates/app/src/ui/browse_tab/selection_input.rs create mode 100644 linux/crates/app/src/ui/browse_tab/selection_snapshot.rs create mode 100644 linux/crates/app/src/ui/browse_tab/tests/load.rs create mode 100644 linux/crates/app/src/ui/browse_tab/tests/mod.rs create mode 100644 linux/crates/app/src/ui/browse_tab/tests/total.rs create mode 100644 linux/crates/app/src/ui/clipboard/clipboard_copy.rs create mode 100644 linux/crates/app/src/ui/clipboard/clipboard_format.rs create mode 100644 linux/crates/app/src/ui/clipboard/clipboard_payload.rs create mode 100644 linux/crates/app/src/ui/clipboard/clipboard_writer.rs create mode 100644 linux/crates/app/src/ui/clipboard/copy_renderer.rs create mode 100644 linux/crates/app/src/ui/clipboard/mod.rs create mode 100644 linux/crates/app/src/ui/clipboard/tests.rs create mode 100644 linux/crates/app/src/ui/combo_value.rs create mode 100644 linux/crates/app/src/ui/connect_dialog/connect_dialog_mode.rs create mode 100644 linux/crates/app/src/ui/connect_dialog/connect_form.rs create mode 100644 linux/crates/app/src/ui/connect_dialog/connect_submission.rs create mode 100644 linux/crates/app/src/ui/connect_dialog/form_errors.rs create mode 100644 linux/crates/app/src/ui/connect_dialog/form_field.rs create mode 100644 linux/crates/app/src/ui/connect_dialog/imp.rs create mode 100644 linux/crates/app/src/ui/connect_dialog/mod.rs create mode 100644 linux/crates/app/src/ui/connect_dialog/pem_validation.rs create mode 100644 linux/crates/app/src/ui/connect_dialog/read_only_row.rs create mode 100644 linux/crates/app/src/ui/connect_dialog/row_problem.rs create mode 100644 linux/crates/app/src/ui/connect_dialog/sqlite_file_row.rs create mode 100644 linux/crates/app/src/ui/connect_dialog/tests.rs create mode 100644 linux/crates/app/src/ui/connect_dialog/tls_section.rs create mode 100644 linux/crates/app/src/ui/connection_list_banner.rs create mode 100644 linux/crates/app/src/ui/credential_prompt/mod.rs create mode 100644 linux/crates/app/src/ui/credential_prompt/presenter.rs create mode 100644 linux/crates/app/src/ui/credential_prompt/prompt_form.rs create mode 100644 linux/crates/app/src/ui/credential_prompt/prompt_text.rs create mode 100644 linux/crates/app/src/ui/credential_prompt/prompter.rs create mode 100644 linux/crates/app/src/ui/delete_connection_dialog.rs create mode 100644 linux/crates/app/src/ui/discard_alert.rs create mode 100644 linux/crates/app/src/ui/editor/buffer_edit.rs create mode 100644 linux/crates/app/src/ui/editor/catalog_change.rs create mode 100644 linux/crates/app/src/ui/editor/completion/catalog_read.rs create mode 100644 linux/crates/app/src/ui/editor/completion/completion_scope.rs create mode 100644 linux/crates/app/src/ui/editor/completion/fuzzy.rs create mode 100644 linux/crates/app/src/ui/editor/completion/mod.rs create mode 100644 linux/crates/app/src/ui/editor/completion/proposal_kind.rs create mode 100644 linux/crates/app/src/ui/editor/completion/sql_completion_provider.rs create mode 100644 linux/crates/app/src/ui/editor/completion/sql_completion_provider_imp.rs create mode 100644 linux/crates/app/src/ui/editor/completion/sql_proposal.rs create mode 100644 linux/crates/app/src/ui/editor/completion/sql_proposal_imp.rs create mode 100644 linux/crates/app/src/ui/editor/completion/tests.rs create mode 100644 linux/crates/app/src/ui/editor/editor_actions.rs create mode 100644 linux/crates/app/src/ui/editor/editor_file.rs create mode 100644 linux/crates/app/src/ui/editor/editor_session_slot.rs create mode 100644 linux/crates/app/src/ui/editor/find_bar.rs create mode 100644 linux/crates/app/src/ui/editor/find_state.rs create mode 100644 linux/crates/app/src/ui/editor/format_plan.rs create mode 100644 linux/crates/app/src/ui/editor/imp.rs create mode 100644 linux/crates/app/src/ui/editor/mod.rs create mode 100644 linux/crates/app/src/ui/editor/result_item.rs create mode 100644 linux/crates/app/src/ui/editor/result_widgets.rs create mode 100644 linux/crates/app/src/ui/editor/results_pane.rs create mode 100644 linux/crates/app/src/ui/editor/run_history.rs create mode 100644 linux/crates/app/src/ui/editor/run_log.rs create mode 100644 linux/crates/app/src/ui/editor/run_scope.rs create mode 100644 linux/crates/app/src/ui/editor/script_runner.rs create mode 100644 linux/crates/app/src/ui/editor/sidebar_policy.rs create mode 100644 linux/crates/app/src/ui/editor/significant_tokens.rs create mode 100644 linux/crates/app/src/ui/editor/sql_file.rs create mode 100644 linux/crates/app/src/ui/editor/statement_cursor.rs create mode 100644 linux/crates/app/src/ui/editor/tests.rs create mode 100644 linux/crates/app/src/ui/editor/transaction_dirty.rs create mode 100644 linux/crates/app/src/ui/editor_font_provider.rs create mode 100644 linux/crates/app/src/ui/error_text/ddl.rs create mode 100644 linux/crates/app/src/ui/error_text/driver.rs create mode 100644 linux/crates/app/src/ui/error_text/edit.rs create mode 100644 linux/crates/app/src/ui/error_text/mod.rs create mode 100644 linux/crates/app/src/ui/error_text/operation.rs create mode 100644 linux/crates/app/src/ui/error_text/saved_connection.rs create mode 100644 linux/crates/app/src/ui/error_text/script.rs create mode 100644 linux/crates/app/src/ui/error_text/secret.rs create mode 100644 linux/crates/app/src/ui/error_text/ssh.rs create mode 100644 linux/crates/app/src/ui/error_text/tests.rs create mode 100644 linux/crates/app/src/ui/error_text/tls.rs create mode 100644 linux/crates/app/src/ui/error_text/tls_remedy.rs create mode 100644 linux/crates/app/src/ui/export/dialog.rs create mode 100644 linux/crates/app/src/ui/export/document_sink.rs create mode 100644 linux/crates/app/src/ui/export/encoding_sink.rs create mode 100644 linux/crates/app/src/ui/export/export_failure_text.rs create mode 100644 linux/crates/app/src/ui/export/export_format.rs create mode 100644 linux/crates/app/src/ui/export/export_job.rs create mode 100644 linux/crates/app/src/ui/export/export_job_item.rs create mode 100644 linux/crates/app/src/ui/export/export_ledger/mod.rs create mode 100644 linux/crates/app/src/ui/export/export_ledger/tests.rs create mode 100644 linux/crates/app/src/ui/export/export_message.rs create mode 100644 linux/crates/app/src/ui/export/export_operations_button.rs create mode 100644 linux/crates/app/src/ui/export/export_outcome.rs create mode 100644 linux/crates/app/src/ui/export/export_producer.rs create mode 100644 linux/crates/app/src/ui/export/export_query.rs create mode 100644 linux/crates/app/src/ui/export/export_scope.rs create mode 100644 linux/crates/app/src/ui/export/export_sink_parts.rs create mode 100644 linux/crates/app/src/ui/export/export_source.rs create mode 100644 linux/crates/app/src/ui/export/mod.rs create mode 100644 linux/crates/app/src/ui/export/replace_file_writer/mod.rs create mode 100644 linux/crates/app/src/ui/export/replace_file_writer/tests.rs create mode 100644 linux/crates/app/src/ui/file_read.rs create mode 100644 linux/crates/app/src/ui/file_reveal/file_launcher_revealer.rs create mode 100644 linux/crates/app/src/ui/file_reveal/file_revealer.rs create mode 100644 linux/crates/app/src/ui/file_reveal/mod.rs create mode 100644 linux/crates/app/src/ui/file_reveal/recording_revealer.rs create mode 100644 linux/crates/app/src/ui/file_reveal/reveal_failure_toast.rs create mode 100644 linux/crates/app/src/ui/file_write.rs create mode 100644 linux/crates/app/src/ui/filter_strip/filter_rule_object.rs create mode 100644 linux/crates/app/src/ui/filter_strip/imp.rs create mode 100644 linux/crates/app/src/ui/filter_strip/mod.rs create mode 100644 linux/crates/app/src/ui/filter_strip/rule_operators.rs create mode 100644 linux/crates/app/src/ui/filter_strip/rule_row.rs create mode 100644 linux/crates/app/src/ui/filter_strip/rule_value_editor.rs create mode 100644 linux/crates/app/src/ui/filter_strip/tests.rs create mode 100644 linux/crates/app/src/ui/format/mod.rs create mode 100644 linux/crates/app/src/ui/format/thousands.rs create mode 100644 linux/crates/app/src/ui/grid/calendar_editor.rs create mode 100644 linux/crates/app/src/ui/grid/cell_accessibility.rs create mode 100644 linux/crates/app/src/ui/grid/cell_binding.rs create mode 100644 linux/crates/app/src/ui/grid/cell_edit.rs create mode 100644 linux/crates/app/src/ui/grid/cell_editability.rs create mode 100644 linux/crates/app/src/ui/grid/cell_editor_kind.rs create mode 100644 linux/crates/app/src/ui/grid/cell_focus.rs create mode 100644 linux/crates/app/src/ui/grid/cell_lock.rs create mode 100644 linux/crates/app/src/ui/grid/cell_preset.rs create mode 100644 linux/crates/app/src/ui/grid/cell_tooltip.rs create mode 100644 linux/crates/app/src/ui/grid/column_drawing.rs create mode 100644 linux/crates/app/src/ui/grid/column_factory.rs create mode 100644 linux/crates/app/src/ui/grid/column_figures.rs create mode 100644 linux/crates/app/src/ui/grid/column_width_binding.rs create mode 100644 linux/crates/app/src/ui/grid/context_menu.rs create mode 100644 linux/crates/app/src/ui/grid/delete_availability.rs create mode 100644 linux/crates/app/src/ui/grid/display_text.rs create mode 100644 linux/crates/app/src/ui/grid/edit_commit.rs create mode 100644 linux/crates/app/src/ui/grid/edit_navigation.rs create mode 100644 linux/crates/app/src/ui/grid/edit_snapshot.rs create mode 100644 linux/crates/app/src/ui/grid/grid_attributes.rs create mode 100644 linux/crates/app/src/ui/grid/grid_cell.rs create mode 100644 linux/crates/app/src/ui/grid/grid_columns.rs create mode 100644 linux/crates/app/src/ui/grid/grid_content.rs create mode 100644 linux/crates/app/src/ui/grid/grid_keyboard.rs create mode 100644 linux/crates/app/src/ui/grid/grid_menus.rs create mode 100644 linux/crates/app/src/ui/grid/grid_msg.rs create mode 100644 linux/crates/app/src/ui/grid/grid_row_id.rs create mode 100644 linux/crates/app/src/ui/grid/grid_row_index.rs create mode 100644 linux/crates/app/src/ui/grid/inline_editor.rs create mode 100644 linux/crates/app/src/ui/grid/menu_rows.rs create mode 100644 linux/crates/app/src/ui/grid/mod.rs create mode 100644 linux/crates/app/src/ui/grid/multiline_editor.rs create mode 100644 linux/crates/app/src/ui/grid/result_grid.rs create mode 100644 linux/crates/app/src/ui/grid/result_sorters.rs create mode 100644 linux/crates/app/src/ui/grid/row_factory.rs create mode 100644 linux/crates/app/src/ui/grid/row_json_dialog.rs create mode 100644 linux/crates/app/src/ui/grid/row_key_builder.rs create mode 100644 linux/crates/app/src/ui/grid/row_object.rs create mode 100644 linux/crates/app/src/ui/grid/row_snapshot.rs create mode 100644 linux/crates/app/src/ui/grid/row_state_column.rs create mode 100644 linux/crates/app/src/ui/grid/row_state_icon.rs create mode 100644 linux/crates/app/src/ui/grid/row_watches.rs create mode 100644 linux/crates/app/src/ui/grid/selection.rs create mode 100644 linux/crates/app/src/ui/grid/tab_grid_context.rs create mode 100644 linux/crates/app/src/ui/grid/test_grid.rs create mode 100644 linux/crates/app/src/ui/grid/toggle_cell.rs create mode 100644 linux/crates/app/src/ui/grid/value_order.rs create mode 100644 linux/crates/app/src/ui/handler_guard.rs create mode 100644 linux/crates/app/src/ui/history_dialog/connection_filter.rs create mode 100644 linux/crates/app/src/ui/history_dialog/history_actions.rs create mode 100644 linux/crates/app/src/ui/history_dialog/history_entry_object.rs create mode 100644 linux/crates/app/src/ui/history_dialog/history_entry_object_imp.rs create mode 100644 linux/crates/app/src/ui/history_dialog/history_export.rs create mode 100644 linux/crates/app/src/ui/history_dialog/history_filter_state.rs create mode 100644 linux/crates/app/src/ui/history_dialog/history_filters.rs create mode 100644 linux/crates/app/src/ui/history_dialog/history_formatting.rs create mode 100644 linux/crates/app/src/ui/history_dialog/history_paging.rs create mode 100644 linux/crates/app/src/ui/history_dialog/history_row.rs create mode 100644 linux/crates/app/src/ui/history_dialog/imp.rs create mode 100644 linux/crates/app/src/ui/history_dialog/mod.rs create mode 100644 linux/crates/app/src/ui/history_dialog/tests.rs create mode 100644 linux/crates/app/src/ui/host_key_failure.rs create mode 100644 linux/crates/app/src/ui/host_key_review.rs create mode 100644 linux/crates/app/src/ui/icons.rs create mode 100644 linux/crates/app/src/ui/import_dialog.rs create mode 100644 linux/crates/app/src/ui/mod.rs create mode 100644 linux/crates/app/src/ui/name_entry.rs create mode 100644 linux/crates/app/src/ui/picker/connection_menu.rs create mode 100644 linux/crates/app/src/ui/picker/connection_row.rs create mode 100644 linux/crates/app/src/ui/picker/mod.rs create mode 100644 linux/crates/app/src/ui/picker/new_group_dialog.rs create mode 100644 linux/crates/app/src/ui/picker/picker_actions.rs create mode 100644 linux/crates/app/src/ui/picker/tests.rs create mode 100644 linux/crates/app/src/ui/plain_toast.rs create mode 100644 linux/crates/app/src/ui/preferences_dialog/data_browser_group.rs create mode 100644 linux/crates/app/src/ui/preferences_dialog/editor_page.rs create mode 100644 linux/crates/app/src/ui/preferences_dialog/general_page.rs create mode 100644 linux/crates/app/src/ui/preferences_dialog/history_group.rs create mode 100644 linux/crates/app/src/ui/preferences_dialog/imp.rs create mode 100644 linux/crates/app/src/ui/preferences_dialog/mod.rs create mode 100644 linux/crates/app/src/ui/result_pane/mod.rs create mode 100644 linux/crates/app/src/ui/result_pane/result_limit_banner.rs create mode 100644 linux/crates/app/src/ui/result_pane/result_set_view.rs create mode 100644 linux/crates/app/src/ui/result_pane/result_summary.rs create mode 100644 linux/crates/app/src/ui/saved_queries_dialog.rs create mode 100644 linux/crates/app/src/ui/session_window/application_notification_sink.rs create mode 100644 linux/crates/app/src/ui/session_window/connect_failed_page.rs create mode 100644 linux/crates/app/src/ui/session_window/connect_failure_view.rs create mode 100644 linux/crates/app/src/ui/session_window/connect_flow.rs create mode 100644 linux/crates/app/src/ui/session_window/connecting_page.rs create mode 100644 linux/crates/app/src/ui/session_window/connections.rs create mode 100644 linux/crates/app/src/ui/session_window/crash_notice.rs create mode 100644 linux/crates/app/src/ui/session_window/imp.rs create mode 100644 linux/crates/app/src/ui/session_window/locate_file.rs create mode 100644 linux/crates/app/src/ui/session_window/mod.rs create mode 100644 linux/crates/app/src/ui/session_window/notification_request.rs create mode 100644 linux/crates/app/src/ui/session_window/notification_sink.rs create mode 100644 linux/crates/app/src/ui/session_window/notifications.rs create mode 100644 linux/crates/app/src/ui/session_window/picker_page.rs create mode 100644 linux/crates/app/src/ui/session_window/resolve_file_identity.rs create mode 100644 linux/crates/app/src/ui/session_window/secret_retry.rs create mode 100644 linux/crates/app/src/ui/session_window/session_page.rs create mode 100644 linux/crates/app/src/ui/session_window/session_restore.rs create mode 100644 linux/crates/app/src/ui/session_window/session_teardown.rs create mode 100644 linux/crates/app/src/ui/session_window/session_window_command.rs create mode 100644 linux/crates/app/src/ui/session_window/session_window_input.rs create mode 100644 linux/crates/app/src/ui/session_window/sqlite_location_dialog.rs create mode 100644 linux/crates/app/src/ui/session_window/tests/mod.rs create mode 100644 linux/crates/app/src/ui/session_window/tests/notification.rs create mode 100644 linux/crates/app/src/ui/session_window/tests/open_file.rs create mode 100644 linux/crates/app/src/ui/session_window/window_actions.rs create mode 100644 linux/crates/app/src/ui/session_window/work_notifier.rs create mode 100644 linux/crates/app/src/ui/shell/mod.rs create mode 100644 linux/crates/app/src/ui/shell/primary_menu_button.rs create mode 100644 linux/crates/app/src/ui/ssh_inputs.rs create mode 100644 linux/crates/app/src/ui/ssh_section.rs create mode 100644 linux/crates/app/src/ui/structure_tab/column_handlers.rs create mode 100644 linux/crates/app/src/ui/structure_tab/columns.rs create mode 100644 linux/crates/app/src/ui/structure_tab/columns_input.rs create mode 100644 linux/crates/app/src/ui/structure_tab/context.rs create mode 100644 linux/crates/app/src/ui/structure_tab/draft_column_object.rs create mode 100644 linux/crates/app/src/ui/structure_tab/draft_column_object_imp.rs create mode 100644 linux/crates/app/src/ui/structure_tab/fks.rs create mode 100644 linux/crates/app/src/ui/structure_tab/foreign_key_dialog.rs create mode 100644 linux/crates/app/src/ui/structure_tab/foreign_key_draft.rs create mode 100644 linux/crates/app/src/ui/structure_tab/foreign_key_handlers.rs create mode 100644 linux/crates/app/src/ui/structure_tab/foreign_key_object.rs create mode 100644 linux/crates/app/src/ui/structure_tab/foreign_keys_input.rs create mode 100644 linux/crates/app/src/ui/structure_tab/form_dialog.rs create mode 100644 linux/crates/app/src/ui/structure_tab/imp.rs create mode 100644 linux/crates/app/src/ui/structure_tab/index_dialog.rs create mode 100644 linux/crates/app/src/ui/structure_tab/index_draft.rs create mode 100644 linux/crates/app/src/ui/structure_tab/index_handlers.rs create mode 100644 linux/crates/app/src/ui/structure_tab/index_object.rs create mode 100644 linux/crates/app/src/ui/structure_tab/indexes.rs create mode 100644 linux/crates/app/src/ui/structure_tab/indexes_input.rs create mode 100644 linux/crates/app/src/ui/structure_tab/layout.rs create mode 100644 linux/crates/app/src/ui/structure_tab/load_handlers.rs create mode 100644 linux/crates/app/src/ui/structure_tab/load_input.rs create mode 100644 linux/crates/app/src/ui/structure_tab/mod.rs create mode 100644 linux/crates/app/src/ui/structure_tab/referential_action_label.rs create mode 100644 linux/crates/app/src/ui/structure_tab/review_dialog.rs create mode 100644 linux/crates/app/src/ui/structure_tab/save_gate.rs create mode 100644 linux/crates/app/src/ui/structure_tab/save_handlers.rs create mode 100644 linux/crates/app/src/ui/structure_tab/save_input.rs create mode 100644 linux/crates/app/src/ui/structure_tab/sql_preview.rs create mode 100644 linux/crates/app/src/ui/structure_tab/structure_actions.rs create mode 100644 linux/crates/app/src/ui/structure_tab/structure_command.rs create mode 100644 linux/crates/app/src/ui/structure_tab/structure_fetch.rs create mode 100644 linux/crates/app/src/ui/structure_tab/structure_tab_input.rs create mode 100644 linux/crates/app/src/ui/structure_tab/structure_tab_widgets.rs create mode 100644 linux/crates/app/src/ui/structure_tab/table_choice_object.rs create mode 100644 linux/crates/app/src/ui/structure_tab/table_handlers.rs create mode 100644 linux/crates/app/src/ui/structure_tab/table_input.rs create mode 100644 linux/crates/app/src/ui/structure_tab/tests.rs create mode 100644 linux/crates/app/src/ui/style_scheme_grid.rs create mode 100644 linux/crates/app/src/ui/style_scheme_resolver.rs create mode 100644 linux/crates/app/src/ui/tab_load/content_load.rs create mode 100644 linux/crates/app/src/ui/tab_load/first_load.rs create mode 100644 linux/crates/app/src/ui/tab_load/load_failure.rs create mode 100644 linux/crates/app/src/ui/tab_load/load_presentation.rs create mode 100644 linux/crates/app/src/ui/tab_load/mod.rs create mode 100644 linux/crates/app/src/ui/tab_load/stale_banner.rs create mode 100644 linux/crates/app/src/ui/welcome_view.rs create mode 100644 linux/crates/app/src/ui/window_geometry.rs create mode 100644 linux/crates/app/src/ui/workspace/close_flow.rs create mode 100644 linux/crates/app/src/ui/workspace/editor_label.rs create mode 100644 linux/crates/app/src/ui/workspace/finished_work.rs create mode 100644 linux/crates/app/src/ui/workspace/health_banner.rs create mode 100644 linux/crates/app/src/ui/workspace/imp.rs create mode 100644 linux/crates/app/src/ui/workspace/import.rs create mode 100644 linux/crates/app/src/ui/workspace/import_ledger.rs create mode 100644 linux/crates/app/src/ui/workspace/mod.rs create mode 100644 linux/crates/app/src/ui/workspace/row_ops.rs create mode 100644 linux/crates/app/src/ui/workspace/saves.rs create mode 100644 linux/crates/app/src/ui/workspace/session_watches.rs create mode 100644 linux/crates/app/src/ui/workspace/shell.rs create mode 100644 linux/crates/app/src/ui/workspace/sidebar/catalog_watch.rs create mode 100644 linux/crates/app/src/ui/workspace/sidebar/mod.rs create mode 100644 linux/crates/app/src/ui/workspace/sidebar/object_key.rs create mode 100644 linux/crates/app/src/ui/workspace/sidebar/sidebar_affordances.rs create mode 100644 linux/crates/app/src/ui/workspace/sidebar/sidebar_event.rs create mode 100644 linux/crates/app/src/ui/workspace/sidebar/sidebar_header_factory.rs create mode 100644 linux/crates/app/src/ui/workspace/sidebar/sidebar_menu.rs create mode 100644 linux/crates/app/src/ui/workspace/sidebar/sidebar_model.rs create mode 100644 linux/crates/app/src/ui/workspace/sidebar/sidebar_object.rs create mode 100644 linux/crates/app/src/ui/workspace/sidebar/sidebar_object_imp.rs create mode 100644 linux/crates/app/src/ui/workspace/sidebar/sidebar_row_factory.rs create mode 100644 linux/crates/app/src/ui/workspace/sidebar/sidebar_view.rs create mode 100644 linux/crates/app/src/ui/workspace/sidebar/tests.rs create mode 100644 linux/crates/app/src/ui/workspace/status_pages.rs create mode 100644 linux/crates/app/src/ui/workspace/structure.rs create mode 100644 linux/crates/app/src/ui/workspace/structure_target.rs create mode 100644 linux/crates/app/src/ui/workspace/tab_factory.rs create mode 100644 linux/crates/app/src/ui/workspace/tab_identity.rs create mode 100644 linux/crates/app/src/ui/workspace/tab_index.rs create mode 100644 linux/crates/app/src/ui/workspace/tab_spec.rs create mode 100644 linux/crates/app/src/ui/workspace/tab_title.rs create mode 100644 linux/crates/app/src/ui/workspace/table_naming.rs create mode 100644 linux/crates/app/src/ui/workspace/tests/close_tab.rs create mode 100644 linux/crates/app/src/ui/workspace/tests/mod.rs create mode 100644 linux/crates/app/src/ui/workspace/tests/tab_title.rs create mode 100644 linux/crates/app/src/ui/workspace/unsaved_icon.rs create mode 100644 linux/crates/app/src/ui/workspace/window_tabs.rs create mode 100644 linux/crates/app/src/ui/workspace/window_title.rs create mode 100644 linux/crates/app/src/ui/workspace/workspace_actions.rs create mode 100644 linux/crates/app/src/ui/workspace/workspace_persistence.rs create mode 100644 linux/crates/app/src/ui/workspace/workspace_tab_view.rs create mode 100644 linux/crates/app/src/ui/workspace/workspace_tabs.rs create mode 100644 linux/crates/app/src/workspace/close_blocker.rs create mode 100644 linux/crates/app/src/workspace/close_blocker_dialog.rs create mode 100644 linux/crates/app/src/workspace/close_decision.rs create mode 100644 linux/crates/app/src/workspace/close_guard.rs create mode 100644 linux/crates/app/src/workspace/close_guards.rs create mode 100644 linux/crates/app/src/workspace/close_reason.rs create mode 100644 linux/crates/app/src/workspace/close_scope.rs create mode 100644 linux/crates/app/src/workspace/close_step.rs create mode 100644 linux/crates/app/src/workspace/completed_work.rs create mode 100644 linux/crates/app/src/workspace/dialog_response.rs create mode 100644 linux/crates/app/src/workspace/dirty_kind.rs create mode 100644 linux/crates/app/src/workspace/dirty_tab.rs create mode 100644 linux/crates/app/src/workspace/draft_writer.rs create mode 100644 linux/crates/app/src/workspace/editor_file_record.rs create mode 100644 linux/crates/app/src/workspace/engine_profile.rs create mode 100644 linux/crates/app/src/workspace/inhibit_reason.rs create mode 100644 linux/crates/app/src/workspace/legacy_tab_file.rs create mode 100644 linux/crates/app/src/workspace/mod.rs create mode 100644 linux/crates/app/src/workspace/open_sessions.rs create mode 100644 linux/crates/app/src/workspace/open_sessions_load.rs create mode 100644 linux/crates/app/src/workspace/open_sessions_record.rs create mode 100644 linux/crates/app/src/workspace/open_transaction_dialog.rs create mode 100644 linux/crates/app/src/workspace/pending_state.rs create mode 100644 linux/crates/app/src/workspace/persist_debouncer.rs create mode 100644 linux/crates/app/src/workspace/ran_statements.rs create mode 100644 linux/crates/app/src/workspace/restored_drafts.rs create mode 100644 linux/crates/app/src/workspace/rollback_cause.rs create mode 100644 linux/crates/app/src/workspace/save_changes_dialog.rs create mode 100644 linux/crates/app/src/workspace/save_id.rs create mode 100644 linux/crates/app/src/workspace/save_ledger.rs create mode 100644 linux/crates/app/src/workspace/save_outcome.rs create mode 100644 linux/crates/app/src/workspace/save_rejection.rs create mode 100644 linux/crates/app/src/workspace/save_settled.rs create mode 100644 linux/crates/app/src/workspace/save_snapshot.rs create mode 100644 linux/crates/app/src/workspace/save_start.rs create mode 100644 linux/crates/app/src/workspace/save_ticket.rs create mode 100644 linux/crates/app/src/workspace/schema_write.rs create mode 100644 linux/crates/app/src/workspace/session_key_record.rs create mode 100644 linux/crates/app/src/workspace/sort_record.rs create mode 100644 linux/crates/app/src/workspace/tab_directory.rs create mode 100644 linux/crates/app/src/workspace/tab_entry.rs create mode 100644 linux/crates/app/src/workspace/tab_record.rs create mode 100644 linux/crates/app/src/workspace/tab_snapshot.rs create mode 100644 linux/crates/app/src/workspace/test_workspace.rs create mode 100644 linux/crates/app/src/workspace/tests/mod.rs create mode 100644 linux/crates/app/src/workspace/tests/structure_review.rs create mode 100644 linux/crates/app/src/workspace/unknown_cause.rs create mode 100644 linux/crates/app/src/workspace/unknown_outcome_dialog.rs create mode 100644 linux/crates/app/src/workspace/waiting_dialog.rs create mode 100644 linux/crates/app/src/workspace/window_inhibitor.rs create mode 100644 linux/crates/app/src/workspace/work_kind.rs create mode 100644 linux/crates/app/src/workspace/work_outcome.rs create mode 100644 linux/crates/app/src/workspace/workspace_event.rs create mode 100644 linux/crates/app/src/workspace/workspace_file_name.rs create mode 100644 linux/crates/app/src/workspace/workspace_handle.rs create mode 100644 linux/crates/app/src/workspace/workspace_load.rs create mode 100644 linux/crates/app/src/workspace/workspace_load_error.rs create mode 100644 linux/crates/app/src/workspace/workspace_record.rs create mode 100644 linux/crates/app/src/workspace/workspace_record_builder.rs create mode 100644 linux/crates/app/src/workspace/workspace_save_error.rs create mode 100644 linux/crates/app/src/workspace/workspace_store.rs create mode 100644 linux/crates/app/src/workspace/write_job.rs create mode 100644 linux/crates/app/src/workspace/writes_in_flight.rs create mode 100644 linux/crates/app/tests/accessibility/audit.rs create mode 100644 linux/crates/app/tests/accessibility/main.rs create mode 100644 linux/crates/app/tests/accessibility/world.rs create mode 100644 linux/crates/app/tests/application_lifecycle.rs create mode 100644 linux/crates/app/tests/application_session_restore.rs create mode 100644 linux/crates/app/tests/application_uniqueness.rs create mode 100644 linux/crates/app/tests/crash_report.rs create mode 100644 linux/crates/app/tests/desktop_entry.rs create mode 100644 linux/crates/app/tests/fixtures/i18n/en_US.po create mode 100644 linux/crates/app/tests/fixtures/workspace-v1.json create mode 100644 linux/crates/app/tests/i18n_catalog.rs create mode 100644 linux/crates/app/tests/logging.rs create mode 100644 linux/crates/app/tests/open_files.rs create mode 100644 linux/crates/app/tests/secret_save_flow.rs create mode 100644 linux/crates/app/tests/startup.rs create mode 100644 linux/crates/app/tests/support/settings_schema.rs create mode 100644 linux/crates/app/tests/tracker_save_postgres.rs create mode 100644 linux/crates/core/Cargo.toml create mode 100644 linux/crates/core/src/auth_mode.rs create mode 100644 linux/crates/core/src/browse/browse_page.rs create mode 100644 linux/crates/core/src/browse/browse_sql_error.rs create mode 100644 linux/crates/core/src/browse/build_count.rs create mode 100644 linux/crates/core/src/browse/build_export.rs create mode 100644 linux/crates/core/src/browse/build_page.rs create mode 100644 linux/crates/core/src/browse/export_request.rs create mode 100644 linux/crates/core/src/browse/mod.rs create mode 100644 linux/crates/core/src/browse/page_layout.rs create mode 100644 linux/crates/core/src/browse/page_request.rs create mode 100644 linux/crates/core/src/browse/page_spec.rs create mode 100644 linux/crates/core/src/browse/paging_mode.rs create mode 100644 linux/crates/core/src/browse/read_page.rs create mode 100644 linux/crates/core/src/browse/row_key_layout.rs create mode 100644 linux/crates/core/src/browse/select_parts.rs create mode 100644 linux/crates/core/src/browse/sort_key.rs create mode 100644 linux/crates/core/src/browse/split_page.rs create mode 100644 linux/crates/core/src/browse/tests.rs create mode 100644 linux/crates/core/src/call_options.rs create mode 100644 linux/crates/core/src/capabilities.rs create mode 100644 linux/crates/core/src/certificate_file_problem.rs create mode 100644 linux/crates/core/src/client_identity.rs create mode 100644 linux/crates/core/src/column/catalog_type.rs create mode 100644 linux/crates/core/src/column/classify.rs create mode 100644 linux/crates/core/src/column/column_info.rs create mode 100644 linux/crates/core/src/column/column_type.rs create mode 100644 linux/crates/core/src/column/default.rs create mode 100644 linux/crates/core/src/column/integer_kind.rs create mode 100644 linux/crates/core/src/column/kind.rs create mode 100644 linux/crates/core/src/column/kind_parts.rs create mode 100644 linux/crates/core/src/column/mod.rs create mode 100644 linux/crates/core/src/column/read_form.rs create mode 100644 linux/crates/core/src/column/result_column.rs create mode 100644 linux/crates/core/src/column/sql_type_expr.rs create mode 100644 linux/crates/core/src/commit.rs create mode 100644 linux/crates/core/src/config_error.rs create mode 100644 linux/crates/core/src/connect_environment.rs create mode 100644 linux/crates/core/src/connect_options.rs create mode 100644 linux/crates/core/src/connect_target.rs create mode 100644 linux/crates/core/src/connection.rs create mode 100644 linux/crates/core/src/credentials/connect_credentials.rs create mode 100644 linux/crates/core/src/credentials/credential_interaction.rs create mode 100644 linux/crates/core/src/credentials/credential_prompt.rs create mode 100644 linux/crates/core/src/credentials/credential_prompter.rs create mode 100644 linux/crates/core/src/credentials/mod.rs create mode 100644 linux/crates/core/src/credentials/prompt_field.rs create mode 100644 linux/crates/core/src/credentials/prompt_purpose.rs create mode 100644 linux/crates/core/src/credentials/prompt_reason.rs create mode 100644 linux/crates/core/src/credentials/prompt_reply.rs create mode 100644 linux/crates/core/src/credentials/secret_error.rs create mode 100644 linux/crates/core/src/credentials/secret_kind.rs create mode 100644 linux/crates/core/src/credentials/secret_lookup.rs create mode 100644 linux/crates/core/src/credentials/secret_save.rs create mode 100644 linux/crates/core/src/credentials/secret_vault.rs create mode 100644 linux/crates/core/src/ddl/build_ddl_error.rs create mode 100644 linux/crates/core/src/ddl/change_review.rs create mode 100644 linux/crates/core/src/ddl/changed_attributes.rs create mode 100644 linux/crates/core/src/ddl/collation_name.rs create mode 100644 linux/crates/core/src/ddl/column_alteration.rs create mode 100644 linux/crates/core/src/ddl/column_attributes.rs create mode 100644 linux/crates/core/src/ddl/column_capabilities.rs create mode 100644 linux/crates/core/src/ddl/column_definition.rs create mode 100644 linux/crates/core/src/ddl/column_key_roles.rs create mode 100644 linux/crates/core/src/ddl/column_rename.rs create mode 100644 linux/crates/core/src/ddl/column_storage.rs create mode 100644 linux/crates/core/src/ddl/column_visibility.rs create mode 100644 linux/crates/core/src/ddl/ddl_capabilities.rs create mode 100644 linux/crates/core/src/ddl/ddl_dialect.rs create mode 100644 linux/crates/core/src/ddl/ddl_operation.rs create mode 100644 linux/crates/core/src/ddl/ddl_plan.rs create mode 100644 linux/crates/core/src/ddl/ddl_precondition.rs create mode 100644 linux/crates/core/src/ddl/ddl_sql.rs create mode 100644 linux/crates/core/src/ddl/default_text.rs create mode 100644 linux/crates/core/src/ddl/destructive_change.rs create mode 100644 linux/crates/core/src/ddl/diff_table.rs create mode 100644 linux/crates/core/src/ddl/draft_column.rs create mode 100644 linux/crates/core/src/ddl/draft_problem.rs create mode 100644 linux/crates/core/src/ddl/draft_type.rs create mode 100644 linux/crates/core/src/ddl/drop_table_options.rs create mode 100644 linux/crates/core/src/ddl/generated_column.rs create mode 100644 linux/crates/core/src/ddl/identity_style.rs create mode 100644 linux/crates/core/src/ddl/inbound_foreign_key.rs create mode 100644 linux/crates/core/src/ddl/mod.rs create mode 100644 linux/crates/core/src/ddl/order_renames.rs create mode 100644 linux/crates/core/src/ddl/permission.rs create mode 100644 linux/crates/core/src/ddl/primary_key_change.rs create mode 100644 linux/crates/core/src/ddl/primary_key_constraint.rs create mode 100644 linux/crates/core/src/ddl/referential_action.rs create mode 100644 linux/crates/core/src/ddl/refusal_reason.rs create mode 100644 linux/crates/core/src/ddl/schema_object_source.rs create mode 100644 linux/crates/core/src/ddl/table_changes.rs create mode 100644 linux/crates/core/src/ddl/table_definition.rs create mode 100644 linux/crates/core/src/ddl/table_draft.rs create mode 100644 linux/crates/core/src/ddl/table_rebuild.rs create mode 100644 linux/crates/core/src/ddl/table_source.rs create mode 100644 linux/crates/core/src/ddl/tests.rs create mode 100644 linux/crates/core/src/ddl/unmodeled_attribute.rs create mode 100644 linux/crates/core/src/dialect/bind_error.rs create mode 100644 linux/crates/core/src/dialect/bind_target.rs create mode 100644 linux/crates/core/src/dialect/dialect_capabilities.rs create mode 100644 linux/crates/core/src/dialect/insert_batch_limits.rs create mode 100644 linux/crates/core/src/dialect/keyset_direction.rs create mode 100644 linux/crates/core/src/dialect/like_form.rs create mode 100644 linux/crates/core/src/dialect/literal_error.rs create mode 100644 linux/crates/core/src/dialect/mod.rs create mode 100644 linux/crates/core/src/dialect/placeholder.rs create mode 100644 linux/crates/core/src/dialect/sql_dialect.rs create mode 100644 linux/crates/core/src/dialect/test_dialect.rs create mode 100644 linux/crates/core/src/dml/build_delete.rs create mode 100644 linux/crates/core/src/dml/build_insert.rs create mode 100644 linux/crates/core/src/dml/build_key_probe.rs create mode 100644 linux/crates/core/src/dml/build_sql_error.rs create mode 100644 linux/crates/core/src/dml/build_update.rs create mode 100644 linux/crates/core/src/dml/key_component.rs create mode 100644 linux/crates/core/src/dml/mod.rs create mode 100644 linux/crates/core/src/dml/predicate.rs create mode 100644 linux/crates/core/src/dml/tests.rs create mode 100644 linux/crates/core/src/driver.rs create mode 100644 linux/crates/core/src/edit/cell_input.rs create mode 100644 linux/crates/core/src/edit/edit_context.rs create mode 100644 linux/crates/core/src/edit/edit_parse_error.rs create mode 100644 linux/crates/core/src/edit/edit_text.rs create mode 100644 linux/crates/core/src/edit/empty_input_meaning.rs create mode 100644 linux/crates/core/src/edit/is_unchanged.rs create mode 100644 linux/crates/core/src/edit/mod.rs create mode 100644 linux/crates/core/src/edit/parse_edit_text.rs create mode 100644 linux/crates/core/src/edit/parse_literal_text.rs create mode 100644 linux/crates/core/src/editor_session.rs create mode 100644 linux/crates/core/src/engine_stop.rs create mode 100644 linux/crates/core/src/error.rs create mode 100644 linux/crates/core/src/error_category.rs create mode 100644 linux/crates/core/src/export/csv.rs create mode 100644 linux/crates/core/src/export/csv_options.rs create mode 100644 linux/crates/core/src/export/encoder/csv_encoder.rs create mode 100644 linux/crates/core/src/export/encoder/encode_error.rs create mode 100644 linux/crates/core/src/export/encoder/html_encoder.rs create mode 100644 linux/crates/core/src/export/encoder/json_array_encoder.rs create mode 100644 linux/crates/core/src/export/encoder/markdown_encoder.rs create mode 100644 linux/crates/core/src/export/encoder/mod.rs create mode 100644 linux/crates/core/src/export/encoder/row_encoder.rs create mode 100644 linux/crates/core/src/export/encoder/sql_insert_encoder.rs create mode 100644 linux/crates/core/src/export/encoder/tests.rs create mode 100644 linux/crates/core/src/export/encoder/tsv_encoder.rs create mode 100644 linux/crates/core/src/export/encoder/xml_encoder.rs create mode 100644 linux/crates/core/src/export/html.rs create mode 100644 linux/crates/core/src/export/in_clause.rs create mode 100644 linux/crates/core/src/export/json.rs create mode 100644 linux/crates/core/src/export/json_field_names.rs create mode 100644 linux/crates/core/src/export/markdown.rs create mode 100644 linux/crates/core/src/export/mod.rs create mode 100644 linux/crates/core/src/export/render_insert_error.rs create mode 100644 linux/crates/core/src/export/sql.rs create mode 100644 linux/crates/core/src/export/tsv.rs create mode 100644 linux/crates/core/src/export/value_text.rs create mode 100644 linux/crates/core/src/export/xlsx.rs create mode 100644 linux/crates/core/src/export/xml.rs create mode 100644 linux/crates/core/src/file_endpoint.rs create mode 100644 linux/crates/core/src/file_open_mode.rs create mode 100644 linux/crates/core/src/filter/build_filter.rs create mode 100644 linux/crates/core/src/filter/build_filter_error.rs create mode 100644 linux/crates/core/src/filter/filter_op.rs create mode 100644 linux/crates/core/src/filter/filter_rule.rs create mode 100644 linux/crates/core/src/filter/filter_set.rs create mode 100644 linux/crates/core/src/filter/mod.rs create mode 100644 linux/crates/core/src/filter/operators_for.rs create mode 100644 linux/crates/core/src/filter/validate_raw_predicate.rs create mode 100644 linux/crates/core/src/foreign_key_check.rs create mode 100644 linux/crates/core/src/foreign_key_info.rs create mode 100644 linux/crates/core/src/foreign_key_mode.rs create mode 100644 linux/crates/core/src/hex.rs create mode 100644 linux/crates/core/src/import/csv_import.rs create mode 100644 linux/crates/core/src/import/import_column.rs create mode 100644 linux/crates/core/src/import/import_plan.rs create mode 100644 linux/crates/core/src/import/mod.rs create mode 100644 linux/crates/core/src/lib.rs create mode 100644 linux/crates/core/src/link_watch.rs create mode 100644 linux/crates/core/src/liveness_policy.rs create mode 100644 linux/crates/core/src/loss_phase.rs create mode 100644 linux/crates/core/src/meta/index_info.rs create mode 100644 linux/crates/core/src/meta/mod.rs create mode 100644 linux/crates/core/src/meta/object_info.rs create mode 100644 linux/crates/core/src/meta/page.rs create mode 100644 linux/crates/core/src/meta/row_identity.rs create mode 100644 linux/crates/core/src/meta/schema_info.rs create mode 100644 linux/crates/core/src/meta/table_estimate.rs create mode 100644 linux/crates/core/src/meta/table_ref.rs create mode 100644 linux/crates/core/src/network_endpoint.rs create mode 100644 linux/crates/core/src/network_target.rs create mode 100644 linux/crates/core/src/owned_calls.rs create mode 100644 linux/crates/core/src/query_result.rs create mode 100644 linux/crates/core/src/read_only_refusal.rs create mode 100644 linux/crates/core/src/read_statement.rs create mode 100644 linux/crates/core/src/registry.rs create mode 100644 linux/crates/core/src/result_event.rs create mode 100644 linux/crates/core/src/result_sink.rs create mode 100644 linux/crates/core/src/row_limit.rs create mode 100644 linux/crates/core/src/run_options.rs create mode 100644 linux/crates/core/src/save/change_set.rs create mode 100644 linux/crates/core/src/save/mod.rs create mode 100644 linux/crates/core/src/save/plan_save.rs create mode 100644 linux/crates/core/src/save/save_plan.rs create mode 100644 linux/crates/core/src/server_diagnostics.rs create mode 100644 linux/crates/core/src/server_info.rs create mode 100644 linux/crates/core/src/server_name_override.rs create mode 100644 linux/crates/core/src/session_fate.rs create mode 100644 linux/crates/core/src/sql_syntax/credential_rules.rs create mode 100644 linux/crates/core/src/sql_syntax/errors.rs create mode 100644 linux/crates/core/src/sql_syntax/grammar.rs create mode 100644 linux/crates/core/src/sql_syntax/history_recording.rs create mode 100644 linux/crates/core/src/sql_syntax/keywords.rs create mode 100644 linux/crates/core/src/sql_syntax/literal_token.rs create mode 100644 linux/crates/core/src/sql_syntax/mod.rs create mode 100644 linux/crates/core/src/sql_syntax/parse_expression.rs create mode 100644 linux/crates/core/src/sql_syntax/parse_type.rs create mode 100644 linux/crates/core/src/sql_syntax/reserved_words.rs create mode 100644 linux/crates/core/src/sql_syntax/script/batch_error_policy.rs create mode 100644 linux/crates/core/src/sql_syntax/script/compound_tracker.rs create mode 100644 linux/crates/core/src/sql_syntax/script/directive_scan.rs create mode 100644 linux/crates/core/src/sql_syntax/script/lexer.rs create mode 100644 linux/crates/core/src/sql_syntax/script/lexical_settings.rs create mode 100644 linux/crates/core/src/sql_syntax/script/mod.rs create mode 100644 linux/crates/core/src/sql_syntax/script/open_construct.rs create mode 100644 linux/crates/core/src/sql_syntax/script/script_batch.rs create mode 100644 linux/crates/core/src/sql_syntax/script/script_diagnostic.rs create mode 100644 linux/crates/core/src/sql_syntax/script/script_plan.rs create mode 100644 linux/crates/core/src/sql_syntax/script/script_rules.rs create mode 100644 linux/crates/core/src/sql_syntax/script/script_statement.rs create mode 100644 linux/crates/core/src/sql_syntax/script/script_token.rs create mode 100644 linux/crates/core/src/sql_syntax/script/script_token_kind.rs create mode 100644 linux/crates/core/src/sql_syntax/script/text_offsets.rs create mode 100644 linux/crates/core/src/sql_syntax/statement_tokens.rs create mode 100644 linux/crates/core/src/ssh_failure.rs create mode 100644 linux/crates/core/src/statement/bound_param.rs create mode 100644 linux/crates/core/src/statement/mod.rs create mode 100644 linux/crates/core/src/statement/param_type.rs create mode 100644 linux/crates/core/src/statement/row_guard.rs create mode 100644 linux/crates/core/src/statement/sql_build_error.rs create mode 100644 linux/crates/core/src/statement/statement.rs create mode 100644 linux/crates/core/src/statement/statement_builder.rs create mode 100644 linux/crates/core/src/stop_request.rs create mode 100644 linux/crates/core/src/stream_summary.rs create mode 100644 linux/crates/core/src/timeout_phase.rs create mode 100644 linux/crates/core/src/tls_capabilities.rs create mode 100644 linux/crates/core/src/tls_config.rs create mode 100644 linux/crates/core/src/tls_failure.rs create mode 100644 linux/crates/core/src/tls_mode.rs create mode 100644 linux/crates/core/src/transaction_state.rs create mode 100644 linux/crates/core/src/transport.rs create mode 100644 linux/crates/core/src/transport_class.rs create mode 100644 linux/crates/core/src/transport_error.rs create mode 100644 linux/crates/core/src/transport_kind.rs create mode 100644 linux/crates/core/src/transport_route.rs create mode 100644 linux/crates/core/src/user_sql.rs create mode 100644 linux/crates/core/src/value/bit_string.rs create mode 100644 linux/crates/core/src/value/decimal.rs create mode 100644 linux/crates/core/src/value/identity.rs create mode 100644 linux/crates/core/src/value/interval.rs create mode 100644 linux/crates/core/src/value/json_text.rs create mode 100644 linux/crates/core/src/value/mod.rs create mode 100644 linux/crates/core/src/value/offset_timestamp.rs create mode 100644 linux/crates/core/src/value/other.rs create mode 100644 linux/crates/core/src/value/temporal.rs create mode 100644 linux/crates/core/src/value/time.rs create mode 100644 linux/crates/core/src/value/time_with_offset.rs create mode 100644 linux/crates/core/src/value/undecodable.rs create mode 100644 linux/crates/core/src/value/wide_integer.rs create mode 100644 linux/crates/core/src/workspace_settings.rs create mode 100644 linux/crates/core/src/write_batch.rs create mode 100644 linux/crates/core/src/write_ledger.rs create mode 100644 linux/crates/core/src/write_report.rs create mode 100644 linux/crates/core/tests/decimal_allocation.rs create mode 100644 linux/crates/core/tests/script_corpus.rs create mode 100644 linux/crates/drivers/clickhouse/Cargo.toml create mode 100644 linux/crates/drivers/clickhouse/src/call_client.rs create mode 100644 linux/crates/drivers/clickhouse/src/cancel.rs create mode 100644 linux/crates/drivers/clickhouse/src/capabilities.rs create mode 100644 linux/crates/drivers/clickhouse/src/catalog/clickhouse_column_row.rs create mode 100644 linux/crates/drivers/clickhouse/src/catalog/mod.rs create mode 100644 linux/crates/drivers/clickhouse/src/catalog/table_definition.rs create mode 100644 linux/crates/drivers/clickhouse/src/cell_seed.rs create mode 100644 linux/crates/drivers/clickhouse/src/clickhouse_type.rs create mode 100644 linux/crates/drivers/clickhouse/src/client_factory.rs create mode 100644 linux/crates/drivers/clickhouse/src/commit_resolution.rs create mode 100644 linux/crates/drivers/clickhouse/src/connection.rs create mode 100644 linux/crates/drivers/clickhouse/src/ddl.rs create mode 100644 linux/crates/drivers/clickhouse/src/dialect.rs create mode 100644 linux/crates/drivers/clickhouse/src/driver.rs create mode 100644 linux/crates/drivers/clickhouse/src/editor_run.rs create mode 100644 linux/crates/drivers/clickhouse/src/editor_session.rs create mode 100644 linux/crates/drivers/clickhouse/src/endpoint.rs create mode 100644 linux/crates/drivers/clickhouse/src/errors.rs create mode 100644 linux/crates/drivers/clickhouse/src/http_call.rs create mode 100644 linux/crates/drivers/clickhouse/src/key_expression.rs create mode 100644 linux/crates/drivers/clickhouse/src/lib.rs create mode 100644 linux/crates/drivers/clickhouse/src/line_reader.rs create mode 100644 linux/crates/drivers/clickhouse/src/lingering_query.rs create mode 100644 linux/crates/drivers/clickhouse/src/metadata.rs create mode 100644 linux/crates/drivers/clickhouse/src/param_value.rs create mode 100644 linux/crates/drivers/clickhouse/src/result_reader.rs create mode 100644 linux/crates/drivers/clickhouse/src/row_seed.rs create mode 100644 linux/crates/drivers/clickhouse/src/session_status.rs create mode 100644 linux/crates/drivers/clickhouse/src/stream_read.rs create mode 100644 linux/crates/drivers/clickhouse/src/transport_connector.rs create mode 100644 linux/crates/drivers/clickhouse/src/transport_io.rs create mode 100644 linux/crates/drivers/clickhouse/src/write_batch.rs create mode 100644 linux/crates/drivers/clickhouse/src/write_error_class.rs create mode 100644 linux/crates/drivers/clickhouse/tests/integration/conformance_engine.rs create mode 100644 linux/crates/drivers/clickhouse/tests/integration/csv_import.rs create mode 100644 linux/crates/drivers/clickhouse/tests/integration/ddl.rs create mode 100644 linux/crates/drivers/clickhouse/tests/integration/keys_and_decimals.rs create mode 100644 linux/crates/drivers/clickhouse/tests/integration/main.rs create mode 100644 linux/crates/drivers/clickhouse/tests/integration/metadata.rs create mode 100644 linux/crates/drivers/clickhouse/tests/integration/sessions.rs create mode 100644 linux/crates/drivers/clickhouse/tests/integration/support.rs create mode 100644 linux/crates/drivers/clickhouse/tests/integration/tls.rs create mode 100644 linux/crates/drivers/clickhouse/tests/integration/writes.rs create mode 100644 linux/crates/drivers/mssql/Cargo.toml create mode 100644 linux/crates/drivers/mssql/src/cancel.rs create mode 100644 linux/crates/drivers/mssql/src/capabilities.rs create mode 100644 linux/crates/drivers/mssql/src/catalog/cells.rs create mode 100644 linux/crates/drivers/mssql/src/catalog/mod.rs create mode 100644 linux/crates/drivers/mssql/src/catalog/mssql_column_row.rs create mode 100644 linux/crates/drivers/mssql/src/catalog/sql_server_type.rs create mode 100644 linux/crates/drivers/mssql/src/catalog/table_definition.rs create mode 100644 linux/crates/drivers/mssql/src/connection.rs create mode 100644 linux/crates/drivers/mssql/src/ddl.rs create mode 100644 linux/crates/drivers/mssql/src/decode.rs create mode 100644 linux/crates/drivers/mssql/src/dialect.rs create mode 100644 linux/crates/drivers/mssql/src/driver.rs create mode 100644 linux/crates/drivers/mssql/src/editor_run.rs create mode 100644 linux/crates/drivers/mssql/src/editor_session.rs create mode 100644 linux/crates/drivers/mssql/src/encode.rs create mode 100644 linux/crates/drivers/mssql/src/endpoint.rs create mode 100644 linux/crates/drivers/mssql/src/errors.rs create mode 100644 linux/crates/drivers/mssql/src/in_band_request.rs create mode 100644 linux/crates/drivers/mssql/src/lib.rs create mode 100644 linux/crates/drivers/mssql/src/login.rs create mode 100644 linux/crates/drivers/mssql/src/metadata.rs create mode 100644 linux/crates/drivers/mssql/src/mssql_manager.rs create mode 100644 linux/crates/drivers/mssql/src/mssql_pooled.rs create mode 100644 linux/crates/drivers/mssql/src/physical.rs create mode 100644 linux/crates/drivers/mssql/src/read_only_guard.rs create mode 100644 linux/crates/drivers/mssql/src/standalone.rs create mode 100644 linux/crates/drivers/mssql/src/stream_read.rs create mode 100644 linux/crates/drivers/mssql/src/tds_message.rs create mode 100644 linux/crates/drivers/mssql/src/tls_config.rs create mode 100644 linux/crates/drivers/mssql/src/transport_io.rs create mode 100644 linux/crates/drivers/mssql/src/workspace_call.rs create mode 100644 linux/crates/drivers/mssql/src/write_batch.rs create mode 100644 linux/crates/drivers/mssql/tests/integration/cancellation.rs create mode 100644 linux/crates/drivers/mssql/tests/integration/conformance_engine.rs create mode 100644 linux/crates/drivers/mssql/tests/integration/csv_import.rs create mode 100644 linux/crates/drivers/mssql/tests/integration/ddl.rs create mode 100644 linux/crates/drivers/mssql/tests/integration/decode.rs create mode 100644 linux/crates/drivers/mssql/tests/integration/filter.rs create mode 100644 linux/crates/drivers/mssql/tests/integration/main.rs create mode 100644 linux/crates/drivers/mssql/tests/integration/metadata.rs create mode 100644 linux/crates/drivers/mssql/tests/integration/read_only.rs create mode 100644 linux/crates/drivers/mssql/tests/integration/sessions.rs create mode 100644 linux/crates/drivers/mssql/tests/integration/support.rs create mode 100644 linux/crates/drivers/mssql/tests/integration/tls.rs create mode 100644 linux/crates/drivers/mssql/tests/integration/write_batch.rs create mode 100644 linux/crates/drivers/mysql/Cargo.toml create mode 100644 linux/crates/drivers/mysql/src/cancel.rs create mode 100644 linux/crates/drivers/mysql/src/capabilities.rs create mode 100644 linux/crates/drivers/mysql/src/catalog/catalog_cells.rs create mode 100644 linux/crates/drivers/mysql/src/catalog/column_type_scan_error.rs create mode 100644 linux/crates/drivers/mysql/src/catalog/escaped_expression.rs create mode 100644 linux/crates/drivers/mysql/src/catalog/mod.rs create mode 100644 linux/crates/drivers/mysql/src/catalog/mysql_column_row.rs create mode 100644 linux/crates/drivers/mysql/src/catalog/mysql_column_type.rs create mode 100644 linux/crates/drivers/mysql/src/catalog/mysql_extra.rs create mode 100644 linux/crates/drivers/mysql/src/catalog/table_definition.rs create mode 100644 linux/crates/drivers/mysql/src/connection.rs create mode 100644 linux/crates/drivers/mysql/src/ddl/mod.rs create mode 100644 linux/crates/drivers/mysql/src/ddl/mysql_column_definition.rs create mode 100644 linux/crates/drivers/mysql/src/decode.rs create mode 100644 linux/crates/drivers/mysql/src/dialect.rs create mode 100644 linux/crates/drivers/mysql/src/driver.rs create mode 100644 linux/crates/drivers/mysql/src/editor_run.rs create mode 100644 linux/crates/drivers/mysql/src/editor_session.rs create mode 100644 linux/crates/drivers/mysql/src/encode.rs create mode 100644 linux/crates/drivers/mysql/src/errors.rs create mode 100644 linux/crates/drivers/mysql/src/flavour.rs create mode 100644 linux/crates/drivers/mysql/src/lib.rs create mode 100644 linux/crates/drivers/mysql/src/metadata.rs create mode 100644 linux/crates/drivers/mysql/src/mysql_call.rs create mode 100644 linux/crates/drivers/mysql/src/mysql_manager.rs create mode 100644 linux/crates/drivers/mysql/src/mysql_temporal.rs create mode 100644 linux/crates/drivers/mysql/src/mysql_timestamp_mode.rs create mode 100644 linux/crates/drivers/mysql/src/mysql_wire_format.rs create mode 100644 linux/crates/drivers/mysql/src/physical.rs create mode 100644 linux/crates/drivers/mysql/src/server_zone.rs create mode 100644 linux/crates/drivers/mysql/src/session_statements.rs create mode 100644 linux/crates/drivers/mysql/src/standalone.rs create mode 100644 linux/crates/drivers/mysql/src/stream_read.rs create mode 100644 linux/crates/drivers/mysql/src/table_engine.rs create mode 100644 linux/crates/drivers/mysql/src/transaction_probe.rs create mode 100644 linux/crates/drivers/mysql/src/workspace_call.rs create mode 100644 linux/crates/drivers/mysql/src/workspace_session.rs create mode 100644 linux/crates/drivers/mysql/src/write_batch.rs create mode 100644 linux/crates/drivers/mysql/tests/integration/conformance_engine.rs create mode 100644 linux/crates/drivers/mysql/tests/integration/csv_import.rs create mode 100644 linux/crates/drivers/mysql/tests/integration/ddl.rs create mode 100644 linux/crates/drivers/mysql/tests/integration/decode.rs create mode 100644 linux/crates/drivers/mysql/tests/integration/editor_session.rs create mode 100644 linux/crates/drivers/mysql/tests/integration/flavour_suite.rs create mode 100644 linux/crates/drivers/mysql/tests/integration/lenient_server.rs create mode 100644 linux/crates/drivers/mysql/tests/integration/main.rs create mode 100644 linux/crates/drivers/mysql/tests/integration/metadata.rs create mode 100644 linux/crates/drivers/mysql/tests/integration/non_transactional.rs create mode 100644 linux/crates/drivers/mysql/tests/integration/row_limit.rs create mode 100644 linux/crates/drivers/mysql/tests/integration/server_time_zone.rs create mode 100644 linux/crates/drivers/mysql/tests/integration/support.rs create mode 100644 linux/crates/drivers/mysql/tests/integration/tls.rs create mode 100644 linux/crates/drivers/mysql/tests/integration/write_batch.rs create mode 100644 linux/crates/drivers/mysql/tests/statement_logging.rs create mode 100644 linux/crates/drivers/mysql/tests/system_trust.rs create mode 100644 linux/crates/drivers/postgres/Cargo.toml create mode 100644 linux/crates/drivers/postgres/src/cancel.rs create mode 100644 linux/crates/drivers/postgres/src/capabilities.rs create mode 100644 linux/crates/drivers/postgres/src/catalog/mod.rs create mode 100644 linux/crates/drivers/postgres/src/catalog/pg_column_row.rs create mode 100644 linux/crates/drivers/postgres/src/catalog/pg_typmod.rs create mode 100644 linux/crates/drivers/postgres/src/catalog/table_definition.rs create mode 100644 linux/crates/drivers/postgres/src/catalog/type_facts.rs create mode 100644 linux/crates/drivers/postgres/src/codec/array_decode.rs create mode 100644 linux/crates/drivers/postgres/src/codec/decode.rs create mode 100644 linux/crates/drivers/postgres/src/codec/interval_wire.rs create mode 100644 linux/crates/drivers/postgres/src/codec/mod.rs create mode 100644 linux/crates/drivers/postgres/src/codec/network_text.rs create mode 100644 linux/crates/drivers/postgres/src/codec/numeric_wire.rs create mode 100644 linux/crates/drivers/postgres/src/codec/oid.rs create mode 100644 linux/crates/drivers/postgres/src/codec/pg_param.rs create mode 100644 linux/crates/drivers/postgres/src/codec/raw_cell.rs create mode 100644 linux/crates/drivers/postgres/src/codec/temporal_wire.rs create mode 100644 linux/crates/drivers/postgres/src/codec/wire_error.rs create mode 100644 linux/crates/drivers/postgres/src/commit_resolution.rs create mode 100644 linux/crates/drivers/postgres/src/connection.rs create mode 100644 linux/crates/drivers/postgres/src/ddl.rs create mode 100644 linux/crates/drivers/postgres/src/dialect.rs create mode 100644 linux/crates/drivers/postgres/src/driver.rs create mode 100644 linux/crates/drivers/postgres/src/editor/actor.rs create mode 100644 linux/crates/drivers/postgres/src/editor/cursor_flight.rs create mode 100644 linux/crates/drivers/postgres/src/editor/editor_dispatch.rs create mode 100644 linux/crates/drivers/postgres/src/editor/editor_session.rs create mode 100644 linux/crates/drivers/postgres/src/editor/mod.rs create mode 100644 linux/crates/drivers/postgres/src/editor/run.rs create mode 100644 linux/crates/drivers/postgres/src/editor/session_core.rs create mode 100644 linux/crates/drivers/postgres/src/editor/session_status.rs create mode 100644 linux/crates/drivers/postgres/src/editor/statement_class.rs create mode 100644 linux/crates/drivers/postgres/src/editor/stop_watch.rs create mode 100644 linux/crates/drivers/postgres/src/editor/transaction_tracker.rs create mode 100644 linux/crates/drivers/postgres/src/errors.rs create mode 100644 linux/crates/drivers/postgres/src/lib.rs create mode 100644 linux/crates/drivers/postgres/src/metadata/estimate.rs create mode 100644 linux/crates/drivers/postgres/src/metadata/foreign_keys.rs create mode 100644 linux/crates/drivers/postgres/src/metadata/identity.rs create mode 100644 linux/crates/drivers/postgres/src/metadata/indexes.rs create mode 100644 linux/crates/drivers/postgres/src/metadata/mod.rs create mode 100644 linux/crates/drivers/postgres/src/metadata/objects.rs create mode 100644 linux/crates/drivers/postgres/src/pg_call.rs create mode 100644 linux/crates/drivers/postgres/src/physical.rs create mode 100644 linux/crates/drivers/postgres/src/pool.rs create mode 100644 linux/crates/drivers/postgres/src/standalone.rs create mode 100644 linux/crates/drivers/postgres/src/stream_read.rs create mode 100644 linux/crates/drivers/postgres/src/tls.rs create mode 100644 linux/crates/drivers/postgres/src/transport_io.rs create mode 100644 linux/crates/drivers/postgres/src/workspace_call.rs create mode 100644 linux/crates/drivers/postgres/src/write_batch.rs create mode 100644 linux/crates/drivers/postgres/tests/integration/cancellation.rs create mode 100644 linux/crates/drivers/postgres/tests/integration/conformance_engine.rs create mode 100644 linux/crates/drivers/postgres/tests/integration/csv_import.rs create mode 100644 linux/crates/drivers/postgres/tests/integration/ddl.rs create mode 100644 linux/crates/drivers/postgres/tests/integration/decode.rs create mode 100644 linux/crates/drivers/postgres/tests/integration/editor_session.rs create mode 100644 linux/crates/drivers/postgres/tests/integration/filter.rs create mode 100644 linux/crates/drivers/postgres/tests/integration/flight.rs create mode 100644 linux/crates/drivers/postgres/tests/integration/liveness.rs create mode 100644 linux/crates/drivers/postgres/tests/integration/main.rs create mode 100644 linux/crates/drivers/postgres/tests/integration/metadata.rs create mode 100644 linux/crates/drivers/postgres/tests/integration/paging.rs create mode 100644 linux/crates/drivers/postgres/tests/integration/pgbouncer.rs create mode 100644 linux/crates/drivers/postgres/tests/integration/read_only.rs create mode 100644 linux/crates/drivers/postgres/tests/integration/slow_route.rs create mode 100644 linux/crates/drivers/postgres/tests/integration/support.rs create mode 100644 linux/crates/drivers/postgres/tests/integration/tls.rs create mode 100644 linux/crates/drivers/postgres/tests/integration/write_batch.rs create mode 100644 linux/crates/drivers/postgres/tests/tls_provider.rs create mode 100644 linux/crates/drivers/sqlite/Cargo.toml create mode 100644 linux/crates/drivers/sqlite/src/autocommit.rs create mode 100644 linux/crates/drivers/sqlite/src/capabilities.rs create mode 100644 linux/crates/drivers/sqlite/src/catalog/dependents.rs create mode 100644 linux/crates/drivers/sqlite/src/catalog/mod.rs create mode 100644 linux/crates/drivers/sqlite/src/catalog/runtime_version.rs create mode 100644 linux/crates/drivers/sqlite/src/catalog/sqlite_column_row.rs create mode 100644 linux/crates/drivers/sqlite/src/catalog/table_definition.rs create mode 100644 linux/crates/drivers/sqlite/src/connection.rs create mode 100644 linux/crates/drivers/sqlite/src/create_file.rs create mode 100644 linux/crates/drivers/sqlite/src/ddl/column_constraint_segment.rs create mode 100644 linux/crates/drivers/sqlite/src/ddl/create_table_source.rs create mode 100644 linux/crates/drivers/sqlite/src/ddl/declared_shape.rs create mode 100644 linux/crates/drivers/sqlite/src/ddl/expression_columns.rs create mode 100644 linux/crates/drivers/sqlite/src/ddl/listed_columns.rs create mode 100644 linux/crates/drivers/sqlite/src/ddl/mod.rs create mode 100644 linux/crates/drivers/sqlite/src/ddl/native_change_rules.rs create mode 100644 linux/crates/drivers/sqlite/src/ddl/rebuild_plan.rs create mode 100644 linux/crates/drivers/sqlite/src/ddl/source_element.rs create mode 100644 linux/crates/drivers/sqlite/src/ddl/source_read_error.rs create mode 100644 linux/crates/drivers/sqlite/src/ddl/tests.rs create mode 100644 linux/crates/drivers/sqlite/src/deadline.rs create mode 100644 linux/crates/drivers/sqlite/src/decode.rs create mode 100644 linux/crates/drivers/sqlite/src/dialect.rs create mode 100644 linux/crates/drivers/sqlite/src/driver.rs create mode 100644 linux/crates/drivers/sqlite/src/editor_session.rs create mode 100644 linux/crates/drivers/sqlite/src/encode.rs create mode 100644 linux/crates/drivers/sqlite/src/errors.rs create mode 100644 linux/crates/drivers/sqlite/src/foreign_key_verification.rs create mode 100644 linux/crates/drivers/sqlite/src/index_sql.rs create mode 100644 linux/crates/drivers/sqlite/src/interrupt.rs create mode 100644 linux/crates/drivers/sqlite/src/lib.rs create mode 100644 linux/crates/drivers/sqlite/src/lock_wait.rs create mode 100644 linux/crates/drivers/sqlite/src/metadata.rs create mode 100644 linux/crates/drivers/sqlite/src/open.rs create mode 100644 linux/crates/drivers/sqlite/src/result_stream.rs create mode 100644 linux/crates/drivers/sqlite/src/sqlite_pooled.rs create mode 100644 linux/crates/drivers/sqlite/src/sqlite_standalone.rs create mode 100644 linux/crates/drivers/sqlite/src/write_batch.rs create mode 100644 linux/crates/drivers/sqlite/tests/csv_import.rs create mode 100644 linux/crates/drivers/sqlite/tests/integration/catalog.rs create mode 100644 linux/crates/drivers/sqlite/tests/integration/conformance_engine.rs create mode 100644 linux/crates/drivers/sqlite/tests/integration/ddl.rs create mode 100644 linux/crates/drivers/sqlite/tests/integration/decode.rs create mode 100644 linux/crates/drivers/sqlite/tests/integration/dialect.rs create mode 100644 linux/crates/drivers/sqlite/tests/integration/lock_wait.rs create mode 100644 linux/crates/drivers/sqlite/tests/integration/main.rs create mode 100644 linux/crates/drivers/sqlite/tests/integration/metadata.rs create mode 100644 linux/crates/drivers/sqlite/tests/integration/open_semantics.rs create mode 100644 linux/crates/drivers/sqlite/tests/integration/sessions.rs create mode 100644 linux/crates/drivers/sqlite/tests/integration/support.rs create mode 100644 linux/crates/drivers/sqlite/tests/integration/write_batch_foreign_keys.rs create mode 100644 linux/crates/drivers/sqlite/tests/script_plan_matches_sqlite3_complete.rs create mode 100644 linux/crates/drivers/sqlite/tests/statement_logging.rs create mode 100644 linux/crates/drivers/sqlite/tests/values.rs create mode 100644 linux/crates/net/Cargo.toml create mode 100644 linux/crates/net/src/dial.rs create mode 100644 linux/crates/net/src/direct_tcp.rs create mode 100644 linux/crates/net/src/lib.rs create mode 100644 linux/crates/net/src/socket_options.rs create mode 100644 linux/crates/net/src/tls/classify.rs create mode 100644 linux/crates/net/src/tls/client_config.rs create mode 100644 linux/crates/net/src/tls/mod.rs create mode 100644 linux/crates/net/src/tls/mysql_bundle.rs create mode 100644 linux/crates/net/src/tls/off_runtime.rs create mode 100644 linux/crates/net/src/tls/pem_check.rs create mode 100644 linux/crates/net/src/tls/setup_error.rs create mode 100644 linux/crates/net/src/tls/unverified.rs create mode 100644 linux/crates/net/tests/empty_trust_store.rs create mode 100644 linux/crates/net/tests/tls_handshake.rs create mode 100644 linux/crates/session/Cargo.toml create mode 100644 linux/crates/session/clippy.toml create mode 100644 linux/crates/session/src/connect/attempt_generation.rs create mode 100644 linux/crates/session/src/connect/attempt_outcome.rs create mode 100644 linux/crates/session/src/connect/attempt_ticket.rs create mode 100644 linux/crates/session/src/connect/connect_attempts.rs create mode 100644 linux/crates/session/src/connect/connect_error.rs create mode 100644 linux/crates/session/src/connect/connect_intent.rs create mode 100644 linux/crates/session/src/connect/connect_request.rs create mode 100644 linux/crates/session/src/connect/connect_services.rs create mode 100644 linux/crates/session/src/connect/connect_source.rs create mode 100644 linux/crates/session/src/connect/connection_metadata.rs create mode 100644 linux/crates/session/src/connect/connection_writes.rs create mode 100644 linux/crates/session/src/connect/connector.rs create mode 100644 linux/crates/session/src/connect/credential_source.rs create mode 100644 linux/crates/session/src/connect/driver_connector.rs create mode 100644 linux/crates/session/src/connect/keyring_source.rs create mode 100644 linux/crates/session/src/connect/mod.rs create mode 100644 linux/crates/session/src/connect/no_stored_credentials.rs create mode 100644 linux/crates/session/src/connect/pending_save.rs create mode 100644 linux/crates/session/src/connect/pending_session.rs create mode 100644 linux/crates/session/src/connect/persist_pending_save.rs create mode 100644 linux/crates/session/src/connect/request_target.rs create mode 100644 linux/crates/session/src/connect/resolve_saved.rs create mode 100644 linux/crates/session/src/fakes/fake_connector.rs create mode 100644 linux/crates/session/src/fakes/fake_credential_source.rs create mode 100644 linux/crates/session/src/fakes/mod.rs create mode 100644 linux/crates/session/src/health/failure_cause.rs create mode 100644 linux/crates/session/src/health/health_monitor.rs create mode 100644 linux/crates/session/src/health/health_policy.rs create mode 100644 linux/crates/session/src/health/link_generation.rs create mode 100644 linux/crates/session/src/health/mod.rs create mode 100644 linux/crates/session/src/health/monitor_action.rs create mode 100644 linux/crates/session/src/health/monitor_mailbox.rs create mode 100644 linux/crates/session/src/health/monitor_signal.rs create mode 100644 linux/crates/session/src/health/pending_signals.rs create mode 100644 linux/crates/session/src/health/probe_outcome.rs create mode 100644 linux/crates/session/src/health/reachability.rs create mode 100644 linux/crates/session/src/health/reachability_target.rs create mode 100644 linux/crates/session/src/health/reconnect_plan.rs create mode 100644 linux/crates/session/src/health/reconnect_result.rs create mode 100644 linux/crates/session/src/health/reviewed_host_key.rs create mode 100644 linux/crates/session/src/health/session_health.rs create mode 100644 linux/crates/session/src/health/trusted_host_key.rs create mode 100644 linux/crates/session/src/lib.rs create mode 100644 linux/crates/session/src/link/close_report.rs create mode 100644 linux/crates/session/src/link/connection_lease.rs create mode 100644 linux/crates/session/src/link/connection_link.rs create mode 100644 linux/crates/session/src/link/established_connection.rs create mode 100644 linux/crates/session/src/link/mod.rs create mode 100644 linux/crates/session/src/link/retired_connection.rs create mode 100644 linux/crates/session/src/operation/deadline_policy.rs create mode 100644 linux/crates/session/src/operation/default_deadlines.rs create mode 100644 linux/crates/session/src/operation/mod.rs create mode 100644 linux/crates/session/src/operation/operation_error.rs create mode 100644 linux/crates/session/src/operation/operation_kind.rs create mode 100644 linux/crates/session/src/operation/operation_runner.rs create mode 100644 linux/crates/session/src/operation/replay_rule.rs create mode 100644 linux/crates/session/src/registry/claim_error.rs create mode 100644 linux/crates/session/src/registry/file_identity.rs create mode 100644 linux/crates/session/src/registry/mod.rs create mode 100644 linux/crates/session/src/registry/registry_event.rs create mode 100644 linux/crates/session/src/registry/registry_subscription.rs create mode 100644 linux/crates/session/src/registry/session_claim.rs create mode 100644 linux/crates/session/src/registry/session_key.rs create mode 100644 linux/crates/session/src/registry/session_registry.rs create mode 100644 linux/crates/session/src/runtime/catch_panic.rs create mode 100644 linux/crates/session/src/runtime/latest_wins_writer.rs create mode 100644 linux/crates/session/src/runtime/mod.rs create mode 100644 linux/crates/session/src/runtime/process_hold.rs create mode 100644 linux/crates/session/src/runtime/task_failure.rs create mode 100644 linux/crates/session/src/runtime/task_panic.rs create mode 100644 linux/crates/session/src/runtime/task_result.rs create mode 100644 linux/crates/session/src/runtime/tasks.rs create mode 100644 linux/crates/session/src/runtime/watch_forwarder.rs create mode 100644 linux/crates/session/src/session/editor_session_shutdown.rs create mode 100644 linux/crates/session/src/session/mod.rs create mode 100644 linux/crates/session/src/session/object_list_snapshot.rs create mode 100644 linux/crates/session/src/session/schema_catalog.rs create mode 100644 linux/crates/session/src/session/session.rs create mode 100644 linux/crates/session/src/session/session_deps.rs create mode 100644 linux/crates/session/src/test_support.rs create mode 100644 linux/crates/session/tests/session_postgres.rs create mode 100644 linux/crates/ssh/Cargo.toml create mode 100644 linux/crates/ssh/src/argv.rs create mode 100644 linux/crates/ssh/src/askpass_bridge.rs create mode 100644 linux/crates/ssh/src/bin/tablepro-askpass.rs create mode 100644 linux/crates/ssh/src/config.rs create mode 100644 linux/crates/ssh/src/destination.rs create mode 100644 linux/crates/ssh/src/forward.rs create mode 100644 linux/crates/ssh/src/hop_config.rs create mode 100644 linux/crates/ssh/src/host_key_checking.rs create mode 100644 linux/crates/ssh/src/known_hosts.rs create mode 100644 linux/crates/ssh/src/lib.rs create mode 100644 linux/crates/ssh/src/password_owner.rs create mode 100644 linux/crates/ssh/src/prompt.rs create mode 100644 linux/crates/ssh/src/route.rs create mode 100644 linux/crates/ssh/src/runtime.rs create mode 100644 linux/crates/ssh/src/runtime_cell.rs create mode 100644 linux/crates/ssh/src/services.rs create mode 100644 linux/crates/ssh/src/session.rs create mode 100644 linux/crates/ssh/src/stderr_classify.rs create mode 100644 linux/crates/ssh/src/supervisor.rs create mode 100644 linux/crates/ssh/src/sweep.rs create mode 100644 linux/crates/ssh/tests/askpass_helper.rs create mode 100644 linux/crates/ssh/tests/fixtures/stderr/auth_rejected.txt create mode 100644 linux/crates/ssh/tests/fixtures/stderr/config_rejected_option.txt create mode 100644 linux/crates/ssh/tests/fixtures/stderr/control_path_too_long.txt create mode 100644 linux/crates/ssh/tests/fixtures/stderr/host_key_changed.txt create mode 100644 linux/crates/ssh/tests/fixtures/stderr/host_key_revoked.txt create mode 100644 linux/crates/ssh/tests/fixtures/stderr/host_key_unknown_strict.txt create mode 100644 linux/crates/ssh/tests/fixtures/stderr/name_resolution.txt create mode 100644 linux/crates/ssh/tests/fixtures/stderr/refused.txt create mode 100644 linux/crates/ssh/tests/fixtures/stderr/timeout.txt create mode 100644 linux/crates/ssh/tests/openssh_session.rs create mode 100644 linux/crates/storage/Cargo.toml create mode 100644 linux/crates/storage/migrations/20260914000000_create_history.sql create mode 100644 linux/crates/storage/migrations/20260918000000_create_saved_queries.sql create mode 100644 linux/crates/storage/migrations/20261014000000_history_substring_search.sql create mode 100644 linux/crates/storage/src/connection_color.rs create mode 100644 linux/crates/storage/src/connection_group_name.rs create mode 100644 linux/crates/storage/src/connections.rs create mode 100644 linux/crates/storage/src/credentials/credential_demand.rs create mode 100644 linux/crates/storage/src/credentials/credential_error.rs create mode 100644 linux/crates/storage/src/credentials/keyring_credentials.rs create mode 100644 linux/crates/storage/src/credentials/mod.rs create mode 100644 linux/crates/storage/src/credentials/resolved_secret.rs create mode 100644 linux/crates/storage/src/credentials/secret_labeler.rs create mode 100644 linux/crates/storage/src/document.rs create mode 100644 linux/crates/storage/src/document_problem.rs create mode 100644 linux/crates/storage/src/drafts/draft_id.rs create mode 100644 linux/crates/storage/src/drafts/draft_scope.rs create mode 100644 linux/crates/storage/src/drafts/draft_store.rs create mode 100644 linux/crates/storage/src/drafts/mod.rs create mode 100644 linux/crates/storage/src/error.rs create mode 100644 linux/crates/storage/src/fs.rs create mode 100644 linux/crates/storage/src/group_name_error.rs create mode 100644 linux/crates/storage/src/lib.rs create mode 100644 linux/crates/storage/src/paths.rs create mode 100644 linux/crates/storage/src/query_history/entry.rs create mode 100644 linux/crates/storage/src/query_history/export.rs create mode 100644 linux/crates/storage/src/query_history/history_connection.rs create mode 100644 linux/crates/storage/src/query_history/history_cursor.rs create mode 100644 linux/crates/storage/src/query_history/history_page.rs create mode 100644 linux/crates/storage/src/query_history/history_query.rs create mode 100644 linux/crates/storage/src/query_history/mod.rs create mode 100644 linux/crates/storage/src/query_history/new_entry.rs create mode 100644 linux/crates/storage/src/query_history/outcome.rs create mode 100644 linux/crates/storage/src/query_history/prune_report.rs create mode 100644 linux/crates/storage/src/query_history/retention.rs create mode 100644 linux/crates/storage/src/query_history/schema.rs create mode 100644 linux/crates/storage/src/query_history/search_fold.rs create mode 100644 linux/crates/storage/src/query_history/status_filter.rs create mode 100644 linux/crates/storage/src/saved_queries/mod.rs create mode 100644 linux/crates/storage/src/saved_queries/new_saved_query.rs create mode 100644 linux/crates/storage/src/saved_queries/saved_query.rs create mode 100644 linux/crates/storage/src/secrets/keyring_error.rs create mode 100644 linux/crates/storage/src/secrets/mod.rs create mode 100644 linux/crates/storage/src/secrets/secret_attributes.rs create mode 100644 linux/crates/storage/src/secrets/secret_store.rs create mode 100644 linux/crates/storage/src/settings/editor_font.rs create mode 100644 linux/crates/storage/src/settings/error.rs create mode 100644 linux/crates/storage/src/settings/keys.rs create mode 100644 linux/crates/storage/src/settings/mod.rs create mode 100644 linux/crates/storage/src/settings/window_geometry.rs create mode 100644 linux/crates/storage/src/unix_time.rs create mode 100644 linux/crates/storage/tests/fixtures/connections-v1.json create mode 100644 linux/crates/storage/tests/isolation.rs create mode 100644 linux/crates/storage/tests/runtime_fallback.rs create mode 100644 linux/crates/storage/tests/secret_service.rs create mode 100644 linux/crates/storage/tests/settings.rs create mode 100644 linux/crates/storage/tests/stored_formats.rs create mode 100644 linux/crates/test-fixtures/Cargo.toml create mode 100644 linux/crates/test-fixtures/src/clickhouse_fixture.rs create mode 100644 linux/crates/test-fixtures/src/container_file.rs create mode 100644 linux/crates/test-fixtures/src/exec_output.rs create mode 100644 linux/crates/test-fixtures/src/fixture_credentials.rs create mode 100644 linux/crates/test-fixtures/src/fixture_error.rs create mode 100644 linux/crates/test-fixtures/src/fixture_image.rs create mode 100644 linux/crates/test-fixtures/src/hba_mode.rs create mode 100644 linux/crates/test-fixtures/src/host_certificate.rs create mode 100644 linux/crates/test-fixtures/src/host_command.rs create mode 100644 linux/crates/test-fixtures/src/host_key_revocation.rs create mode 100644 linux/crates/test-fixtures/src/lib.rs create mode 100644 linux/crates/test-fixtures/src/mssql_fixture.rs create mode 100644 linux/crates/test-fixtures/src/mysql_fixture.rs create mode 100644 linux/crates/test-fixtures/src/mysql_flavour.rs create mode 100644 linux/crates/test-fixtures/src/mysql_options.rs create mode 100644 linux/crates/test-fixtures/src/openssh_fixture.rs create mode 100644 linux/crates/test-fixtures/src/pgbouncer_fixture.rs create mode 100644 linux/crates/test-fixtures/src/pki_algorithm.rs create mode 100644 linux/crates/test-fixtures/src/pki_file.rs create mode 100644 linux/crates/test-fixtures/src/pool_mode.rs create mode 100644 linux/crates/test-fixtures/src/postgres_fixture.rs create mode 100644 linux/crates/test-fixtures/src/postgres_version.rs create mode 100644 linux/crates/test-fixtures/src/scripted_askpass.rs create mode 100644 linux/crates/test-fixtures/src/ssh_agent.rs create mode 100644 linux/crates/test-fixtures/src/ssh_auth_variant.rs create mode 100644 linux/crates/test-fixtures/src/ssh_key_pair.rs create mode 100644 linux/crates/test-fixtures/src/test_network.rs create mode 100644 linux/crates/test-fixtures/src/test_pki.rs create mode 100644 linux/crates/test-fixtures/tests/fixtures.rs create mode 100644 linux/crates/test-support/Cargo.toml create mode 100644 linux/crates/test-support/src/call_counters.rs create mode 100644 linux/crates/test-support/src/close_behaviour.rs create mode 100644 linux/crates/test-support/src/close_race.rs create mode 100644 linux/crates/test-support/src/conformance/bounded_ddl_engine.rs create mode 100644 linux/crates/test-support/src/conformance/cases/cancellation.rs create mode 100644 linux/crates/test-support/src/conformance/cases/close.rs create mode 100644 linux/crates/test-support/src/conformance/cases/commit_resolution.rs create mode 100644 linux/crates/test-support/src/conformance/cases/ddl.rs create mode 100644 linux/crates/test-support/src/conformance/cases/deadlines.rs create mode 100644 linux/crates/test-support/src/conformance/cases/liveness.rs create mode 100644 linux/crates/test-support/src/conformance/cases/metadata.rs create mode 100644 linux/crates/test-support/src/conformance/cases/mod.rs create mode 100644 linux/crates/test-support/src/conformance/cases/pool.rs create mode 100644 linux/crates/test-support/src/conformance/cases/read_only.rs create mode 100644 linux/crates/test-support/src/conformance/cases/row_guards.rs create mode 100644 linux/crates/test-support/src/conformance/cases/row_limit.rs create mode 100644 linux/crates/test-support/src/conformance/cases/statement_timeout.rs create mode 100644 linux/crates/test-support/src/conformance/cases/streaming.rs create mode 100644 linux/crates/test-support/src/conformance/cases/suite_flags.rs create mode 100644 linux/crates/test-support/src/conformance/cases/tls.rs create mode 100644 linux/crates/test-support/src/conformance/cases/type_fallback.rs create mode 100644 linux/crates/test-support/src/conformance/cases/writes.rs create mode 100644 linux/crates/test-support/src/conformance/client_identity_engine.rs create mode 100644 linux/crates/test-support/src/conformance/conformance_engine.rs create mode 100644 linux/crates/test-support/src/conformance/conformance_failure.rs create mode 100644 linux/crates/test-support/src/conformance/connection_suite_flags.rs create mode 100644 linux/crates/test-support/src/conformance/connection_suite_macro.rs create mode 100644 linux/crates/test-support/src/conformance/ddl_conformance_engine.rs create mode 100644 linux/crates/test-support/src/conformance/ddl_suite_macro.rs create mode 100644 linux/crates/test-support/src/conformance/editor_transaction_engine.rs create mode 100644 linux/crates/test-support/src/conformance/fake_engine.rs create mode 100644 linux/crates/test-support/src/conformance/mod.rs create mode 100644 linux/crates/test-support/src/conformance/network_conformance_engine.rs create mode 100644 linux/crates/test-support/src/conformance/pooled_conformance_engine.rs create mode 100644 linux/crates/test-support/src/conformance/run_case.rs create mode 100644 linux/crates/test-support/src/conformance/suite_endpoint.rs create mode 100644 linux/crates/test-support/src/conformance/support.rs create mode 100644 linux/crates/test-support/src/conformance/test_liveness.rs create mode 100644 linux/crates/test-support/src/conformance/tls_conformance_engine.rs create mode 100644 linux/crates/test-support/src/conformance/tls_suite_macro.rs create mode 100644 linux/crates/test-support/src/conformance/typed_conformance_engine.rs create mode 100644 linux/crates/test-support/src/conformance/unsupported_type_case.rs create mode 100644 linux/crates/test-support/src/connection_script.rs create mode 100644 linux/crates/test-support/src/csv_import.rs create mode 100644 linux/crates/test-support/src/executable.rs create mode 100644 linux/crates/test-support/src/fake_connection.rs create mode 100644 linux/crates/test-support/src/fake_dialect.rs create mode 100644 linux/crates/test-support/src/fake_driver.rs create mode 100644 linux/crates/test-support/src/fake_editor_session.rs create mode 100644 linux/crates/test-support/src/fake_prompter.rs create mode 100644 linux/crates/test-support/src/fake_reply.rs create mode 100644 linux/crates/test-support/src/fake_run.rs create mode 100644 linux/crates/test-support/src/fake_secret_vault.rs create mode 100644 linux/crates/test-support/src/fault/fault_state.rs create mode 100644 linux/crates/test-support/src/fault/fault_stream.rs create mode 100644 linux/crates/test-support/src/fault/fault_transport.rs create mode 100644 linux/crates/test-support/src/fault/mod.rs create mode 100644 linux/crates/test-support/src/fault/route_proxy.rs create mode 100644 linux/crates/test-support/src/history_cases.rs create mode 100644 linux/crates/test-support/src/lib.rs create mode 100644 linux/crates/test-support/src/replies.rs create mode 100644 linux/crates/test-support/src/stream_end.rs create mode 100644 linux/data/app.tablepro.TablePro.desktop.in.in create mode 100644 linux/data/app.tablepro.TablePro.gschema.xml.in create mode 100644 linux/data/app.tablepro.TablePro.metainfo.xml.in.in create mode 100644 linux/data/app.tablepro.TablePro.service.in create mode 100644 linux/data/icons/hicolor/scalable/apps/app.tablepro.TablePro.Devel.svg create mode 100644 linux/data/icons/hicolor/scalable/apps/app.tablepro.TablePro.svg create mode 100644 linux/data/icons/hicolor/symbolic/apps/app.tablepro.TablePro-symbolic.svg create mode 100644 linux/data/licenses/THIRD-PARTY-NOTICE.md create mode 100644 linux/data/licenses/third-party.json create mode 100644 linux/data/meson.build create mode 100644 linux/data/resources/gtk/menus.ui create mode 100644 linux/data/resources/icons/scalable/actions/document-modified-symbolic.svg create mode 100644 linux/data/resources/icons/scalable/actions/statement-pending-symbolic.svg create mode 100644 linux/data/resources/icons/scalable/actions/table-relation-symbolic.svg create mode 100644 linux/data/resources/resources.gresource.xml create mode 100644 linux/data/resources/shortcuts-dialog.ui create mode 100644 linux/data/resources/style.css create mode 100644 linux/data/resources/ui/browse-tab.ui create mode 100644 linux/data/resources/ui/connect-dialog.ui create mode 100644 linux/data/resources/ui/connect-failed-page.ui create mode 100644 linux/data/resources/ui/connecting-page.ui create mode 100644 linux/data/resources/ui/export-operations-button.ui create mode 100644 linux/data/resources/ui/filter-rule-row.ui create mode 100644 linux/data/resources/ui/filter-strip.ui create mode 100644 linux/data/resources/ui/find-bar.ui create mode 100644 linux/data/resources/ui/history-dialog.ui create mode 100644 linux/data/resources/ui/history-row.ui create mode 100644 linux/data/resources/ui/pager-bar.ui create mode 100644 linux/data/resources/ui/pending-changes-bar.ui create mode 100644 linux/data/resources/ui/picker-page.ui create mode 100644 linux/data/resources/ui/preferences-data-browser-group.ui create mode 100644 linux/data/resources/ui/preferences-dialog.ui create mode 100644 linux/data/resources/ui/preferences-editor-page.ui create mode 100644 linux/data/resources/ui/preferences-general-page.ui create mode 100644 linux/data/resources/ui/preferences-history-group.ui create mode 100644 linux/data/resources/ui/results-pane.ui create mode 100644 linux/data/resources/ui/session-window.ui create mode 100644 linux/data/resources/ui/sql-editor.ui create mode 100644 linux/data/resources/ui/sqlite-file-row.ui create mode 100644 linux/data/resources/ui/ssh-section.ui create mode 100644 linux/data/resources/ui/structure-tab.ui create mode 100644 linux/data/resources/ui/tls-section.ui create mode 100644 linux/data/resources/ui/welcome-view.ui create mode 100644 linux/data/resources/ui/workspace.ui create mode 100644 linux/data/screenshots/01-table.png create mode 100644 linux/data/screenshots/02-editor.png create mode 100644 linux/data/screenshots/03-structure.png create mode 100644 linux/data/screenshots/04-connect.png create mode 100644 linux/data/tablepro.1 create mode 100644 linux/deny.toml create mode 100644 linux/docs/README.md create mode 100644 linux/docs/adding-drivers.md create mode 100644 linux/docs/decisions/0001-no-plugin-system.md create mode 100644 linux/docs/decisions/0002-rust-gtk4-libadwaita.md create mode 100644 linux/docs/decisions/0003-relm4-architecture.md create mode 100644 linux/docs/decisions/0004-libsecret-secret-storage.md create mode 100644 linux/docs/decisions/0005-supply-chain-policy.md create mode 100644 linux/docs/decisions/0006-meson-cargo-build.md create mode 100644 linux/docs/decisions/0007-gsettings-preferences.md create mode 100644 linux/docs/decisions/0008-flatpak-sandbox-permissions.md create mode 100644 linux/docs/decisions/0009-crypto-provider-and-sources.md create mode 100644 linux/docs/decisions/0010-ddl-plans-and-table-definitions.md create mode 100644 linux/docs/decisions/0012-window-per-session.md create mode 100644 linux/docs/decisions/0013-openssh-client-transport.md create mode 100644 linux/docs/decisions/0014-lossless-values-and-dialects.md create mode 100644 linux/docs/decisions/0015-gnome-50-platform-floor.md create mode 100644 linux/docs/decisions/0016-driver-contract-v2.md create mode 100644 linux/docs/decisions/0017-distribution-channels.md create mode 100644 linux/docs/decisions/0018-ppa-copr-and-flathub-later.md create mode 100644 linux/docs/decisions/README.md create mode 100644 linux/docs/drivers/clickhouse.md create mode 100644 linux/docs/drivers/mysql.md create mode 100644 linux/docs/drivers/postgresql.md create mode 100644 linux/docs/drivers/sql-server.md create mode 100644 linux/docs/drivers/sqlite.md create mode 100644 linux/docs/error-handling.md create mode 100644 linux/docs/regression-tests.md create mode 100644 linux/docs/releasing.md create mode 100644 linux/docs/state-management.md create mode 100644 linux/docs/storage.md create mode 100644 linux/docs/testing.md create mode 100644 linux/docs/troubleshooting.md create mode 100644 linux/meson.build create mode 100644 linux/meson.options create mode 100644 linux/po/LINGUAS create mode 100644 linux/po/POTFILES.in create mode 100644 linux/po/README.md create mode 100644 linux/po/meson.build create mode 100644 linux/po/tablepro.pot create mode 100644 linux/rust-toolchain.toml create mode 100644 linux/rustfmt.toml create mode 100644 linux/towncrier.toml create mode 100644 scripts/ci/test_check_banned_words.py diff --git a/.claude/rules/changelog.md b/.claude/rules/changelog.md index 25734f6f74..223539ca16 100644 --- a/.claude/rules/changelog.md +++ b/.claude/rules/changelog.md @@ -1,19 +1,19 @@ --- paths: - - "CHANGELOG.md" - - "changelog.d/**/*" - - "towncrier.toml" + - "**/CHANGELOG.md" + - "**/changelog.d/**/*" + - "**/towncrier.toml" --- # Changelog -Unreleased changes are towncrier fragments in `changelog.d/`. A release turns them into a `CHANGELOG.md` section with `scripts/release/notes.py assemble` (`RELEASING.md`, Notes). +Unreleased changes are towncrier fragments in `changelog.d/`. A release turns them into a `CHANGELOG.md` section with `scripts/release/notes.py assemble` (`RELEASING.md`, Notes). The Linux app is a second towncrier project under the same rules, in `linux/changelog.d/` and `linux/CHANGELOG.md`; its release runs `towncrier build --dir linux` (`linux/docs/releasing.md`). - **One file per user-visible change**, named `changelog.d/..md`: `` is the pull request that adds it, `` is `added`, `changed`, `deprecated`, `removed`, `fixed` or `security`. A second entry of the same type in one PR adds a counter, as `towncrier create` does: `3452.fixed.1.md`, then `.2`. The counter does not order entries. - **One line, at most 120 characters, ending with a period.** Added and Changed name the new thing (`Open in Window for row inspector text fields.`); Fixed names the bug, not the fix (`Empty context menu when right-clicking a read-only inspector field.`). No file paths or type names. - **No refs, no credit, no `- ` prefix.** towncrier links the PR named by the file and adds the bullet; `notes.py` adds ` by @login` when the PR's author is not a maintainer. - **One entry per thing a user would notice.** Defects with the same visible symptom share an entry. - **No fragment** for a fix to something still unreleased (edit that fragment instead) or for a docs-only change. -- **Only a `chore(release): vX.Y.Z` PR edits `CHANGELOG.md`.** +- **Only a `chore(release): vX.Y.Z` PR edits `CHANGELOG.md`**, and only a `chore(release): linux-vX.Y.Z` PR edits `linux/CHANGELOG.md`. - **The lead block and `[CRITICAL]`**: `RELEASING.md`, Notes and Channels. -- `scripts/ci/check-changelog.py` checks the fragments' text, the headings and the lead blocks in Repo Hygiene, then `towncrier build --draft --version 0.0.0` renders the fragments. towncrier refuses an unknown type and a non-numeric PR number (`ignore`, `issue_pattern`). In PR title, `--pr` checks that a PR into main names each new fragment after itself, that only a release PR edits `CHANGELOG.md`, and that a release PR changes nothing else. +- `scripts/ci/check-changelog.py` checks both projects' fragment text, headings and lead blocks in Repo Hygiene, then `towncrier build --draft --version 0.0.0` renders the root fragments; `--dir linux` renders the Linux ones. towncrier refuses an unknown type and a non-numeric PR number (`ignore`, `issue_pattern`). In PR title, `--pr` checks that a PR into main names each new fragment after itself, that only a release PR edits a `CHANGELOG.md`, and that a release PR changes nothing else; a Linux one also sets the version. diff --git a/.claude/rules/ci.md b/.claude/rules/ci.md index 8215631cbc..9b2f3c0190 100644 --- a/.claude/rules/ci.md +++ b/.claude/rules/ci.md @@ -100,7 +100,7 @@ The required check names are in `AGENTS.md` (CI). Reading a red check: `.claude/ - `shellcheck --severity=warning` on every `*.sh` under `scripts` and `.claude/skills`; - the tooling tests: `scripts/ci/test_*.py`, `scripts/lib/test_*.py`, `scripts/test_localization.py` and `scripts/ci/test_libs_release.sh`; - `scripts/release/test_*.py` on Python 3.9 from `actions/setup-python`, in a venv that installs `scripts/release/requirements.txt` with `pip --require-hashes`; - - in the same venv, `check-changelog.py` and `towncrier build --draft --version 0.0.0`, which fails on a fragment name it cannot parse; + - in the same venv, `check-changelog.py` and `towncrier build --draft --version 0.0.0` for the root and `--dir linux`, which fails on a fragment name it cannot parse; - `check-lib-sourcing.py`, `check-plugin-manifest.py`, `check-plugin-provides.py`, `check-deployment-target.sh`, `check-ios-shared-isolation.py`; - `localization.py plugins` and `plugins --target ios`; - `check-string-catalogs.py`, `check-license-inventory.py`, `check-entitlements.py`, `check-test-plans.py`; @@ -119,6 +119,7 @@ The required check names are in `AGENTS.md` (CI). Reading a red check: `.claude/ - `ios` watches `TableProMobile/`, `Libs/`, `Plugins/TableProPluginKit/` (the package target is a symlink to it), and every `../` path it reads out of `TableProMobile/project.yml`. - `oracle` watches `Packages/TableProOracle/`; `hana` watches `Native/HanaBridge/`. Each suite also watches its own workflow. - `release` watches what the Release build reads: the app, plugin, package, native, config and libs folders, `project.yml`, the app's `Package.resolved`, `.github/plugin-registry.json`, the release scripts it runs (`build.sh`, `package.sh`, `registry.py`, `version.py`, `notes.py`), the build and CI scripts it runs and the setup actions. It skips `Plugins/*Tests/` and `Packages/*/Tests/`. +- `linux` watches `linux/`, the docs and banned-words scripts the Linux fast job runs, the detector itself and the three Linux workflows. - A path a suite newly reads joins that suite's pattern in the same change, or the suite skips on it. - `CHANGELOG.md` and `changelog.d/` are on no suite's pattern: the What's New phase writes nothing at `0.0.0-dev`, and Repo Hygiene renders every fragment and section instead. @@ -126,16 +127,16 @@ The required check names are in `AGENTS.md` (CI). Reading a red check: `.claude/ - A pull request: both changes jobs; macOS Tests and iOS and Package Tests unless their detector says false (Oracle only when its detector is not false); SwiftLint; Repo Hygiene; PR title. `AllPlugins` only when the `plugins` detector is not false. - A push to main or `release/**`: the same minus PR title, plus Release Build; never cancelled, `AllPlugins` always, the SPM cache saved on a miss. Every code push runs both plans in full; a docs-only or changelog-only push skips the suites through the detector. -- A `v*` tag runs only `release.yml`. The test workflows never run on a tag. +- A `v*` tag runs only `release.yml`, and a `linux-v*` tag only `release-linux.yml`, which calls `linux-packages.yml`. The test workflows never run on a tag. - `workflow_dispatch` exists on every workflow except `pr-title.yml` and `release.yml`. -- `build-linux.yml` is on `main` only for its Monday schedule, which GitHub runs from the default branch. Until the Linux app's directory lands on `main`, every build job skips and `weekly-dispatch` starts the workflow on the branch the `LINUX_BRANCH` variable names (the linux branch when unset), whose testing guide describes the jobs. +- `build-linux.yml` builds and tests the Linux app (`linux/docs/testing.md`, CI). Its `Changes (Linux)` job skips the rest when nothing under `linux/` or the scripts and workflows it reads changed, and its `Linux` job gates on all of them. ## Caches and runner budget - Swift package checkouts live in `~/.spm-cache`, keyed `spm--` with prefix restore. macOS Tests saves it only on a push and only on a miss: a cache saved on a PR ref is visible to that PR alone. - `release.yml` restores that cache and never saves it, since a save in tag scope is unreadable from other refs. - `release-build.yml` restores it and leaves the save to macOS Tests. -- There is no other cache: no libs or DerivedData cache, and none in the iOS, plugin or Ubuntu workflows. +- There is no other Apple cache: no libs or DerivedData cache, and none in the iOS or plugin workflows. The Linux jobs cache Cargo builds through `Swatinem/rust-cache` and the Flatpak build. - CI fits the GitHub Free and Pro plans: no paid larger runner, no self-hosted Mac. At most 5 macOS jobs run at once, and main runs queue behind PR runs for those slots. ## Writing workflow and lint code diff --git a/.claude/skills/fix-issue/references/platform-linux.md b/.claude/skills/fix-issue/references/platform-linux.md new file mode 100644 index 0000000000..3d03b4d8ad --- /dev/null +++ b/.claude/skills/fix-issue/references/platform-linux.md @@ -0,0 +1,54 @@ +# Platform: Linux + +The native Linux app. Every profile has these seven sections in this order, so a value is always in the same place. + +## Covers + +A report from Linux. The code is `linux/`: a Cargo workspace of crates under `linux/crates/` (the GTK app, the UI-free core, storage, sessions, networking, SSH and one crate per driver), built by Meson. Drivers are linked in at compile time; there are no plugins. A fix lands in one or more crates; name them. + +## Base + +- Worktree base: `origin/main`. +- Pull request base: `main`. + +## Done + +`linux/AGENTS.md`, plus the root `AGENTS.md` principles and writing style it points to. Under Claude Code, `linux/CLAUDE.md` loads it once a file under `linux/` is read; read it by path anyway in Phase 0, since the worktree path is not guaranteed to trigger that. Codex does not load it at all, so read it yourself. + +## Native standard + +GTK 4 and libadwaita, used the way the GNOME Human Interface Guidelines describe, with the widget the toolkit already provides before anything drawn by hand. The version floor is GNOME 50: GTK 4.22, libadwaita 1.9, GtkSourceView 5.18, GLib 2.88. The compiler enforces it through the binding crates' feature gates, so an API above the floor does not build. + +Sources, the API ground truth and how to build a probe: `references/research-linux.md`. + +## Verify + +Pass `--platform linux`, which a Linux machine picks by default. Every step runs inside `/linux` with the environment `meson devenv` gives Cargo. + +| Step | When | +| --- | --- | +| `setup` | a new tree; `linux/meson.build`, `linux/meson.options` or a file under `linux/data/` changed | +| `build` | always | +| `test [filter]` | always, for each crate you changed | +| `test` | before the pull request: every suite, as the CI fast job runs them | +| `lint` | always: rustfmt, then clippy with and without default features | +| `data` | a desktop entry, the GSettings schema, the metainfo or the icons changed | +| `l10n` | a user-facing string or a file that holds them changed | +| `docker ` | a driver, SSH or anything they connect through changed | +| `secrets` | `tablepro-storage` secret handling changed | +| `dist` | `meson.build` install rules changed (commit first) | + +- The first step after a merge from the base is `setup`. +- Pick packages by what you changed: `grep -rl linux/crates --include='*.rs'`. Package names are in each crate's `Cargo.toml`. +- Extra review pass: none beyond the security pass. +- Traps and remedies: `references/verification-linux.md`. + +## Screen + +Run the app from the tree with `meson devenv -C _build ./crates/app/tablepro` inside `linux/`. A development build uses its own application ID and data directories, so it never touches an installed one; point the `XDG_*_HOME` variables at a scratch directory as well when the shot must not show the user's real connections. Nothing drives the app from a script yet: only `app.*` actions reach D-Bus, and the window's actions do not. Launch it in the state the shot needs and capture with the `org.freedesktop.portal.Screenshot` portal, which GNOME allows for an unsandboxed caller; `org.gnome.Shell.Screenshot` refuses. The portal captures the whole screen, so crop to the window. Dark and light: `ADW_DEBUG_COLOR_SCHEME=force-dark` or `force-light`. + +## Ship + +- A user-visible change gets one fragment in `linux/changelog.d/` (`.claude/rules/changelog.md`), never in the root `changelog.d/`, which is the macOS release notes. +- Commit scope `linux`, or `linux-` for one area of the app. +- A probe worth keeping becomes a test in the crate it measures; the Linux workspace keeps no separate probe directory. diff --git a/.claude/skills/fix-issue/references/research-linux.md b/.claude/skills/fix-issue/references/research-linux.md new file mode 100644 index 0000000000..ae8d5959d3 --- /dev/null +++ b/.claude/skills/fix-issue/references/research-linux.md @@ -0,0 +1,47 @@ +# Research sources: Linux + +Where to find the correct behavior for the Linux app, and what counts as evidence for it. The method is in the research brief (`references/lanes.md`); this file is the sources. + +## GNOME platform + +- **API ground truth: the GObject Introspection files** under `/usr/share/gir-1.0/` (`Gtk-4.0.gir`, `Adw-1.gir`, `GtkSource-5.gir`, `Gio-2.0.gir`; GLib's is under `/usr/lib//gir-1.0/`). Each symbol carries the version that introduced it, so `grep -n -A3 '` gives each. +- Where the source does not settle it, measure: a test against the real engine (marked `#[ignore = "requires docker"]` when it needs a container) or a scratch Cargo project that links what we link. The workspace has no mocks, so a probe uses the real thing. + +## Comparable clients + +A Linux user describing how something "should" work is usually describing one of these. None of them is a GTK app, so they answer what capability users expect, never how it should look. + +| Client | Why read it | Where | +| --- | --- | --- | +| DBeaver | The default database client on Linux; widest driver and dialect coverage | `github.com/dbeaver/dbeaver` | +| DataGrip | Deepest SQL tooling; take the capability, not the interaction | `jetbrains.com/datagrip` | +| Beekeeper Studio | Open source, ships Linux builds | `github.com/beekeeper-studio/beekeeper-studio` | +| DbGate | Open source, cross-engine | `github.com/dbgate/dbgate` | +| pgAdmin 4 | The PostgreSQL reference tool | `github.com/pgadmin-org/pgadmin4` | +| DB Browser for SQLite | The SQLite reference tool | `github.com/sqlitebrowser/sqlitebrowser` | + +For how a surface should look and behave, read GNOME applications built on the same stack: + +| Application | Read it for | Where | +| --- | --- | --- | +| libadwaita demo | One page per pattern, in its `demo` folder | `gitlab.gnome.org/GNOME/libadwaita` | +| GNOME Text Editor | A GtkSourceView editor with tabs, a sidebar and search | `gitlab.gnome.org/GNOME/gnome-text-editor` | +| GNOME Builder | Panes, a source editor, search and trees at IDE scale | `gitlab.gnome.org/GNOME/gnome-builder` | +| Loupe, Snapshot, Fractal | Rust apps with the same Meson and Cargo shape | `gitlab.gnome.org/GNOME/loupe`, `gitlab.gnome.org/GNOME/snapshot`, `gitlab.gnome.org/World/fractal` | +| Nautilus | Sidebars, column views and selection mode | `gitlab.gnome.org/GNOME/nautilus` | + +None of these can be run from here, so every claim about one is confirmed from a source you read or marked inferred. diff --git a/.claude/skills/fix-issue/references/verification-linux.md b/.claude/skills/fix-issue/references/verification-linux.md new file mode 100644 index 0000000000..69f8d57548 --- /dev/null +++ b/.claude/skills/fix-issue/references/verification-linux.md @@ -0,0 +1,43 @@ +# Verification traps: Linux + +Read this when a `verify.sh` verdict needs interpreting for the Linux app, or before writing a test. The usage line is `verify.sh [--platform linux] [--root ] [--run ] [--offline] [args]`; on a Linux machine the platform is this one unless the option names another. + +## Why the wrapper, always + +Cargo prints one "test result:" line per test binary and never adds them up, three of the app crate's test binaries print no counts at all, and Meson prints only the failing tests. The wrapper sums the counts, treats a harness-free binary as one case, copies Meson's `testlog.txt` into the log before the next run overwrites it, and runs Cargo through `meson devenv` so it reuses the tree's build instead of starting a second one in `linux/target/`. + +## Setup and build + +- **A new tree has no `linux/_build`**: run `setup`, then `build`. Every other step reports `INCONCLUSIVE` until then. +- **The first build of a tree downloads every crate** into `linux/_build/cargo-home` and builds about 20 GB into `linux/_build/cargo-target`. No network means a failed build that is not the change; `--offline` sets `CARGO_NET_OFFLINE` once the tree has its crates. +- **`cargo` not on PATH** in a shell that never read the user's profile: the wrapper sources `~/.cargo/env`, which is where rustup puts it. Run by hand, `meson devenv cargo ...` fails with "Command not found: cargo". +- **A schema or desktop file edit needs `build`**, which recompiles the GSettings schema; a stale one aborts the app on its first settings read. +- **`setup` again after a merge** from the base, or after `linux/meson.build`, `linux/meson.options` or a template under `linux/data/` changed. A new `.rs` file needs nothing: Cargo finds modules itself. + +## Tests + +- **A filter that matches nothing passes.** `cargo test -p ` exits 0 with "0 passed", and `meson test --suite ` exits 0 with "No suitable tests defined". The wrapper reports a filter that matched no test as `INCONCLUSIVE`; check the executed count anyway. +- **Harness-free tests ignore the filter.** `startup`, `logging` and `i18n_catalog` in the app crate are `harness = false`: they run whatever the filter says, print nothing on success and count as one case each. +- **Exit 101 is both a compile error and a test failure.** The wrapper tells them apart by whether any test binary ran. +- **Widget tests need `GTK_A11Y=test` and `GSK_RENDERER=cairo`.** Meson's `gtk` suite sets them and `meson devenv` does not; the wrapper adds them for the `tablepro` package. Without the test backend GTK records no accessible properties and the accessibility helpers fail. +- **No display**: GTK fails to initialise ("Failed to initialize GTK", exit 101). Run inside the desktop session; without one the wrapper uses `xvfb-run` when it is installed, as CI does. Unsetting `DISPLAY` and `WAYLAND_DISPLAY` is not enough to reproduce it, since GDK falls back to `wayland-0`. +- **Tests that need a container are ignored** with `#[ignore = "requires docker"]` and run only through `docker `, which CI runs per package: `tablepro-driver-postgres`, `tablepro-driver-mysql`, `tablepro-driver-mssql`, `tablepro-driver-clickhouse`, `tablepro-ssh` and `tablepro-test-fixtures`. SQL Server publishes no arm64 image, so on an arm64 machine the MSSQL tests need x86 emulation and are otherwise CI's to run. +- **Secret Service tests are ignored** and run only through `secrets`, on a private session bus with a throwaway keyring. Never run them with `--ignored` on the user's own bus: they would write to the real keyring. +- **Every test runs on a private session bus** through `dbus-run-session`, as CI does, so test traffic never reaches the user's desktop. + +## Lint + +- **`lint` covers the whole workspace**, not the files you pass: rustfmt in check mode, then clippy with `-D warnings` with and without default features. The second run compiles the code behind `#[cfg(not(feature = "kerberos"))]`, which the default build never sees. +- **A bare `#[ignore]` fails clippy**: every ignore carries its reason. +- **`unwrap`, `expect` and `panic!` are denied** in the workspace lints, and `clippy.toml` allows them in tests only. The app crate has its own `clippy.toml` with methods it forbids, each with the reason. + +## Translations and data + +- **`l10n` checks the file list and the catalog**, not the template. Extracting the template needs gettext 1.0 or later: 0.24 to 0.26 read Rust but skip path-qualified calls such as `crate::i18n::gettext(...)`, and an older `xgettext` reads it with the C lexer. Neither template may be committed. CI's Translations job is the gate for it. +- **`i18n_catalog` needs the `en_US.UTF-8` locale and `msgfmt`**: `localedef -i en_US -f UTF-8 en_US.UTF-8` when the locale is missing. +- **`data` skips a validator whose tool is not installed** (`desktop-file-validate`, `appstreamcli`, `glib-compile-schemas`) and reports it. + +## Packaging + +- **`dist` packs the last commit** and refuses a tree with uncommitted changes, so run it after the commit. +- **The wrapper builds neither the Flatpak nor the packages.** CI builds both, and checks the workspace on the minimum Rust version (`linux/docs/testing.md`, CI). Locally: `flatpak-builder` (`linux/README.md`, Flatpak) and `linux/build-aux/packaging/build-packages.sh` (`linux/README.md`, Packages). diff --git a/.claude/skills/fix-issue/scripts/lib/platform-linux.sh b/.claude/skills/fix-issue/scripts/lib/platform-linux.sh new file mode 100644 index 0000000000..15660a1d66 --- /dev/null +++ b/.claude/skills/fix-issue/scripts/lib/platform-linux.sh @@ -0,0 +1,481 @@ +# shellcheck shell=bash +# shellcheck disable=SC2034 +# +# Steps on the Linux platform (the GTK app under linux/), each run in linux/ with the environment +# `meson devenv` gives Cargo: +# verify.sh [options] setup # meson setup _build, or reconfigure an existing one +# verify.sh [options] build # meson compile +# verify.sh [options] test [filter] # one crate's tests through Cargo +# verify.sh [options] test # every Meson suite, as the CI fast job runs them +# verify.sh [options] lint # rustfmt, then clippy with and without default features +# verify.sh [options] data # the desktop entry, schema and metainfo validators +# verify.sh [options] l10n # POTFILES.in against the tree, and the catalog test +# verify.sh [options] docker # the tests that need a container +# verify.sh [options] secrets # the Secret Service tests on a private session bus +# verify.sh [options] dist # meson dist of the last commit +# +# Here --offline keeps Cargo off the network. --no-wait changes nothing: Cargo waits for its own lock. + +# Sourced by verify.sh, which owns the options, the log and the verdict. STEP_DETAIL is read by +# emit over there, which is all SC2034 is off for. + +LINUX_DIR="$REPO_ROOT/linux" +BUILD_DIR="$LINUX_DIR/_build" +CARGO_FAILURE_PATTERN='^error(\[E[0-9]+\])?: |^test .* \.\.\. FAILED$|process didn.t exit successfully' + +# ---------------------------------------------------------------------------- environment + +# rustup puts cargo in ~/.cargo/bin and asks the shell to source ~/.cargo/env, which a shell that +# never read the user's profile has not done. Meson stored cargo's absolute path at setup, so +# compile and test work anyway, and only `meson devenv cargo` failed with "Command not found". +load_rust_environment() { + if ! command -v cargo > /dev/null 2>&1 && [ -f "$HOME/.cargo/env" ]; then + # shellcheck source=/dev/null + . "$HOME/.cargo/env" + fi + if is_offline; then + export CARGO_NET_OFFLINE=true + fi +} + +require_linux_tree() { + [ -f "$LINUX_DIR/meson.build" ] && return 0 + STATUS=INCONCLUSIVE + note "cause: $REPO_ROOT has no linux/. Make the tree from origin/main." + emit "" 2 +} + +require_configured() { + [ -f "$BUILD_DIR/build.ninja" ] && return 0 + STATUS=INCONCLUSIVE + note "cause: linux/_build is not configured in this tree. Run the setup step first. Nothing was run." + emit "" 2 +} + +prepare() { + require_linux_tree + load_rust_environment + require_tool meson + require_tool cargo +} + +# Test traffic stays off the user's session bus, as in CI. Without a display the GTK suite cannot +# initialise, so it gets a virtual one when xvfb-run is there to provide it. +test_wrapper() { + if [ -z "${DISPLAY:-}" ] && [ -z "${WAYLAND_DISPLAY:-}" ] && command -v xvfb-run > /dev/null 2>&1; then + printf '%s\n' xvfb-run -a + fi + if command -v dbus-run-session > /dev/null 2>&1; then + printf '%s\n' dbus-run-session -- + fi +} + +in_devenv() { + meson devenv -C "$BUILD_DIR" --workdir "$LINUX_DIR" "$@" +} + +# A service the private session bus started can outlive the step and still write to the log. With +# the file opened for truncation, its writes landed at its own offset, over whatever the step had +# appended since. Opened for appending, every write goes to the end. +run_appending() { + local log="$1"; shift + : > "$log" + "$@" >> "$log" 2>&1 +} + +# Known false failures. Each one presents as a code defect and is not one. +diagnose_environment() { + local log="$1" found=1 + if grep -q 'Failed to initialize GTK' "$log" 2> /dev/null; then + note "cause: GTK found no display. Run inside the desktop session, or install xvfb-run." + found=0 + fi + if grep -qE "Unable to update registry|failed to download|failed to fetch|Couldn't resolve host|failed to get .* as a dependency" "$log" 2> /dev/null; then + note "cause: Cargo could not reach the network. A new tree downloads every crate into linux/_build/cargo-home on its first build." + found=0 + fi + if grep -q 'No space left on device' "$log" 2> /dev/null; then + note "cause: the disk is full. Each tree's linux/_build holds about 20 GB." + found=0 + fi + if grep -qE 'Cannot connect to the Docker daemon|permission denied while trying to connect to the [Dd]ocker' "$log" 2> /dev/null; then + note "cause: Docker is not reachable from this shell." + found=0 + fi + return $found +} + +# Compiler and clippy diagnostics print the message on one line and the location on a later +# "-->" line. Pair them, so a note names the file and line instead of only the message. +report_errors() { + local log="$1" errors total + errors="$(awk ' + /^(error|warning)(\[[A-Za-z0-9_:]+\])?: / { message = $0; next } + message != "" && /^ *--> / { sub(/^ *--> /, ""); print $0 ": " message; message = "" } + ' "$log" 2> /dev/null | grep -v ': warning: build failed' | sort -u | head -12)" + [ -n "$errors" ] || errors="$(grep -E '^error' "$log" 2> /dev/null | sort -u | head -12)" + [ -n "$errors" ] || return 0 + echo "errors:" + printf '%s\n' "$errors" | sed 's/^/ /' + total="$(grep -cE '^error(\[E[0-9]+\])?: ' "$log" 2> /dev/null)" + [ "${total:-0}" -gt 12 ] && echo " … $((total - 12)) more, see the log" +} + +# rustfmt names each file it would change as "Diff in ::", and an older one as +# "Diff in at line :". +report_unformatted() { + local unformatted + unformatted="$(sed -nE 's/^Diff in (.*):([0-9]+):$/\1:\2/p; s/^Diff in (.*) at line ([0-9]+):$/\1:\2/p' "$1" 2> /dev/null \ + | sed "s|^$LINUX_DIR/||" | sort -u | head -8)" + [ -n "$unformatted" ] || return 0 + note "not formatted (cargo fmt --all fixes it):" + note "$(printf '%s\n' "$unformatted" | sed 's/^/ /')" +} + +# ---------------------------------------------------------------------------- test reporting + +# `cargo test` sums nothing itself: each test binary prints its own "test result:" line. Three +# binaries in the app crate have no harness and print no counts, so each one is a single case, +# failed when Cargo names it in "process didn't exit successfully". A filter that matches nothing +# exits 0 with "0 passed", and a harness-free binary ignores the filter and runs anyway, so +# "nothing matched" is decided on the counted cases alone. +report_tests() { + local log="$1" filtered="$2" counts passed failed harnessless harnessless_failed executed + counts="$(awk ' + function close_block() { if (open && !counted) harnessless++; open = 0 } + /^ *(Running|Doc-tests) / { close_block(); open = 1; counted = 0; next } + /^running [0-9]+ tests?$/ { counted = 1 } + /^test result: / { + for (i = 1; i <= NF; i++) { + if ($(i + 1) ~ /^passed/) passed += $i + if ($(i + 1) ~ /^failed/) failed += $i + } + } + /^ *Summary \[/ { + for (i = 1; i <= NF; i++) { + if ($(i + 1) ~ /^passed/) passed += $i + if ($(i + 1) ~ /^failed/) failed += $i + } + } + END { close_block(); print passed + 0, failed + 0, harnessless + 0 } + ' "$log" 2> /dev/null)" + read -r passed failed harnessless <<< "$counts" + harnessless_failed="$(grep -cE "process didn.t exit successfully: .*/deps/(startup|logging|i18n_catalog)-" "$log" 2> /dev/null)" + executed=$((passed + failed + harnessless)) + note "cases: $executed executed, $((passed + harnessless - harnessless_failed)) passed, $((failed + harnessless_failed)) failed" + + if [ "$filtered" = 1 ] && [ $((passed + failed)) -eq 0 ]; then + STATUS=INCONCLUSIVE + note "cause: the filter matched no test. Cargo still exits 0 and prints \"0 passed\"." + return + fi + if [ "$executed" -eq 0 ]; then + STATUS=INCONCLUSIVE + note "cause: zero cases executed. Read the log." + return + fi + if [ "$failed" -eq 0 ] && [ "$harnessless_failed" -eq 0 ] && ! grep -qE "$CARGO_FAILURE_PATTERN" "$log" 2> /dev/null; then + STATUS=PASS + return + fi + STATUS=FAIL + local names + names="$(sed -nE 's/^test (.*) \.\.\. FAILED$/\1/p; s/.*process didn.t exit successfully: .*\/deps\/([a-z_]+)-[0-9a-f]+.*/\1 (harness-free test binary)/p' "$log" 2> /dev/null | sort -u | head -10)" + if [ -n "$names" ]; then + note "failing tests:" + note "$(printf '%s\n' "$names" | sed 's/^/ /')" + fi +} + +# A compile error ends the run before any test, with the same exit code a failing test has. +compile_failed() { + grep -qE '^error: could not compile|^error(\[E[0-9]+\])?: ' "$1" 2> /dev/null \ + && ! grep -qE '^test result: |^running [0-9]+ tests?$' "$1" 2> /dev/null +} + +# meson test prints only the failing tests and keeps every test's output in testlog.txt, which the +# next run overwrites. Copy it into the log, which is what the counts are read from. +append_meson_testlog() { + local log="$1" testlog="$BUILD_DIR/meson-logs/testlog.txt" + [ -f "$testlog" ] || return 0 + { + echo "== $testlog" + cat "$testlog" + } >> "$log" +} + +# The summary Meson printed, read from before the copied testlog.txt, which repeats it. +meson_summary() { + awk '/^== .*testlog\.txt$/ { exit } /^(Ok|Expected Fail|Fail|Unexpected Pass|Skipped|Timeout): +[0-9]+/' "$1" 2> /dev/null \ + | tr -s ' ' | paste -sd ' ' - +} + +# ---------------------------------------------------------------------------- steps + +platform_parse() { + local log="$1" + STATUS=PASS + case "$(basename "$log")" in + test-* | docker-* | secrets-*) + report_tests "$log" 0 + ;; + *) + if grep -qE '^error|^Diff in ' "$log" 2> /dev/null; then + STATUS=FAIL + report_unformatted "$log" + note "$(report_errors "$log")" + fi + ;; + esac + diagnose_environment "$log" && STATUS=INCONCLUSIVE +} + +platform_step() { + case "$STEP" in + setup) + STEP_DETAIL="" + prepare + log="$(new_log setup)" + if [ -f "$BUILD_DIR/build.ninja" ]; then + (cd "$LINUX_DIR" && meson setup --reconfigure _build -Dprofile=development) > "$log" 2>&1 + else + (cd "$LINUX_DIR" && meson setup _build -Dprofile=development) > "$log" 2>&1 + fi + code=$? + if [ $code -eq 0 ]; then + STATUS=PASS + else + STATUS=FAIL + note "$(grep -E 'ERROR|error' "$log" | tail -5 | sed 's/^/ /')" + diagnose_environment "$log" && STATUS=INCONCLUSIVE + fi + emit "$log" $code + ;; + + build) + STEP_DETAIL="" + prepare + require_configured + log="$(new_log build)" + run_logged "$log" meson compile -C "$BUILD_DIR" + code=$? + if [ $code -eq 0 ]; then + STATUS=PASS + elif diagnose_environment "$log"; then + STATUS=INCONCLUSIVE + else + STATUS=FAIL + note "$(report_errors "$log")" + fi + emit "$log" $code + ;; + + test) + prepare + require_configured + if [ $# -eq 0 ]; then + STEP_DETAIL="every Meson suite" + log="$(new_log test-suites)" + local wrapper=() + mapfile -t wrapper < <(test_wrapper) + run_appending "$log" ${wrapper[@]+"${wrapper[@]}"} meson test -C "$BUILD_DIR" --print-errorlogs + code=$? + append_meson_testlog "$log" + note "suites: $(meson_summary "$log")" + report_tests "$log" 0 + [ $code -ne 0 ] && [ "$STATUS" = PASS ] && STATUS=FAIL + else + local package="$1" filter=() + shift + [ $# -ge 1 ] && filter=("$@") + STEP_DETAIL="$package${filter[0]:+ $*}" + log="$(new_log "test-$package")" + local wrapper=() gtk_env=() + mapfile -t wrapper < <(test_wrapper) + # The app crate's widget tests need the accessibility test backend and a renderer + # that works without a GPU, which only Meson's gtk suite sets; devenv does not. + [ "$package" = tablepro ] && gtk_env=(env GTK_A11Y=test GSK_RENDERER=cairo) + run_appending "$log" ${wrapper[@]+"${wrapper[@]}"} ${gtk_env[@]+"${gtk_env[@]}"} \ + meson devenv -C "$BUILD_DIR" --workdir "$LINUX_DIR" \ + cargo test --locked --no-fail-fast -p "$package" ${filter[@]+"${filter[@]}"} + code=$? + if grep -q "package ID specification .* did not match any packages" "$log" 2> /dev/null; then + echo "no such package: $package (the names are in each linux/crates/*/Cargo.toml)" >&2 + exit 3 + fi + if compile_failed "$log"; then + STATUS=FAIL + note "cause: the tests failed to compile. The errors below are compile errors, not test failures." + note "$(report_errors "$log")" + else + local filtered=0 + [ ${#filter[@]} -gt 0 ] && filtered=1 + report_tests "$log" "$filtered" + [ $code -ne 0 ] && [ "$STATUS" = PASS ] && STATUS=FAIL + fi + fi + diagnose_environment "$log" && STATUS=INCONCLUSIVE + emit "$log" $code + ;; + + lint) + STEP_DETAIL="rustfmt, clippy, clippy --no-default-features" + prepare + require_configured + log="$(new_log lint)" + : > "$log" + code=0 + { + echo "== cargo fmt --all -- --check" + (cd "$LINUX_DIR" && cargo fmt --all -- --check) || code=1 + echo "== cargo clippy --locked --workspace --all-targets -- -D warnings" + in_devenv cargo clippy --locked --workspace --all-targets -- -D warnings || code=1 + echo "== cargo clippy --locked --workspace --all-targets --no-default-features -- -D warnings" + in_devenv cargo clippy --locked --workspace --all-targets --no-default-features -- -D warnings || code=1 + } >> "$log" 2>&1 + if [ $code -eq 0 ]; then + STATUS=PASS + note "violations: 0" + elif diagnose_environment "$log"; then + STATUS=INCONCLUSIVE + else + STATUS=FAIL + report_unformatted "$log" + note "$(report_errors "$log")" + fi + emit "$log" $code + ;; + + data) + STEP_DETAIL="desktop entry, schema, metainfo" + prepare + require_configured + log="$(new_log data)" + run_logged "$log" meson test -C "$BUILD_DIR" --no-rebuild --suite data --print-errorlogs + code=$? + note "suites: $(meson_summary "$log")" + if [ $code -ne 0 ]; then + STATUS=FAIL + note "$(grep -vE '^(Ok|Fail|Skipped|Expected Fail|Unexpected Pass|Timeout):' "$log" | grep -iE 'error|fail|invalid' | head -10 | sed 's/^/ /')" + elif grep -qE '^Ok: +0$' "$log"; then + STATUS=INCONCLUSIVE + note "cause: no validator ran. Each one is skipped when its tool is not installed." + else + STATUS=PASS + grep -qE '^Skipped: +[1-9]' "$log" && note "some validators were skipped: their tools are not installed here" + fi + emit "$log" $code + ;; + + l10n) + STEP_DETAIL="POTFILES.in, i18n_catalog" + prepare + require_configured + log="$(new_log l10n)" + : > "$log" + code=0 + { + # The same file list CI regenerates, compared without writing to the tree. + echo "== POTFILES.in against the tree" + (cd "$LINUX_DIR" && git ls-files 'crates/app/src/*.rs' 'data/resources/*.ui' 'data/*.desktop.in.in' \ + 'data/*.gschema.xml.in' 'data/*.metainfo.xml.in.in' | diff - po/POTFILES.in) || code=1 + echo "== cargo test -p tablepro --test i18n_catalog" + in_devenv cargo test --locked -p tablepro --test i18n_catalog || code=1 + } >> "$log" 2>&1 + if [ $code -eq 0 ]; then + STATUS=PASS + else + STATUS=FAIL + note "$(grep -E '^[<>] |could not|missing|error' "$log" | head -10 | sed 's/^/ /')" + grep -qE '^[<>] ' "$log" && note " POTFILES.in is stale: list the files above in linux/po/POTFILES.in" + diagnose_environment "$log" && STATUS=INCONCLUSIVE + fi + note "the POT template itself is checked by CI's Translations job, which needs gettext 0.24 or later" + emit "$log" $code + ;; + + docker) + [ $# -ge 1 ] || usage + local package="$1" + STEP_DETAIL="$package" + prepare + require_configured + log="$(new_log "docker-$package")" + if ! docker version > "$log" 2>&1; then + STATUS=INCONCLUSIVE + note "cause: Docker is not reachable from this shell. Nothing was run." + emit "$log" 2 + fi + # CI runs these through nextest, whose test groups keep one container engine busy at a + # time. Without nextest, one thread does the same. + if cargo nextest --version > /dev/null 2>&1; then + run_logged "$log" in_devenv cargo nextest run --locked --profile ci -p "$package" --run-ignored only + else + run_logged "$log" in_devenv cargo test --locked --no-fail-fast -p "$package" -- --ignored --test-threads=1 + note "cargo-nextest is not installed, so this ran through cargo test on one thread" + fi + code=$? + if compile_failed "$log"; then + STATUS=FAIL + note "$(report_errors "$log")" + else + report_tests "$log" 0 + [ $code -ne 0 ] && [ "$STATUS" = PASS ] && STATUS=FAIL + fi + diagnose_environment "$log" && STATUS=INCONCLUSIVE + emit "$log" $code + ;; + + secrets) + STEP_DETAIL="tablepro-storage secret_service" + prepare + require_configured + require_tool dbus-run-session + require_tool gnome-keyring-daemon + log="$(new_log secrets)" + # gnome-keyring-daemon keeps its keyrings under XDG_DATA_HOME and its control socket + # under XDG_RUNTIME_DIR. A private bus alone left both pointing at the user's own, so + # this step rewrote the developer's login keyring with the test password. Both now + # point at a directory that exists only for this run. + local keyring_home + keyring_home="$(mktemp -d)" || { STATUS=INCONCLUSIVE; note "cause: no temporary directory for the throwaway keyring. Nothing was run."; emit "" 2; } + mkdir -m 700 "$keyring_home/data" "$keyring_home/run" + run_appending "$log" env XDG_DATA_HOME="$keyring_home/data" XDG_RUNTIME_DIR="$keyring_home/run" \ + dbus-run-session -- bash -c \ + "gnome-keyring-daemon --components=secrets --daemonize --unlock <<< ci > /dev/null \ + && meson devenv -C '$BUILD_DIR' --workdir '$LINUX_DIR' \ + cargo test --locked -p tablepro-storage --test secret_service -- --ignored" + code=$? + rm -rf "$keyring_home" + report_tests "$log" 0 + [ $code -ne 0 ] && [ "$STATUS" = PASS ] && STATUS=FAIL + diagnose_environment "$log" && STATUS=INCONCLUSIVE + emit "$log" $code + ;; + + dist) + STEP_DETAIL="" + prepare + require_configured + log="$(new_log dist)" + run_logged "$log" meson dist -C "$BUILD_DIR" --no-tests --formats xztar + code=$? + if [ $code -eq 0 ]; then + STATUS=PASS + note "$(grep -E '^Created ' "$log" | sed 's/^/ /')" + elif grep -qi 'uncommitted\|not clean\|local changes' "$log"; then + STATUS=INCONCLUSIVE + note "cause: meson dist packs the last commit and refuses a tree with uncommitted changes. Commit first." + else + STATUS=FAIL + note "$(tail -8 "$log" | sed 's/^/ /')" + fi + emit "$log" $code + ;; + + *) + echo "unknown step: $STEP" >&2 + usage + ;; + esac +} diff --git a/.gitattributes b/.gitattributes index 9ce661c79d..8f50204cdd 100644 --- a/.gitattributes +++ b/.gitattributes @@ -43,7 +43,21 @@ *.yaml text eol=lf *.plist text eol=lf -# Vendored C headers (database driver bridges, tree-sitter grammars) +# Vendored C headers (database driver bridges, SQLite, tree-sitter grammars) Plugins/*/C*/include/** linguist-vendored +Packages/TableProCore/Sources/CSQLite/include/sqlite3*.h linguist-vendored TablePro/Core/SSH/CLibSSH2/** linguist-vendored Packages/TableProGrammars/Sources/TreeSitterGrammars/** linguist-vendored + +# Third-party license texts, copied verbatim +Native/DamengBridge/LICENSES/** linguist-vendored +Packages/TableProEditor/LICENSE-*.md linguist-vendored +TablePro/Resources/ThirdPartyLicenses/texts/** linguist-vendored +# Joins the SQLite copyright page and the source blessing with a sentence of its own. +TablePro/Resources/ThirdPartyLicenses/texts/sqlite.txt -linguist-vendored + +# Generated Linux files (Cargo, xgettext, cargo-about, license_metadata.py) +linux/Cargo.lock linguist-generated +linux/po/tablepro.pot linguist-generated +linux/data/licenses/** linguist-generated +linux/build-aux/debian/copyright linguist-generated diff --git a/.github/workflows/build-linux.yml b/.github/workflows/build-linux.yml index 3a430ff54d..2057c4edb9 100644 --- a/.github/workflows/build-linux.yml +++ b/.github/workflows/build-linux.yml @@ -1,19 +1,9 @@ name: Build Linux on: - push: - branches: [main, linux, "linux/**", "feat/linux-**"] - paths: - - "linux/**" - - ".github/workflows/build-linux.yml" - - ".github/workflows/linux-packages.yml" - - ".github/workflows/release-linux.yml" pull_request: - paths: - - "linux/**" - - ".github/workflows/build-linux.yml" - - ".github/workflows/linux-packages.yml" - - ".github/workflows/release-linux.yml" + push: + branches: [main] schedule: - cron: "13 14 * * 1" workflow_dispatch: @@ -35,51 +25,27 @@ env: LYCHEE_SHA256: 1f4e0ef7f6554a6ed33dd7ac144fb2e1bbed98598e7af973042fc5cd43951c9a jobs: - # The same file runs on main, where linux/ does not exist until the - # first Linux release. Every build job waits for this one and is skipped - # there. - detect: - name: Linux tree + changes: + name: Changes (Linux) runs-on: ubuntu-24.04 timeout-minutes: 5 outputs: - linux: ${{ steps.tree.outputs.linux }} + run: ${{ steps.decide.outputs.run }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: + fetch-depth: 0 persist-credentials: false - sparse-checkout: linux/meson.build - sparse-checkout-cone-mode: false - - name: Look for linux/meson.build - id: tree - run: | - if [ -f linux/meson.build ]; then - echo "linux=true" >> "$GITHUB_OUTPUT" - else - echo "linux=false" >> "$GITHUB_OUTPUT" - fi - - # GitHub runs a schedule only from the default branch. Until linux/ is - # on main, the weekly run starts this workflow on the Linux branch. - weekly-dispatch: - name: Start the weekly run on the Linux branch - needs: detect - if: github.event_name == 'schedule' && needs.detect.outputs.linux == 'false' - runs-on: ubuntu-24.04 - timeout-minutes: 5 - permissions: - actions: write - steps: - - name: Dispatch this workflow + - name: Decide whether the Linux jobs run + id: decide env: - GH_TOKEN: ${{ github.token }} - LINUX_BRANCH: ${{ vars.LINUX_BRANCH || 'linux' }} - run: gh workflow run build-linux.yml --repo "$GITHUB_REPOSITORY" --ref "$LINUX_BRANCH" + BASE: ${{ github.event.pull_request.base.sha || github.event.before }} + run: echo "run=$(scripts/ci/detect-changed-paths.sh --base "$BASE" linux)" >> "$GITHUB_OUTPUT" fast: name: Fast checks - needs: detect - if: needs.detect.outputs.linux == 'true' + needs: changes + if: ${{ !cancelled() && needs.changes.outputs.run != 'false' }} runs-on: ubuntu-24.04 timeout-minutes: 150 # The host runner ships GLib 2.80, below the GNOME 50 floor. @@ -174,7 +140,7 @@ jobs: run: | # The $0 in the awk program is awk's record, not a shell variable. # shellcheck disable=SC2016 - found=$(find crates/app/src/ui -name '*.rs' ! -name tests.rs -print0 \ + found=$(find crates/app/src/ui -name '*.rs' ! -name tests.rs ! -path '*/tests/*' -print0 \ | xargs -0 awk '/^mod tests \{/ { nextfile } /std::fs::/ || /\.(exists|try_exists|is_dir|is_file|is_symlink|canonicalize|read_dir|read_link|symlink_metadata)\(/ || (/_blocking\(/ && !/spawn_blocking_task\(/) { print FILENAME ":" FNR ": " $0 }') if [ -n "$found" ]; then echo "$found" @@ -260,8 +226,8 @@ jobs: # compiler and not with rustup's. msrv: name: Minimum Rust version - needs: detect - if: needs.detect.outputs.linux == 'true' + needs: changes + if: ${{ !cancelled() && needs.changes.outputs.run != 'false' }} runs-on: ubuntu-24.04 timeout-minutes: 60 container: @@ -295,8 +261,8 @@ jobs: docs: name: Documentation links - needs: detect - if: needs.detect.outputs.linux == 'true' + needs: changes + if: ${{ !cancelled() && needs.changes.outputs.run != 'false' }} runs-on: ubuntu-24.04 timeout-minutes: 10 steps: @@ -319,8 +285,8 @@ jobs: supply-chain: name: Supply chain - needs: detect - if: needs.detect.outputs.linux == 'true' + needs: changes + if: ${{ !cancelled() && needs.changes.outputs.run != 'false' }} runs-on: ubuntu-24.04 timeout-minutes: 30 defaults: @@ -365,8 +331,8 @@ jobs: i18n: name: Translations - needs: detect - if: needs.detect.outputs.linux == 'true' + needs: changes + if: ${{ !cancelled() && needs.changes.outputs.run != 'false' }} runs-on: ubuntu-24.04 timeout-minutes: 30 # ubuntu:26.10 ships gettext 1.0, the first release whose xgettext @@ -428,8 +394,8 @@ jobs: flatpak-devel: name: Flatpak Devel (${{ matrix.arch }}) - needs: detect - if: needs.detect.outputs.linux == 'true' + needs: changes + if: ${{ !cancelled() && needs.changes.outputs.run != 'false' }} strategy: fail-fast: false matrix: @@ -467,8 +433,8 @@ jobs: integration: name: Docker tests (${{ matrix.package }}) - needs: [detect, fast] - if: needs.detect.outputs.linux == 'true' + needs: [changes, fast] + if: ${{ !cancelled() && needs.changes.outputs.run != 'false' && needs.fast.result == 'success' }} runs-on: ubuntu-24.04 timeout-minutes: 90 strategy: @@ -530,8 +496,8 @@ jobs: # is mounted; testcontainers sees /.dockerenv and reaches the # containers it starts through the bridge gateway. name: Docker tests (tablepro-session) - needs: [detect, fast] - if: needs.detect.outputs.linux == 'true' + needs: [changes, fast] + if: ${{ !cancelled() && needs.changes.outputs.run != 'false' && needs.fast.result == 'success' }} runs-on: ubuntu-24.04 timeout-minutes: 90 container: @@ -593,8 +559,8 @@ jobs: # session job. Only the named test targets run: the app's startup # test needs Meson and the fast job already covers it. name: Docker tests (tablepro) - needs: [detect, fast] - if: needs.detect.outputs.linux == 'true' + needs: [changes, fast] + if: ${{ !cancelled() && needs.changes.outputs.run != 'false' && needs.fast.result == 'success' }} runs-on: ubuntu-24.04 timeout-minutes: 90 container: @@ -650,3 +616,21 @@ jobs: with: name: junit-tablepro path: linux/target/nextest/ci/junit.xml + + # The one check a branch rule requires: a job skipped by a path decision + # reports success, and this fails when any Linux job failed or was cancelled. + linux: + name: Linux + needs: [changes, fast, msrv, docs, supply-chain, i18n, flatpak-devel, integration, integration-session, integration-app] + if: always() + runs-on: ubuntu-24.04 + timeout-minutes: 5 + steps: + - name: Every Linux job passed or was skipped + env: + RESULTS: ${{ join(needs.*.result, ' ') }} + run: | + echo "$RESULTS" + case " $RESULTS " in + *" failure "* | *" cancelled "*) exit 1 ;; + esac diff --git a/.github/workflows/linux-packages.yml b/.github/workflows/linux-packages.yml new file mode 100644 index 0000000000..c2292ee4b5 --- /dev/null +++ b/.github/workflows/linux-packages.yml @@ -0,0 +1,245 @@ +name: Linux packages + +# Builds the Ubuntu and Fedora packages from the release source tarballs. +# The Debian source package is built once for each Ubuntu series the PPA +# builds for, and its binaries are built from it the way Launchpad builds +# them: offline, in a fresh container with only the declared build +# dependencies, then checked with lintian. The .rpm is built in a fresh +# fedora:44 with only its BuildRequires. Each package is then installed in +# another fresh container with only its runtime dependencies and started. +# release-linux.yml calls this workflow and uploads the source packages. +# A run by hand can add arm64, which pushes and pull requests leave out. +# Given a release tag, it packages that release's tarballs instead, with +# the packaging from this checkout, for a PPA upload with a new revision. + +on: + pull_request: + paths: + - "linux/build-aux/debian/**" + - "linux/build-aux/rpm/**" + - "linux/build-aux/packaging/**" + - "linux/Cargo.toml" + - "linux/Cargo.lock" + - "linux/meson.build" + - "linux/meson.options" + - "linux/crates/**/meson.build" + - "linux/data/**/meson.build" + - "linux/po/meson.build" + - ".github/workflows/linux-packages.yml" + # After a merge every change runs, so a failure only one distribution's + # build shows turns up then and not at tag time. + push: + branches: [main] + paths: + - "linux/**" + - ".github/workflows/linux-packages.yml" + workflow_dispatch: + inputs: + arm64: + description: Also build, test and install the arm64 .deb and .rpm + type: boolean + default: false + workflow_call: + inputs: + deb-runners: + description: JSON list of runners; each series' .deb is built once per runner architecture. + type: string + default: '["ubuntu-24.04"]' + rpm-runners: + description: JSON list of runners; the .rpm is built once per runner architecture. + type: string + default: '["ubuntu-24.04"]' + release-tag: + description: A published linux-vX.Y.Z whose tarballs are packaged, with the .deb packaging from this checkout. No .rpm is built. + type: string + default: "" + revision: + description: The PPA upload number, the N in X.Y.Z-0ppaN. + type: string + default: "1" + +permissions: + contents: read + +jobs: + sources: + name: Source tarballs + runs-on: ubuntu-24.04 + timeout-minutes: 30 + container: + image: ubuntu:26.04 + steps: + # Before the checkout: without git, actions/checkout downloads a + # tarball, which leaves no .git for meson dist to archive. + - name: Install git + run: | + apt-get update + apt-get install -y --no-install-recommends ca-certificates git + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Make the source and vendor tarballs + if: inputs.release-tag == '' + run: linux/build-aux/packaging/make-sources.sh --install-deps _packages/sources + - name: Download the released source and vendor tarballs + if: inputs.release-tag != '' + env: + TAG: ${{ inputs.release-tag }} + run: linux/build-aux/packaging/fetch-release-sources.sh --install-deps "$GITHUB_REPOSITORY" "$TAG" _packages/sources + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: linux-sources + path: _packages/sources + if-no-files-found: error + + source-package: + name: Debian source packages + needs: sources + runs-on: ubuntu-24.04 + timeout-minutes: 30 + container: + image: ubuntu:26.04 + env: + # Every Ubuntu series the PPA builds for. + UBUNTU_SERIES: resolute stonking + outputs: + series: ${{ steps.build.outputs.series }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: linux-sources + path: _packages/sources + - name: Build an unsigned source package for each series + id: build + env: + RELEASE_TAG: ${{ inputs.release-tag }} + REVISION: ${{ inputs.revision || '1' }} + run: | + dist="$(find _packages/sources -name 'tablepro-*.tar.xz' ! -name '*-vendor.tar.xz')" + vendor="$(find _packages/sources -name 'tablepro-*-vendor.tar.xz')" + set -- --revision "$REVISION" + if [ -n "$RELEASE_TAG" ]; then + set -- "$@" --debian linux/build-aux/debian + fi + # shellcheck disable=SC2086 # one word per series + linux/build-aux/packaging/build-source.sh --install-deps "$@" "$dist" "$vendor" _packages/source $UBUNTU_SERIES + series="" + for name in $UBUNTU_SERIES; do + series="$series${series:+, }\"$name\"" + done + echo "series=[$series]" >> "$GITHUB_OUTPUT" + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: linux-source-package + path: _packages/source + if-no-files-found: error + + # On the runner itself rather than in a job container, so build-deb.sh + # can start the containers it builds in, the build one with no network. + deb: + name: Debian package (${{ matrix.series }}, ${{ matrix.runner }}) + needs: source-package + strategy: + fail-fast: false + matrix: + runner: ${{ fromJSON(inputs.deb-runners || (inputs.arm64 && '["ubuntu-24.04", "ubuntu-24.04-arm"]') || '["ubuntu-24.04"]') }} + series: ${{ fromJSON(needs.source-package.outputs.series) }} + runs-on: ${{ matrix.runner }} + timeout-minutes: 300 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: linux-source-package + path: _packages/source + - name: Build offline, test and lint the package + env: + SERIES: ${{ matrix.series }} + run: | + changes="$(grep -l -x "Distribution: $SERIES" _packages/source/*_source.changes)" + linux/build-aux/packaging/build-deb.sh "${changes%_source.changes}.dsc" _packages/deb + - name: Keep the Meson test log + if: failure() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: linux-deb-testlog-${{ matrix.series }}-${{ runner.arch }} + path: _packages/deb/testlog.txt + if-no-files-found: ignore + retention-days: 7 + - name: Install, start and validate in a fresh container + env: + SERIES: ${{ matrix.series }} + run: | + docker run --rm -v "$PWD:$PWD" -w "$PWD" "ubuntu:$SERIES" \ + linux/build-aux/packaging/smoke-test.sh "$(find _packages/deb -name 'tablepro_*.deb')" + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: linux-deb-${{ matrix.series }}-${{ runner.arch }} + path: _packages/deb + if-no-files-found: error + + rpm: + name: RPM package (${{ matrix.runner }}) + needs: sources + if: inputs.release-tag == '' + strategy: + fail-fast: false + matrix: + runner: ${{ fromJSON(inputs.rpm-runners || (inputs.arm64 && '["ubuntu-24.04", "ubuntu-24.04-arm"]') || '["ubuntu-24.04"]') }} + runs-on: ${{ matrix.runner }} + timeout-minutes: 300 + container: + image: fedora:44 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: linux-sources + path: _packages/sources + - name: Build, test and lint the package + run: | + dist="$(find _packages/sources -name 'tablepro-*.tar.xz' ! -name '*-vendor.tar.xz')" + vendor="$(find _packages/sources -name 'tablepro-*-vendor.tar.xz')" + linux/build-aux/packaging/build-rpm.sh "$dist" "$vendor" _packages/rpm + - name: Keep the Meson test log + if: failure() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: linux-rpm-testlog-${{ runner.arch }} + path: _packages/rpm/testlog.txt + if-no-files-found: ignore + retention-days: 7 + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: linux-rpm-${{ runner.arch }} + path: _packages/rpm + if-no-files-found: error + + rpm-install: + name: Install the RPM package (${{ matrix.runner }}) + needs: rpm + strategy: + fail-fast: false + matrix: + runner: ${{ fromJSON(inputs.rpm-runners || (inputs.arm64 && '["ubuntu-24.04", "ubuntu-24.04-arm"]') || '["ubuntu-24.04"]') }} + runs-on: ${{ matrix.runner }} + timeout-minutes: 20 + container: + image: fedora:44 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: linux-rpm-${{ runner.arch }} + path: _packages/rpm + - name: Install, start and validate + run: linux/build-aux/packaging/smoke-test.sh "$(find _packages/rpm -name 'tablepro-[0-9]*.rpm' ! -name '*.src.rpm')" diff --git a/.github/workflows/release-linux.yml b/.github/workflows/release-linux.yml new file mode 100644 index 0000000000..dbd860a0d4 --- /dev/null +++ b/.github/workflows/release-linux.yml @@ -0,0 +1,245 @@ +name: Release Linux + +# A linux-vX.Y.Z tag publishes the Linux release: the source and vendor +# tarballs on GitHub Releases, the signed Debian source package for each +# Ubuntu series to the Launchpad PPA, which builds and publishes the .deb, +# and the tag to the COPR project's custom webhook, which builds the +# Fedora packages from the release (linux/build-aux/rpm/copr-sources.sh). +# A pre-release tag such as linux-v0.2.0-rc.1 publishes a GitHub +# pre-release and skips the PPA and COPR. +# +# Run by hand on a branch, it uploads a published release to the PPA again +# as X.Y.Z-0ppaN, from that release's tarballs with the branch's .deb +# packaging: a packaging fix that needs no new version. linux/docs/releasing.md +# has the steps. +# +# Environment linux-release, limited to linux-v* tags and the branch a +# packaging fix runs on, with a required reviewer, holds two secrets. +# LINUX_PPA_SIGNING_KEY: the ASCII-armored OpenPGP secret key, with no +# passphrase, that signs the uploads. Its public key is registered on a +# Launchpad account that may upload to the PPA. +# LINUX_COPR_WEBHOOK: the custom webhook URL from the COPR project's +# Integrations page, ending in the package name: ...//tablepro/. +# Variable LINUX_PPA: the PPA, as owner/name. +# Variable LINUX_BRANCH: the branch a release tag must be on, main when unset. + +on: + push: + tags: ["linux-v*"] + workflow_dispatch: + inputs: + tag: + description: The published release to upload again, such as linux-v0.1.0 + required: true + type: string + revision: + description: The PPA upload number, 2 or more (X.Y.Z-0ppaN) + required: true + type: string + +permissions: + contents: read + +concurrency: + group: release-linux-${{ github.ref }} + cancel-in-progress: false + +jobs: + verify: + name: Tag, version and release notes + if: github.event_name == 'push' + runs-on: ubuntu-24.04 + timeout-minutes: 10 + container: + image: ubuntu:26.04 + outputs: + prerelease: ${{ steps.check.outputs.prerelease }} + steps: + - name: Install git and meson + run: | + apt-get update + apt-get install -y --no-install-recommends ca-certificates git meson python3 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + - name: The tag, metainfo, spec and changelog name one version + id: check + working-directory: linux + env: + TAG: ${{ github.ref_name }} + LINUX_BRANCH: ${{ vars.LINUX_BRANCH || 'main' }} + run: | + git config --global --add safe.directory "$GITHUB_WORKSPACE" + if ! git merge-base --is-ancestor HEAD "origin/$LINUX_BRANCH"; then + echo "::error::$GITHUB_SHA is not on $LINUX_BRANCH" + exit 1 + fi + version="$(meson introspect --projectinfo meson.build \ + | python3 -c 'import json, sys; print(json.load(sys.stdin)["version"])')" + tag_date="$(TZ=UTC git log -1 --date=format-local:%Y-%m-%d --format=%cd)" + python3 build-aux/packaging/check_packaging.py --source-root . \ + --version "$version" --tag "$TAG" --tag-date "$tag_date" + case "$version" in + *-*) echo "prerelease=true" >> "$GITHUB_OUTPUT" ;; + *) echo "prerelease=false" >> "$GITHUB_OUTPUT" ;; + esac + + packages: + name: Packages + needs: verify + uses: ./.github/workflows/linux-packages.yml + with: + deb-runners: '["ubuntu-24.04", "ubuntu-24.04-arm"]' + rpm-runners: '["ubuntu-24.04", "ubuntu-24.04-arm"]' + + github-release: + name: GitHub release + needs: [verify, packages] + runs-on: ubuntu-24.04 + environment: linux-release + timeout-minutes: 15 + permissions: + contents: write + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: linux-sources + path: release + # A pre-release has no CHANGELOG.md section; its notes are a draft of the fragments at the tag. + - name: Install towncrier + if: needs.verify.outputs.prerelease == 'true' + run: | + python3 -m venv "$RUNNER_TEMP/release-venv" + "$RUNNER_TEMP/release-venv/bin/pip" install --quiet --disable-pip-version-check --require-hashes \ + -r scripts/release/requirements.txt + - name: Publish the release + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ github.ref_name }} + PRERELEASE: ${{ needs.verify.outputs.prerelease }} + run: | + version="${TAG#linux-v}" + (cd release && sha256sum -- * > SHA256SUMS) + if [ "$PRERELEASE" = true ]; then + python3 linux/build-aux/packaging/check_packaging.py --source-root linux \ + --version "$version" --tag "$TAG" + "$RUNNER_TEMP/release-venv/bin/towncrier" build --draft --dir linux --version "$version" > draft.md + sed 1d draft.md > notes.md + set -- --prerelease + else + python3 linux/build-aux/packaging/check_packaging.py --source-root linux \ + --version "$version" --tag "$TAG" --notes notes.md + fi + # The repository's latest release stays the macOS one. + gh release create "$TAG" release/* --repo "$GITHUB_REPOSITORY" --verify-tag --latest=false \ + --title "TablePro for Linux $version" --notes-file notes.md "$@" + + revision: + name: Release and revision to upload again + if: github.event_name == 'workflow_dispatch' + runs-on: ubuntu-24.04 + timeout-minutes: 5 + steps: + - name: The tag is a published release, and the revision a new upload + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ inputs.tag }} + REVISION: ${{ inputs.revision }} + run: | + version="${TAG#linux-v}" + case "$version" in + "$TAG" | "" | *[!0-9.]*) + echo "::error::$TAG is not the tag of a Linux release; a pre-release is not on the PPA" + exit 1 + ;; + esac + case "$REVISION" in + "" | *[!0-9]* | 0* | 1) + echo "::error::Revision $REVISION is not 2 or more; 1 was the release's own upload" + exit 1 + ;; + esac + prerelease="$(gh release view "$TAG" --repo "$GITHUB_REPOSITORY" --json isPrerelease --jq .isPrerelease)" + if [ "$prerelease" != false ]; then + echo "::error::$TAG is not a published release" + exit 1 + fi + + revision-packages: + name: Packages + needs: revision + uses: ./.github/workflows/linux-packages.yml + with: + deb-runners: '["ubuntu-24.04", "ubuntu-24.04-arm"]' + release-tag: ${{ inputs.tag }} + revision: ${{ inputs.revision }} + + ppa: + name: Upload to the Launchpad PPA + needs: [verify, packages, revision-packages] + if: >- + !cancelled() && ( + (needs.packages.result == 'success' && needs.verify.outputs.prerelease == 'false') || + needs.revision-packages.result == 'success') + runs-on: ubuntu-24.04 + environment: linux-release + timeout-minutes: 30 + container: + image: ubuntu:26.04 + steps: + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: linux-source-package + path: source + # Launchpad takes an upload signed by a key registered on an account + # with upload rights to the PPA, through dput's ppa: target. + - name: Sign the source packages and upload them + env: + SIGNING_KEY: ${{ secrets.LINUX_PPA_SIGNING_KEY }} + PPA: ${{ vars.LINUX_PPA }} + run: | + if [ -z "$SIGNING_KEY" ] || [ -z "$PPA" ]; then + echo "::error::Set the LINUX_PPA_SIGNING_KEY secret and the LINUX_PPA variable to upload to the PPA" + exit 1 + fi + apt-get update + apt-get install -y --no-install-recommends devscripts dput gpg gpg-agent + GNUPGHOME="$(mktemp -d)" + export GNUPGHOME + trap 'gpgconf --kill all; rm -rf "$GNUPGHOME"' EXIT + printf '%s\n' "$SIGNING_KEY" | gpg --batch --import + key="$(gpg --batch --with-colons --list-secret-keys | awk -F: '$1 == "fpr" { print $10; exit }')" + # dput checks the signatures before it uploads, and reports one + # from a key with no trust as an error. + echo "$key:6:" | gpg --batch --import-ownertrust + for changes in source/*_source.changes; do + debsign -k"$key" "$changes" + dput "ppa:$PPA" "$changes" + done + + copr: + name: Start the COPR build + needs: [verify, github-release] + if: needs.verify.outputs.prerelease == 'false' + runs-on: ubuntu-24.04 + environment: linux-release + timeout-minutes: 5 + steps: + # COPR answers with the build's id. The webhook's UUID is the + # project's secret, which lasts until a maintainer regenerates it. + - name: Post the tag to the COPR webhook + env: + WEBHOOK: ${{ secrets.LINUX_COPR_WEBHOOK }} + TAG: ${{ github.ref_name }} + run: | + if [ -z "$WEBHOOK" ]; then + echo "::error::Set the LINUX_COPR_WEBHOOK secret to build the Fedora packages" + exit 1 + fi + build="$(curl --fail-with-body --silent --show-error --proto '=https' --request POST \ + --header 'Content-Type: text/plain' --data-raw "$TAG" "$WEBHOOK")" + echo "COPR build $build: https://copr.fedorainfracloud.org/coprs/build/$build/" diff --git a/.github/workflows/repo-hygiene.yml b/.github/workflows/repo-hygiene.yml index be478e449d..4595744d01 100644 --- a/.github/workflows/repo-hygiene.yml +++ b/.github/workflows/repo-hygiene.yml @@ -77,6 +77,7 @@ jobs: set -euo pipefail "$RUNNER_TEMP/release-venv/bin/python" scripts/ci/check-changelog.py "$RUNNER_TEMP/release-venv/bin/towncrier" build --draft --version 0.0.0 + "$RUNNER_TEMP/release-venv/bin/towncrier" build --draft --dir linux --version 0.0.0 # shellcheck cannot tell a library function from a command, so a missing source is exit 127 # at release time (v0.67.1). diff --git a/.gitignore b/.gitignore index a78a15eff8..d90f501afd 100644 --- a/.gitignore +++ b/.gitignore @@ -147,6 +147,7 @@ Derived/ # towncrier venv for scripts/release/notes.py (RELEASING.md, Notes) /.venv/ +__pycache__/ # Static libraries (downloaded from GitHub Releases via scripts/download-libs.sh) Libs/*.a diff --git a/AGENTS.md b/AGENTS.md index 86364dea00..72ceb678b1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,7 +2,7 @@ A native macOS database client (SwiftUI and AppKit), a lightweight alternative to TablePlus. macOS 13.0+, Swift 6 language mode for the app targets (`Configs/Base.xcconfig`), Universal Binary. `TableProMobile/` is the iOS app. -This guide covers the macOS and iOS apps; another platform's app keeps its own guide beside its code. Claude Code loads it through `CLAUDE.md`, Codex directly. +This guide covers the macOS and iOS apps; `linux/AGENTS.md` covers the Linux app. Claude Code loads it through `CLAUDE.md`, Codex directly. ## Principles @@ -36,6 +36,7 @@ Every capability is core, a module or a plugin. The first rule that fits decides - Tests: `TableProTests/`, `TableProUITests/`, `Plugins/*Tests/`, `Packages/*/Tests/`, `TableProMobile/TableProMobileTests/`; plans in `TestPlans/`. Which holds what: `.claude/rules/tests.md`. - `Native/`: first-party Rust and Go bridges (`.claude/rules/native-bridges.md`). - `Libs/`: static libraries and iOS xcframeworks from `scripts/download-libs.sh`, not in git. +- `linux/`: the GTK 4 Linux app, built with Meson and Cargo. - Docs site: TableProApp/docs, cloned beside this repo as `../tablepro-docs`, under its own `AGENTS.md` and `STYLE.md`. - `TablePro.xcodeproj` and `TableProMobile/TableProMobile.xcodeproj` are generated by XcodeGen from their `project.yml`, gitignored except their `Package.resolved`; never hand-edit them. Signing overrides go in `Configs/Secrets.xcconfig`. @@ -129,7 +130,7 @@ Claude Code loads a rule when it reads or edits a file the rule covers. Under Co | Rule | Read before editing | | --- | --- | | `ai-mcp-security.md` | `TablePro/Core/AI`, `TablePro/Core/MCP`, `Packages/TableProMacCore/Sources/TableProMCP` | -| `changelog.md` | `CHANGELOG.md`, `changelog.d`, `towncrier.toml` | +| `changelog.md` | `**/CHANGELOG.md`, `**/changelog.d`, `**/towncrier.toml` | | `ci.md` | `.github/workflows`, `.github/actions`, `.github/actionlint.yaml`, `.github/*baseline*`, `scripts/ci`, `scripts/release`, `.swiftlint.yml`, `**/.swiftlint.yml` | | `connection-window.md` | `TablePro/Core/Services/Infrastructure`, `TablePro/Models/Connection`, `TablePro/Core/Database/DatabaseManager*.swift`, `Packages/TableProCore/Sources/TableProDatabase`, `TablePro/Views/Main` | | `data-grid.md` | `TablePro/Views/Results`, `TablePro/Core/DataGrid`, `TableProTests/Views/Results` | diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2931b28145..801c841817 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,6 +1,6 @@ # Contributing to TablePro -[`AGENTS.md`](AGENTS.md) holds the invariants and the rules every change meets; where this file and it disagree, it wins. The docs site lives in [TableProApp/docs](https://github.com/TableProApp/docs), and releasing is in [`RELEASING.md`](RELEASING.md). +[`AGENTS.md`](AGENTS.md) holds the invariants and the rules every change meets; where this file and it disagree, it wins. The docs site lives in [TableProApp/docs](https://github.com/TableProApp/docs), and releasing is in [`RELEASING.md`](RELEASING.md). The Linux app has its own setup and rules in [`linux/CONTRIBUTING.md`](linux/CONTRIBUTING.md). ## Setup diff --git a/README.ko.md b/README.ko.md index 2534517d5d..fd8f9ac95e 100644 --- a/README.ko.md +++ b/README.ko.md @@ -65,7 +65,7 @@ TablePro는 네 번째 선택지입니다. 네이티브이고 여러 데이터 |----------|--------| | macOS 13+ | 안정 버전 | | iOS / iPadOS 18+ | 안정 버전 | -| Linux | 프로토타입, 아직 설치할 수 없음 | +| Linux (GNOME 50+) | [`linux/`](linux/README.md)에서 개발 중, 아직 출시 전 | | Windows | 지원하지 않음 | ## 지원 데이터베이스 @@ -104,6 +104,8 @@ brew install --cask tablepro ## 빌드 방법 +아래 단계는 macOS와 iOS 앱을 빌드합니다. Linux 앱은 Meson과 Cargo로 빌드합니다([`linux/README.md`](linux/README.md)). + TablePro를 빌드하려면 Xcode 26 이상과 [XcodeGen](https://github.com/yonaskolb/XcodeGen)이 필요합니다. 저장소 루트에서 최초 설정을 실행하세요. diff --git a/README.md b/README.md index c4ba9e4ccc..a0257cace6 100644 --- a/README.md +++ b/README.md @@ -65,7 +65,7 @@ TablePro is the missing fourth: native, multi-database, and open source. |----------|--------| | macOS 13+ | Stable | | iOS / iPadOS 18+ | Stable | -| Linux | Prototype, nothing to install yet | +| Linux (GNOME 50+) | In development in [`linux/`](linux/README.md), not released yet | | Windows | No | ## Supported Databases @@ -104,6 +104,8 @@ Or download from [GitHub Releases](https://github.com/TableProApp/TablePro/relea ## How to Build +These steps build the macOS and iOS apps; the Linux app builds with Meson and Cargo ([`linux/README.md`](linux/README.md)). + Building TablePro requires Xcode 26 or later and [XcodeGen](https://github.com/yonaskolb/XcodeGen). diff --git a/README.vi.md b/README.vi.md index c7140b8dcd..0bc23e2e6f 100644 --- a/README.vi.md +++ b/README.vi.md @@ -65,7 +65,7 @@ TablePro là mảnh thứ tư còn thiếu: native, đa database, và mã nguồ |----------|-----------| | macOS 13+ | Ổn định | | iOS / iPadOS 18+ | Ổn định | -| Linux | Bản thử nghiệm, chưa có gì để cài | +| Linux (GNOME 50+) | Đang phát triển trong [`linux/`](linux/README.md), chưa phát hành | | Windows | Không | ## Database hỗ trợ diff --git a/README.zh.md b/README.zh.md index 19b5c929c3..19e3c4d5b7 100644 --- a/README.zh.md +++ b/README.zh.md @@ -65,7 +65,7 @@ TablePro 补上缺失的第四类:原生、多数据库、开源。 |------|------| | macOS 13+ | 稳定版 | | iOS / iPadOS 18+ | 稳定版 | -| Linux | 原型阶段,暂无可安装版本 | +| Linux (GNOME 50+) | 正在 [`linux/`](linux/README.md) 中开发,尚未发布 | | Windows | 无 | ## 支持的数据库 diff --git a/RELEASING.md b/RELEASING.md index 660fc4310e..5c75b09107 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -1,6 +1,6 @@ # Releasing -This runbook covers the macOS app, its registry plugins and the static libraries; the iOS app ships by hand. The `release` skill (`.claude/skills/release/SKILL.md`) decides what ships and points at the sections here. Every command below is the maintainer's to run. +This runbook covers the macOS app, its registry plugins and the static libraries; the iOS app ships by hand, and the Linux app's runbook is `linux/docs/releasing.md`. The `release` skill (`.claude/skills/release/SKILL.md`) decides what ships and points at the sections here. Every command below is the maintainer's to run. ## Channels diff --git a/linux/.config/nextest.toml b/linux/.config/nextest.toml new file mode 100644 index 0000000000..e63027822b --- /dev/null +++ b/linux/.config/nextest.toml @@ -0,0 +1,72 @@ +[test-groups] +postgres = { max-threads = 1 } +mysql = { max-threads = 1 } +mssql = { max-threads = 1 } +clickhouse = { max-threads = 1 } +ssh = { max-threads = 1 } +fixtures = { max-threads = 1 } +session = { max-threads = 1 } +app = { max-threads = 1 } + +[profile.default] +# tiberius 0.13 stops reading at the first end of message, so an attention +# sent while SQL Server is still waiting is never acknowledged and the +# connection is dropped: https://github.com/tiberius-rs/tiberius/issues/459. +# Remove once a release has the fix. +default-filter = """ +not (package(tablepro-driver-mssql) & ( + test(=cancellation::editor_waitfor_cancel_then_select1_same_client) + | test(=cancellation::pooled_query_bound_waitfor_deadline_server_cancelled_client_reused) + | test(=conformance_engine::suite::editor_cancel_reports_cancelled_and_session_stays_ready) +)) +""" + +[[profile.default.overrides]] +filter = 'package(tablepro-driver-postgres)' +test-group = 'postgres' + +[[profile.default.overrides]] +filter = 'package(tablepro-driver-mysql)' +test-group = 'mysql' + +[[profile.default.overrides]] +filter = 'package(tablepro-driver-mssql)' +test-group = 'mssql' + +[[profile.default.overrides]] +filter = 'package(tablepro-driver-clickhouse)' +test-group = 'clickhouse' + +[[profile.default.overrides]] +filter = 'package(tablepro-ssh)' +test-group = 'ssh' + +[[profile.default.overrides]] +filter = 'package(tablepro-test-fixtures)' +test-group = 'fixtures' + +[[profile.default.overrides]] +filter = 'package(tablepro-session)' +test-group = 'session' + +# The app's docker tests are integration targets. Its unit tests start +# no container, so they keep running in parallel. +[[profile.default.overrides]] +filter = 'package(tablepro) & kind(test)' +test-group = 'app' + +# These press real keys on the X display every test shares. A window +# another test presents takes the focus, and the keys with it, so each +# runs with no other test beside it. +[[profile.default.overrides]] +filter = 'package(tablepro) & test(/::real_keys::/)' +threads-required = 'num-test-threads' + +[profile.ci] +fail-fast = false +# A hung test fails with its name after five minutes. Without a bound it +# holds the runner until the job dies with no report. +slow-timeout = { period = "60s", terminate-after = 5 } + +[profile.ci.junit] +path = "junit.xml" diff --git a/linux/.editorconfig b/linux/.editorconfig new file mode 100644 index 0000000000..aa659063e0 --- /dev/null +++ b/linux/.editorconfig @@ -0,0 +1,25 @@ +# No root = true: the repository's .editorconfig sets the defaults, and this file overrides them. + +[{meson.build,meson.options,meson_options.txt}] +indent_size = 2 + +[*.{ui,xml,xml.in,xml.in.in,svg}] +indent_size = 2 + +[*.css] +indent_size = 2 + +[*.hbs] +indent_size = 2 + +[build-aux/flatpak/*.json] +indent_size = 4 + +[*.rs] +max_line_length = 120 + +[build-aux/debian/rules] +indent_style = tab + +[build-aux/debian/{control,copyright}] +indent_size = 1 diff --git a/linux/.gitignore b/linux/.gitignore new file mode 100644 index 0000000000..fb3d5826dd --- /dev/null +++ b/linux/.gitignore @@ -0,0 +1,5 @@ +target/ +_build/ +_packages/ +_flatpak/ +.flatpak-builder/ diff --git a/linux/AGENTS.md b/linux/AGENTS.md new file mode 100644 index 0000000000..92bccb661c --- /dev/null +++ b/linux/AGENTS.md @@ -0,0 +1,54 @@ +# TablePro for Linux + +The native Linux app: Rust, GTK 4 and libadwaita through gtk-rs, built with Meson and Cargo. The platform floor is GNOME 50; `docs/decisions/0015-gnome-50-platform-floor.md` has the library versions. It shares no code with the macOS app. + +This is the guide for work under `linux/`. The root `AGENTS.md` describes the macOS and iOS apps; of it, only the principles named below, "Writing style" and the commit rules apply here. Claude Code loads this file through `linux/CLAUDE.md` once a file under `linux/` is read; Codex reads it when started in `linux/`, so otherwise read it yourself. + +## Principles + +1. **Security first, fix the cause, tests define behavior**, as in the root `AGENTS.md`. +2. **Native only.** GTK 4 and libadwaita, used the way the GNOME Human Interface Guidelines describe, with the widget the toolkit provides before anything drawn by hand. No web views, no cross-platform toolkit. +3. **One-directional crates.** The domain crate (`tablepro-core`) never imports the GUI or a driver, and a new database engine is one crate. `ARCHITECTURE.md` has the graph. +4. **Drivers are linked in at compile time.** There is no plugin system and no plugin ABI (`docs/decisions/0001-no-plugin-system.md`). +5. **Decisions are recorded.** A change that reverses a decision in `docs/decisions/` says so and updates the record. + +## Map + +- `crates/app`: the binary, the GTK application, its windows and views. `crates/core`: domain types and traits, no GUI. `crates/storage`: secrets, settings and files. `crates/session`, `crates/net`, `crates/ssh`: sessions, the network layer and the OpenSSH transport. `crates/drivers/*`: one crate per engine. `crates/test-fixtures` and `crates/test-support`: test-only. +- `data/`: desktop entry, D-Bus service, metainfo and screenshots, GSettings schema, man page, icons, GResource and license notices. `po/`: translations. +- `build-aux/flatpak/`: the Flatpak manifest. `build-aux/debian/`, `build-aux/rpm/` and `build-aux/packaging/`: the .deb and .rpm and the scripts that build them. `build-aux/about/`: the license templates. `build-aux/tests/`: the packaging tests. +- `ARCHITECTURE.md`, `CONTRIBUTING.md` and `docs/` (testing, storage, error handling, state management, adding a driver, decisions) are the reference. Read the one for the area you touch. + +## Commands + +From the repository root, through the verification wrapper, which keeps the full log and prints a verdict: + +```bash +.claude/skills/fix-issue/scripts/verify.sh --platform linux setup # once per tree +.claude/skills/fix-issue/scripts/verify.sh --platform linux build +.claude/skills/fix-issue/scripts/verify.sh --platform linux test [filter] +.claude/skills/fix-issue/scripts/verify.sh --platform linux lint +``` + +`--help` lists the rest. By hand, from `linux/`: `meson setup _build -Dprofile=development`, `meson compile -C _build`, `meson devenv -C _build ./crates/app/tablepro` to run the app, and every `cargo` command as `meson devenv -C _build cargo ...` so it reuses the build. `CONTRIBUTING.md` has the CI fast-job sequence. + +## Code style + +`rustfmt.toml`, `clippy.toml` and the workspace lints in `Cargo.toml` are the source of truth. Beyond them: + +- **Comments say why, never what**, as in the root guide. +- No `unwrap`, `expect`, `panic!`, `todo!` or `unimplemented!` outside tests; return a real error. Errors cross crate boundaries as `thiserror` enums (`docs/error-handling.md`). +- Logging goes through `tracing`, never `println!` or `eprintln!`; the lints deny both. +- An `#[ignore]` or `#[expect]` always carries its reason. + +## Every change + +1. **Tests.** A unit test for testable behavior, a `#[gtk4::test]` widget test for UI behavior, and a test against the real engine, ignored with `requires docker`, for driver behavior. No mocks. Details: `docs/testing.md`. +2. **Changelog.** A user-visible change adds `linux/changelog.d/..md`, never the root `changelog.d/`; only a release PR edits `linux/CHANGELOG.md`. Format: the root `.claude/rules/changelog.md`. +3. **Translations.** User-facing strings go through `crate::i18n` (`gettext`, `gettext_f` with named placeholders, `ngettext_f`, `pgettext`). A new or removed file under `crates/app/src` or `data/resources` regenerates `po/POTFILES.in`. See `po/README.md`. +4. **Lint** the workspace: rustfmt, then clippy with and without default features. +5. **Commits and pull requests** follow the root rules: Conventional Commits, at most 72 characters, scope `linux` or `linux-` (for example `linux-grid`, `linux-storage`), and the root `.github/pull_request_template.md`. + +## CI + +The Linux workflows are `.github/workflows/build-linux.yml`, `.github/workflows/linux-packages.yml` and `.github/workflows/release-linux.yml`. `docs/testing.md`, CI, lists every job, what starts it and what it runs; read it before changing a workflow or reading a red check. Every action is pinned by commit SHA; a new one is too. diff --git a/linux/ARCHITECTURE.md b/linux/ARCHITECTURE.md new file mode 100644 index 0000000000..903242489a --- /dev/null +++ b/linux/ARCHITECTURE.md @@ -0,0 +1,326 @@ +# Architecture + +TablePro Linux is a layered Rust workspace with strict, one-directional dependencies. The shape is chosen so that adding a database engine touches one crate, replacing the GUI framework would touch one crate, and the domain layer never imports either. + +## Crate layout + +``` +linux/ +├── meson.build build entry point; owns install and the build-time config +├── Cargo.toml workspace manifest +├── about.toml cargo-about settings for the third-party license notices +├── data/ desktop entry, D-Bus service, AppStream metainfo and screenshots, GSettings schema, man page, icons, GResource, licenses +├── build-aux/flatpak/ Devel Flatpak manifest, lint exceptions and krb5.conf +├── build-aux/debian/, rpm/ .deb and .rpm packaging +├── build-aux/packaging/, tests/ scripts that build, check and smoke-test the packages, and their tests +├── build-aux/about/ templates that turn Cargo.lock into data/licenses +├── po/ translation catalogue +└── crates/ + ├── app/ binary, the GTK application, its windows and views + ├── core/ domain types and the driver contract, no GUI deps + ├── net/ direct TCP transport and rustls client configs + ├── ssh/ system OpenSSH transport and the askpass helper + ├── session/ connect, sessions, health monitor, operation runner + ├── storage/ secrets, settings, file persistence + ├── test-fixtures/ dev-only container fixtures with a generated PKI + ├── test-support/ dev-only fakes and the conformance suites + └── drivers/ + ├── postgres/ tokio-postgres + ├── mysql/ sqlx-mysql, MySQL and MariaDB + ├── sqlite/ sqlx-sqlite + ├── mssql/ tiberius + └── clickhouse/ clickhouse over hyper +``` + +## Dependency graph + +| Crate | Depends on | +|---|---| +| `core` | no workspace crate | +| `net`, `ssh`, `storage` | `core` | +| `drivers/postgres`, `drivers/mysql`, `drivers/mssql`, `drivers/clickhouse` | `core`, `net` | +| `drivers/sqlite` | `core` | +| `session` | `core`, `net`, `ssh`, `storage` | +| `app` | every crate above | + +Rules, enforced by review: + +- `core` depends on **no other workspace crate**. It holds the domain types and the driver contract, and no I/O beyond the traits. +- `net`, `ssh` and `storage` depend on `core` only. +- Each `drivers/` crate depends on `core`, and on `net` when it speaks TLS or dials TCP. **Drivers never depend on each other, on `session` or on `ssh`.** +- `session` depends on `core`, `net`, `ssh` and `storage`, and on no driver outside its tests: it reaches drivers through `DriverRegistry`. Its Docker test takes `tablepro-driver-postgres` as a dev-dependency. +- `app` depends on all of them. It is the only crate that names every driver. +- `test-support` and `test-fixtures` are dev-dependencies, with one exception: `session` takes `test-support` as an optional dependency behind its `fakes` feature, which only `app`'s dev-dependencies turn on. + +Consequences: + +- Adding a driver does not touch `core`, `session`, `storage` or any other driver. +- Replacing the GUI framework would require rewriting only `app`. +- Drivers are tested against `core` traits without pulling GTK. + +## Composition root + +The driver registry is built once, by `build_registry` in `crates/app/src/lib.rs`, before the GTK application starts running: + +```rust +fn build_registry() -> DriverRegistry { + let mut r = DriverRegistry::new(); + r.register(Arc::new(tablepro_driver_clickhouse::ClickhouseDriver)); + r.register(Arc::new(tablepro_driver_mssql::MssqlDriver)); + r.register(Arc::new(tablepro_driver_mysql::MysqlDriver)); + r.register(Arc::new(tablepro_driver_postgres::PgDriver)); + r.register(Arc::new(tablepro_driver_sqlite::SqliteDriver)); + r +} +``` + +Adding a new driver = adding one workspace member + one `register` call. There is no runtime discovery, no ABI versioning, no plugin manifest. The trade-off is documented in [docs/decisions/0001-no-plugin-system.md](docs/decisions/0001-no-plugin-system.md). + +## Async architecture + +The GTK main loop owns every widget and runs on the main thread. Everything else runs on one tokio runtime, `AppRuntime`, which `run()` builds before GTK starts: multi-threaded, two to four workers named `tablepro-rt`. Code reaches it only through `Tasks` (`tablepro_session::runtime`), which `AppServices` hands to every window: + +- `spawn_task` for async work, `spawn_blocking_task` for a blocking call such as a durable write, and `spawn_owned` for a loop that stops when its handle drops. +- Once the main loop returns, `AppRuntime::shutdown` gives the work still in flight two seconds. + +Views cross between the two through `crate::runtime` (`spawn_and_deliver`, `spawn_held`, the input queues and `WatchForwarder`), and `crates/app/clippy.toml` bans the ways around it; [state-management.md](docs/state-management.md#rules-for-every-view) has the rules every view follows. + +One second runtime remains. Relm4 runs a component's commands (`sender.command`, `oneshot_command`) on a runtime of its own, built the first time one runs, with one worker since nothing sets `RELM_THREADS`. The saved-queries dialog still loads through commands, so that runtime starts when it opens. + +## The application + +`TableProApplication` (`crates/app/src/application/`) is an `adw::Application` subclass. `run()` builds it with the `AppServices` every window shares: the driver registry, the connector and its deadlines, the liveness policy, the `SessionRegistry`, the settings, the storage, the saved connection list and its editor, the SSH services, the credential sources, the file revealer, `Tasks`, the process hold, the open-sessions record and the query history. + +- **One process per profile.** GApplication owns the application id on the session bus. A second launch parses its own options, sends the running process an activate, or an open with the files it was given, and exits. With `--new-window` (`-n`), `handle_local_options` sends `app.new-window` instead. The development profile has an id of its own and runs beside the stable app. +- **Startup**, in the primary process only, adds the app actions (`new-window`, `quit`, `preferences`, `about`, `focus-tab`) and installs the application accelerators from the accelerator table, opens the query history, checks the askpass helper, ends SSH masters a crashed instance left running, and starts `SessionSignals`, which tells every session about network changes and a resume from suspend. +- **Windows.** `open_window` builds a `SessionWindow` for a `WindowLaunch`: the picker, a saved connection, a file, or a session to restore. The application keeps each window in its `WindowRegistry` by window id, and a window reaches the others through its `WindowHost`, which holds the application weakly. +- **Holds.** A `ProcessHold` keeps the process alive while work that must finish runs: a session closing, a save, a durable write, a file being resolved. The app's `GioProcessHold` is `gio::Application::hold`, so the process ends once the last window and the last hold are gone. +- **Quit.** `request_quit` runs `QuitSequence`, which asks each window in turn through its close prompts and never calls `g_application_quit`. A second Quit while a window asks brings that window forward. At logout, `query-end` brings forward the first window that holds the session back. + +### Restoring sessions and opening files + +`OpenSessions` (`crates/app/src/workspace/open_sessions.rs`) records the session each window adopts, a file or a saved connection, and drops it on a disconnect or a closed window. With the `restore-session` key on (the default, under Preferences), the first activate of a launch reads the record under a hold, opens a window for each session in it with `WindowLaunch::Restore`, then opens the files handed over while it read, or the picker when that opens nothing. A restored saved connection is looked up in the list again, and one deleted since says so in a toast. With the key off, the first activate opens the picker. + +A quit freezes the record before it asks the first window, so each window it closes stays on it, and writes it before the process may end. An approved quit then empties the list in memory; a cancelled one keeps only the windows still showing or connecting their session. Shutdown freezes it too: windows that go with the process are not the user closing them. An approved quit can leave the process running while a session closes, and an activate that reaches it then restores the record as a new launch would. + +Files come from Open With in a file manager or from the command line (`HANDLES_OPEN`). They wait until the restore has read the record, since a file's window adds to it. Each file is resolved off the GTK thread, its path made canonical and matched against the saved connections, then it opens in the window that has it open, in the active window while that shows only the picker, or in a new window. + +## Windows and sessions + +Each window is a `SessionWindow` (`crates/app/src/ui/session_window/`) for one connection session ([ADR 0012](docs/decisions/0012-window-per-session.md)). Its `SessionPage` is the picker of saved connections, the connect while it runs, the failure when it fails, or the session's `Workspace`. Every picker's `WelcomeView` binds the one `SavedConnectionList` that `AppServices` builds, which follows the list the `ConnectionStore` publishes after each write, so a save, a connect's last-opened stamp, an edit or a delete in any window reaches every picker. A row's menu and its `picker` actions name the connection by id and read it from the list again when they run. Every page past the picker holds the `SessionClaim` on the session's key, so leaving the page releases it. + +- A connect claims the key in the `SessionRegistry` before it starts, so a second window asking for the same database brings the first forward. The connect runs on `Tasks` under a cancel token, and a result for an attempt the user cancelled or replaced is closed instead of adopted. +- Adopting a connected session starts its `Session`, attaches it to the claim, which `SessionSignals` hears through the registry, records the key in `OpenSessions` and builds the `Workspace`. +- A workspace never moves to another session. Disconnect asks the close prompts, then `tear_down` writes what the workspace shows, takes its editor sessions, shuts the workspace down, drops the claim and the record's key, and closes the session and those editor sessions under one hold before the window returns to the picker. An approved close runs the same teardown. +- The close prompts (`CloseGuards`) belong to the window, so a tab's close, the close button and Ctrl+W ask on every page, one prompt at a time. While the workspace has something a logout would lose, a `WindowInhibitor` holds logout back, and suspend too while a save or an export runs. +- The window's actions (`disconnect`, `reconnect`, `close-current` and the rest) go on its own action map, which GTK exports as `win`. +- When a run, a save or an export ends in a tab the user is not looking at, the window's `WorkNotifier` sends a desktop notification through `GApplication`: one per tab, with no time threshold, as GNOME Console does. A tab counts as looked at while the window is active and the tab is in front. The notification names the tab and the connection only, since it can show on the lock screen, so an editor tab goes by its number rather than its script. Clicking it runs `app.focus-tab`, which brings the tab forward in its window. It is withdrawn once the tab is in front of an active window, when the tab closes, and when the session goes. A save that settles while a close prompt covering its tab decides is not notified: the prompt waited for it in front of the user, and then closes the tab or asks about it again. +- The window's content is the page itself, never a wrapper, and is at least 360 by 294 pixels. Each page has its own header bars and its own `AdwToastOverlay`, and the window's toasts go to the page on screen. The main menu comes from `data/resources/gtk/menus.ui`, which `GtkApplication` loads at startup. + +The `Workspace` (`crates/app/src/ui/workspace/`) shows one session: the sidebar of its objects, the tab view and the banners. Its template puts the `AdwTabOverview` at the root, as libadwaita expects, around an `AdwBreakpointBin` with an `AdwOverlaySplitView`: the sidebar and the tabs each have their own header bar. Below 600sp the sidebar collapses into an overlay, and below 450sp an `AdwTabButton` takes the tab bar's place. The `show-sidebar` window action and F9 show and hide the sidebar. It adds its own actions to the window and removes them when it shuts down, follows the session's health and object list through `WatchForwarder`s, and tells the window what it shows through its `title-changed`, `dirty-changed`, `saves-in-flight-changed`, `work-completed` and `tabs-changed` signals. Its `WorkspaceHandle` runs the saves and tells the close prompts what closing any part of it would lose. Its `ExportLedger` writes exports off the GTK thread: the rows are encoded on the runtime and GIO writes them. An export takes the rows a grid holds, or every row of a table or an editor statement, streamed through `Connection::stream_read` on a read-only connection of its own with no deadline. `ReplaceFileWriter` removes a file the export made when it stops or fails. An existing regular file gets a temporary file beside it, renamed over it at the end, so a stopped export leaves it as it was. GLib truncates a symbolic link's target, a hard-linked file or a file in a folder that refuses the temporary file when the export opens it, and a stopped export then says that file is incomplete. [Workspace tab system](#workspace-tab-system) covers its tabs, and [Sessions and health](#sessions-and-health) the `Session` itself. + +## UI architecture + +The application, its windows, the workspace and every view a rewrite has reached are gtk-rs subclasses. A widget with a layout has a CompositeTemplate `.ui` file under `data/resources/ui/`, compiled into the GResource: the session window and its pages, the workspace, the browse, structure and SQL editor tabs, the browse tab's filter strip and its rule rows, its pager (`PagerBar`) and its pending-changes bar (`PendingChangesBar`), the results pane, the SQL editor's find bar, the picker's welcome view, the query history dialog and its rows, the export operations button, and the connect and preferences dialogs with their sections. A subclass keeps its state in its `imp` struct and reports through signals. The window and the workspace take input through their input queues; a browse or structure tab handles its input in `send` at once, and one that arrives while another is being handled waits for it. + +A few views are still [Relm4](https://relm4.org) components ([ADR 0003](docs/decisions/0003-relm4-architecture.md)): the saved-queries and CSV import dialogs. Their output reaches the workspace through `connect_receiver`, which puts it on the workspace's input queue. The two patterns coexist until a rewrite turns each component into a subclass. + +See [docs/state-management.md](docs/state-management.md) for the patterns and naming we use. + +### Keyboard shortcuts + +Every key the app binds or relies on is one `Accelerator` in `ACCELERATORS` (`crates/app/src/application/accelerators.rs`): the action it fires, its triggers, its `AcceleratorScope` and where the shortcuts dialog lists it. + +- **Application** keys go in with `set_accels_for_action` at startup. GtkWindow runs them in the capture phase, before the focused widget, so none of them is a key a text field or a list uses. +- **Local** keys are a `local_shortcut_controller(owner)` on the owner's widget, firing the owner's action group through `NamedAction`: `browse` on a table tab and its column view, `editor` on the SQL editor's source view and, for find and replace, on the whole editor tab, `structure`, `filter`, the sidebar's list, which opens the selected table's menu, the query history's list, which opens the focused row's menu, and a picker row, which opens its menu. A disabled action lets its key go on. Bubble phase leaves a text field inside the owner its own Ctrl+Z and Ctrl+C; the source view's keys run in the capture phase, because GtkTextView binds Ctrl+/ itself. +- **Component** keys are bound by a component's own controller (the grid cell keys, the cell editors), which reads them from the table through `component_trigger` or `component_matches`. +- **Stock** keys belong to GTK or libadwaita: GtkListBase's selection and movement, AdwTabView's tab switching, AdwApplication's Ctrl+?. The workspace removes AdwTabView's Ctrl+Home and Ctrl+End pairs so the lists and the editor in its tabs keep them. + +`data/resources/shortcuts-dialog.ui` lists every listed entry under an id made from its action, and `shortcuts_dialog_matches_accelerator_table` holds the two in step both ways. Menus show a Local or Component key through `menu_item`, and `describe_icon_button` puts a button's key on its accessible node instead of in its tooltip. + +## Driver layer + +How the app reaches a database: the contract in `tablepro-core`, a transport that carries the bytes, TLS each driver builds from the connection's trust settings, and a session that watches the link. + +### The contract + +`DatabaseDriver` describes an engine before any connection exists, through `DriverCapabilities`, and opens a `Connection`. The `Connection` serves the workspace from a pool; every call takes `CallOptions` (a deadline and a cancel token) and runs on a task the connection owns (`OwnedCalls`), so a stop reaches the server instead of leaving the statement running. Saves report what they left in the database through a `WriteLedger`. Each editor tab gets an `EditorSession` of its own that streams results into a `ResultSink`. [docs/adding-drivers.md](docs/adding-drivers.md) is the full contract, [ADR 0016](docs/decisions/0016-driver-contract-v2.md) the reasons for it, and the engine pages say how each driver meets it: [PostgreSQL](docs/drivers/postgresql.md), [MySQL and MariaDB](docs/drivers/mysql.md), [SQLite](docs/drivers/sqlite.md), [SQL Server](docs/drivers/sql-server.md), [ClickHouse](docs/drivers/clickhouse.md). + +### SQL scripts + +`tablepro_core::sql_syntax::script` reads a script the way the engine's own client does. `ScriptPlan::build(text, grammar, settings)` lexes the text once and returns its tokens, its statements, its batches, and a `ScriptDiagnostic` for a string, quoted name, comment or dollar quote left open. Ranges are in bytes; `text_offsets` maps them to the characters GTK counts. + +A driver names its grammar through `SqlDialect::grammar`. The rules for each `SqlGrammar` are one table in core (`crates/core/src/sql_syntax/script/script_rules.rs`), so the editor's runs, completion and formatting, the drivers' own splitting and read-only guards, and the history's credential check all read a script the same way. `LexicalSettings` holds what a session can change: whether a backslash escapes in a plain string. + +| Grammar | Splits at | Kept whole across `;` | Beyond standard quotes and comments | +|---|---|---|---| +| PostgreSQL | `;` | dollar-quoted bodies, the `BEGIN ... END` body of a `CREATE FUNCTION` or `PROCEDURE`, parentheses | `E'...'` escapes, nested block comments, backslash escapes only with `standard_conforming_strings` off | +| MySQL, MariaDB | `;`, or the delimiter a `DELIMITER` line sets | nothing | backtick names, `"` strings, `#` comments, `--` only before whitespace, a control character or the end of the text, backslash escapes | +| SQLite | `;` | a `CREATE TRIGGER` body, as `sqlite3_complete` reads it | `[name]` and backtick names | +| SQL Server | a `GO` line, with an optional count | the whole batch, whatever `;` it holds | `[name]` with `]]` inside, nested block comments | +| ClickHouse | `;` | heredocs, `$tag$ ... $tag$` | backslash escapes in every quote, `//`, `# ` and `#!` comments, nested block comments | + +A failed statement stops the rest of a script, except on SQL Server, where each `GO` batch runs on its own as in sqlcmd (`BatchErrorPolicy::ContinueNextBatch`). The editor sends one statement, or one `GO` batch, per run; [docs/state-management.md](docs/state-management.md#the-sql-editor) has the run. + +**MySQL stored programs need `DELIMITER`.** The mysql client splits at every `;` outside a quote or a comment, `BEGIN ... END` included, and the plan does the same: `CREATE PROCEDURE p() BEGIN SELECT 1; SELECT 2; END` is three statements, and the server refuses the first. A trigger, procedure, function or event whose body holds a `;` goes between `DELIMITER` lines, as in the mysql client: + +```sql +DELIMITER // +CREATE PROCEDURE p() BEGIN SELECT 1; SELECT 2; END// +DELIMITER ; +``` + +A `DELIMITER` or `\d` line that starts a line outside a statement is a directive and is never sent. The MySQL driver honours the same lines when a whole script reaches it, and sends text without them as one multi-statement batch, which the server parses itself. + +### Connecting + +`DriverConnector` in `tablepro-session` turns a saved connection into a connected one: + +1. It checks the request against the driver's capabilities: the auth mode, the TLS settings for the route, a required database name. +2. It builds the transport: `SshSession::connect` and then `SshSession::forward` for an SSH connection, or a `DirectTcpTransport`. +3. It calls `DatabaseDriver::connect` with `ConnectOptions`: the `NetworkTarget` (transport, credentials, database, TLS settings) or the `FileEndpoint`, the `WorkspaceSettings`, the `LivenessPolicy` and the app's version. +4. It reads the first catalogue, the schemas and the default schema's objects, under the metadata deadline. + +The cancel token is checked between stages, and a stage that finishes after a cancel closes what it built, because a driver blocked in a login cannot always be interrupted. + +### Transports + +`Transport` (`crates/core/src/transport.rs`) is how a driver reaches a server without knowing whether the route is direct or through SSH: + +- `service_endpoint()`: the host and port the user typed, which TLS verifies and Kerberos names. +- `kind()` and `route()`: `DirectTcp` with a TCP endpoint, or `SshForward` with the Unix socket the forward listens on. +- `open()`: a new byte stream to the server. +- `reroute(endpoint)`: the same kind of route to another endpoint, for SQL Server's login routing. +- `explain_closed_stream()`: why a stream closed, when the transport knows better than "connection lost", as when the SSH master exited. + +| | `DirectTcpTransport` (`tablepro-net`) | `SshForwardTransport` (`tablepro-ssh`) | +|---|---|---| +| `open` | Resolves the name, then tries each address in turn under the connect timeout | Connects to the forward's Unix socket under the connect timeout | +| Socket options | `TCP_NODELAY`; keepalive after 30 s idle, every 10 s, 3 probes; `TCP_USER_TIMEOUT` 45 s | OpenSSH's own: `ServerAliveInterval=15`, `ServerAliveCountMax=3` | +| `reroute` | A new direct transport | A new forward on the same master | +| `explain_closed_stream` | Nothing to add | The master's exit, or the "open failed" line ssh printed | + +PostgreSQL, SQL Server and ClickHouse open every stream through `open()`. sqlx dials its own sockets, so the MySQL driver passes it the route instead: host and port, or the forward's socket path. Nothing relays bytes between the two. The socket options above apply only to streams `open()` makes, so a direct MySQL connection has no keepalive and finds a dead peer through the probe and its deadlines. + +SSH runs the system OpenSSH client ([ADR 0013](docs/decisions/0013-openssh-client-transport.md)): one ControlMaster (`ssh -M -N -T`) per connection, with the user's `~/.ssh/config` applied, and one `ssh -O forward` Unix socket per server it reaches. The sockets live under `$XDG_RUNTIME_DIR/app//ssh/` in directories only the user can open, with mode 0600. Only Flatpak creates `app/`, so a host install makes the missing levels with mode 0700, each opened without following a symlink and checked to be the user's; with `XDG_RUNTIME_DIR` unset, GLib puts the same layout under the cache directory. Every prompt (host key, password, passphrase, keyboard-interactive) reaches the app through the `tablepro-askpass` helper, and the 20 s handshake timeout pauses while a prompt is open. Under Flatpak, `ssh` comes from the GNOME runtime, and a `ProxyCommand` or `Match exec` that runs a host program, the host's `/etc/ssh` and FIDO keys kept in files do not work. + +### Trust + +A connection's `TlsConfig` holds the `TlsMode`, an optional CA file, an optional client certificate and key, and an optional server name: + +- `Disable`: no TLS. +- `Require`: encrypted, and the certificate is not checked. +- `VerifyFull`: the certificate must chain to a trusted root and carry the server name. + +A CA file goes only with `VerifyFull`, the one mode that checks the certificate. libpq and the mysql client verify against a CA file given with their require mode, so taking one with `Require` and ignoring it would leave a user who expects a check with none. `validate` refuses the pair, the connect dialog shows the CA row only for Verify full, and a saved or imported connection that holds the pair, from a build that allowed it, loads as `VerifyFull` with its CA file: a server the file does not vouch for then fails to connect instead of connecting unchecked. + +The name checked is `NetworkTarget::tls_server_name()`: the override when one is set, otherwise the host the user typed, never the address of a local SSH forward. `TlsConfig::validate` refuses a mode, file or override the driver cannot use on that route, and the connect dialog reads the chosen files up front, so a file with no certificate is named before anything connects. A CA, certificate or key file is read only when it is a regular file of at most 1 MiB, and drivers read it on the blocking pool, so a device or a pipe named in an imported connection can neither fill memory nor hold a connect. + +Each engine's TLS stack takes trust in its own form, so the driver builds it: + +| Engine | `VerifyFull` trusts | Client certificate | A failure is classified from | +|---|---|---|---| +| PostgreSQL | The system store (rustls-platform-verifier), plus the CA file | Yes | The rustls error | +| ClickHouse | The system store (rustls-platform-verifier), plus the CA file | Yes, as the login, with no password sent | The rustls error | +| MySQL, MariaDB | The system store and the CA file as one PEM bundle (`tablepro_net::tls::mysql_ca_bundle_pem`), plus the Mozilla roots compiled into sqlx | Yes | The rustls error | +| SQL Server | The system store (loaded by tiberius), plus the CA file | No | rustls's message text, because tiberius keeps nothing else | +| SQLite | No TLS | | | + +PostgreSQL and ClickHouse get their rustls `ClientConfig` from `tablepro_net::tls::build_client_config`. Every engine uses the aws-lc-rs provider ([ADR 0009](docs/decisions/0009-crypto-provider-and-sources.md)). A CA file is added to the engine's roots, never in place of them. `docs/error-handling.md` lists [what each TLS failure means and what the app offers](docs/error-handling.md#tlsfailure-and-its-remedies). + +On Linux the system store is the distribution's CA bundle. Under Flatpak it is the runtime's bundle: a CA added on the host with `update-ca-certificates` or `update-ca-trust` is not visible in the sandbox (freedesktop-sdk issue 1905). + +#### Choosing a CA file under Flatpak + +- For a server whose certificate comes from a private CA, set Encryption to Verify full and choose the CA's PEM file, whatever the host trusts. +- The development manifest grants the home folder and the mount points of removable drives (`/media`, `/run/media` and `/mnt`), so a file under any of them is read at its real path. +- A file chosen from anywhere else comes back from the file chooser portal as a path under `/run/user//doc/`. It works for as long as the portal keeps the grant. If it goes, the connect fails naming the CA file, and choosing the file again fixes it. Keeping a copy of the CA in your home folder avoids this. +- The file is read at every connect, so a replaced CA applies on the next connect. + +### Liveness and deadlines + +`LivenessPolicy::DESKTOP` bounds every phase of a connection: + +| Bound | Value | +|---|---| +| TCP connect, per address | 10 s | +| Login, including TLS | 15 s | +| TCP keepalive | after 30 s idle, every 10 s, 3 probes | +| `TCP_USER_TIMEOUT` | 45 s | +| Probe | 5 s | +| Wait for a pool slot | 5 s | +| Cancel grace, after a stop | 5 s | +| SSH handshake, paused while a prompt is open | 20 s | +| SSH forward request | 10 s | +| SSH keepalive | every 15 s, 3 missed | +| Close | 10 s | + +`DefaultDeadlines` in `tablepro-session` sets each operation's deadline: metadata 15 s, a page or a count 60 s, row changes 120 s, schema changes none. A call returns within its deadline plus the cancel grace. + +### Sessions and health + +A window holds one `Session`. Every use of its connection goes through the `OperationRunner`, which adds no timer of its own: the deadline travels in `CallOptions` so the driver stops the statement on the server. The session's monitor probes every 30 s, retries an unanswered probe after 10 s, and calls the connection lost after three unanswered probes in a row, when a probe finds the link gone, or as soon as `Connection::lost()` fires, which a driver resolves only when the link is gone, never for one dead backend in its pool. It then reconnects on a backoff from 5 s to 60 s. A failure that a retry cannot fix (authentication, TLS, configuration, a host key to review) parks the session until the user acts. A read that failed because its connection was lost runs once more on the replacement link; a save never does. + +The application tells each session what its connection cannot see. When `GNetworkMonitor` reports a change, and when a window adopts a session, it asks whether that session's server, or its first SSH hop, is reachable; a file, a socket and a loopback server are never asked about. A session that loses a server the network cannot reach waits offline, checking every 60 s, and reconnects as soon as the server is reachable again. While a session is open, a 5 s timer compares `CLOCK_BOOTTIME` with `CLOCK_MONOTONIC`, and after a suspend each server is checked again and a session that slept more than 30 s probes its connection. A session parked on an SSH host key keeps the error that parked it, and its banner's Review Key shows the fingerprint. Trusting it retries with ssh's question about that one key answered yes, so ssh still checks the key and writes known_hosts; a changed key is first removed with `ssh-keygen -R`, since ssh never accepts one. When the review cannot accept the key, because ssh's configuration takes keys only from known_hosts or the key is revoked, its dialog offers Retry for after the user fixes the key outside TablePro. + +The session's `SchemaCatalog` holds what the window knows about the database: the schemas, each scope's objects and each table's columns. Callers that ask for the same thing at once share one fetch, a fetch runs as its own task so a view that closes does not stop it, and a failed refresh keeps the list it had. A reconnect commits the catalog the new connection read and marks the rest stale; `invalidate_table` does the same for one table after a schema change. The sidebar shows every schema's tables and views in one `GtkListView` that recycles its rows, a section per schema with the default one first and the engine's own last, and one menu whose items carry the table they act on. It follows every schema's list through a watch the window drops before the session closes, and reads that list again when a reconnect commits the default schema's; a refresh that fails keeps the list and says so in a toast. The editor's completion reads the catalog when it opens: the default schema's objects, a named schema's objects after `schema.`, and the columns of the tables the statement names. + +## Workspace tab system + +The session drives a single `AdwTabView` hosting heterogeneous tabs. The `Workspace` owns the strip; tabs are typed via the `WorkspaceTab` enum: + +```rust +pub enum WorkspaceTab { + Editor(EditorTabSlot), // SQL editor, free-form query + Structure(StructureTabSlot), // a new table's draft, or an existing table's definition + Table(TableTabSlot), // one (schema, table) in the browse grid +} +``` + +A table's structure opens in a Structure tab of its own. Saving a new table closes its Structure tab and opens a Table tab on it. + +Each tab is a gtk-rs widget with a composite template, which the `AdwTabView` adopts. The workspace keeps a `HashMap` keyed by the tab's UUID; the canonical display order comes from `tab_view.pages()` (drag-reorderable). A `TabIndex` maps each `AdwTabPage` to its tab's UUID, so close and right-click actions can recover it. `TabFactory` builds every tab from a `TabSpec`, whether the sidebar, the overview's new-tab button, a restore or a reopen asks for it. Every tab's title comes from its `TabIdentity` through `resolve_tab_title`, and the window's from the tab in front through `resolve_window_title`. A tab that closing would lose something in shows `document-modified-symbolic` as its page icon, as GNOME Text Editor does; its title never changes for it. + +Routing is hub-and-spoke: a tab emits signals, and the closures the workspace connects tag them with the tab's UUID and queue them as `WorkspaceInput`. The workspace looks up the slot and calls back into the tab. This keeps the tabs ignorant of each other and gives the workspace one place to enforce cross-tab invariants (reload the tabs on a table whose schema changed, close all tabs for a dropped table, etc.). A reply a tab's request brings back after a disconnect reaches a closed queue and is dropped. + +A browse or structure tab reads its own data through the session, one request of each kind at a time, and keeps what it shows through a failed read; [state-management.md](docs/state-management.md#rules-for-every-view) has the rules. + +Reopening a closed tab uses a 10-deep `VecDeque` snapshot taken in `finish_close_workspace_tab` before the slot is dropped. The stack goes with the workspace on disconnect because descriptors reference tables in its session. + +## Per-tab pending-change registries + +Each workspace owns two registries holding the in-flight edit state for its tabs, keyed by the same UUID as the workspace slot. The workspace creates them, registers a tracker when it opens a tab, passes the handle into the tab's init, and unregisters it when the tab closes or the connection goes away: + +| Registry | Handle | What it tracks | Materialised by | +|---|---|---|---| +| `ChangeTrackerRegistry` | `TabTrackerHandle` | Row-level INSERT / UPDATE / DELETE for Table tabs | `TabTrackerHandle::change_set` → `TrackedChanges` | +| `StructureTrackerRegistry` | `StructureTrackerHandle` | The table's definition, the user's `TableDraft` and the `TableChanges` between them, for Structure tabs | `PendingStructure::plan` through the connection's `DdlDialect` → `DdlPlan` | + +A handle is a newtype over `Rc>`. Every method borrows only for its own body, calls no GTK and no callbacks, and returns owned data (`CellOverlay`, `RowState`, `TrackerDelta`), so a grid bind that runs while a tab is applying a change never meets a held borrow. A row edit goes in as a `TrackerEdit` and comes back as a `TrackerDelta`: the rows to redraw, the draft rows the list store gains or loses, and the new pending count. The tab applies it to its `gio::ListStore` after the call returns. A grid row is a `RowObject` whose identity is fixed when it is made: a saved row's key and cells as fetched, or a draft's id, whose cells live only in the tracker. A tracked change bumps the row's `revision` property, and each cell bound to the row listens for it and draws again in place, so the store never swaps a row and its selection and focus stay put. The tab finds the row through a `GridRowIndex` of weak references keyed like the tracker. A grid cell resolves its row through its column view cell when an event fires, so an edit, a menu action or a toggle reaches the row it was made on even after rows above it came or went. Every pending change carries a `Touch` from the tracker's counter. An `UndoOp` holds the pending state on both sides of a step, touch included, so undo after A, B, C shows B, not the fetched value. `change_set` emits one change per row in touch order, and `plan_save` keeps that order: a row deleted and replaced by a draft with the same unique value saves, because its DELETE runs first. `WorkspaceHandle::dirty_tabs` reads both registries, with each editor's open transaction, for the close prompts, the teardown guard and the tabs' unsaved icon. CI fails on a `thread_local!` under `crates/app/src/services`, since a process-wide registry would share one window's unsaved edits with every other window. + +The Structure tab is **definition + draft**, not a per-change log. The `TableDefinition` one read returned is what the tab plans against; `diff_table` turns the draft into `TableChanges`, and the engine's `DdlDialect` plans them into statements in its own order. Discard copies the definition back over the draft, and is the only restore point. The tracker holds the latest `PendingStructure`, so the close prompt and the save dispatcher read the same change without the tab, and the save plans it again with the dialect of the connection that runs it ([ADR 0010](docs/decisions/0010-ddl-plans-and-table-definitions.md)). A save that loses something, or that has several statements on an engine that applies DDL one statement at a time, first shows the user its `ChangeReview` and runs only the statements reviewed, whether the tab's Save or a close prompt asked for it ([state-management.md](docs/state-management.md#rules-for-every-view)). + +## Persistence + +[docs/storage.md](docs/storage.md) says which store holds which data and where each file goes. This section covers how the workspace writes and restores its own files. + +Workspace tabs are one JSON file per session in `$XDG_STATE_HOME/tablepro/workspaces/`, named by the connection id, or `file-.json` for a file. Their writes are debounced 500 ms and run off the GTK thread, and so are the editor drafts', one file per tab. `WorkspaceStore` reads a session's file once its session is adopted, and the window writes the tab list only after that read, so a write never lands on tabs not yet restored. A file that cannot be read, that another version wrote, or that is over 1 MiB is left as it is and nothing is written over it until the user picks Reset on the toast, which moves it aside as `.json.corrupt-`. Every open tab is saved and restored, with no cap on the count. Writes go through one task that keeps the newest record per file. Editor records carry a draft id, never the text, and the editor's `content-changed` signal carries no text either: the tab label reads at most 200 characters. + +`OpenSessions` writes the record of open sessions off the GTK thread, holding the process until each write lands; [Restoring sessions and opening files](#restoring-sessions-and-opening-files) says when it changes. A record that cannot be read is moved aside as `open-sessions.json.corrupt-`. + +A restore deletes the drafts no saved or open tab names, except while a moved-aside copy of the session's file is there, since that copy may name them. A draft that cannot be read is moved aside as `.sql.corrupt-`, which the cleanup never deletes, and its tab opens empty with a toast. A disconnect writes the open tabs' text before their buffers go. + +`SavedConnection::last_opened_at` is stamped through `touch_last_opened` each time a window adopts a saved connection's session. Every picker shows the list in one order (`saved_connection_order::sort`): ungrouped connections first, then each group by name. Inside each block the connection opened last comes first, ties sort by name, and never-opened connections go to the end of their block. + +## Build & CI + +Meson is the build system and Cargo is the Rust compiler; see [ADR 0006](docs/decisions/0006-meson-cargo-build.md). `meson setup _build && meson compile -C _build` produces `tablepro` and `tablepro-askpass`, and `meson test -C _build` runs the `unit`, `gtk`, `data` and `packaging` suites. + +The platform floor is GNOME 50 ([ADR 0015](docs/decisions/0015-gnome-50-platform-floor.md)), and `meson.build` refuses to configure below it. [docs/testing.md](docs/testing.md#ci) lists every CI job, what starts it and where it runs. + +## Out of scope + +- **Plugin system**. Drivers are static. The macOS plugin model does not transfer. +- **In-process scripting**. No embedded JavaScript / Python / Lua. SQL is enough. +- **Cross-platform builds**. Linux only. macOS / iOS have their own targets. +- **Hot reload**. Compile-time only. Use `cargo watch` during development. diff --git a/linux/CHANGELOG.md b/linux/CHANGELOG.md new file mode 100644 index 0000000000..f06ad894dc --- /dev/null +++ b/linux/CHANGELOG.md @@ -0,0 +1,10 @@ +# Changelog + +All notable changes to TablePro for Linux will be documented in this file. + +The format is based on [Keep a Changelog 1.1.0](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +Unreleased changes are files in `changelog.d/`. + + diff --git a/linux/CLAUDE.md b/linux/CLAUDE.md new file mode 100644 index 0000000000..43c994c2d3 --- /dev/null +++ b/linux/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/linux/CONTRIBUTING.md b/linux/CONTRIBUTING.md new file mode 100644 index 0000000000..34021a136c --- /dev/null +++ b/linux/CONTRIBUTING.md @@ -0,0 +1,156 @@ +# Contributing to TablePro Linux + +This file governs the Linux subproject only. [AGENTS.md](AGENTS.md) is the short guide to it, and the repository-level [AGENTS.md](../AGENTS.md) holds the rules every app shares: security first, root-cause fixes, tests define behavior and the writing style. + +## Dev environment + +Install the system packages listed in [README.md](README.md#quickstart). After that, work happens entirely from the `linux/` directory. + +```bash +cd linux +meson setup _build -Dprofile=development +meson compile -C _build # builds tablepro and tablepro-askpass +meson test -C _build # unit, gtk, data and packaging suites +meson devenv -C _build ./crates/app/tablepro # run the app +meson devenv -C _build cargo test --workspace --exclude tablepro # the unit suite through cargo +GTK_A11Y=test GSK_RENDERER=cairo GDK_BACKEND=x11 meson devenv -C _build cargo test -p tablepro # the app's tests +cargo fmt --all # format +``` + +The app's tests need the variables the gtk Meson suite sets +(`crates/app/meson.build`), and `meson devenv` does not export them: +`GTK_A11Y=test` for the accessibility checks, `GSK_RENDERER=cairo` for a +machine with no GPU, and `GDK_BACKEND=x11` for the tests that press keys +through XTEST. + +Run every other `cargo` command through `meson devenv -C _build` too. It +points Cargo at the `CARGO_HOME` and target directory inside `_build`, so +Cargo reuses what `meson compile` already built, and it puts the compiled +GSettings schema on the search path. A bare `cargo` builds a second copy +of the whole tree into `target/`, and a bare `cargo run -p tablepro` aborts +with `GSettings schema app.tablepro.TablePro is not installed`. + +Run `meson compile -C _build` after editing `data/*.gschema.xml.in`, so the +compiled schema stays in step with it. A development build uses the +`app.tablepro.TablePro.Devel` application ID, so it never writes over an +installed one. + +### Fast-job commands + +The fast job in `.github/workflows/build-linux.yml` runs these steps in this order: + +```bash +localedef -i en_US -f UTF-8 en_US.UTF-8 # as root, once: the catalogue test needs a real locale +meson setup _build -Dprofile=development +cargo fmt --all -- --check +meson devenv -C _build cargo clippy --locked --workspace --all-targets -- -D warnings +meson devenv -C _build cargo clippy --locked --workspace --all-targets --no-default-features -- -D warnings +meson compile -C _build +xvfb-run -a dbus-run-session -- meson test -C _build --print-errorlogs +dbus-run-session -- bash -c "gnome-keyring-daemon --components=secrets --daemonize --unlock <<< ci \ + && meson devenv -C _build cargo test --locked -p tablepro-storage --test secret_service -- --ignored" +meson dist -C _build --no-tests --formats xztar +``` + +Inside a desktop session, `dbus-run-session -- meson test -C _build` runs the same suites without xvfb. + +The fast job also runs a few source greps and `scripts/check-doc-symbols.sh` from the repository root. The supply-chain, documentation and Docker jobs run these, among others; [docs/testing.md](docs/testing.md#ci) describes every job, and the Docker job runs the last line once per package that has Docker tests: + +```bash +cargo deny --locked check advisories bans licenses sources +cargo shear +build-aux/update-third-party-licenses.sh # then a diff against the committed files +lychee --offline --include-fragments --no-progress 'linux/**/*.md' # from the repository root +cargo nextest run --locked --profile ci -p tablepro-driver-postgres --run-ignored only +``` + +Logs go to stderr and the filter comes from `RUST_LOG`; a development +build defaults to `debug`. `TABLEPRO_LOG_FORMAT=json` writes one JSON +object per line. + +```bash +RUST_LOG=debug meson devenv -C _build ./crates/app/tablepro +``` + +The gtk Meson suite sets `GTK_A11Y=test`, and `meson devenv` does not. With `GTK_A11Y=none` GTK records no accessible properties, and the accessibility helpers report that the test backend is missing. + +### Dependencies + +`data/licenses` holds the license notices of every crate the app ships, written from `Cargo.lock` by cargo-about. The About dialog embeds `third-party.json`. The packages ship the notices as their license files: `%license` in the RPM, `debian/copyright` in the .deb, and `share/licenses` in the Flatpak. After a change to `Cargo.lock`, regenerate them and commit the result; the Supply chain CI job fails until they match: + +```bash +cargo install --locked cargo-about@0.9.2 --features cli +build-aux/update-third-party-licenses.sh +``` + +A license added to `[licenses].allow` in `deny.toml` goes into `accepted` in `about.toml` at the same position. + +The script stops and names each crate it read no license file for, since the notice would carry the SPDX template text with no copyright holder. Give that crate a `[.clarify]` entry in `about.toml` with the file's path and SHA-256: under `files` for a file in the crate, under `git` for one in its repository at the published commit. An entry whose checksum no longer matches names its crate the same way. The script needs the network for the `git` entries. + +## Code style + +| Tool | Config | Notes | +|---|---|---| +| `rustfmt` | `rustfmt.toml` at the workspace root | CI fails on any diff from `cargo fmt --all -- --check`. There is no pre-commit hook. | +| `clippy` | `[workspace.lints]` in `Cargo.toml`, `clippy.toml`, `crates/app/clippy.toml`, `crates/session/clippy.toml` | Every crate passes with `-D warnings`, with and without default features. | +| Edition | 2024 | Set per workspace. Do not override per crate. | +| MSRV | 1.97 | `rust-version` in `Cargo.toml`: the `rustc-1.97` the PPA builds with, checked by CI. `rust-toolchain.toml` pins 1.98, the Flatpak rust-stable extension's. | + +The workspace denies `unwrap_used`, `expect_used`, `panic`, `todo`, `unimplemented`, `dbg_macro`, `print_stdout`, `print_stderr`, `allow_attributes`, `allow_attributes_without_reason`, `ignore_without_reason` and `too_many_lines`, plus `unsafe_op_in_unsafe_fn`. `clippy.toml` lets tests unwrap, expect, panic, print and use `dbg!`, and bans `serde_json::Map` and `Value::as_object`, whose maps reorder a server's JSON. Clippy reads only the `clippy.toml` nearest a crate, so `crates/app/clippy.toml` and `crates/session/clippy.toml` replace the root file for their crate. Both keep its test allowances and its JSON bans, and ban reaching for the ambient tokio runtime: background work goes through `Tasks`. The app's also bans the Relm4 spawns, `MainContext::block_on` and `ApplicationExt::quit`. + +Conventions, beyond what `rustfmt` decides: + +- **Comments say why, never what.** A short comment belongs where the code would surprise a careful reader: a hidden constraint, an invariant, a platform quirk. Code is otherwise self-documenting through naming. +- **No `unwrap()` or `expect()` in production paths.** Tests are the only callers; the workspace lints deny the rest. +- **No `panic!`, `todo!`, `unimplemented!` in merged code.** Stub a real `Err` variant instead. +- **One public type per module file** when the type's surface is non-trivial. Internal helpers stay private. +- **A function holds at most 100 lines of code**, tests included. The workspace denies `clippy::too_many_lines` at its default threshold, so split a long function by what each part does instead of allowing the lint. +- **User-facing strings go through `crate::i18n`.** `gettext` for a plain + string, `gettext_f` with named `{placeholders}` when values are + interpolated, `ngettext_f` for counts, `pgettext` when a word's sense + depends on where it appears. See [po/README.md](po/README.md). +- **Errors are `thiserror` enums.** The workspace has no `anyhow`. See [docs/error-handling.md](docs/error-handling.md). + +## Adding a database driver + +This is the most common substantive change. Follow [docs/adding-drivers.md](docs/adding-drivers.md) end to end. It is short and the steps are mechanical. Skipping a step (most often the registry registration) breaks the app silently. + +## Commits + +Conventional Commits, single line, at most 72 characters, same rule as the macOS app. The scope is `linux`, or `linux-` for one area of the app: `linux-grid`, `linux-browse`, `linux-structure`, `linux-editor`, `linux-history`, `linux-export`, `linux-sidebar`, `linux-picker`, `linux-session`, `linux-storage`, `linux-ssh`, `linux-core`, `linux-flatpak`, or a driver as `linux-postgres`, `linux-mysql`, `linux-sqlite`, `linux-mssql`, `linux-clickhouse`. + +``` +fix(linux-sidebar): keep the filter text when the catalog refreshes +feat(linux-export): export the entire table from the table tab +docs(linux): clarify the TLS step for a new driver +``` + +## Changelog + +A user-visible change adds one towncrier fragment, `changelog.d/..md` in this directory, which the release turns into a [CHANGELOG.md](CHANGELOG.md) section ([docs/releasing.md](docs/releasing.md)). The repository-level `CHANGELOG.md` and `changelog.d/` are the macOS release notes; a Linux change never goes there. [`.claude/rules/changelog.md`](../.claude/rules/changelog.md) has the rules for both. + +## Pull requests + +1. Branch off `main` and open the PR against `main`, as the root [CONTRIBUTING.md](../CONTRIBUTING.md#branch-naming) says. +2. PR title is the conventional commit message you intend to land. +3. The PR description follows the repository's `.github/pull_request_template.md`. +4. Run the [fast-job commands](#fast-job-commands) locally before pushing. +5. A change to `Cargo.lock` commits what `build-aux/update-third-party-licenses.sh` rewrites: `data/licenses`, `build-aux/debian/copyright` and the spec's `License` tag ([Dependencies](#dependencies)). +6. A new or removed file under `crates/app/src` regenerates `po/POTFILES.in`, and a changed user-facing string regenerates `po/tablepro.pot` ([po/README.md](po/README.md)). +7. A user-visible change has its [changelog](#changelog) fragment. +8. UI changes include before and after screenshots in the PR description, light and dark when color is involved. A change to a window shown in the metainfo retakes its screenshot in `data/screenshots` ([README.md](README.md#screenshots)). + +## What does not belong here + +- Documentation for end users (installation, FAQ, screenshots) lives in the [TableProApp/docs](https://github.com/TableProApp/docs) repository. +- Cross-platform decisions (release cadence, branding, pricing) are not made in this subproject. +- macOS plugin work, which lives in the repository's `Plugins/` directory. + +## Where to start as a contributor + +In rough order of impact: + +1. Read [ARCHITECTURE.md](ARCHITECTURE.md) and [docs/decisions/](docs/decisions/). 20 minutes, fixes most "why is it shaped like this" questions. +2. Pick an issue labelled `platform-linux`, or `good first issue` among those. +3. If adding a driver, start from the most recently merged driver crate and [docs/adding-drivers.md](docs/adding-drivers.md). +4. Open the PR small. We prefer five small PRs over one big one. diff --git a/linux/Cargo.lock b/linux/Cargo.lock new file mode 100644 index 0000000000..46d73fb052 --- /dev/null +++ b/linux/Cargo.lock @@ -0,0 +1,6046 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[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 = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[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.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "ashpd" +version = "0.13.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb8421aaa9644a5faf26735f258b669b15f063313ef8f8e2bdb28912a1a6f111" +dependencies = [ + "enumflags2", + "futures-util", + "getrandom 0.4.3", + "serde", + "tokio", + "zbus", +] + +[[package]] +name = "asn1-rs" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f43a50ac4fdca5df8e885c21b835997f0a1cdee65494a6847694a98652d9d8" +dependencies = [ + "asn1-rs-derive", + "asn1-rs-impl", + "displaydoc", + "nom", + "num-traits", + "rusticata-macros", + "thiserror", + "time", +] + +[[package]] +name = "asn1-rs-derive" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "asn1-rs-impl" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "astral-tokio-tar" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b18457efd137254e016bbde5e1d88df61c4e1a5ae2223746e56123bac6af2463" +dependencies = [ + "futures-core", + "libc", + "portable-atomic", + "rustc-hash", + "rustix", + "tokio", + "tokio-stream", + "xattr", +] + +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[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 2.0.119", +] + +[[package]] +name = "async-stream" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" +dependencies = [ + "async-stream-impl", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-stream-impl" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "asynchronous-codec" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a860072022177f903e59730004fb5dc13db9275b79bb2aef7ba8ce831956c233" +dependencies = [ + "bytes", + "futures-sink", + "futures-util", + "memchr", + "pin-project-lite", +] + +[[package]] +name = "atoi" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528" +dependencies = [ + "num-traits", +] + +[[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.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "aws-lc-rs" +version = "1.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b281d307588d634de920874890732659e2e7672f72b5e10e81badc1a8a83621e" +dependencies = [ + "aws-lc-sys", + "untrusted 0.7.1", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bff6c3b54fad79a2e60b8102caf565819711497c1f5f092f49508e2f5c31b27" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", + "pkg-config", +] + +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "sync_wrapper", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", +] + +[[package]] +name = "base16ct" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd307490d624467aa6f74b0eabb77633d1f758a7b25f12bceb0b22e08d9726f6" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bindgen" +version = "0.71.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f58bf3d7db68cfbac37cfc485a8d711e87e064c3d0fe0435b92f7a407f9d6b3" +dependencies = [ + "bitflags 2.13.2", + "cexpr", + "clang-sys", + "itertools 0.13.0", + "log", + "prettyplease", + "proc-macro2", + "quote", + "regex", + "rustc-hash", + "shlex 1.3.0", + "syn 2.0.119", +] + +[[package]] +name = "bindgen" +version = "0.72.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" +dependencies = [ + "bitflags 2.13.2", + "cexpr", + "clang-sys", + "itertools 0.13.0", + "proc-macro2", + "quote", + "regex", + "rustc-hash", + "shlex 1.3.0", + "syn 2.0.119", +] + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec 0.8.0", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + +[[package]] +name = "bit-vec" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51" +dependencies = [ + "serde", +] + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" +dependencies = [ + "serde_core", +] + +[[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-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-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 = "bnum" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "119771309b95163ec7aaf79810da82f7cd0599c19722d48b9c03894dca833966" + +[[package]] +name = "bollard" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee04c4c84f1f811b017f2fbb7dd8815c976e7ca98593de9c1e2afad0f636bff4" +dependencies = [ + "async-stream", + "base64 0.22.1", + "bitflags 2.13.2", + "bollard-buildkit-proto", + "bollard-stubs", + "bytes", + "futures-core", + "futures-util", + "hex", + "home", + "http", + "http-body-util", + "hyper", + "hyper-named-pipe", + "hyper-rustls", + "hyper-util", + "hyperlocal", + "log", + "num", + "pin-project-lite", + "rand 0.9.5", + "rustls", + "rustls-native-certs", + "rustls-pki-types", + "serde", + "serde_derive", + "serde_json", + "serde_urlencoded", + "thiserror", + "time", + "tokio", + "tokio-stream", + "tokio-util", + "tonic", + "tower-service", + "url", + "winapi", +] + +[[package]] +name = "bollard-buildkit-proto" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85a885520bf6249ab931a764ffdb87b0ceef48e6e7d807cfdb21b751e086e1ad" +dependencies = [ + "prost", + "prost-types", + "tonic", + "tonic-prost", + "ureq", +] + +[[package]] +name = "bollard-stubs" +version = "1.52.1-rc.29.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f0a8ca8799131c1837d1282c3f81f31e76ceb0ce426e04a7fe1ccee3287c066" +dependencies = [ + "base64 0.22.1", + "bollard-buildkit-proto", + "bytes", + "prost", + "serde", + "serde_json", + "serde_repr", + "time", +] + +[[package]] +name = "bs58" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "bstr" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6bb31b46c14244e20ee9984b11bf5c992b91fb6939fea616e3512c8baecdbe5f" +dependencies = [ + "memchr", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" +dependencies = [ + "serde", +] + +[[package]] +name = "cairo-rs" +version = "0.22.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df683f1d30b457964673a5541a4603208ef95ab6873d2a55871895cf310f3b56" +dependencies = [ + "bitflags 2.13.2", + "cairo-sys-rs", + "glib", + "libc", +] + +[[package]] +name = "cairo-sys-rs" +version = "0.22.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee548131103ad8f698c6725669647b9c757b3b66992dd6a026037596417bee21" +dependencies = [ + "glib-sys", + "libc", + "system-deps 9.0.0", +] + +[[package]] +name = "cbc" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" +dependencies = [ + "cipher", +] + +[[package]] +name = "cc" +version = "1.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3eb0f42d6c360dc3f8a821f6bf2fdea7f72bfd36b3076eb0e6d1e9e0752fff4" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex 2.0.1", +] + +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom", +] + +[[package]] +name = "cfg-expr" +version = "0.20.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe4ece8474b5f766c63426647e7b4b316b67431ade1036a8313cee24a03ae917" +dependencies = [ + "smallvec", + "target-lexicon", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chacha20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "rand_core 0.10.1", +] + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common 0.1.7", + "inout", + "zeroize", +] + +[[package]] +name = "clang-sys" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "157a8ba7b480713b56f4c09fd13fc3e0a22a5dfab8097ba61cbc5feef950788a" +dependencies = [ + "glob", + "libc", + "libloading", +] + +[[package]] +name = "clickhouse" +version = "0.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7654154fde4d97ec1321bfe4c3965570be6fcd09e091da8afc3166ab5ee27be2" +dependencies = [ + "bnum", + "bstr", + "bytes", + "clickhouse-macros", + "clickhouse-types", + "futures-channel", + "futures-util", + "half", + "hashbrown 0.17.1", + "http-body-util", + "hyper", + "hyper-util", + "serde", + "serde_json", + "thiserror", + "tokio", + "tracing", + "url", +] + +[[package]] +name = "clickhouse-macros" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff6669899e23cb87b43daf7996f0ea3b9c07d0fb933d745bb7b815b052515ae3" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 2.0.119", +] + +[[package]] +name = "clickhouse-types" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3966eea13bfc826e24d471e49cb2095727261009be59d0ac5a6c0be0286974d1" +dependencies = [ + "bytes", + "thiserror", +] + +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + +[[package]] +name = "combine" +version = "4.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "connection-string" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "510ca239cf13b7f8d16a2b48f263de7b4f8c566f0af58d901031473c76afb1e3" + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +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_detect" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48" + +[[package]] +name = "cpubits" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae" + +[[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.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + +[[package]] +name = "crc" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" + +[[package]] +name = "crc32fast" +version = "1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-queue" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03e8bd762f7479489c70ed6c768ddca99d7296857de437a68dcb2a94365b3fae" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "crypto-bigint" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a52aa3fcda4e6302a9f48734f234d35d4721b96f8fe07d073f07ce9df4f0271" +dependencies = [ + "cpubits", + "ctutils", + "num-traits", + "rand_core 0.10.1", + "serdect", + "zeroize", +] + +[[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 = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "crypto-primes" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3633a51a39c69ebbaa4feaa694bd83d241e4093901c84a0963b19d9bb3f0cf8f" +dependencies = [ + "crypto-bigint", + "rand_core 0.10.1", +] + +[[package]] +name = "csv" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52cd9d68cf7efc6ddfaaee42e7288d3a99d613d4b50f76ce9827ae0c6e14f938" +dependencies = [ + "csv-core", + "itoa", + "ryu", + "serde_core", +] + +[[package]] +name = "csv-core" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "704a3c26996a80471189265814dbc2c257598b96b8a7feae2d31ace646bb9782" +dependencies = [ + "memchr", +] + +[[package]] +name = "ctutils" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +dependencies = [ + "cmov", +] + +[[package]] +name = "darling" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed17f5901b6630b993ca003def43f2f8ef4014fc13b047b57aad617ff32bc2ec" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6837e2cf7485aaae18f86181d2f0e9a7ed297a025e220aeabf63fdebd3a2ddff" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 3.0.5", +] + +[[package]] +name = "darling_macro" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785" +dependencies = [ + "darling_core", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "data-encoding" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" + +[[package]] +name = "deadpool" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e98a7e119cd347f4201e1159b19831029e203e2d8b790547708e8157b4acf1e" +dependencies = [ + "deadpool-runtime", + "tokio", +] + +[[package]] +name = "deadpool-postgres" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65a536565624b97fc19f758cd01b15d12908d3344425066efc8162236fbd3749" +dependencies = [ + "async-trait", + "deadpool", + "getrandom 0.4.3", + "tokio", + "tokio-postgres", + "tracing", +] + +[[package]] +name = "deadpool-runtime" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2657f61fb1dd8bf37a8d51093cc7cee4e77125b22f7753f49b289f831bec2bae" +dependencies = [ + "tokio", +] + +[[package]] +name = "defmt" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1" +dependencies = [ + "bitflags 1.3.2", + "defmt-macros", +] + +[[package]] +name = "defmt-macros" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8" +dependencies = [ + "defmt-parser", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "defmt-parser" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" +dependencies = [ + "thiserror", +] + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid 0.9.6", + "der_derive", + "flagset", + "zeroize", +] + +[[package]] +name = "der" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a878c850e9e421b20262e9b41f9c860e4785fa07541c266b62ff9d1ef998a80a" +dependencies = [ + "const-oid 0.10.2", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "der-parser" +version = "10.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07da5016415d5a3c4dd39b11ed26f915f52fc4e0dc197d87908bc916e51bc1a6" +dependencies = [ + "asn1-rs", + "displaydoc", + "nom", + "num-bigint", + "num-traits", + "rusticata-macros", +] + +[[package]] +name = "der_derive" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8034092389675178f570469e6c3b0465d3d30b4505c294a6550db47f3c17ad18" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "serde_core", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "crypto-common 0.1.7", + "subtle", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "const-oid 0.10.2", + "crypto-common 0.2.2", + "ctutils", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "docker_credential" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29547a1dc60885a552306986316bc9701ba120c1a8db6769fa68691529ad373d" +dependencies = [ + "base64 0.22.1", + "serde", + "serde_json", +] + +[[package]] +name = "dotenvy" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[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.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" +dependencies = [ + "serde", +] + +[[package]] +name = "encoding_rs" +version = "0.8.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5ef0006ac9ab233c38522f5ae99cae3625151de8f706cacee1cba4b8e2832a" +dependencies = [ + "cfg-if", + "core_detect", + "multiversion", + "multiversion_no_op", + "rustversion", + "scopeguard", + "simdutf8", +] + +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + +[[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 2.0.119", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[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 = "etcetera" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de48cc4d1c1d97a20fd819def54b890cadde72ed3ad0c614822a0a433361be96" +dependencies = [ + "cfg-if", + "windows-sys 0.61.2", +] + +[[package]] +name = "event-listener" +version = "5.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" +dependencies = [ + "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", + "pin-project-lite", +] + +[[package]] +name = "fallible-iterator" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4443176a9f2c162692bd3d352d745ef9413eec5782a80d8fd6f8a1ac692a07f7" + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" +dependencies = [ + "getrandom 0.4.3", +] + +[[package]] +name = "ferroid" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee93edf3c501f0035bbeffeccfed0b79e14c311f12195ec0e661e114a0f60da4" +dependencies = [ + "portable-atomic", + "rand 0.10.2", + "web-time", +] + +[[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 = "find-msvc-tools" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e0f1c7c3a72c66fd80abe965175f7523475c0489a87d3ff9d6e8c87d87a9d2d" + +[[package]] +name = "fixed_decimal" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79c3c892f121fff406e5dd6b28c1b30096b95111c30701a899d4f2b18da6d1bd" +dependencies = [ + "displaydoc", + "smallvec", + "writeable", +] + +[[package]] +name = "flagset" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7ac824320a75a52197e8f2d787f6a38b6718bb6897a35142d749af3c0e8f4fe" + +[[package]] +name = "flate2" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" +dependencies = [ + "zlib-rs", +] + +[[package]] +name = "flume" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e139bc46ca777eb5efaf62df0ab8cc5fd400866427e56c68b22e414e53bd3be" +dependencies = [ + "fastrand", + "futures-core", + "futures-sink", + "spin", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[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 = "fragile" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8878864ba14bb86e818a412bfd6f18f9eabd4ec0f008a28e8f7eb61db532fcf9" +dependencies = [ + "futures-core", +] + +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + +[[package]] +name = "futures" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-executor" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-intrusive" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f" +dependencies = [ + "futures-core", + "lock_api", + "parking_lot", +] + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-timer" +version = "3.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af43fadb8a98512d547e37b4e92e0ced13e205c061b87b4623eff01d918d6968" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "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.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d5209294ba3775f9b650bf78bfadeba4332089f2329b3e2f6ce0a4957a45bff" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps 9.0.0", +] + +[[package]] +name = "gdk4" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d81e2a6c6ecba2aab60633a98df1868b03fa0bfdce8105edc27c1bccf71f0e39" +dependencies = [ + "cairo-rs", + "gdk-pixbuf", + "gdk4-sys", + "gio", + "glib", + "libc", + "pango", +] + +[[package]] +name = "gdk4-sys" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d8f608d8d7d229975c4d0d026f5d3071598c4ddab3c5262b0a31840fec78d13" +dependencies = [ + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "pkg-config", + "system-deps 7.0.8", +] + +[[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", + "windows-link", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi 0.11.1+wasi-snapshot-preview1", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 6.0.0", + "rand_core 0.10.1", + "wasm-bindgen", +] + +[[package]] +name = "gettext-rs" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9df5e3fa8c077b15fdfa8ce130e2bc73d663b8aaa6d51b71421b8659980b1d80" +dependencies = [ + "gettext-sys", + "locale_config", +] + +[[package]] +name = "gettext-sys" +version = "0.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea859ab0dd7e70ff823032b3e077d03d39c965d68c6c10775add60e999d8ee9" +dependencies = [ + "cc", + "temp-dir", +] + +[[package]] +name = "gio" +version = "0.22.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b399f4650bb52c051f3fdf49a234e8a27ab5725c8cd774556c55eee64b2c0350" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "gio-sys", + "glib", + "libc", + "pin-project-lite", + "smallvec", +] + +[[package]] +name = "gio-sys" +version = "0.22.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c28739f914c15b87a9856000bed9cbd5b3a8afbca5e982d9a299e1601422cb5" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps 9.0.0", + "windows-sys 0.61.2", +] + +[[package]] +name = "gio-unix" +version = "0.22.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce8d768735e229742c135afa3d3326604088d2df0679901cdb05e791fe2fbe0a" +dependencies = [ + "gio", + "gio-unix-sys", + "glib", + "libc", +] + +[[package]] +name = "gio-unix-sys" +version = "0.22.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c01dbb58f92f67548d85e2209e073acc7ee04e107c7e04ee69b49e38b4933b4" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps 9.0.0", +] + +[[package]] +name = "glib" +version = "0.22.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18b9b8d350db41f690ec1b87109f202782748bbd8ab2f2ede17cb40d29e2838c" +dependencies = [ + "bitflags 2.13.2", + "futures-channel", + "futures-core", + "futures-executor", + "futures-task", + "futures-util", + "gio-sys", + "glib-macros", + "glib-sys", + "gobject-sys", + "libc", + "memchr", + "smallvec", +] + +[[package]] +name = "glib-build-tools" +version = "0.22.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9871f38b67853c358b8190f77b9f878eb27d933a950f1045b244c4559a9f5f0" + +[[package]] +name = "glib-macros" +version = "0.22.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9597c68fa7bdf4154a3079642cd21c4dccac0b0bdd2897d82861523bf91e7b9" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "glib-sys" +version = "0.22.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99b38907e67e40dec9b60f858bcada952598719cb512a13eb13d6cdcd16f8295" +dependencies = [ + "libc", + "system-deps 9.0.0", +] + +[[package]] +name = "glob" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" + +[[package]] +name = "gobject-sys" +version = "0.22.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07595c9ba696bd9819cb6a8df39f08078f3dd679508fe052dd558492c2053834" +dependencies = [ + "glib-sys", + "libc", + "system-deps 9.0.0", +] + +[[package]] +name = "graphene-rs" +version = "0.22.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb856b9c558971c3f13ab692358926da710b046932a4e087aedcc35b040d7dff" +dependencies = [ + "glib", + "graphene-sys", +] + +[[package]] +name = "graphene-sys" +version = "0.22.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97842c7543828f98aa5583aa54fcda28aa84fa2b0f8c556142177f7bbb638057" +dependencies = [ + "glib-sys", + "libc", + "system-deps 9.0.0", +] + +[[package]] +name = "gsk4" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b867be1c5f14dcb8f552c0eff6e9a9b1da5f8b43943e8efc3a63c889d84952ff" +dependencies = [ + "cairo-rs", + "gdk4", + "glib", + "graphene-rs", + "gsk4-sys", + "libc", + "pango", +] + +[[package]] +name = "gsk4-sys" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b7c7eb2e681ee896646cfb8872b431f24d09f53ba9283289d9b10caa6707088" +dependencies = [ + "cairo-sys-rs", + "gdk4-sys", + "glib-sys", + "gobject-sys", + "graphene-sys", + "libc", + "pango-sys", + "system-deps 7.0.8", +] + +[[package]] +name = "gtk4" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98a0a0466484f64b07b5b8184d43fa46be78eb0b8e04ae4e179af31d770b76d9" +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.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ac7179400a36a04de039c24206bb841c5596992b907b43b23ee8d5bdc40d00e" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "gtk4-sys" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f954786af0b1984425c4446b77f5ff6594346181316be3f850caab1c6f01" +dependencies = [ + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gdk4-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "graphene-sys", + "gsk4-sys", + "libc", + "pango-sys", + "system-deps 7.0.8", +] + +[[package]] +name = "h2" +version = "0.4.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef8e5e5a340588f4452631496976cf8636d4a7ecf600239fdc27615d2530bc16" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap 2.14.2", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "hashlink" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824e001ac4f3012dd16a264bec811403a67ca9deb6c102fc5049b32c4574b35f" +dependencies = [ + "hashbrown 0.16.1", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac 0.12.1", +] + +[[package]] +name = "hkdf" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4aaa26c720c68b866f2c96ef5c1264b3e6f473fe5d4ce61cd44bbe913e553018" +dependencies = [ + "hmac 0.13.0", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest 0.10.7", +] + +[[package]] +name = "hmac" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" +dependencies = [ + "digest 0.11.3", +] + +[[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 = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hybrid-array" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17" +dependencies = [ + "typenum", +] + +[[package]] +name = "hyper" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b501faa50e7a26c3d3560ca625132f4078a17771f4810baf70475ae48cbe43" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-named-pipe" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fab3637d6b04a8037af8a266fdf6cf92ea957e8c53981a2bf6136572531025bf" +dependencies = [ + "hex", + "hyper", + "hyper-util", + "pin-project-lite", + "tokio", + "tower-service", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-timeout" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b90d566bffbce6a75bd8b09a05aa8c2cb1fabb6cb348f8840c9e4c90a0d83b0" +dependencies = [ + "hyper", + "hyper-util", + "pin-project-lite", + "tokio", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "libc", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "hyperlocal" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "986c5ce3b994526b3cd75578e62554abd09f0899d6206de48b3e96ab34ccc8c7" +dependencies = [ + "hex", + "http-body-util", + "hyper", + "hyper-util", + "pin-project-lite", + "tokio", + "tower-service", +] + +[[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", +] + +[[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 = "icu_collections" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_decimal" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9eb8f655bba2d0c0459e43a5b0e6cd3cd388109898fb3d85d048165e8b0abd08" +dependencies = [ + "displaydoc", + "fixed_decimal", + "icu_decimal_data", + "icu_locale_core", + "icu_locale_fallback", + "icu_plurals", + "icu_provider", + "serde", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_decimal_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4c887fc7d4c297cf3f9436864af9e3dba7f8be9415a6c2a7f392f3b1636298c" + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "serde", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_locale_fallback" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "251af8e57c9400e3eb58242fe5b8b1152b2a64fdf4cf632f923c38ccee6f2fa9" +dependencies = [ + "icu_locale_core", + "icu_locale_fallback_data", + "icu_provider", + "potential_utf", + "tinystr", + "zerovec", +] + +[[package]] +name = "icu_locale_fallback_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "decf2a22ec8fa68f1a0c1129a3f8583f8f8bc24e8b9ccbe98ead99f62a4dc3a8" + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_plurals" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e475e6766ef87b1d3c1f97be6363995b0bfaeddbc789671615df598a97ff0593" +dependencies = [ + "fixed_decimal", + "icu_locale_fallback", + "icu_plurals_data", + "icu_provider", + "zerovec", +] + +[[package]] +name = "icu_plurals_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1251aa39a95e1333888e499b1263e1c196447a28948acf5bdabd82c046f153bf" + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +dependencies = [ + "displaydoc", + "icu_locale_core", + "serde", + "stable_deref_trait", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[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.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[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 = "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.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ab1baf72f08796de0260609515130699b890ac25f30e610ad894bc5856cafdb" +dependencies = [ + "defmt", + "jiff-core", + "jiff-static", + "jiff-tzdb-platform", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", + "windows-link", +] + +[[package]] +name = "jiff-core" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e52fe76043ccecc9005d2305ebaadf7d7fc0cc89ca6baa10a94d6bc68c7128c" +dependencies = [ + "defmt", + "log", +] + +[[package]] +name = "jiff-static" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "378268a1116ad67ae6228701118ac9f491d78fda38a40a1f1a9e1348de6f7212" +dependencies = [ + "jiff-core", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "jiff-tzdb" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "142bd39932ad231f10513df9ab62661fead8719872150b7ad02a2df79f4e141e" + +[[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" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys", + "log", + "simd_cesu8", + "thiserror", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.119", +] + +[[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 2.0.119", +] + +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom 0.4.3", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libadwaita" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85b9900e67182a4b5b1f157b448d94f0715c8b9770cce21cf000801917f53bfa" +dependencies = [ + "gdk4", + "gio", + "glib", + "gtk4", + "libadwaita-sys", + "pango", +] + +[[package]] +name = "libadwaita-sys" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d3c27642b389852aa99341bd4a4c19ec6f8a2b63ebdd7f5ba1952198079ccd" +dependencies = [ + "gdk4-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "gtk4-sys", + "libc", + "pango-sys", + "system-deps 7.0.8", +] + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libgssapi" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e440a6151f5ef06571612e4121709aed90cfc0c40f8161f9090c71656758086d" +dependencies = [ + "bitflags 2.13.2", + "bytes", + "libgssapi-sys", +] + +[[package]] +name = "libgssapi-sys" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5103ac4557eacd36ff678b654b943f8966d3db9688fbd180a0b4c5464759ce17" +dependencies = [ + "bindgen 0.71.1", + "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", +] + +[[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.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61ff90caf6077a803a240f62fdbe88645a890bbca49ef8174c3cb0404362171d" +dependencies = [ + "libc", +] + +[[package]] +name = "libsqlite3-sys" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f111c8c41e7c61a49cd34e44c7619462967221a6443b0ec299e0ac30cfb9b1" +dependencies = [ + "bindgen 0.72.1", + "pkg-config", + "vcpkg", +] + +[[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.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "locale_config" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d2c35b16f4483f6c26f0e4e9550717a2f6575bcd6f12a53ff0c490a94a6934" +dependencies = [ + "lazy_static", + "objc", + "objc-foundation", + "regex", + "winapi", +] + +[[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.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "malloc_buf" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb" +dependencies = [ + "libc", +] + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest 0.10.7", +] + +[[package]] +name = "md-5" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98" +dependencies = [ + "cfg-if", + "digest 0.11.3", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "mio" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8" +dependencies = [ + "libc", + "wasi 0.11.1+wasi-snapshot-preview1", + "windows-sys 0.61.2", +] + +[[package]] +name = "multiversion" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ca4bea16ffc3f443cf7d866912118196bfef4c6a1556ca00f9f9b00bb43f7c" +dependencies = [ + "multiversion-macros", +] + +[[package]] +name = "multiversion-macros" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d416831a7317ef4b08bee00b69cbbb9c8763da7959a7026244d6266869f9c83" +dependencies = [ + "proc-macro2", + "quote", + "rustversion", + "syn 3.0.5", +] + +[[package]] +name = "multiversion_no_op" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743fb55ba31b18fb1ecef6bdc9aa2743314978ac084044301a7eee33fb99a20d" + +[[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 = "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.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +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.5", + "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-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" +dependencies = [ + "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", +] + +[[package]] +name = "objc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1" +dependencies = [ + "malloc_buf", +] + +[[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-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.13.2", +] + +[[package]] +name = "objc2-system-configuration" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7216bd11cbda54ccabcab84d523dc93b858ec75ecfb3a7d89513fa22464da396" +dependencies = [ + "objc2-core-foundation", +] + +[[package]] +name = "objc_id" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92d4ddb4bd7b50d730c215ff871754d0da6b2178849f8a2a2ab69712d0c073b" +dependencies = [ + "objc", +] + +[[package]] +name = "oid-registry" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f40cff3dde1b6087cc5d5f5d4d65712f34016a03ed60e9c08dcc392736b5b7" +dependencies = [ + "asn1-rs", +] + +[[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", + "cbc", + "cipher", + "digest 0.10.7", + "endi", + "futures-util", + "getrandom 0.4.3", + "hkdf 0.12.4", + "hmac 0.12.1", + "md-5 0.10.6", + "num", + "num-bigint-dig", + "pbkdf2", + "serde", + "serde_bytes", + "sha2 0.10.9", + "subtle", + "tokio", + "zbus", + "zbus_macros", + "zeroize", + "zvariant", +] + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[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 = "pango" +version = "0.22.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b2022dbbd82e1c42bd950a6f1df9b98c796e725dce5ec275e03cbf9772e4efa" +dependencies = [ + "gio", + "glib", + "pango-sys", +] + +[[package]] +name = "pango-sys" +version = "0.22.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca02d64761b74d56cdd4b437db6d73dc95e6c0d40f27e1c707952c0f09052948" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps 9.0.0", +] + +[[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", + "smallvec", + "windows-link", +] + +[[package]] +name = "parse-display" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "914a1c2265c98e2446911282c6ac86d8524f495792c38c5bd884f80499c7538a" +dependencies = [ + "parse-display-derive", + "regex", + "regex-syntax", +] + +[[package]] +name = "parse-display-derive" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ae7800a4c974efd12df917266338e79a7a74415173caf7e70aa0a0707345281" +dependencies = [ + "proc-macro2", + "quote", + "regex", + "regex-syntax", + "structmeta", + "syn 2.0.119", +] + +[[package]] +name = "pbkdf2" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" +dependencies = [ + "digest 0.10.7", + "hmac 0.12.1", +] + +[[package]] +name = "pem" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d354a98a3d1251555de99e8fdd8afda05573c31b82f59063a7b0a29b5527f120" +dependencies = [ + "base64 0.23.1", + "serde_core", +] + +[[package]] +name = "pem-rfc7468" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6305423e0e7738146434843d1694d621cce767262b2a86910beab705e4493d9" +dependencies = [ + "base64ct", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "phf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_shared", + "serde", +] + +[[package]] +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkcs1" +version = "0.8.0-rc.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "986d2e952779af96ea048f160fd9194e1751b4faea78bcf3ceb456efe008088e" +dependencies = [ + "der 0.8.2", + "spki 0.8.0", +] + +[[package]] +name = "pkcs8" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "451913da69c775a56034ea8d9003d27ee8948e12443eae7c038ba100a4f21cb7" +dependencies = [ + "der 0.8.2", + "spki 0.8.0", +] + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + +[[package]] +name = "portable-atomic-util" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10ab3eb7f3becc3a1cbc4f2c6f20267996cfc1a6467a873763411b136a122715" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "postgres-protocol" +version = "0.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08808e3c483c46e999108051c78334f473d5adb59d78bb80a1268c7e6aa6c514" +dependencies = [ + "base64 0.22.1", + "byteorder", + "bytes", + "fallible-iterator", + "hmac 0.13.0", + "md-5 0.11.0", + "memchr", + "rand 0.10.2", + "sha2 0.11.0", + "stringprep", +] + +[[package]] +name = "postgres-types" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "851ca9db4932932d69f3ea811b1abe63087a0f740a47692619dd40d4899b68be" +dependencies = [ + "bytes", + "chrono", + "fallible-iterator", + "postgres-protocol", +] + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "serde_core", + "writeable", + "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 = "pretty-hex" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a65843dfefbafd3c879c683306959a6de478443ffe9c9adf02f5976432402d7" + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.119", +] + +[[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.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "proptest" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" +dependencies = [ + "bit-set", + "bit-vec 0.8.0", + "bitflags 2.13.2", + "num-traits", + "rand 0.9.5", + "rand_chacha", + "rand_xorshift", + "regex-syntax", + "rusty-fork", + "tempfile", + "unarray", +] + +[[package]] +name = "prost" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "528ac67416ff8646872a3c02cad9cc4ee5dc9f9540c9b10771855c95cb2e5ae1" +dependencies = [ + "bytes", + "prost-derive", +] + +[[package]] +name = "prost-derive" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b570b25f7617e43d59005d0990ccb79e950a423952cea19671b7a876da390adf" +dependencies = [ + "anyhow", + "itertools 0.14.0", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "prost-types" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f94967dc7688f3054c7fac87473ffae4cc4c3904800e2d9f5b857246d8963b0a" +dependencies = [ + "prost", +] + +[[package]] +name = "quick-error" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +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.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha", + "rand_core 0.9.5", +] + +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", +] + +[[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.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_xorshift" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" +dependencies = [ + "rand_core 0.9.5", +] + +[[package]] +name = "rcgen" +version = "0.14.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8774e05a7d0de114588e6a28fe7e71694b82614ed569d86d8b389dfbc98b8ad8" +dependencies = [ + "aws-lc-rs", + "pem", + "rustls-pki-types", + "time", + "x509-parser", + "yasna", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.13.2", +] + +[[package]] +name = "ref-cast" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e440fb4e4b4147295338efb76001ab9e4efc0e5839df2c47fc5ac2381d365c3" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "relm4" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6420f090f0545e9ec9656469d139a4e1b66ff9c30b808fe2247892724f71a198" +dependencies = [ + "flume", + "fragile", + "futures", + "gtk4", + "libadwaita", + "once_cell", + "relm4-macros", + "tokio", + "tracing", +] + +[[package]] +name = "relm4-macros" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36c9dbf50a60c82375e66b61d522c936b187a11b25c0a42e91c516326ad24a4f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[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 0.9.0", + "windows-sys 0.52.0", +] + +[[package]] +name = "rsa" +version = "0.10.0-rc.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30b2aa4ba0d89f73d1e332df05be0eeab8840351c36ca5654341dfdb57bb3caf" +dependencies = [ + "const-oid 0.10.2", + "crypto-bigint", + "crypto-primes", + "digest 0.11.3", + "pkcs1", + "pkcs8", + "rand_core 0.10.1", + "signature", + "spki 0.8.0", + "zeroize", +] + +[[package]] +name = "rust_xlsxwriter" +version = "0.99.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34f2d2cda7223168a14fb86bc7411dbc3379bd7d49749ecc53b91ed98585b6b0" +dependencies = [ + "chrono", + "tempfile", + "zip", +] + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rusticata-macros" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" +dependencies = [ + "nom", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.13.2", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf" +dependencies = [ + "aws-lc-rs", + "log", + "once_cell", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-platform-verifier" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" +dependencies = [ + "core-foundation", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + +[[package]] +name = "rustls-webpki" +version = "0.103.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +dependencies = [ + "aws-lc-rs", + "ring", + "rustls-pki-types", + "untrusted 0.9.0", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "rusty-fork" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2" +dependencies = [ + "fnv", + "quick-error", + "tempfile", + "wait-timeout", +] + +[[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 = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "secrecy" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e891af845473308773346dc847b2c23ee78fe442e0472ac50e22a18a93d3ae5a" +dependencies = [ + "serde", + "zeroize", +] + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.13.2", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +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.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[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 2.0.119", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_repr" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[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_with" +version = "3.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "935177bb8c0cd8ca1a4e6d1a2ac8988bea69cab4f9d3a31311e012ad27868ea4" +dependencies = [ + "base64 0.23.1", + "bs58", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.14.2", + "jiff", + "schemars 0.9.0", + "schemars 1.2.2", + "serde_core", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d607aa01a3cb0ad757d6fd216136910db3c97b102fe686585689615a02dbcdc" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "serdect" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66cf8fedced2fcf12406bcb34223dffb92eaf34908ede12fed414c82b7f00b3e" +dependencies = [ + "base16ct", + "serde", +] + +[[package]] +name = "sha1" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "digest 0.11.3", +] + +[[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 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "digest 0.11.3", +] + +[[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 = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[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 = "signature" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d567dcbaf0049cb8ac2608a76cd95ff9e4412e1899d389ee400918ca7537f5" +dependencies = [ + "digest 0.11.3", + "rand_core 0.10.1", +] + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "siphasher" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33f4fe9184a62d842c9ef383018f3306d8ba224fd9d836f56d7288308847c256" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba467056f1b547ed52077911161fc86985becbc60e8e1857c8a144dab0def891" +dependencies = [ + "serde", +] + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "sourceview5" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5b64e13e9ae8d8e35529a2d32d80c7ecd60ffb348c1188b5b790676f5d5b50c" +dependencies = [ + "futures-channel", + "futures-core", + "gdk-pixbuf", + "gdk4", + "gio", + "glib", + "gtk4", + "libc", + "pango", + "sourceview5-sys", +] + +[[package]] +name = "sourceview5-sys" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23a6d23d5b5a052b39ad92ff8ab0730cff284378d7f97012368317a5e622452e" +dependencies = [ + "gdk-pixbuf-sys", + "gdk4-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "gtk4-sys", + "libc", + "pango-sys", + "system-deps 7.0.8", +] + +[[package]] +name = "spin" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" +dependencies = [ + "lock_api", +] + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der 0.7.10", +] + +[[package]] +name = "spki" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d9efca8738c78ee9484207732f728b1ef517bbb1833d6fc0879ca898a522f6f" +dependencies = [ + "base64ct", + "der 0.8.2", +] + +[[package]] +name = "sqlformat" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0705994df478b895f05b8e290e0d46e53187b26f8d889d37b2a0881234922d94" +dependencies = [ + "unicode_categories", + "winnow 0.7.15", +] + +[[package]] +name = "sqlparser" +version = "0.63.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3679862809bd1f92e563cf6fd820e28f39135303668001c10331d5027183e8b4" +dependencies = [ + "log", +] + +[[package]] +name = "sqlx" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "378620ccc25c62c89d8be1c819e76a88d59bdcc3304733330788948e619bfd71" +dependencies = [ + "sqlx-core", + "sqlx-macros", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", +] + +[[package]] +name = "sqlx-core" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05b44e85bf579a8eeb4ceaa77a3a523baf2bf0e9bac7e40f405d537b5d2d5ccb" +dependencies = [ + "base64 0.22.1", + "bytes", + "cfg-if", + "chrono", + "crc", + "crossbeam-queue", + "either", + "event-listener", + "futures-core", + "futures-intrusive", + "futures-io", + "futures-util", + "hashbrown 0.16.1", + "hashlink", + "indexmap 2.14.2", + "log", + "memchr", + "percent-encoding", + "rustls", + "serde", + "serde_json", + "sha2 0.10.9", + "smallvec", + "thiserror", + "tokio", + "tokio-stream", + "tracing", + "url", + "uuid", + "webpki-roots", +] + +[[package]] +name = "sqlx-macros" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd2b84f2bc39a5705ef27ec785a11c934a41bbd4a24941e257927cddc26b60bf" +dependencies = [ + "proc-macro2", + "quote", + "sqlx-core", + "sqlx-macros-core", + "syn 2.0.119", +] + +[[package]] +name = "sqlx-macros-core" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb8d96de5fdc85a5c4ec813432b523ec637e80ba98f046555f75f7908ddac7c3" +dependencies = [ + "cfg-if", + "dotenvy", + "either", + "heck", + "hex", + "proc-macro2", + "quote", + "serde", + "serde_json", + "sha2 0.10.9", + "sqlx-core", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", + "syn 2.0.119", + "thiserror", + "tokio", + "url", +] + +[[package]] +name = "sqlx-mysql" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90b8020fe17c5f2c245bfa2505d7ef59c5604839527c740266ad2214acebea27" +dependencies = [ + "bitflags 2.13.2", + "byteorder", + "bytes", + "chrono", + "crc", + "digest 0.11.3", + "dotenvy", + "either", + "futures-core", + "futures-util", + "generic-array", + "log", + "percent-encoding", + "rand 0.10.2", + "rsa", + "serde", + "sha1", + "sha2 0.11.0", + "sqlx-core", + "thiserror", + "tracing", + "uuid", +] + +[[package]] +name = "sqlx-postgres" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87a2bdd6e83f6b3ea525ca9fee568030508b58355a43d0b2c1674d5f79dcd65e" +dependencies = [ + "atoi", + "base64 0.22.1", + "bitflags 2.13.2", + "byteorder", + "chrono", + "crc", + "dotenvy", + "etcetera", + "futures-channel", + "futures-core", + "futures-util", + "hex", + "hkdf 0.13.0", + "hmac 0.13.0", + "itoa", + "log", + "md-5 0.11.0", + "memchr", + "rand 0.10.2", + "serde", + "serde_json", + "sha2 0.11.0", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror", + "tracing", + "uuid", + "whoami", +] + +[[package]] +name = "sqlx-sqlite" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488e99c397a62007e4229aec669a179816339afc6d2620ca6fa420dbee2e982c" +dependencies = [ + "atoi", + "chrono", + "flume", + "form_urlencoded", + "futures-channel", + "futures-core", + "futures-executor", + "futures-intrusive", + "futures-util", + "libsqlite3-sys", + "log", + "percent-encoding", + "serde", + "sqlx-core", + "thiserror", + "tracing", + "url", + "uuid", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "stringprep" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" +dependencies = [ + "unicode-bidi", + "unicode-normalization", + "unicode-properties", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "structmeta" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e1575d8d40908d70f6fd05537266b90ae71b15dbbe7a8b7dffa2b759306d329" +dependencies = [ + "proc-macro2", + "quote", + "structmeta-derive", + "syn 2.0.119", +] + +[[package]] +name = "structmeta-derive" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "152a0b65a590ff6c3da95cabe2353ee04e6167c896b28e3b14478c2636c922fc" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[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 = "system-deps" +version = "9.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a0dae2cbca1f0ff93795713cfe0a4c02f760e3c0b8ae2ab4ec4045808ff429f" +dependencies = [ + "cfg-expr", + "heck", + "pkg-config", + "toml", + "version-compare", +] + +[[package]] +name = "tablepro" +version = "0.0.0" +dependencies = [ + "async-trait", + "chrono", + "futures", + "gettext-rs", + "gio", + "gio-unix", + "glib", + "glib-build-tools", + "gtk4", + "icu_decimal", + "icu_locale_core", + "libadwaita", + "log", + "proptest", + "relm4", + "rustix", + "secrecy", + "serde", + "serde_json", + "sourceview5", + "sqlformat", + "tablepro-core", + "tablepro-driver-clickhouse", + "tablepro-driver-mssql", + "tablepro-driver-mysql", + "tablepro-driver-postgres", + "tablepro-driver-sqlite", + "tablepro-net", + "tablepro-session", + "tablepro-ssh", + "tablepro-storage", + "tablepro-test-fixtures", + "tablepro-test-support", + "tempfile", + "thiserror", + "tokio", + "tokio-util", + "tracing", + "tracing-log", + "tracing-subscriber", + "uuid", + "x11rb", +] + +[[package]] +name = "tablepro-core" +version = "0.0.0" +dependencies = [ + "async-trait", + "chrono", + "csv", + "futures", + "num-bigint", + "proptest", + "rust_xlsxwriter", + "rustls-pki-types", + "secrecy", + "serde", + "serde_json", + "sqlparser", + "thiserror", + "tokio", + "tokio-util", + "uuid", + "zip", +] + +[[package]] +name = "tablepro-driver-clickhouse" +version = "0.0.0" +dependencies = [ + "async-trait", + "chrono", + "clickhouse", + "clickhouse-types", + "http", + "hyper-rustls", + "hyper-util", + "rustls", + "rustls-pki-types", + "secrecy", + "serde", + "serde_json", + "sqlparser", + "tablepro-core", + "tablepro-net", + "tablepro-test-fixtures", + "tablepro-test-support", + "tokio", + "tokio-util", + "tower-service", + "tracing", + "uuid", +] + +[[package]] +name = "tablepro-driver-mssql" +version = "0.0.0" +dependencies = [ + "async-trait", + "chrono", + "deadpool", + "futures-util", + "secrecy", + "tablepro-core", + "tablepro-net", + "tablepro-test-fixtures", + "tablepro-test-support", + "tiberius", + "tokio", + "tokio-util", + "tracing", + "uuid", +] + +[[package]] +name = "tablepro-driver-mysql" +version = "0.0.0" +dependencies = [ + "async-trait", + "chrono", + "deadpool", + "futures-util", + "secrecy", + "sqlx", + "tablepro-core", + "tablepro-net", + "tablepro-test-fixtures", + "tablepro-test-support", + "tempfile", + "thiserror", + "tokio", + "tokio-util", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "tablepro-driver-postgres" +version = "0.0.0" +dependencies = [ + "async-trait", + "bytes", + "chrono", + "deadpool-postgres", + "fallible-iterator", + "futures-util", + "postgres-protocol", + "postgres-types", + "rustls", + "secrecy", + "tablepro-core", + "tablepro-net", + "tablepro-test-fixtures", + "tablepro-test-support", + "tokio", + "tokio-postgres", + "tokio-postgres-rustls", + "tokio-util", + "tracing", + "uuid", +] + +[[package]] +name = "tablepro-driver-sqlite" +version = "0.0.0" +dependencies = [ + "async-trait", + "futures", + "libsqlite3-sys", + "sqlparser", + "sqlx", + "tablepro-core", + "tablepro-test-support", + "tempfile", + "tokio", + "tokio-util", + "tracing", + "tracing-subscriber", + "uuid", +] + +[[package]] +name = "tablepro-net" +version = "0.0.0" +dependencies = [ + "async-trait", + "http", + "hyper-rustls", + "hyper-util", + "pem-rfc7468", + "rcgen", + "rustls", + "rustls-native-certs", + "rustls-pki-types", + "rustls-platform-verifier", + "socket2", + "tablepro-core", + "tempfile", + "thiserror", + "tokio", + "tokio-rustls", + "tower-service", + "tracing", +] + +[[package]] +name = "tablepro-session" +version = "0.0.0" +dependencies = [ + "futures", + "secrecy", + "tablepro-core", + "tablepro-driver-postgres", + "tablepro-net", + "tablepro-ssh", + "tablepro-storage", + "tablepro-test-fixtures", + "tablepro-test-support", + "tempfile", + "thiserror", + "tokio", + "tokio-util", + "tracing", + "uuid", +] + +[[package]] +name = "tablepro-ssh" +version = "0.0.0" +dependencies = [ + "async-trait", + "futures", + "rustix", + "secrecy", + "tablepro-core", + "tablepro-test-support", + "tempfile", + "testcontainers", + "thiserror", + "tokio", + "tokio-util", + "tracing", + "uuid", + "zeroize", +] + +[[package]] +name = "tablepro-storage" +version = "0.0.0" +dependencies = [ + "async-trait", + "chrono", + "gio", + "glib", + "oo7", + "secrecy", + "serde", + "serde_json", + "sqlx", + "tablepro-core", + "tempfile", + "thiserror", + "tokio", + "uuid", +] + +[[package]] +name = "tablepro-test-fixtures" +version = "0.0.0" +dependencies = [ + "rcgen", + "rustls", + "tablepro-test-support", + "tempfile", + "testcontainers", + "testcontainers-modules", + "thiserror", + "tiberius", + "tokio", + "tokio-rustls", + "tokio-util", + "uuid", +] + +[[package]] +name = "tablepro-test-support" +version = "0.0.0" +dependencies = [ + "async-trait", + "futures", + "secrecy", + "tablepro-core", + "tablepro-net", + "tempfile", + "tokio", + "tokio-util", + "uuid", +] + +[[package]] +name = "target-lexicon" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca" + +[[package]] +name = "temp-dir" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83176759e9416cf81ee66cb6508dbfe9c96f20b8b56265a39917551c23c70964" + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.3.4", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "testcontainers" +version = "0.27.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfd5785b5483672915ed5fe3cddf9f546802779fc1eceff0a6fb7321fac81c1e" +dependencies = [ + "astral-tokio-tar", + "async-trait", + "bollard", + "bytes", + "docker_credential", + "either", + "etcetera", + "ferroid", + "futures", + "http", + "itertools 0.14.0", + "log", + "memchr", + "parse-display", + "pin-project-lite", + "serde", + "serde_json", + "serde_with", + "thiserror", + "tokio", + "tokio-stream", + "tokio-util", + "url", +] + +[[package]] +name = "testcontainers-modules" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5985fde5befe4ffa77a052e035e16c2da86e8bae301baa9f9904ad3c494d357" +dependencies = [ + "testcontainers", +] + +[[package]] +name = "thiserror" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "thread_local" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tiberius" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e07324791de2bdaed058af4aa433a0b5ec5c0beac407a9797419c19805df2415" +dependencies = [ + "async-trait", + "asynchronous-codec", + "byteorder", + "bytes", + "chrono", + "connection-string", + "encoding_rs", + "enumflags2", + "futures-timer", + "futures-util", + "libc", + "libgssapi", + "num-traits", + "once_cell", + "pin-project-lite", + "pretty-hex", + "rustls-native-certs", + "rustls-pki-types", + "rustls-webpki", + "secrecy", + "thiserror", + "tiberius-macros", + "tokio-rustls", + "tokio-util", + "tracing", + "uuid", + "zeroize", +] + +[[package]] +name = "tiberius-macros" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "907fed5ea55695d902cc3359fffa4a35021dfa9b95ab4eb9c8376d42ffb9eec8" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "serde_core", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" + +[[package]] +name = "tls_codec" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de2e01245e2bb89d6f05801c564fa27624dbd7b1846859876c7dad82e90bf6b" +dependencies = [ + "tls_codec_derive", + "zeroize", +] + +[[package]] +name = "tls_codec_derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d2e76690929402faae40aebdda620a2c0e25dd6d3b9afe48867dfd95991f4bd" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "tracing", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "tokio-postgres" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a528f7d280f6d5b9cd149635c8705b0dd049754bc67d81d31fa25169a93809d3" +dependencies = [ + "async-trait", + "byteorder", + "bytes", + "fallible-iterator", + "futures-channel", + "futures-util", + "log", + "parking_lot", + "percent-encoding", + "phf", + "pin-project-lite", + "postgres-protocol", + "postgres-types", + "rand 0.10.2", + "socket2", + "tokio", + "tokio-util", + "whoami", +] + +[[package]] +name = "tokio-postgres-rustls" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c2ad44aa0ae96db89c4742212ed41645b2f597311ff6e1945542a4d9fadc2fb" +dependencies = [ + "rustls", + "sha2 0.11.0", + "tokio", + "tokio-postgres", + "tokio-rustls", + "x509-cert", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0c85f2c3ef0b1cd58b36682f4b17aaa995f0e5db534d85692b4903abce21f67" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" +dependencies = [ + "bytes", + "futures-core", + "futures-io", + "futures-sink", + "futures-util", + "libc", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "1.1.6+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "920602543f0911ab71da12c50d59701da54c196d1a2bf5cb4b75667f137a406a" +dependencies = [ + "indexmap 2.14.2", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow 1.0.4", +] + +[[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.15+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1340ea94a5856333492c9064b02c778b191dd2c853778d9609debdcdfea3a614" +dependencies = [ + "indexmap 2.14.2", + "toml_datetime", + "toml_parser", + "winnow 1.0.4", +] + +[[package]] +name = "toml_parser" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +dependencies = [ + "winnow 1.0.4", +] + +[[package]] +name = "toml_writer" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" + +[[package]] +name = "tonic" +version = "0.14.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac2a5518c70fa84342385732db33fb3f44bc4cc748936eb5833d2df34d6445ef" +dependencies = [ + "async-trait", + "axum", + "base64 0.22.1", + "bytes", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-timeout", + "hyper-util", + "percent-encoding", + "pin-project", + "socket2", + "sync_wrapper", + "tokio", + "tokio-stream", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tonic-prost" +version = "0.14.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50849f68853be452acf590cde0b146665b8d507b3b8af17261df47e02c209ea0" +dependencies = [ + "bytes", + "prost", + "tonic", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "indexmap 2.14.2", + "pin-project-lite", + "slab", + "sync_wrapper", + "tokio", + "tokio-util", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[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-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[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-serde" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "704b1aeb7be0d0a84fc9828cae51dab5970fee5088f83d1dd7ee6f6246fc6ff1" +dependencies = [ + "serde", + "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", + "serde", + "serde_json", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", + "tracing-serde", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typed-path" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[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 = "unarray" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" + +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[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_categories" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39ec24b3121d976906ece63c9daad25b85969647682eee313cb5779fdd69e14e" + +[[package]] +name = "untrusted" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "ureq" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a7ac20be9b7726e0bbdbf974c059676d9acb1cd414961f570a4e8231cacd7fc" +dependencies = [ + "base64 0.23.1", + "log", + "percent-encoding", + "rustls", + "rustls-pki-types", + "ureq-proto", + "utf8-zero", +] + +[[package]] +name = "ureq-proto" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b0809a01d1ca5a51ca70db32bb2a19157582a526505ef3c19e3b343a59aa5ad" +dependencies = [ + "base64 0.23.1", + "http", + "httparse", + "log", +] + +[[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", + "serde_derive", +] + +[[package]] +name = "utf8-zero" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version-compare" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wait-timeout" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" +dependencies = [ + "libc", +] + +[[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 = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasi" +version = "0.14.7+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "883478de20367e224c0090af9cf5f9fa85bed63a95c1abf3afc5c083ebc06e8c" +dependencies = [ + "wasip2", +] + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasite" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66fe902b4a6b8028a753d5424909b764ccf79b7a209eac9bf97e59cda9f71a42" +dependencies = [ + "wasi 0.14.7+wasi-0.2.4", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.5", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fbddc4a036f00ec4f18c83445bd3115cb306a91da554919a099d9222fe4a7f8" +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-root-certs" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "webpki-roots" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "whoami" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "626c4bac6755d76ffc12cb01b2eac751db1996b9e0041de9aa02c8c211ddc82c" +dependencies = [ + "libc", + "libredox", + "objc2-system-configuration", + "wasite", + "web-sys", +] + +[[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-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[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 2.0.119", +] + +[[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 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[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 = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[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_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[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_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[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_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[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.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "x11rb" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a8885a854a8bfdf87a301e53e41b17c5f8f33639903131338b997b1eb614f44" +dependencies = [ + "gethostname", + "rustix", + "x11rb-protocol", +] + +[[package]] +name = "x11rb-protocol" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acf4d1bc32aa46eec18caa634ec3cf4c05bfa151f12b93b510b15190f69a1ca8" + +[[package]] +name = "x509-cert" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1301e935010a701ae5f8655edc0ad17c44bad3ac5ce8c39185f75453b720ae94" +dependencies = [ + "const-oid 0.9.6", + "der 0.7.10", + "spki 0.7.3", + "tls_codec", +] + +[[package]] +name = "x509-parser" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d43b0f71ce057da06bc0851b23ee24f3f86190b07203dd8f567d0b706a185202" +dependencies = [ + "asn1-rs", + "aws-lc-rs", + "data-encoding", + "der-parser", + "lazy_static", + "nom", + "oid-registry", + "rusticata-macros", + "thiserror", + "time", +] + +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix", +] + +[[package]] +name = "yasna" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5f6765e852b9b4dc8e2a76843e4d64d1cea8e79bcde0b6901aea8e7c7f08282" +dependencies = [ + "bit-vec 0.9.1", + "time", +] + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +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 2.0.119", + "synstructure", +] + +[[package]] +name = "zbus" +version = "5.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5db4be7c075cb421e4b7ee645541604239bd243ba7c357511f4ff3a74b555907" +dependencies = [ + "async-broadcast", + "async-recursion", + "async-trait", + "enumflags2", + "event-listener", + "futures-core", + "futures-lite", + "hex", + "libc", + "ordered-stream", + "rustix", + "serde", + "serde_repr", + "tokio", + "tracing", + "uds_windows", + "uuid", + "windows-sys 0.61.2", + "winnow 1.0.4", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "5.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2990635d09ade6df1868f72f8cac69a876a90981e8bd3c40b1be413f8dc88f40" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 3.0.5", + "zbus_names", + "zvariant", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "4.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e" +dependencies = [ + "serde", + "winnow 1.0.4", + "zvariant", +] + +[[package]] +name = "zcheapstr" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1afec51604565183aeb5c54c20aeab286120d4e4460f7f76e3e8bb8c0d99473" +dependencies = [ + "serde", +] + +[[package]] +name = "zerocopy" +version = "0.8.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d35102a9f36d089ccae9e4c6802bc118be4487b80aaffc0ab4e0cf5ce92d2873" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "146c01f5ab44258da43cf276c74a2763db2ff3969c9c652c3f2de07041d0b2bc" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +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 2.0.119", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "zerovec" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +dependencies = [ + "serde", + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "zip" +version = "8.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d04a6b5381502aa6087c94c669499eb1602eb9c5e8198e534de571f7154809b" +dependencies = [ + "crc32fast", + "flate2", + "indexmap 2.14.2", + "memchr", + "typed-path", + "zopfli", +] + +[[package]] +name = "zlib-rs" +version = "0.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[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 = "zvariant" +version = "5.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1d34c27cc6cdd1f458427519dd6b8612f7b7e3f7b9a0b2355d041dda9869147" +dependencies = [ + "endi", + "enumflags2", + "serde", + "serde_bytes", + "winnow 1.0.4", + "zcheapstr", + "zvariant_derive", + "zvariant_utils", +] + +[[package]] +name = "zvariant_derive" +version = "5.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "864155e69b4352db0c7f374917bf45d1e0c8d17659c8b3dbf9795f3673f8c497" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 3.0.5", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad0294361a320b694a328460dc73add56c306150f5cb6bfafc44446120008a3" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn 3.0.5", + "winnow 1.0.4", +] diff --git a/linux/Cargo.toml b/linux/Cargo.toml new file mode 100644 index 0000000000..d61ec72a1d --- /dev/null +++ b/linux/Cargo.toml @@ -0,0 +1,125 @@ +[workspace] +resolver = "3" +members = [ + "crates/app", + "crates/core", + "crates/net", + "crates/session", + "crates/ssh", + "crates/storage", + "crates/test-fixtures", + "crates/test-support", + "crates/drivers/clickhouse", + "crates/drivers/mssql", + "crates/drivers/mysql", + "crates/drivers/postgres", + "crates/drivers/sqlite", +] + +[workspace.package] +edition = "2024" +rust-version = "1.97" +license = "AGPL-3.0-or-later" +publish = false + +[workspace.dependencies] +tablepro-core = { path = "crates/core" } +tablepro-driver-clickhouse = { path = "crates/drivers/clickhouse" } +tablepro-driver-mssql = { path = "crates/drivers/mssql" } +tablepro-driver-mysql = { path = "crates/drivers/mysql" } +tablepro-driver-postgres = { path = "crates/drivers/postgres" } +tablepro-driver-sqlite = { path = "crates/drivers/sqlite" } +tablepro-net = { path = "crates/net" } +tablepro-session = { path = "crates/session" } +tablepro-ssh = { path = "crates/ssh" } +tablepro-storage = { path = "crates/storage" } +tablepro-test-fixtures = { path = "crates/test-fixtures" } +tablepro-test-support = { path = "crates/test-support" } +async-trait = "0.1" +thiserror = "2" +serde = { version = "1", features = ["derive"] } +serde_json = { version = "1", features = ["raw_value"] } +sqlparser = { version = "0.63", default-features = false, features = ["std"] } +tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "time", "sync", "process"] } +tokio-util = { version = "0.7", default-features = false, features = ["codec", "rt"] } +secrecy = { version = "0.10", features = ["serde"] } +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] } +# The driver dials through its own hyper-rustls connector, so the crate's +# own TLS features would only add a second, unused TLS stack. +clickhouse = { version = "0.15", default-features = false } +sqlx = { version = "0.9", default-features = false, features = ["runtime-tokio", "chrono", "uuid", "json"] } +# The driver reads and writes DECIMAL through tiberius's own Numeric. +tiberius = { version = "0.13", default-features = false, features = ["tds73", "rustls", "chrono"] } +chrono = { version = "0.4", default-features = false, features = ["clock", "serde"] } +csv = "1.4" +# No zlib feature: it pulls libz-sys, and the pure-Rust deflate keeps +# the build free of another C dependency. Constant memory lets a sheet +# spill its rows to a temporary file instead of holding them. +rust_xlsxwriter = { version = "0.99", default-features = false, features = ["chrono", "constant_memory"] } +zip = { version = "8.3", default-features = false, features = ["deflate"] } +futures = "0.3" +futures-util = { version = "0.3", default-features = false, features = ["std"] } +bytes = "1" +fallible-iterator = "0.2" +tokio-postgres = { version = "0.7.18", default-features = false, features = ["runtime"] } +postgres-types = { version = "0.2.14", features = ["with-chrono-0_4"] } +postgres-protocol = "0.6.12" +deadpool-postgres = { version = "0.14.2", default-features = false, features = ["rt_tokio_1"] } +deadpool = { version = "0.13.1", default-features = false, features = ["managed", "rt_tokio_1"] } +clickhouse-types = "0.1.3" +# The provider comes from the ClientConfig the driver builds, so neither +# provider feature is needed, and leaving both off keeps ring out. +tokio-postgres-rustls = { version = "0.14.0", default-features = false } +libsqlite3-sys = ">=0.30.1, <0.38.0" +num-bigint = { version = "0.4", default-features = false, features = ["std"] } +proptest = "1.11" +rustls = { version = "0.23", default-features = false, features = ["aws_lc_rs", "std", "tls12"] } +rustls-native-certs = "0.8" +rustls-platform-verifier = "0.7" +pem-rfc7468 = { version = "1", features = ["alloc"] } +socket2 = { version = "0.6", features = ["all"] } +rustix = { version = "1.1", default-features = false, features = ["std"] } +tokio-rustls = { version = "0.26", default-features = false, features = ["aws_lc_rs", "logging", "tls12"] } +hyper-util = { version = "0.1", features = ["client-legacy", "http1", "tokio"] } +hyper-rustls = { version = "0.27", default-features = false, features = ["aws-lc-rs", "http1", "tls12"] } +tower-service = "0.3" +http = "1" +rcgen = { version = "0.14", default-features = false, features = ["crypto", "pem", "aws_lc_rs"] } +rustls-pki-types = { version = "1", features = ["std"] } +gtk4 = { version = "0.11", features = ["gnome_50"] } +libadwaita = { version = "0.9", features = ["v1_9"] } +sourceview5 = { version = "0.11", features = ["v5_18"] } +gio = { version = "0.22", features = ["v2_88"] } +gio-unix = { version = "0.22", features = ["v2_84"] } +glib = { version = "0.22", features = ["v2_88"] } +glib-build-tools = "0.22" +uuid = { version = "1", features = ["v4", "serde"] } +zeroize = "1.9" +tempfile = "3" +testcontainers = { version = "0.27", default-features = false, features = ["aws-lc-rs"] } +testcontainers-modules = { version = "0.15", default-features = false, features = ["aws-lc-rs"] } +oo7 = { version = "0.6", default-features = false, features = ["tokio", "native_crypto"] } +relm4 = { version = "0.11", default-features = false, features = ["macros", "libadwaita"] } + +[workspace.lints.rust] +unsafe_op_in_unsafe_fn = "deny" + +[workspace.lints.clippy] +unwrap_used = "deny" +expect_used = "deny" +panic = "deny" +todo = "deny" +unimplemented = "deny" +dbg_macro = "deny" +print_stdout = "deny" +print_stderr = "deny" +allow_attributes = "deny" +allow_attributes_without_reason = "deny" +ignore_without_reason = "deny" +too_many_lines = "deny" +self_named_module_files = "deny" + +[profile.release] +lto = "fat" +codegen-units = 1 diff --git a/linux/LICENSE b/linux/LICENSE new file mode 100644 index 0000000000..be3f7b28e5 --- /dev/null +++ b/linux/LICENSE @@ -0,0 +1,661 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/linux/README.md b/linux/README.md new file mode 100644 index 0000000000..c4fdf5e5ad --- /dev/null +++ b/linux/README.md @@ -0,0 +1,269 @@ +# TablePro Linux + +Native Linux database client, the Linux sibling of the macOS TablePro app. The two share no code. + +## Status + +It connects to five engines: PostgreSQL, MySQL and MariaDB, SQL Server, ClickHouse and SQLite. Each connection session gets a window of its own. Nothing is released yet: no `linux-v*` tag exists. [ROADMAP.md](ROADMAP.md) lists what is built, what is not, and the release criteria. + +## Install + +These commands work from the first release, 0.1.0, on amd64 and arm64. + +Ubuntu 26.04 and 26.10, from the Launchpad PPA: + +```bash +sudo add-apt-repository ppa:tablepro/tablepro +sudo apt install tablepro +``` + +Fedora 44 and later, from COPR: + +```bash +sudo dnf copr enable @tablepro/tablepro +sudo dnf install tablepro +``` + +apt and dnf, and Software Updater and GNOME Software with them, install the updates. Other distributions build from source ([Quickstart](#quickstart)) until Flathub has the app. + +[docs/troubleshooting.md](docs/troubleshooting.md) covers saved passwords, SSH, Kerberos, logs, crash reports and bug reports. + +## Privacy + +TablePro makes no network requests of its own: no telemetry, no update check and no crash upload. It connects to the database servers and SSH hosts you set up, and nothing else. Crash reports stay on your disk until you attach one to a bug report. Updates come from apt or dnf. + +## Stack + +| Layer | Pick | +|---|---| +| Language | Rust: 1.98 in `rust-toolchain.toml`, 1.97 the `rust-version` floor | +| GUI toolkit | GTK 4, libadwaita, GtkSourceView and GLib at the GNOME 50 floor ([ADR 0015](docs/decisions/0015-gnome-50-platform-floor.md)) | +| App architecture | gtk-rs subclasses with composite templates, one window per connection session ([ADR 0012](docs/decisions/0012-window-per-session.md)); the saved-queries and CSV import dialogs are still [Relm4](https://relm4.org) 0.11 components | +| Async | the app's tokio runtime (`AppRuntime`) for background work, results delivered to the GLib main loop | +| Database drivers | tokio-postgres with deadpool-postgres (PostgreSQL), sqlx 0.9 (MySQL and MariaDB, SQLite), tiberius 0.13 (SQL Server) and the official `clickhouse` crate, all from crates.io ([ADR 0016](docs/decisions/0016-driver-contract-v2.md)) | +| SSH | the system OpenSSH client ([ADR 0013](docs/decisions/0013-openssh-client-transport.md)) | +| TLS | rustls with aws-lc-rs as the only crypto provider ([ADR 0009](docs/decisions/0009-crypto-provider-and-sources.md)) | +| Secrets | oo7: the Secret portal inside Flatpak, the Secret Service over D-Bus on the host ([ADR 0004](docs/decisions/0004-libsecret-secret-storage.md)) | +| Storage | GSettings for preferences and window state ([ADR 0007](docs/decisions/0007-gsettings-preferences.md)), JSON files for connections and workspace state, SQLite for query history ([docs/storage.md](docs/storage.md)) | +| Build | Meson driving Cargo ([ADR 0006](docs/decisions/0006-meson-cargo-build.md)) | +| Distribution | A Launchpad PPA for Ubuntu 26.04 and later and a COPR project for Fedora 44 and later, Flathub later ([ADR 0018](docs/decisions/0018-ppa-copr-and-flathub-later.md)) | + +## What this is not + +| Not | Why | +|---|---| +| A port of the macOS app | The macOS app is AppKit and SwiftUI, which Linux does not have, and Swift's GTK bindings are immature. The Linux app shares no source with macOS. | +| A plugin host | Drivers are statically linked at compile time. Adding a database engine = adding one crate + one register call. See [decisions/0001-no-plugin-system.md](docs/decisions/0001-no-plugin-system.md). | +| Cross-platform | Linux only. macOS and iOS have separate apps in this monorepo. | +| Electron / WebView | Native GTK4 widgets throughout. No HTML rendering of any kind. | + +## Quickstart + +System dependencies: + +```bash +# Ubuntu 26.04 +sudo apt install -y build-essential pkgconf meson ninja-build gettext desktop-file-utils appstream libglib2.0-bin libxml2-utils libgtk-4-dev libadwaita-1-dev libgtksourceview-5-dev libsqlite3-dev libkrb5-dev clang libclang-dev openssh-client + +# Fedora 44 +sudo dnf install -y gcc pkgconf meson ninja-build gettext desktop-file-utils appstream glib2-devel libxml2 gtk4-devel libadwaita-devel gtksourceview5-devel sqlite-devel krb5-devel clang-devel openssh-clients + +# Arch (not covered by CI) +sudo pacman -S --needed base-devel pkgconf meson ninja gettext desktop-file-utils appstream libxml2 gtk4 libadwaita gtksourceview5 sqlite krb5 clang openssh +``` + +The Ubuntu list is the .deb's build dependencies in `build-aux/debian/control` and the Fedora list the RPM's in `build-aux/rpm/tablepro.spec`, less the ones only their tests need. CI builds both. + +The test suites need a few more: an X server for the GTK tests, a session +bus, the icon theme, a generated `en_US.UTF-8` locale for the catalogue +test, and gnome-keyring for the Secret Service tests. + +```bash +# Ubuntu 26.04 +sudo apt install -y xvfb xauth dbus-daemon adwaita-icon-theme locales gnome-keyring +sudo localedef -i en_US -f UTF-8 en_US.UTF-8 + +# Fedora 44 +sudo dnf install -y xorg-x11-server-Xvfb xorg-x11-xauth dbus-daemon adwaita-icon-theme glibc-langpack-en gnome-keyring +``` + +Check the installed versions against the floors in +[ADR 0015](docs/decisions/0015-gnome-50-platform-floor.md). SQLite needs +3.46 or later. + +```bash +pkg-config --modversion gtk4 libadwaita-1 gtksourceview-5 glib-2.0 sqlite3 +``` + +Install Rust with [rustup](https://rustup.rs). `rust-toolchain.toml` pins +1.98, and rustup fetches it on the first `cargo` call in this directory. +The workspace also builds on 1.97, the `rustc` the PPA packages build +with. + +Build and run: + +```bash +cd linux +meson setup _build -Dprofile=development +meson compile -C _build +meson devenv -C _build ./crates/app/tablepro +``` + +`meson devenv` puts the compiled GSettings schema and the askpass helper on +the search paths. Starting `./_build/crates/app/tablepro` outside it aborts +with `GSettings schema app.tablepro.TablePro is not installed`. + +Install with `meson install -C _build`, which places the binary, the +askpass helper, the desktop entry, the D-Bus service, the AppStream +metainfo, the GSettings schema, the icons and the man page. See +[ADR 0006](docs/decisions/0006-meson-cargo-build.md). + +Before pushing, run the [fast-job commands](CONTRIBUTING.md#fast-job-commands). [docs/testing.md](docs/testing.md) describes the test suites, including the ones that need Docker. + +`libsqlite3-dev` is there because the SQLite driver and the query history +link the system SQLite through sqlx's `sqlite-unbundled` feature rather +than compiling a second copy into the binary. `libclang-dev` lets +`libsqlite3-sys` run bindgen against the installed `sqlite3.h`. + +`libkrb5-dev` is there for the app's default `kerberos` feature: SQL +Server Windows integrated auth links MIT Kerberos and runs bindgen against +its headers. `meson setup _build -Dkerberos=false`, or +`cargo build -p tablepro --no-default-features`, leaves it out, and the +connect dialog then hides Windows (Kerberos). clang and libclang stay +either way, for the SQLite bindings. + +## Flatpak + +`build-aux/flatpak/app.tablepro.TablePro.Devel.json` builds the development +profile from this tree, with network access during the build so cargo can +fetch the crates `Cargo.lock` pins. From `linux/`: + +```bash +flatpak-builder --user --install-deps-from=flathub --install --force-clean \ + _flatpak build-aux/flatpak/app.tablepro.TablePro.Devel.json +flatpak run app.tablepro.TablePro.Devel +``` + +Lint the manifest with Flathub's linter. Its two expected findings are +listed in `build-aux/flatpak/lint-exceptions.json`, and the file only +counts when `--exceptions` is passed too: + +```bash +flatpak run --command=flatpak-builder-lint org.flatpak.Builder \ + --exceptions --user-exceptions build-aux/flatpak/lint-exceptions.json \ + manifest build-aux/flatpak/app.tablepro.TablePro.Devel.json +``` + +The sandbox gets the network, the display, the home folder and the mount +points of removable drives, the SSH agent and the Kerberos KCM socket. The +Secret Service, file choosers, notifications and links go through portals. +[ADR 0008](docs/decisions/0008-flatpak-sandbox-permissions.md) gives the +reason for each grant. + +The repository keeps no Flathub manifest. Flathub's policy refuses +manifests and submissions written with AI tools, so maintainers write and +submit that one by hand ([ADR 0018](docs/decisions/0018-ppa-copr-and-flathub-later.md)). + +### Screenshots + +The metainfo lists the PNGs in `data/screenshots` at the release tag's +URL, so a catalogue fetches the ones of the version it lists. + +Take new ones from the app at default settings (light style, blue accent, +Adwaita Sans and Adwaita Mono at 11 pt, scale 1) with a 1000×700 window, +keeping the window shadow and rounded corners, and keep the file names and +captions in step with the metainfo. + +## Packages + +A `linux-vX.Y.Z` tag publishes the source and vendor tarballs on GitHub +Releases, uploads a source package for Ubuntu 26.04 and 26.10 to the +Launchpad PPA, and starts a COPR build of the spec for Fedora 44 and +later. Both build offline from those tarballs, Launchpad with the Ubuntu +Rust team's `rustc-1.97` and COPR with Fedora's Rust, and apt, dnf and +the software centres update the installed packages +([ADR 0018](docs/decisions/0018-ppa-copr-and-flathub-later.md)). +The packaging is `build-aux/debian` and `build-aux/rpm/tablepro.spec`, and +[docs/releasing.md](docs/releasing.md) has the release steps. + +To build both from the committed tree the way CI does, each step in a +fresh container, with Docker or Podman: + +```bash +build-aux/packaging/build-packages.sh all _packages +``` + +It writes the source and vendor tarballs, the Debian source package and +its .deb built offline as Launchpad builds it (checked by lintian), and +the RPMs (checked by rpmlint) under `_packages`, then installs each +package in a fresh container and starts it. `deb` or `rpm` in place of +`all` builds one, and Ubuntu series names after the output directory +replace the default `resolute`. `meson dist` archives `HEAD`, so commit +first. + +After a change to `Cargo.lock`, `build-aux/update-third-party-licenses.sh` +rewrites `debian/copyright` and the spec's `License` tag along with +`data/licenses`. + +## Opening SQLite files + +Once installed, TablePro is in the file manager's **Open With** for +SQLite databases (`application/vnd.sqlite3`). From a terminal: + +```bash +tablepro app.sqlite3 +``` + +A running TablePro opens the file in its own window and the new launch +exits. A file already open brings its window forward. A database on a +network or FUSE filesystem asks whether to open read-only first, because +SQLite's locking cannot be trusted there. + +## SQL Server with Windows integrated auth + +Pick **Method → Windows (Kerberos)** in the connect dialog. There is no +username or password to enter: the driver uses whatever ticket `klist` +shows, so get one first. + +```bash +kinit you@EXAMPLE.COM +``` + +The driver asks for `MSSQLSvc/:`, built from the host and +port you typed, not from an SSH tunnel's local forward. Two things are +worth knowing: + +- tiberius imports that SPN as a raw Kerberos principal, so it takes the + default realm and nothing else: `default_realm` from `/etc/krb5.conf`, + or under Flatpak a DNS record. `[domain_realm]` does not apply: that + lookup only runs for host-based service names, and tiberius exposes no + SPN override. A server in another realm works only when your KDC + answers with a referral, which Active Directory does inside a forest. + Otherwise the login fails with `KRB5KDC_ERR_S_PRINCIPAL_UNKNOWN`. +- The host has to match the SPN registered on the server. An IP address + or a CNAME usually does not. + +Under Flatpak the app brings its own Kerberos configuration and reads +tickets from the KCM cache. [Kerberos inside Flatpak](docs/drivers/sql-server.md#kerberos-inside-flatpak) +says where the realm, the KDC and the tickets come from there. + +## Documentation index + +| Topic | File | +|---|---| +| Layered architecture, crate boundaries, dependency rules | [ARCHITECTURE.md](ARCHITECTURE.md) | +| What is built, the backlog and the release criteria | [ROADMAP.md](ROADMAP.md) | +| Contributing: dev workflow, lint, commits, PRs | [CONTRIBUTING.md](CONTRIBUTING.md) | +| **Adding a database driver** | [docs/adding-drivers.md](docs/adding-drivers.md) | +| State management: windows, sessions, background work | [docs/state-management.md](docs/state-management.md) | +| Persistence: secrets, settings, files | [docs/storage.md](docs/storage.md) | +| Error handling conventions | [docs/error-handling.md](docs/error-handling.md) | +| Testing conventions, suites and CI jobs | [docs/testing.md](docs/testing.md) | +| Saved passwords, SSH, Kerberos, logs and crash reports, for users | [docs/troubleshooting.md](docs/troubleshooting.md) | +| Release setup, releases and packaging fixes | [docs/releasing.md](docs/releasing.md) | +| Every guide and driver page | [docs/README.md](docs/README.md) | +| Architecture decision records | [docs/decisions/](docs/decisions/) | + +## License + +AGPL-3.0-or-later, as the parent TablePro project: see [LICENSE](LICENSE). diff --git a/linux/ROADMAP.md b/linux/ROADMAP.md new file mode 100644 index 0000000000..74e18b8527 --- /dev/null +++ b/linux/ROADMAP.md @@ -0,0 +1,203 @@ +# Roadmap + +**Status, 2026-10-18**: every capability in the tables below is in the tree. Nothing has shipped yet: no `linux-v*` tag has been cut. The [release criteria](#release-criteria) say what has to hold first, and the [backlog](#backlog) lists what is not built. + +The app runs on GNOME 50 ([ADR 0015](docs/decisions/0015-gnome-50-platform-floor.md)) and connects to five engines, each a crate linked in at compile time ([ADR 0001](docs/decisions/0001-no-plugin-system.md)): [PostgreSQL](docs/drivers/postgresql.md), [MySQL and MariaDB](docs/drivers/mysql.md), [SQL Server](docs/drivers/sql-server.md), [ClickHouse](docs/drivers/clickhouse.md) and [SQLite](docs/drivers/sqlite.md). + +## Implemented + +### Connections + +| Capability | Detail | +|---|---| +| Saved connections | Create and edit (the name included), duplicate with the secrets carried to the copy, delete with or without the connection's history | +| Groups and colour tags | From the connection row's menu; each group is a section of the connection list | +| Import and export | A JSON file with no secrets in it. On import, an entry with the same id and the same settings updates its name, colour and group; any other is added as a new connection | +| Test before saving | Test connects once and reports, then disconnects | +| Passwords | Kept in the Secret Service; a prompt asks when one is missing or refused, with Remember Password | +| Read-only connections | SQLite and ClickHouse refuse writes at the engine, PostgreSQL and MySQL run in read-only transactions, and SQL Server relies on the login, with the app's own reads in a rolled-back transaction and editor SQL unguarded. Every engine refuses saves, and the switch's subtitle says which | +| SQLite files | Choose a file, create a new one, open one from Files, find a moved one with Locate, and choose read-only or read-write for one on a network or FUSE filesystem | +| Kerberos for SQL Server | From the ticket cache, in the Flatpak through the KCM socket | +| Health and reconnect | A banner while the connection is lost, reconnect with a backoff from 5 to 60 seconds, and metadata, page and count reads replayed once after it; writes are never replayed | +| Cancel on the server | Stopping a query stops the statement on the server (in process on SQLite); a ClickHouse mutation already running in a save is left to finish | + +### SSH + +| Capability | Detail | +|---|---| +| System OpenSSH client | One ControlMaster per connection ([ADR 0013](docs/decisions/0013-openssh-client-transport.md)); empty fields come from `~/.ssh/config` | +| Jump hosts | A ProxyJump chain, passed to `ssh -J` | +| Authentication | Agent, private key, password and keyboard-interactive; prompts go through the app's askpass helper | +| Host keys | An unknown or changed key is shown with its SHA256 fingerprint before connecting, even where ssh_config says accept-new or no. A changed key can be replaced in known_hosts, except under StrictHostKeyChecking yes, where the user fixes known_hosts outside the app and retries. A revoked key is refused | + +### TLS + +| Capability | Detail | +|---|---| +| Modes | Disable, Require and Verify full; Verify full is the default for a new connection | +| Certificates | CA file, client certificate and key (every network engine but SQL Server), and a server name override (MySQL only through an SSH tunnel) | +| Remedies | A TLS failure says what went wrong. An unknown issuer jumps to the CA file, and a name mismatch to the server name | + +### Browse and edit + +| Capability | Detail | +|---|---| +| Data grid | A virtualized `GtkColumnView` with keyboard-focusable cells, pages of 100 to 10,000 rows, and a pager with an exact or estimated row count; a table of about 100,000 rows or fewer is counted on its own, a larger one offers Count Rows | +| Filters | Per-column operators, a raw WHERE fragment checked as one expression, match any or all, kept per table | +| Sorting | A header click sorts tables on the server and query results in the grid, one column at a time | +| Cell editing | Editors by column kind: inline text, a multi-line editor, a calendar for dates, a toggle for booleans, JSON checked before saving; values are edited from their exact text, never from what the cell displays. Table tabs only, on tables with a primary key; key, generated and auto-increment columns, and binary, spatial, array and composite values, are read-only | +| Rows | Insert, duplicate and delete; Set NULL and Empty, and DEFAULT on a new row; undo and redo; save or discard pending changes, and stop a save. On ClickHouse a save is not atomic, and Stop takes effect between statements | +| Bulk delete | Deleting 10 rows or more asks first, unless Confirm Before Deleting Rows is off in Preferences | +| Copy | Copy; Copy As rows, with headers, JSON, CSV with or without headers, Markdown or IN clause, and INSERT in an editable table tab; Copy Column Name; Show Row as JSON | +| Column widths | Kept per table | +| CSV import | Into an existing table, with a column mapping, in one write batch (one transaction where the engine has them), up to 64 MiB | +| Sidebar | Tables (partitioned ones included), views, materialized views and foreign tables by schema, with search and Refresh, read again after the editor runs DDL, Open in New Tab and Show CREATE TABLE; Edit Structure, Import CSV and Drop Table for tables | + +### Structure + +| Capability | Detail | +|---|---| +| Table definition | Columns, indexes, foreign keys and column comments | +| Changes | Create, rename and drop tables; add, drop and rename columns, and change their type, nullability, default, comment, primary key and auto-increment; add and drop indexes (column keys, unique, descending) and foreign keys. ClickHouse creates no tables, indexes or foreign keys and changes no primary key or auto-increment; SQL Server changes no auto-increment; SQLite keeps no column comments | +| Review | The SQL is previewed, and a save that loses data or can stop halfway is reviewed before it runs | +| SQLite rebuilds | Changes SQLite cannot ALTER rebuild the table in one transaction, keeping its rows, indexes, triggers, the views and triggers that name it and its AUTOINCREMENT counter, with foreign keys checked before commit. SQLite itself checks the views and triggers made again; a trigger that lists a dropped column or inserts by position is refused before Save | + +### SQL editor + +| Capability | Detail | +|---|---| +| Editing | GtkSourceView with SQL highlighting, a scheme that follows the light or dark style, and toggle comment | +| Completion | Tables, views, columns, schemas, functions and keywords, with aliases resolved | +| Formatter | Format SQL, leaving a statement as typed when formatting would change its tokens | +| Scripts | Split per dialect (`DELIMITER`, `GO` and `GO n`, routine and trigger bodies) and sent a statement at a time, or a batch at a time on SQL Server; each result set, row count, notice or error is its own entry, and a failure stops the script except on SQL Server. Run Selection, and run the statement at the cursor | +| Find and replace | Ctrl+F and Ctrl+H, with case, whole word and regular expressions | +| Files | Open a .sql file into the editor (Ctrl+O) or drop one on it, and save to it (Ctrl+S, or Save As with Ctrl+Shift+S) | +| Results | Several result sets per run, a limit of 10,000 rows per result and 100,000 per run with a banner and Export All, and Go to Error | +| Drafts | Each editor tab's text is kept, and a connection's tabs (table tabs with page and sort, structure and editor tabs) reopen when it opens | + +### History and saved queries + +| Capability | Detail | +|---|---| +| Query history | A SQLite database with full-text search, filters by connection, time and status, pins, export as SQL or CSV, and a retention period | +| What is recorded | Editor runs, and the SQL the app runs for grid saves, structure saves and successful imports; a run that can carry a password or connection secret, or whose text does not tokenize, is not recorded, and recording can be turned off | +| Show in Files | For the history database, from the history dialog and Preferences | +| Saved queries | Saved from the editor against a connection, listed by connection with the active one first, and opened in a new tab or the current one | + +### Export + +| Capability | Detail | +|---|---| +| Formats | CSV with its options, JSON, Markdown, HTML, XML, Excel, and SQL INSERT from a table tab | +| Scope | From a table tab, the current page with its pending edits, or every saved row under the filter and sort. From the editor, the rows shown, or every row by running the statement again on a separate read-only connection without the tab's USE or SET; a statement that writes is refused | +| Running | Off the GTK thread with progress and Stop; closing or quitting asks about a running export | + +### Windows and sessions + +| Capability | Detail | +|---|---| +| Window per session | A window holds one connection; another connection opens in a window of its own, and one already open brings its window forward ([ADR 0012](docs/decisions/0012-window-per-session.md)) | +| One instance | A second launch presents the running app; New Window (Ctrl+Shift+N) opens a picker | +| Tabs | `AdwTabView` with the tab overview and split header bars, adapting down to 360×294 | +| Session restore | The windows of saved connections and SQLite files open at quit open again at launch (Restore Session, on by default), also after New Window or a quit during the restore | +| Closing safely | Unsaved edits, a save in flight or of unknown outcome, an open editor transaction, or a running export ask before a window closes or the app quits; logout waits while any of those holds, and suspend while a save runs or an export writes | +| Notifications | Work that finishes in a tab you are not looking at sends a notification | +| Preferences | Restore session, page size, the bulk delete confirmation, query history, editor font and scheme, query timeout | +| Shortcuts and About | A shortcuts dialog, and About from the metainfo with third-party licenses and debug info | + +### Packaging and platform + +| Capability | Detail | +|---|---| +| Flatpak | The Devel manifest builds on GNOME 51 for x86_64 and aarch64 with every Meson suite in the sandbox, and is linted, on every pull request that touches `linux/` | +| .deb and .rpm | Ubuntu 26.04 and 26.10 and Fedora 44, built offline in CI the way Launchpad and COPR build them, linted, then installed and started in fresh containers; a `linux-v*` tag uploads to the PPA and starts the COPR build ([ADR 0018](docs/decisions/0018-ppa-copr-and-flathub-later.md)) | +| Desktop integration | Desktop file with Open With for SQLite files, D-Bus activation, a man page, metainfo with screenshots, and SVG icons | +| Translations | The app's own strings go through gettext, and CI regenerates the template with gettext 1.0 and fails when it or `POTFILES.in` is stale. Some error details from the core crates still reach the user in English (see Release criteria) | +| Logs | stderr, which the session journals, with JSON lines on request. A panic also writes a crash report under `$XDG_STATE_HOME`, offered with Show Report on the next launch | +| Tests | Unit, gtk (with an accessibility audit of the picker, the connect pages, the workspace, the connect dialog and every preferences page), data and packaging suites; connection, DDL and TLS conformance on every engine; Docker jobs for PostgreSQL with PgBouncer, MySQL and MariaDB, SQL Server, ClickHouse, SSH, the session and the grid save ([testing.md](docs/testing.md)); every critical audit finding has named guards ([regression-tests.md](docs/regression-tests.md)) | + +## Backlog + +Not built yet, in no order. + +### Connections + +- A statement timeout per connection. Today one preference covers editor runs. Other calls have fixed limits: 15 seconds for metadata, 60 for a page or a count, 120 for a row save or a CSV import, and none for a schema change or an export; the server also stops any table-tab statement at 60 seconds and a lock wait at 10. +- Kerberos against a service outside the client's realm, which needs an SPN override in tiberius. + +### Browse, edit and structure + +- Paste rows from the clipboard. Ctrl+V in the grid says it is not supported yet. +- Set a cell to `NOW()` or `CURRENT_TIMESTAMP`, which needs SQL expressions as cell values. +- Edit binary columns from and to a file. They are read-only today, as are array, spatial, composite and variant columns. +- Time and timestamp pickers. Dates have a calendar; times and timestamps are typed. +- Save the column order per table. +- Functions, procedures, sequences and triggers in the sidebar. The drivers already list the first three. +- The CREATE INDEX statement for one index. Show CREATE TABLE lists them after the table on PostgreSQL, SQL Server and SQLite (with the triggers on SQLite). +- Jump from a foreign key to the table it references. +- Reorder columns in the structure tab by dragging, with the ALTER previewed. +- An ER diagram. + +### SQL editor + +- Multi-cursor editing. +- Vim mode. +- Snippets. + +### Release and project + +- Before the first tag, the release setup [ADR 0018](docs/decisions/0018-ppa-copr-and-flathub-later.md#consequences) lists: the PPA, the COPR project, and the `linux-release` environment with what `release-linux.yml` names in its header. +- Write the Flathub manifest and the exception request by hand, and submit them. +- An AUR PKGBUILD, kept by the community. +- The first translations in `po/LINGUAS`. +- A manual pass with Orca, the keyboard alone, High Contrast, 1.5x text scaling and a right-to-left locale. +- Install and use it on Ubuntu 26.04 and Fedora 44 by hand, and under KDE Plasma with the Adwaita style. +- A Linux user guide on docs.tablepro.app, a page on the marketing site, and a support channel. + +### More engines + +Each one is a crate under `crates/drivers` ([adding-drivers.md](docs/adding-drivers.md)), on a client library that meets [adding-drivers.md, The client library](docs/adding-drivers.md#1-the-client-library): maintained on crates.io, TLS through aws-lc-rs, a stream or Unix socket transport for SSH, and a way to stop a statement. + +| Engine | Client | Condition | +|---|---|---| +| Redis | redis 1.x (redis-rs) | None. fred, named here before, has had no release since 2025-02-27 | +| MongoDB | mongodb 3.x, default features off, `rustls-tls-aws-lc` | It must use the system trust store first: it fills its roots from webpki-roots unless `ca_file_path` is set, so it needs native roots upstream or `ca_file_path` set to the platform bundle | +| DuckDB | duckdb, from the DuckDB project | None | +| Cassandra and ScyllaDB | scylla 1.x with `rustls-023` | None | +| Oracle | none | Blocked. The oracle crate has one maintainer, no release since 2025-01-02, and needs the proprietary Oracle client | + +Microsoft's mssql-tds is checked again each release as a SQL Server client. Today its TLS on Linux goes through OpenSSL and it takes no outside pull requests, so tiberius stays. + +### Ideas + +Not committed: server monitoring (sessions, locks, slow queries), maintenance commands, backup and restore, replication status, comparing result sets, schema diff and data sync between connections, scheduled queries, and opt-in usage telemetry. + +## Release criteria + +What has to hold before the first release, for a user on GNOME 50 with Ubuntu 26.04 or Fedora 44, and where each stands on 2026-10-18: + +| # | The user | Holds today | Still needed | +|---|---|---|---| +| 1 | Installs `tablepro` from the PPA or the COPR project, and gets updates from the system's updater | CI builds both packages offline the way Launchpad and COPR do, and starts them in fresh containers | The release setup, the first tag, and an install then an upgrade from the PPA and COPR | +| 2 | Connects to their everyday PostgreSQL, MySQL, MariaDB, SQL Server, ClickHouse or SQLite database | Docker suites against PostgreSQL 13, 17 and 18 with PgBouncer, MySQL 8.4, MariaDB 11.4, SQL Server 2022 and ClickHouse 25.8 | The supported versions stated, and older ones such as MySQL 8.0 and SQL Server 2019 tested or ruled out | +| 3 | Browses tables of real-world data (dates, decimals, JSON, UUIDs, NULLs) and sees correct values | Per-engine decoding with the type fallback and unsupported-type conformance cases ([ADR 0014](docs/decisions/0014-lossless-values-and-dialects.md)) | A corpus test per engine with real values | +| 4 | Runs typical queries against tables of a million rows or more without running out of memory or freezing | The 10,000-row editor limit with Export All, and driver tests over 2,000,000 rows. Pages seek past the edge row's key, or walk PostgreSQL heap block windows for a table with no key; OFFSET only under a user sort, a restored deep offset or a partitioned keyless table. Docker tests page 60k and 25k-row tables both ways and check the plan | A test on a 1,000,000-row table that pages to the end and exports, with time and memory bounds | +| 5 | Edits data in place, and the changes commit correctly even when the connection or the app misbehaves | Write, row guard and commit resolution conformance on every engine, with the fault fakes and the grid save Docker test | A test that kills the app during a save | +| 6 | Trusts the app with credentials: TLS, SSH tunnels, no password in plain text | Verify full by default, the TLS conformance suite, the Secret Service, and statement logging silenced | An outside security review | +| 7 | Recovers when the network drops or the database restarts | Health monitoring, backoff and replay rules with their tests, and a stopped container in the liveness tests | A test that restarts the database and checks that reads replay and writes do not | +| 8 | Works with a screen reader, the keyboard alone and larger text | Label audits of the picker, the connect pages, the workspace, the connect dialog and preferences, labelled cells and real-key tests. Sorting from the cell menu, named filter fields and cell editors, focus handed on when a control hides, and announced query results and errors | Resizing columns from the keyboard, and the manual pass in the backlog | +| 9 | Sees no message that cannot be translated, even while only English ships | Every string the app writes is in the template | Core error details (CSV import, cell parsing, type and default syntax, secret storage, driver details) shown in English | +| 10 | Uses the app daily for a week without an unrecoverable failure | Nothing yet | A dated week of daily use | + +## Out of scope + +| Item | Reason | +|---|---| +| Plugins loaded at runtime | Drivers are linked in at compile time ([ADR 0001](docs/decisions/0001-no-plugin-system.md)) | +| Windows and macOS builds | Those platforms have their own apps in this repository | +| Embedded scripting (JavaScript, Python, Lua) | SQL is enough, and a scripting engine widens the attack surface | +| Hot-reloading drivers | Drivers are compiled in | +| Cloud sync of connections through a TablePro service | Not without a business model that needs it | +| KDE-native styling | The app runs with the Adwaita style everywhere | + +The distribution channels, and why there is no AppImage or Snap, are in [ADR 0018](docs/decisions/0018-ppa-copr-and-flathub-later.md). diff --git a/linux/about.toml b/linux/about.toml new file mode 100644 index 0000000000..b3a39bafca --- /dev/null +++ b/linux/about.toml @@ -0,0 +1,157 @@ +# cargo-about settings for data/licenses, which +# build-aux/update-third-party-licenses.sh writes. +# +# `accepted` is deny.toml's [licenses].allow in the same order, so a crate +# under "MIT OR Apache-2.0" is listed under the first one deny accepts. +# third-party.json also keeps the whole expression of each crate, which +# the Debian copyright file and the RPM License tag name. +accepted = [ + "AGPL-3.0-or-later", + "MIT", + "Apache-2.0", + "Apache-2.0 WITH LLVM-exception", + "BSD-2-Clause", + "BSD-3-Clause", + "ISC", + "Zlib", + "Unicode-3.0", + "CC0-1.0", + "MPL-2.0", + "OpenSSL", + "CDLA-Permissive-2.0", + "LGPL-2.1-or-later", + "0BSD", +] +targets = ["x86_64-unknown-linux-gnu", "aarch64-unknown-linux-gnu"] +# Neither kind of crate ends up in the shipped binaries. +ignore-build-dependencies = true +ignore-dev-dependencies = true +# The workspace's own crates are TablePro, under LICENSE. +private = { ignore = true } + +# Without a license file cargo-about can read, a crate gets the SPDX +# template text, which names no copyright holder. Each entry below names +# the crate's own file: in the crate, or, for a crate that packages none +# or only a symlink, in its repository at the published commit. An entry +# whose checksum no longer matches is dropped, and +# update-third-party-licenses.sh then names the crate. + +# The LICENSE lists the copyright of every bundled component. +[aws-lc-sys.clarify] +license = "ISC AND (Apache-2.0 OR ISC) AND Apache-2.0 AND MIT AND BSD-3-Clause AND (Apache-2.0 OR ISC OR MIT) AND (Apache-2.0 OR ISC OR MIT-0)" +[[aws-lc-sys.clarify.files]] +path = "LICENSE" +checksum = "728536b4160e051f86d7c9c388f704866b3d512cd7df97ac3516c65279523c4e" + +[aws-lc-rs.clarify] +license = "ISC AND (Apache-2.0 OR ISC)" +[[aws-lc-rs.clarify.files]] +path = "LICENSE" +checksum = "b50b376e7d24a0598488b730c3034ffcd0b58dd36c0913a24b9903a3cfd04bf7" + +# LICENSE.txt holds both licenses, each under its own copyright line. +[chrono.clarify] +license = "MIT OR Apache-2.0" +[[chrono.clarify.files]] +path = "LICENSE.txt" +license = "MIT" +start = "The MIT License (MIT)" +end = "THE SOFTWARE." +checksum = "332b974a713ff4e5536be4732fbffd1026694d4a1cbe8d832c969625d991f22c" + +# The copyright of the musl and FreeBSD code follows the license texts. +[libm.clarify] +license = "MIT" +[[libm.clarify.files]] +path = "LICENSE.txt" +checksum = "3823dda7cf046602f4b4e77ec8e227863dc4736037cc85bb33d9f19febe16bb7" + +[ryu.clarify] +license = "Apache-2.0 OR BSL-1.0" +[[ryu.clarify.files]] +path = "LICENSE-APACHE" +license = "Apache-2.0" +checksum = "62c7a1e35f56406896d7aa7ca52d0cc0d272ac022b5d2796e7d6905db8a3636a" + +[rust_xlsxwriter.clarify] +license = "MIT OR Apache-2.0" +[[rust_xlsxwriter.clarify.files]] +path = "LICENSE_MIT" +license = "MIT" +checksum = "6719e95f813f9f93e1ae52f9c13b89b3766655d65620399638b3753fc24f4c56" + +[whoami.clarify] +license = "Apache-2.0 OR BSL-1.0 OR MIT" +[[whoami.clarify.files]] +path = "LICENSE_MIT" +license = "MIT" +checksum = "508a77d2e7b51d98adeed32648ad124b7b30241a8e70b2e72c99f92d8e5874d1" + +[clickhouse-macros.clarify] +license = "MIT OR Apache-2.0" +[[clickhouse-macros.clarify.git]] +path = "LICENSE-MIT" +license = "MIT" +checksum = "23f18e03dc49df91622fe2a76176497404e46ced8a715d9d2b67a7446571cca3" + +[clickhouse-types.clarify] +license = "MIT OR Apache-2.0" +[[clickhouse-types.clarify.git]] +path = "LICENSE-MIT" +license = "MIT" +checksum = "23f18e03dc49df91622fe2a76176497404e46ced8a715d9d2b67a7446571cca3" + +[libgssapi.clarify] +license = "MIT" +[[libgssapi.clarify.git]] +path = "LICENSE" +checksum = "002c2696d92b5c8cf956c11072baa58eaf9f6ade995c031ea635c6a1ee342ad1" + +[libgssapi-sys.clarify] +license = "MIT" +[[libgssapi-sys.clarify.git]] +path = "LICENSE" +checksum = "002c2696d92b5c8cf956c11072baa58eaf9f6ade995c031ea635c6a1ee342ad1" + +[tiberius-macros.clarify] +license = "MIT OR Apache-2.0" +[[tiberius-macros.clarify.git]] +path = "LICENSE-MIT.txt" +license = "MIT" +checksum = "2e9c4e8b1bb2e73696358b5b1d74d3d56403d33da333e70aa504c9b1d7b4bdd0" + +# The sqlx crates package LICENSE-MIT as a symlink to the workspace root's. +[sqlx-core.clarify] +license = "MIT OR Apache-2.0" +[[sqlx-core.clarify.git]] +path = "LICENSE-MIT" +license = "MIT" +checksum = "5abbdd842310c8f1650fcde3a5a51a3e09412b9e5a6eea87519e0db9e32b339d" + +[sqlx-macros.clarify] +license = "MIT OR Apache-2.0" +[[sqlx-macros.clarify.git]] +path = "LICENSE-MIT" +license = "MIT" +checksum = "5abbdd842310c8f1650fcde3a5a51a3e09412b9e5a6eea87519e0db9e32b339d" + +[sqlx-macros-core.clarify] +license = "MIT OR Apache-2.0" +[[sqlx-macros-core.clarify.git]] +path = "LICENSE-MIT" +license = "MIT" +checksum = "5abbdd842310c8f1650fcde3a5a51a3e09412b9e5a6eea87519e0db9e32b339d" + +[sqlx-mysql.clarify] +license = "MIT OR Apache-2.0" +[[sqlx-mysql.clarify.git]] +path = "LICENSE-MIT" +license = "MIT" +checksum = "5abbdd842310c8f1650fcde3a5a51a3e09412b9e5a6eea87519e0db9e32b339d" + +[sqlx-sqlite.clarify] +license = "MIT OR Apache-2.0" +[[sqlx-sqlite.clarify.git]] +path = "LICENSE-MIT" +license = "MIT" +checksum = "5abbdd842310c8f1650fcde3a5a51a3e09412b9e5a6eea87519e0db9e32b339d" diff --git a/linux/build-aux/about/THIRD-PARTY-NOTICE.md.hbs b/linux/build-aux/about/THIRD-PARTY-NOTICE.md.hbs new file mode 100644 index 0000000000..3937641d9c --- /dev/null +++ b/linux/build-aux/about/THIRD-PARTY-NOTICE.md.hbs @@ -0,0 +1,27 @@ +# Third-party licenses + +TablePro is licensed under the GNU Affero General Public License, version 3 or later, in `LICENSE`. It is built with the Rust crates below, each listed under the license it is used under. + +`build-aux/update-third-party-licenses.sh` writes this file from `Cargo.lock`. Do not edit it by hand. + +## Overview + +{{#each overview}} +- {{{name}}} ({{{id}}}) +{{/each}} + +## License texts +{{#each licenses}} + +### {{{name}}} + +Used by: + +{{#each used_by}} +- {{{crate.name}}} {{{crate.version}}} +{{/each}} + +~~~~text +{{{text}}} +~~~~ +{{/each}} diff --git a/linux/build-aux/about/fallbacks.hbs b/linux/build-aux/about/fallbacks.hbs new file mode 100644 index 0000000000..d71faf9940 --- /dev/null +++ b/linux/build-aux/about/fallbacks.hbs @@ -0,0 +1,7 @@ +{{#each licenses}} +{{#unless source_path}} +{{#each used_by}} +{{{crate.name}}} {{{crate.version}}}: {{{../id}}} +{{/each}} +{{/unless}} +{{/each}} diff --git a/linux/build-aux/about/third-party.json.hbs b/linux/build-aux/about/third-party.json.hbs new file mode 100644 index 0000000000..1736131e28 --- /dev/null +++ b/linux/build-aux/about/third-party.json.hbs @@ -0,0 +1,21 @@ +{ + "licenses": [ +{{#each licenses}} + { + "id": {{json id}}, + "name": {{json name}}, + "text": {{json text}}, + "used_by": [ +{{#each used_by}} + { "crate": { "name": {{json crate.name}}, "version": {{json crate.version}} } }{{#unless @last}},{{/unless}} +{{/each}} + ] + }{{#unless @last}},{{/unless}} +{{/each}} + ], + "crates": [ +{{#each crates}} + { "name": {{json package.name}}, "version": {{json package.version}}, "license": {{json license}} }{{#unless @last}},{{/unless}} +{{/each}} + ] +} diff --git a/linux/build-aux/debian/README.source b/linux/build-aux/debian/README.source new file mode 100644 index 0000000000..e846db52c6 --- /dev/null +++ b/linux/build-aux/debian/README.source @@ -0,0 +1,33 @@ +tablepro for Ubuntu +=================== + +This is the debian/ directory of the source package TablePro uploads to its +Launchpad PPA. It lives in the upstream tree under build-aux/debian. +build-aux/packaging/build-source.sh copies it to debian/ and writes +debian/changelog for the version and Ubuntu series being built. + +Sources: tablepro_X.Y.Z.orig.tar.xz is the meson dist tarball. +tablepro_X.Y.Z.orig-vendor.tar.xz holds vendor/ from cargo vendor +--versioned-dirs, with the source replacement cargo printed as +vendor/cargo-config.toml. dpkg-source unpacks it into vendor/, and +debian/rules copies the replacement to .cargo/config.toml before it +configures, so the build needs no network. + +Toolchain: the package build-depends on rustc-X.Y and cargo-X.Y, X.Y being +the workspace rust-version, and debian/rules puts /usr/lib/rust-X.Y/bin first +on PATH. build-aux/packaging/check_packaging.py keeps the three in step. +Ubuntu 26.04 gets them from ppa:rust-toolchain/staging, which the PPA depends +on; Ubuntu 26.10 has them in its archive. + +Versions: X.Y.Z-0ppaN~YY.MM, as Launchpad's PPA guide describes for a +package built for several series. The revision 0 marks a package Debian does +not carry, so any archive version of X.Y.Z sorts above it. ppaN counts the +uploads of one upstream version. The series version keeps each series' +version unique and sorts a later series higher. It leaves out the word +ubuntu, which dpkg-source reads as an Ubuntu upload by a non-Ubuntu +maintainer, and a third dotted part, which lintian reads as a binary NMU. +An upstream pre-release such as 0.2.0-rc.1 becomes 0.2.0~rc.1, which sorts +below 0.2.0. + +debian/copyright is generated from data/licenses/third-party.json by +build-aux/update-third-party-licenses.sh. Do not edit it by hand. diff --git a/linux/build-aux/debian/clean b/linux/build-aux/debian/clean new file mode 100644 index 0000000000..112c7dffb3 --- /dev/null +++ b/linux/build-aux/debian/clean @@ -0,0 +1 @@ +.cargo/ diff --git a/linux/build-aux/debian/control b/linux/build-aux/debian/control new file mode 100644 index 0000000000..8c6e964b70 --- /dev/null +++ b/linux/build-aux/debian/control @@ -0,0 +1,50 @@ +Source: tablepro +Section: database +Maintainer: TablePro Authors +Build-Depends: + debhelper-compat (= 13), + cargo-1.97, + clang, + desktop-file-utils, + gettext, + libadwaita-1-dev (>= 1.9), + libclang-dev, + libglib2.0-bin, + libglib2.0-dev (>= 2.88), + libgtk-4-dev (>= 4.22), + libgtksourceview-5-dev (>= 5.18), + libkrb5-dev, + libsqlite3-dev (>= 3.46), + libxml2-utils, + meson (>= 1.4), + pkgconf, + rustc-1.97, + adwaita-icon-theme , + appstream , + ca-certificates , + dbus-daemon , + locales-all , + openssh-client , + xauth , + xvfb , +Standards-Version: 4.7.3 +Rules-Requires-Root: no +Homepage: https://tablepro.app +Vcs-Browser: https://github.com/TableProApp/TablePro/tree/main/linux/build-aux/debian +Vcs-Git: https://github.com/TableProApp/TablePro.git -b main [linux/build-aux] + +Package: tablepro +Architecture: amd64 arm64 +Depends: + openssh-client (>= 1:8.7), + ${misc:Depends}, + ${shlibs:Depends}, +Recommends: + ca-certificates, + gnome-keyring | kwallet6 | keepassxc, +Suggests: + krb5-user, +Description: database client for GNOME + TablePro connects to PostgreSQL, MySQL, SQLite, SQL Server and ClickHouse. + Browse tables, edit rows in place, run SQL with syntax highlighting, and + reach servers behind an SSH bastion. diff --git a/linux/build-aux/debian/copyright b/linux/build-aux/debian/copyright new file mode 100644 index 0000000000..95e8b794e4 --- /dev/null +++ b/linux/build-aux/debian/copyright @@ -0,0 +1,5791 @@ +Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/ +Upstream-Name: TablePro +Upstream-Contact: https://github.com/TableProApp/TablePro/issues +Source: https://github.com/TableProApp/TablePro +Comment: build-aux/packaging/license_metadata.py writes this file from + data/licenses/third-party.json. The vendor/ paragraphs cover the crates + linked into tablepro and tablepro-askpass. Each names every license + the crate offers and holds the text of the one TablePro uses, as the + crate ships it. + +Files: * +Copyright: TablePro Authors +License: AGPL-3.0+ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + . + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + . + Preamble + . + The GNU Affero General Public License is a free, copyleft license for + software and other kinds of works, specifically designed to ensure + cooperation with the community in the case of network server software. + . + The licenses for most software and other practical works are designed + to take away your freedom to share and change the works. By contrast, + our General Public Licenses are intended to guarantee your freedom to + share and change all versions of a program--to make sure it remains free + software for all its users. + . + When we speak of free software, we are referring to freedom, not + price. Our General Public Licenses are designed to make sure that you + have the freedom to distribute copies of free software (and charge for + them if you wish), that you receive source code or can get it if you + want it, that you can change the software or use pieces of it in new + free programs, and that you know you can do these things. + . + Developers that use our General Public Licenses protect your rights + with two steps: (1) assert copyright on the software, and (2) offer + you this License which gives you legal permission to copy, distribute + and/or modify the software. + . + A secondary benefit of defending all users' freedom is that + improvements made in alternate versions of the program, if they + receive widespread use, become available for other developers to + incorporate. Many developers of free software are heartened and + encouraged by the resulting cooperation. However, in the case of + software used on network servers, this result may fail to come about. + The GNU General Public License permits making a modified version and + letting the public access it on a server without ever releasing its + source code to the public. + . + The GNU Affero General Public License is designed specifically to + ensure that, in such cases, the modified source code becomes available + to the community. It requires the operator of a network server to + provide the source code of the modified version running there to the + users of that server. Therefore, public use of a modified version, on + a publicly accessible server, gives the public access to the source + code of the modified version. + . + An older license, called the Affero General Public License and + published by Affero, was designed to accomplish similar goals. This is + a different license, not a version of the Affero GPL, but Affero has + released a new version of the Affero GPL which permits relicensing under + this license. + . + The precise terms and conditions for copying, distribution and + modification follow. + . + TERMS AND CONDITIONS + . + 0. Definitions. + . + "This License" refers to version 3 of the GNU Affero General Public License. + . + "Copyright" also means copyright-like laws that apply to other kinds of + works, such as semiconductor masks. + . + "The Program" refers to any copyrightable work licensed under this + License. Each licensee is addressed as "you". "Licensees" and + "recipients" may be individuals or organizations. + . + To "modify" a work means to copy from or adapt all or part of the work + in a fashion requiring copyright permission, other than the making of an + exact copy. The resulting work is called a "modified version" of the + earlier work or a work "based on" the earlier work. + . + A "covered work" means either the unmodified Program or a work based + on the Program. + . + To "propagate" a work means to do anything with it that, without + permission, would make you directly or secondarily liable for + infringement under applicable copyright law, except executing it on a + computer or modifying a private copy. Propagation includes copying, + distribution (with or without modification), making available to the + public, and in some countries other activities as well. + . + To "convey" a work means any kind of propagation that enables other + parties to make or receive copies. Mere interaction with a user through + a computer network, with no transfer of a copy, is not conveying. + . + An interactive user interface displays "Appropriate Legal Notices" + to the extent that it includes a convenient and prominently visible + feature that (1) displays an appropriate copyright notice, and (2) + tells the user that there is no warranty for the work (except to the + extent that warranties are provided), that licensees may convey the + work under this License, and how to view a copy of this License. If + the interface presents a list of user commands or options, such as a + menu, a prominent item in the list meets this criterion. + . + 1. Source Code. + . + The "source code" for a work means the preferred form of the work + for making modifications to it. "Object code" means any non-source + form of a work. + . + A "Standard Interface" means an interface that either is an official + standard defined by a recognized standards body, or, in the case of + interfaces specified for a particular programming language, one that + is widely used among developers working in that language. + . + The "System Libraries" of an executable work include anything, other + than the work as a whole, that (a) is included in the normal form of + packaging a Major Component, but which is not part of that Major + Component, and (b) serves only to enable use of the work with that + Major Component, or to implement a Standard Interface for which an + implementation is available to the public in source code form. A + "Major Component", in this context, means a major essential component + (kernel, window system, and so on) of the specific operating system + (if any) on which the executable work runs, or a compiler used to + produce the work, or an object code interpreter used to run it. + . + The "Corresponding Source" for a work in object code form means all + the source code needed to generate, install, and (for an executable + work) run the object code and to modify the work, including scripts to + control those activities. However, it does not include the work's + System Libraries, or general-purpose tools or generally available free + programs which are used unmodified in performing those activities but + which are not part of the work. For example, Corresponding Source + includes interface definition files associated with source files for + the work, and the source code for shared libraries and dynamically + linked subprograms that the work is specifically designed to require, + such as by intimate data communication or control flow between those + subprograms and other parts of the work. + . + The Corresponding Source need not include anything that users + can regenerate automatically from other parts of the Corresponding + Source. + . + The Corresponding Source for a work in source code form is that + same work. + . + 2. Basic Permissions. + . + All rights granted under this License are granted for the term of + copyright on the Program, and are irrevocable provided the stated + conditions are met. This License explicitly affirms your unlimited + permission to run the unmodified Program. The output from running a + covered work is covered by this License only if the output, given its + content, constitutes a covered work. This License acknowledges your + rights of fair use or other equivalent, as provided by copyright law. + . + You may make, run and propagate covered works that you do not + convey, without conditions so long as your license otherwise remains + in force. You may convey covered works to others for the sole purpose + of having them make modifications exclusively for you, or provide you + with facilities for running those works, provided that you comply with + the terms of this License in conveying all material for which you do + not control copyright. Those thus making or running the covered works + for you must do so exclusively on your behalf, under your direction + and control, on terms that prohibit them from making any copies of + your copyrighted material outside their relationship with you. + . + Conveying under any other circumstances is permitted solely under + the conditions stated below. Sublicensing is not allowed; section 10 + makes it unnecessary. + . + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + . + No covered work shall be deemed part of an effective technological + measure under any applicable law fulfilling obligations under article + 11 of the WIPO copyright treaty adopted on 20 December 1996, or + similar laws prohibiting or restricting circumvention of such + measures. + . + When you convey a covered work, you waive any legal power to forbid + circumvention of technological measures to the extent such circumvention + is effected by exercising rights under this License with respect to + the covered work, and you disclaim any intention to limit operation or + modification of the work as a means of enforcing, against the work's + users, your or third parties' legal rights to forbid circumvention of + technological measures. + . + 4. Conveying Verbatim Copies. + . + You may convey verbatim copies of the Program's source code as you + receive it, in any medium, provided that you conspicuously and + appropriately publish on each copy an appropriate copyright notice; + keep intact all notices stating that this License and any + non-permissive terms added in accord with section 7 apply to the code; + keep intact all notices of the absence of any warranty; and give all + recipients a copy of this License along with the Program. + . + You may charge any price or no price for each copy that you convey, + and you may offer support or warranty protection for a fee. + . + 5. Conveying Modified Source Versions. + . + You may convey a work based on the Program, or the modifications to + produce it from the Program, in the form of source code under the + terms of section 4, provided that you also meet all of these conditions: + . + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + . + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + . + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + . + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + . + A compilation of a covered work with other separate and independent + works, which are not by their nature extensions of the covered work, + and which are not combined with it such as to form a larger program, + in or on a volume of a storage or distribution medium, is called an + "aggregate" if the compilation and its resulting copyright are not + used to limit the access or legal rights of the compilation's users + beyond what the individual works permit. Inclusion of a covered work + in an aggregate does not cause this License to apply to the other + parts of the aggregate. + . + 6. Conveying Non-Source Forms. + . + You may convey a covered work in object code form under the terms + of sections 4 and 5, provided that you also convey the + machine-readable Corresponding Source under the terms of this License, + in one of these ways: + . + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + . + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + . + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + . + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + . + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + . + A separable portion of the object code, whose source code is excluded + from the Corresponding Source as a System Library, need not be + included in conveying the object code work. + . + A "User Product" is either (1) a "consumer product", which means any + tangible personal property which is normally used for personal, family, + or household purposes, or (2) anything designed or sold for incorporation + into a dwelling. In determining whether a product is a consumer product, + doubtful cases shall be resolved in favor of coverage. For a particular + product received by a particular user, "normally used" refers to a + typical or common use of that class of product, regardless of the status + of the particular user or of the way in which the particular user + actually uses, or expects or is expected to use, the product. A product + is a consumer product regardless of whether the product has substantial + commercial, industrial or non-consumer uses, unless such uses represent + the only significant mode of use of the product. + . + "Installation Information" for a User Product means any methods, + procedures, authorization keys, or other information required to install + and execute modified versions of a covered work in that User Product from + a modified version of its Corresponding Source. The information must + suffice to ensure that the continued functioning of the modified object + code is in no case prevented or interfered with solely because + modification has been made. + . + If you convey an object code work under this section in, or with, or + specifically for use in, a User Product, and the conveying occurs as + part of a transaction in which the right of possession and use of the + User Product is transferred to the recipient in perpetuity or for a + fixed term (regardless of how the transaction is characterized), the + Corresponding Source conveyed under this section must be accompanied + by the Installation Information. But this requirement does not apply + if neither you nor any third party retains the ability to install + modified object code on the User Product (for example, the work has + been installed in ROM). + . + The requirement to provide Installation Information does not include a + requirement to continue to provide support service, warranty, or updates + for a work that has been modified or installed by the recipient, or for + the User Product in which it has been modified or installed. Access to a + network may be denied when the modification itself materially and + adversely affects the operation of the network or violates the rules and + protocols for communication across the network. + . + Corresponding Source conveyed, and Installation Information provided, + in accord with this section must be in a format that is publicly + documented (and with an implementation available to the public in + source code form), and must require no special password or key for + unpacking, reading or copying. + . + 7. Additional Terms. + . + "Additional permissions" are terms that supplement the terms of this + License by making exceptions from one or more of its conditions. + Additional permissions that are applicable to the entire Program shall + be treated as though they were included in this License, to the extent + that they are valid under applicable law. If additional permissions + apply only to part of the Program, that part may be used separately + under those permissions, but the entire Program remains governed by + this License without regard to the additional permissions. + . + When you convey a copy of a covered work, you may at your option + remove any additional permissions from that copy, or from any part of + it. (Additional permissions may be written to require their own + removal in certain cases when you modify the work.) You may place + additional permissions on material, added by you to a covered work, + for which you have or can give appropriate copyright permission. + . + Notwithstanding any other provision of this License, for material you + add to a covered work, you may (if authorized by the copyright holders of + that material) supplement the terms of this License with terms: + . + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + . + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + . + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + . + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + . + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + . + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + . + All other non-permissive additional terms are considered "further + restrictions" within the meaning of section 10. If the Program as you + received it, or any part of it, contains a notice stating that it is + governed by this License along with a term that is a further + restriction, you may remove that term. If a license document contains + a further restriction but permits relicensing or conveying under this + License, you may add to a covered work material governed by the terms + of that license document, provided that the further restriction does + not survive such relicensing or conveying. + . + If you add terms to a covered work in accord with this section, you + must place, in the relevant source files, a statement of the + additional terms that apply to those files, or a notice indicating + where to find the applicable terms. + . + Additional terms, permissive or non-permissive, may be stated in the + form of a separately written license, or stated as exceptions; + the above requirements apply either way. + . + 8. Termination. + . + You may not propagate or modify a covered work except as expressly + provided under this License. Any attempt otherwise to propagate or + modify it is void, and will automatically terminate your rights under + this License (including any patent licenses granted under the third + paragraph of section 11). + . + However, if you cease all violation of this License, then your + license from a particular copyright holder is reinstated (a) + provisionally, unless and until the copyright holder explicitly and + finally terminates your license, and (b) permanently, if the copyright + holder fails to notify you of the violation by some reasonable means + prior to 60 days after the cessation. + . + Moreover, your license from a particular copyright holder is + reinstated permanently if the copyright holder notifies you of the + violation by some reasonable means, this is the first time you have + received notice of violation of this License (for any work) from that + copyright holder, and you cure the violation prior to 30 days after + your receipt of the notice. + . + Termination of your rights under this section does not terminate the + licenses of parties who have received copies or rights from you under + this License. If your rights have been terminated and not permanently + reinstated, you do not qualify to receive new licenses for the same + material under section 10. + . + 9. Acceptance Not Required for Having Copies. + . + You are not required to accept this License in order to receive or + run a copy of the Program. Ancillary propagation of a covered work + occurring solely as a consequence of using peer-to-peer transmission + to receive a copy likewise does not require acceptance. However, + nothing other than this License grants you permission to propagate or + modify any covered work. These actions infringe copyright if you do + not accept this License. Therefore, by modifying or propagating a + covered work, you indicate your acceptance of this License to do so. + . + 10. Automatic Licensing of Downstream Recipients. + . + Each time you convey a covered work, the recipient automatically + receives a license from the original licensors, to run, modify and + propagate that work, subject to this License. You are not responsible + for enforcing compliance by third parties with this License. + . + An "entity transaction" is a transaction transferring control of an + organization, or substantially all assets of one, or subdividing an + organization, or merging organizations. If propagation of a covered + work results from an entity transaction, each party to that + transaction who receives a copy of the work also receives whatever + licenses to the work the party's predecessor in interest had or could + give under the previous paragraph, plus a right to possession of the + Corresponding Source of the work from the predecessor in interest, if + the predecessor has it or can get it with reasonable efforts. + . + You may not impose any further restrictions on the exercise of the + rights granted or affirmed under this License. For example, you may + not impose a license fee, royalty, or other charge for exercise of + rights granted under this License, and you may not initiate litigation + (including a cross-claim or counterclaim in a lawsuit) alleging that + any patent claim is infringed by making, using, selling, offering for + sale, or importing the Program or any portion of it. + . + 11. Patents. + . + A "contributor" is a copyright holder who authorizes use under this + License of the Program or a work on which the Program is based. The + work thus licensed is called the contributor's "contributor version". + . + A contributor's "essential patent claims" are all patent claims + owned or controlled by the contributor, whether already acquired or + hereafter acquired, that would be infringed by some manner, permitted + by this License, of making, using, or selling its contributor version, + but do not include claims that would be infringed only as a + consequence of further modification of the contributor version. For + purposes of this definition, "control" includes the right to grant + patent sublicenses in a manner consistent with the requirements of + this License. + . + Each contributor grants you a non-exclusive, worldwide, royalty-free + patent license under the contributor's essential patent claims, to + make, use, sell, offer for sale, import and otherwise run, modify and + propagate the contents of its contributor version. + . + In the following three paragraphs, a "patent license" is any express + agreement or commitment, however denominated, not to enforce a patent + (such as an express permission to practice a patent or covenant not to + sue for patent infringement). To "grant" such a patent license to a + party means to make such an agreement or commitment not to enforce a + patent against the party. + . + If you convey a covered work, knowingly relying on a patent license, + and the Corresponding Source of the work is not available for anyone + to copy, free of charge and under the terms of this License, through a + publicly available network server or other readily accessible means, + then you must either (1) cause the Corresponding Source to be so + available, or (2) arrange to deprive yourself of the benefit of the + patent license for this particular work, or (3) arrange, in a manner + consistent with the requirements of this License, to extend the patent + license to downstream recipients. "Knowingly relying" means you have + actual knowledge that, but for the patent license, your conveying the + covered work in a country, or your recipient's use of the covered work + in a country, would infringe one or more identifiable patents in that + country that you have reason to believe are valid. + . + If, pursuant to or in connection with a single transaction or + arrangement, you convey, or propagate by procuring conveyance of, a + covered work, and grant a patent license to some of the parties + receiving the covered work authorizing them to use, propagate, modify + or convey a specific copy of the covered work, then the patent license + you grant is automatically extended to all recipients of the covered + work and works based on it. + . + A patent license is "discriminatory" if it does not include within + the scope of its coverage, prohibits the exercise of, or is + conditioned on the non-exercise of one or more of the rights that are + specifically granted under this License. You may not convey a covered + work if you are a party to an arrangement with a third party that is + in the business of distributing software, under which you make payment + to the third party based on the extent of your activity of conveying + the work, and under which the third party grants, to any of the + parties who would receive the covered work from you, a discriminatory + patent license (a) in connection with copies of the covered work + conveyed by you (or copies made from those copies), or (b) primarily + for and in connection with specific products or compilations that + contain the covered work, unless you entered into that arrangement, + or that patent license was granted, prior to 28 March 2007. + . + Nothing in this License shall be construed as excluding or limiting + any implied license or other defenses to infringement that may + otherwise be available to you under applicable patent law. + . + 12. No Surrender of Others' Freedom. + . + If conditions are imposed on you (whether by court order, agreement or + otherwise) that contradict the conditions of this License, they do not + excuse you from the conditions of this License. If you cannot convey a + covered work so as to satisfy simultaneously your obligations under this + License and any other pertinent obligations, then as a consequence you may + not convey it at all. For example, if you agree to terms that obligate you + to collect a royalty for further conveying from those to whom you convey + the Program, the only way you could satisfy both those terms and this + License would be to refrain entirely from conveying the Program. + . + 13. Remote Network Interaction; Use with the GNU General Public License. + . + Notwithstanding any other provision of this License, if you modify the + Program, your modified version must prominently offer all users + interacting with it remotely through a computer network (if your version + supports such interaction) an opportunity to receive the Corresponding + Source of your version by providing access to the Corresponding Source + from a network server at no charge, through some standard or customary + means of facilitating copying of software. This Corresponding Source + shall include the Corresponding Source for any work covered by version 3 + of the GNU General Public License that is incorporated pursuant to the + following paragraph. + . + Notwithstanding any other provision of this License, you have + permission to link or combine any covered work with a work licensed + under version 3 of the GNU General Public License into a single + combined work, and to convey the resulting work. The terms of this + License will continue to apply to the part which is the covered work, + but the work with which it is combined will remain governed by version + 3 of the GNU General Public License. + . + 14. Revised Versions of this License. + . + The Free Software Foundation may publish revised and/or new versions of + the GNU Affero General Public License from time to time. Such new versions + will be similar in spirit to the present version, but may differ in detail to + address new problems or concerns. + . + Each version is given a distinguishing version number. If the + Program specifies that a certain numbered version of the GNU Affero General + Public License "or any later version" applies to it, you have the + option of following the terms and conditions either of that numbered + version or of any later version published by the Free Software + Foundation. If the Program does not specify a version number of the + GNU Affero General Public License, you may choose any version ever published + by the Free Software Foundation. + . + If the Program specifies that a proxy can decide which future + versions of the GNU Affero General Public License can be used, that proxy's + public statement of acceptance of a version permanently authorizes you + to choose that version for the Program. + . + Later license versions may give you additional or different + permissions. However, no additional obligations are imposed on any + author or copyright holder as a result of your choosing to follow a + later version. + . + 15. Disclaimer of Warranty. + . + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY + APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT + HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY + OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, + THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR + PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM + IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF + ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + . + 16. Limitation of Liability. + . + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING + WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS + THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY + GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE + USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF + DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD + PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), + EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF + SUCH DAMAGES. + . + 17. Interpretation of Sections 15 and 16. + . + If the disclaimer of warranty and limitation of liability provided + above cannot be given local legal effect according to their terms, + reviewing courts shall apply local law that most closely approximates + an absolute waiver of all civil liability in connection with the + Program, unless a warranty or assumption of liability accompanies a + copy of the Program in return for a fee. + . + END OF TERMS AND CONDITIONS + . + How to Apply These Terms to Your New Programs + . + If you develop a new program, and you want it to be of the greatest + possible use to the public, the best way to achieve this is to make it + free software which everyone can redistribute and change under these terms. + . + To do so, attach the following notices to the program. It is safest + to attach them to the start of each source file to most effectively + state the exclusion of warranty; and each file should have at least + the "copyright" line and a pointer to where the full notice is found. + . + + Copyright (C) + . + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + . + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + . + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + . + Also add information on how to contact you by electronic and paper mail. + . + If your software can interact with users remotely through a computer + network, you should also make sure that it provides a way for users to + get its source. For example, if your program is a web application, its + interface could display a "Source" link that leads users to an archive + of the code. There are many ways you could offer source, and different + solutions will be better for different programs; see section 13 for the + specific requirements. + . + You should also get your employer (if you work as a programmer) or school, + if any, to sign a "copyright disclaimer" for the program, if necessary. + For more information on this, and how to apply and follow the GNU AGPL, see + . + +Files: data/app.tablepro.TablePro.metainfo.xml.in.in +Copyright: TablePro Authors +License: CC0-1.0 + On Debian systems, the complete text of the CC0 1.0 Universal license + can be found in "/usr/share/common-licenses/CC0-1.0". + +Files: vendor/aes-0.8.4/* +Copyright: 2018 Artyom Pavlov +License: Apache-2.0 or Expat + Copyright (c) 2018 Artyom Pavlov + . + 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. + +Files: vendor/aho-corasick-1.1.5/* + vendor/byteorder-1.5.0/* + vendor/csv-1.4.0/* + vendor/csv-core-0.1.13/* + vendor/memchr-2.8.3/* +Copyright: 2015 Andrew Gallant +License: Expat or Unlicense + The MIT License (MIT) + . + Copyright (c) 2015 Andrew Gallant + . + 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. + +Files: vendor/allocator-api2-0.2.21/* +Copyright: the authors of allocator-api2 +License: Apache-2.0 or Expat + 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. + +Files: vendor/ashpd-0.13.13/* +Copyright: 2020 Bilal Elmoussaoui +License: Expat + MIT License + . + Copyright (c) 2020 Bilal Elmoussaoui + . + 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. + +Files: vendor/async-broadcast-0.7.2/* + vendor/connection-string-0.2.0/* +Copyright: 2020 Yoshua Wuyts +License: Apache-2.0 or Expat + The MIT License (MIT) + . + Copyright (c) 2020 Yoshua Wuyts + . + 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. + +Files: vendor/async-recursion-1.1.1/* + vendor/flume-0.12.0/* + vendor/rsa-0.10.0-rc.18/* +Copyright: the authors of async-recursion, flume, rsa +License: Apache-2.0 or Expat + 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. + +Files: vendor/async-trait-0.1.92/* + vendor/clickhouse-0.15.2/* + vendor/clickhouse-macros-0.3.0/* + vendor/clickhouse-types-0.1.3/* + vendor/displaydoc-0.2.7/* + vendor/event-listener-5.4.2/* + vendor/event-listener-strategy-0.5.4/* + vendor/fastrand-2.5.0/* + vendor/itoa-1.0.18/* + vendor/once_cell-1.21.4/* + vendor/ordered-stream-0.2.0/* + vendor/parking-2.2.1/* + vendor/pin-project-lite-0.2.17/* + vendor/proc-macro-crate-3.5.0/* + vendor/proc-macro2-1.0.107/* + vendor/quote-1.0.47/* + vendor/relm4-0.11.0/* + vendor/relm4-macros-0.11.0/* + vendor/rustversion-1.0.23/* + vendor/serde-1.0.229/* + vendor/serde_bytes-0.11.19/* + vendor/serde_core-1.0.229/* + vendor/serde_derive-1.0.229/* + vendor/serde_derive_internals-0.29.1/* + vendor/serde_json-1.0.151/* + vendor/serde_repr-0.1.21/* + vendor/sqlformat-0.5.0/* + vendor/syn-2.0.119/* + vendor/syn-3.0.5/* + vendor/thiserror-2.0.20/* + vendor/thiserror-impl-2.0.20/* + vendor/typed-path-0.12.3/* +Copyright: the authors of async-trait, clickhouse, clickhouse-macros, + clickhouse-types, displaydoc, event-listener, event-listener-strategy, + fastrand, itoa, once_cell, ordered-stream, parking, pin-project-lite, + proc-macro-crate, proc-macro2, quote, relm4, relm4-macros, rustversion, + serde, serde_bytes, serde_core, serde_derive, serde_derive_internals, + serde_json, serde_repr, sqlformat, syn, thiserror, thiserror-impl, + typed-path +License: Apache-2.0 or Expat + 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. + +Files: vendor/asynchronous-codec-0.7.0/* +Copyright: 2019 - 2020 Matt Hunzinger + 2021 Max Inden +License: Expat + MIT License + . + Copyright (c) 2019 - 2020 Matt Hunzinger + Copyright (c) 2021 Max Inden + . + 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. + +Files: vendor/atoi-2.0.0/* +Copyright: 2017 +License: Expat + MIT License + . + Copyright (c) 2017 + . + 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. + +Files: vendor/atomic-waker-1.1.2/* + vendor/futures-lite-2.6.1/* +Copyright: 2016 Alex Crichton + 2017 The Tokio Authors +License: Apache-2.0 or Expat + =============================================================================== + . + Copyright (c) 2016 Alex Crichton + Copyright (c) 2017 The Tokio Authors + . + 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. + . + =============================================================================== + . + Copyright (c) 2016 Alex Crichton + Copyright (c) 2017 The Tokio Authors + . + 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. + . + 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. + +Files: vendor/aws-lc-rs-1.18.1/* +Copyright: Amazon.com, Inc. or its affiliates. +License: Apache-2.0 or ISC, and ISC + SPDX-License-Identifier: ISC AND (Apache-2.0 OR ISC) + . + . + Apache 2.0 license + ------------------------------------- + . + . + On Debian systems, the complete text of the Apache License, Version 2.0 + can be found in "/usr/share/common-licenses/Apache-2.0". + . + . + ISC license + ------------------------------------- + . + . + Copyright Amazon.com, Inc. or its affiliates. + . + Permission to use, copy, modify, and/or distribute this software for any + purpose with or without fee is hereby granted, provided that the above + copyright notice and this permission notice appear in all copies. + . + THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES + WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF + MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR + ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN + ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF + OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +Files: vendor/aws-lc-sys-0.45.0/* +Copyright: Amazon.com, Inc. or its affiliates. All Rights Reserved. + 2014-2024 Google Inc. + 2025-2026 Google Inc. + 1998-2011 The OpenSSL Project. All rights reserved. + 1995-1998 Eric Young (eay@cryptsoft.com). All rights reserved. + The mlkem-native project authors. + The mldsa-native project authors. + 2015-2020 the fiat-crypto authors. + 2017 - 2025, Stephan Mueller . + 2008 Google Inc. + The Go Authors. All rights reserved. +License: Apache-2.0, and Apache-2.0 or ISC, and Apache-2.0 or ISC or Expat, and Apache-2.0 or ISC or MIT-0, and BSD-3-Clause, and ISC, and Expat + AWS Libcrypto (AWS-LC) + . + AWS-LC is a fork of BoringSSL, which is itself a fork of OpenSSL. + Content from these and other sources retains their original licensing, + as described below. New files from AWS-LC are made available under the Apache-2.0 license OR the ISC + license. These licenses are reproduced at the bottom of this file. + . + ``` + Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + SPDX-License-Identifier: Apache-2.0 OR ISC + ``` + . + . + ================================================================================ + BoringSSL + ================================================================================ + . + BoringSSL is a Google-maintained fork of OpenSSL. Historically, code + authored by Google for BoringSSL was licensed under the ISC License. + BoringSSL has since relicensed upstream to Apache License 2.0. Existing + AWS-LC code originating from BoringSSL retains its ISC license, while + newer code taken from BoringSSL is licensed under Apache License 2.0. + . + ``` + Copyright (c) 2014-2024 Google Inc. + SPDX-License-Identifier: ISC + ``` + . + Additional individual contributions to BoringSSL-derived code are + covered under the ISC license: + . + - Brian Smith (Copyright 2016) + - Robert Nagy (Copyright 2022) + - Arm Ltd (Copyright 2020) + . + ``` + Copyright (c) 2025-2026 Google Inc. + SPDX-License-Identifier: Apache-2.0 + ``` + . + ================================================================================ + OpenSSL + ================================================================================ + . + Code derived from the OpenSSL project is licensed under the + Apache License, Version 2.0. + . + ``` + Copyright (c) 1998-2011 The OpenSSL Project. All rights reserved. + SPDX-License-Identifier: Apache-2.0 + ``` + . + Some OpenSSL-derived files also carry the original SSLeay copyright: + . + ``` + Copyright (c) 1995-1998 Eric Young (eay@cryptsoft.com). All rights reserved. + SPDX-License-Identifier: Apache-2.0 + ``` + . + Portions of OpenSSL-derived code include contributions from: + . + - Sun Microsystems, Inc. (Copyright 2002) + - Nokia (Copyright 2005) + - Intel Corporation (Copyright 2012-2021) + . + These contributions are covered under the Apache-2.0 license. + . + ================================================================================ + mlkem-native + ================================================================================ + . + Code from the mlkem-native project is licensed under Apache License 2.0 or MIT or ISC. + . + ``` + Copyright (c) The mlkem-native project authors. + SPDX-License-Identifier: Apache-2.0 OR ISC OR MIT + ``` + . + ================================================================================ + mldsa-native + ================================================================================ + . + Code from the mldsa-native project is licensed under Apache License 2.0 or MIT or ISC + . + ``` + Copyright (c) The mldsa-native project authors. + SPDX-License-Identifier: Apache-2.0 OR ISC OR MIT + ``` + . + ================================================================================ + Third-Party Libraries (compiled into libcrypto/libssl) + ================================================================================ + . + Fiat Cryptography + ----------------- + Synthesizing Correct-by-Construction Code for Cryptographic Primitives. + See third_party/fiat/LICENSE. + . + ``` + Copyright (c) 2015-2020 the fiat-crypto authors. + SPDX-License-Identifier: MIT + ``` + . + s2n-bignum + ---------- + Integer arithmetic routines for cryptography. + See third_party/s2n-bignum/s2n-bignum-imported/LICENSE. + . + ``` + Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + SPDX-License-Identifier: Apache-2.0 OR ISC OR MIT-0 + ``` + . + Note: ML-KEM/SHA3 code within s2n-bignum is licensed as + Apache-2.0 OR ISC OR MIT (with attribution), sourced from the + mlkem-native project. + . + Jitter Entropy RNG + ------------------- + CPU Jitter Random Number Generator Library. + See third_party/jitterentropy/jitterentropy-library/LICENSE. + . + The Jitter Entropy library is dual-licensed under a BSD-style license + and the GNU General Public License Version 2. Amazon expressly elects + to distribute the package under the 3-Clause BSD License and NOT under + GNU General Public License Version 2. + . + ``` + Copyright (C) 2017 - 2025, Stephan Mueller . + SPDX-License-Identifier: BSD-3-Clause + ``` + . + Keccak / AES Reference Implementations + --------------------------------------- + Public domain (CC0) contributions. + https://creativecommons.org/public-domain/cc0 + . + Code from sources and by authors listed in comments on top of the respective files. + . + . + ================================================================================ + Third-Party Libraries (NOT compiled into libcrypto/libssl) + ================================================================================ + . + The following are used for testing and build tooling only. Distributing + code linked against AWS-LC (libcrypto/libssl) does NOT trigger these + license obligations. + . + Google Test + ----------- + See third_party/googletest/LICENSE. + . + ``` + Copyright 2008 Google Inc. + SPDX-License-Identifier: BSD-3-Clause + ``` + . + Go Standard Library + ------------------- + Code in ssl/test/runner/ is derived from the Go standard library. + . + ``` + Copyright (c) The Go Authors. All rights reserved. + SPDX-License-Identifier: BSD-3-Clause + ``` + . + Wycheproof Test Vectors + ------------------------ + Project Wycheproof is a community managed repository of test vectors that can be used by cryptography library + developers to test against known attacks, specification inconsistencies, and other various implementation bugs. + . + See third_party/wycheproof_testvectors/LICENSE. + . + ``` + SPDX-License-Identifier: Apache-2.0 + ``` + . + . + ================================================================================ + Full License Texts + ================================================================================ + . + Apache License 2.0 + ------------------ + On Debian systems, the complete text of the Apache License, Version 2.0 + can be found in "/usr/share/common-licenses/Apache-2.0". + . + APPENDIX: How to apply the Apache License to your work. + . + To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. + . + Copyright [yyyy] [name of copyright owner] + . + 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. + . + ISC License + ----------- + Permission to use, copy, modify, and/or distribute this software for + any purpose with or without fee is hereby granted, provided that the + above copyright notice and this permission notice appear in all copies. + . + THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL + WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED + WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE + AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL + DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR + PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER + TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR + PERFORMANCE OF THIS SOFTWARE. + . + MIT License + ----------- + 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. + . + BSD 3-Clause License + -------------------- + Redistribution and use in source and binary forms, with or without + modification, are permitted provided that the following conditions + are met: + . + 1. Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + . + 2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + . + 3. Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + . + THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + . + MIT No Attribution (MIT-0) + -------------------------- + 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. + . + 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. + +Files: vendor/base64-0.22.1/* +Copyright: 2015 Alice Maz +License: Apache-2.0 or Expat + The MIT License (MIT) + . + Copyright (c) 2015 Alice Maz + . + 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. + +Files: vendor/base64ct-1.8.3/* +Copyright: 2014 Steve "Sc00bz" Thomas (steve at tobtu dot com) + 2021-2025 The RustCrypto Project Developers +License: Apache-2.0 or Expat + Copyright (c) 2014 Steve "Sc00bz" Thomas (steve at tobtu dot com) + Copyright (c) 2021-2025 The RustCrypto Project Developers + . + 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. + +Files: vendor/bitflags-2.13.2/* + vendor/log-0.4.34/* + vendor/num-0.4.3/* + vendor/num-bigint-0.4.8/* + vendor/num-bigint-dig-0.9.1/* + vendor/num-complex-0.4.6/* + vendor/num-integer-0.1.47/* + vendor/num-iter-0.1.46/* + vendor/num-rational-0.4.2/* + vendor/num-traits-0.2.19/* + vendor/regex-1.13.1/* + vendor/regex-automata-0.4.18/* + vendor/regex-syntax-0.8.11/* +Copyright: 2014 The Rust Project Developers +License: Apache-2.0 or Expat + Copyright (c) 2014 The Rust Project Developers + . + 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. + +Files: vendor/block-buffer-0.10.4/* + vendor/block-padding-0.3.3/* +Copyright: 2018-2019 The RustCrypto Project Developers +License: Apache-2.0 or Expat + Copyright (c) 2018-2019 The RustCrypto Project Developers + . + 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. + +Files: vendor/block-buffer-0.12.1/* +Copyright: 2018-2025 The RustCrypto Project Developers +License: Apache-2.0 or Expat + Copyright (c) 2018-2025 The RustCrypto Project Developers + . + 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. + +Files: vendor/bnum-0.13.0/* +Copyright: 2014 The Rust Project Developers + 2018 Developers of the Rand project + 2022 Isaac Holt +License: Apache-2.0 or Expat + Copyright (c) 2014 The Rust Project Developers + . + 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. + . + Copyright 2018 Developers of the Rand project + Copyright (c) 2014 The Rust Project Developers + . + 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. + . + MIT License + . + Copyright (c) 2022 Isaac Holt + . + 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. + +Files: vendor/bstr-1.13.1/* +Copyright: 2018-2019 Andrew Gallant +License: Apache-2.0 or Expat + The MIT License (MIT) + . + Copyright (c) 2018-2019 Andrew Gallant + . + 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. + +Files: vendor/bumpalo-3.20.3/* +Copyright: 2019 Nick Fitzgerald +License: Apache-2.0 or Expat + Copyright (c) 2019 Nick Fitzgerald + . + 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. + +Files: vendor/bytes-1.12.1/* +Copyright: 2018 Carl Lerche +License: Expat + Copyright (c) 2018 Carl Lerche + . + 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. + +Files: vendor/cairo-rs-0.22.9/* + vendor/cairo-sys-rs-0.22.9/* + vendor/gdk-pixbuf-0.22.0/* + vendor/gdk-pixbuf-sys-0.22.9/* + vendor/gdk4-0.11.4/* + vendor/gdk4-sys-0.11.4/* + vendor/gio-0.22.9/* + vendor/gio-sys-0.22.9/* + vendor/gio-unix-0.22.8/* + vendor/gio-unix-sys-0.22.9/* + vendor/glib-0.22.9/* + vendor/glib-macros-0.22.9/* + vendor/glib-sys-0.22.9/* + vendor/gobject-sys-0.22.9/* + vendor/graphene-rs-0.22.8/* + vendor/graphene-sys-0.22.9/* + vendor/gsk4-0.11.4/* + vendor/gsk4-sys-0.11.4/* + vendor/gtk4-0.11.4/* + vendor/gtk4-macros-0.11.4/* + vendor/gtk4-sys-0.11.4/* + vendor/libadwaita-0.9.2/* + vendor/libadwaita-sys-0.9.2/* + vendor/pango-0.22.9/* + vendor/pango-sys-0.22.9/* + vendor/sourceview5-0.11.2/* + vendor/sourceview5-sys-0.11.2/* +Copyright: the authors of cairo-rs, cairo-sys-rs, gdk-pixbuf, gdk-pixbuf-sys, gdk4, + gdk4-sys, gio, gio-sys, gio-unix, gio-unix-sys, glib, glib-macros, + glib-sys, gobject-sys, graphene-rs, graphene-sys, gsk4, gsk4-sys, gtk4, + gtk4-macros, gtk4-sys, libadwaita, libadwaita-sys, pango, pango-sys, + sourceview5, sourceview5-sys +License: Expat + 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. + +Files: vendor/cbc-0.1.2/* +Copyright: 2018-2022 RustCrypto Developers + 2018 Artyom Pavlov +License: Apache-2.0 or Expat + Copyright (c) 2018-2022 RustCrypto Developers + Copyright (c) 2018 Artyom Pavlov + . + 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. + +Files: vendor/cfg-if-1.0.4/* + vendor/futures-timer-3.0.4/* + vendor/openssl-probe-0.2.1/* + vendor/socket2-0.6.5/* +Copyright: 2014 Alex Crichton +License: Apache-2.0 or Expat + Copyright (c) 2014 Alex Crichton + . + 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. + +Files: vendor/chacha20-0.10.2/* + vendor/zeroize_derive-1.5.0/* +Copyright: 2019-2026 The RustCrypto Project Developers +License: Apache-2.0 or Expat + Copyright (c) 2019-2026 The RustCrypto Project Developers + . + 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. + +Files: vendor/chrono-0.4.45/* +Copyright: 2014, Kang Seonghoon. +License: Apache-2.0 or Expat + The MIT License (MIT) + . + Copyright (c) 2014, Kang Seonghoon. + . + 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. + +Files: vendor/cipher-0.4.4/* +Copyright: 2016-2020 RustCrypto Developers +License: Apache-2.0 or Expat + Copyright (c) 2016-2020 RustCrypto Developers + . + 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. + +Files: vendor/cmov-0.5.4/* + vendor/hybrid-array-0.4.15/* +Copyright: 2022-2026 The RustCrypto Project Developers +License: Apache-2.0 or Expat + Copyright (c) 2022-2026 The RustCrypto Project Developers + . + 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. + +Files: vendor/const-oid-0.10.2/* + vendor/cpufeatures-0.3.1/* + vendor/der-0.8.2/* + vendor/pkcs8-0.11.0/* +Copyright: 2020-2026 The RustCrypto Project Developers +License: Apache-2.0 or Expat + Copyright (c) 2020-2026 The RustCrypto Project Developers + . + 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. + +Files: vendor/const-oid-0.9.6/* + vendor/der_derive-0.7.3/* +Copyright: 2020-2022 The RustCrypto Project Developers +License: Apache-2.0 or Expat + Copyright (c) 2020-2022 The RustCrypto Project Developers + . + 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. + +Files: vendor/core_detect-1.0.0/* +Copyright: 2017-2020 The Rust Project Developers +License: Apache-2.0 or Expat + Copyright (c) 2017-2020 The Rust Project Developers + . + 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. + +Files: vendor/cpubits-0.1.1/* +Copyright: 2023-2026 The RustCrypto Project Developers +License: Apache-2.0 or Expat + Copyright (c) 2023-2026 The RustCrypto Project Developers + . + 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. + +Files: vendor/cpufeatures-0.2.17/* +Copyright: 2020-2025 The RustCrypto Project Developers +License: Apache-2.0 or Expat + Copyright (c) 2020-2025 The RustCrypto Project Developers + . + 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. + +Files: vendor/crc-3.4.0/* +Copyright: 2017 crc-rs Developers +License: Apache-2.0 or Expat + MIT License + . + Copyright (c) 2017 crc-rs Developers + . + 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. + +Files: vendor/crc-catalog-2.5.0/* +Copyright: 2019 Akhil Velagapudi +License: Apache-2.0 or Expat + MIT License + . + Copyright (c) 2019 Akhil Velagapudi + . + 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. + +Files: vendor/crc32fast-1.5.2/* +Copyright: 2018 Sam Rijs, Alex Crichton and contributors +License: Apache-2.0 or Expat + MIT License + . + Copyright (c) 2018 Sam Rijs, Alex Crichton and contributors + . + 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. + +Files: vendor/crossbeam-queue-0.3.14/* + vendor/crossbeam-utils-0.8.23/* +Copyright: 2019 The Crossbeam Project Developers +License: Apache-2.0 or Expat + The MIT License (MIT) + . + Copyright (c) 2019 The Crossbeam Project Developers + . + 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. + +Files: vendor/crypto-bigint-0.7.5/* + vendor/spki-0.8.0/* +Copyright: 2021-2026 The RustCrypto Project Developers +License: Apache-2.0 or Expat + Copyright (c) 2021-2026 The RustCrypto Project Developers + . + 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. + +Files: vendor/crypto-common-0.1.7/* +Copyright: 2021 RustCrypto Developers +License: Apache-2.0 or Expat + Copyright (c) 2021 RustCrypto Developers + . + 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. + +Files: vendor/crypto-common-0.2.2/* +Copyright: 2021-2026 RustCrypto Developers +License: Apache-2.0 or Expat + Copyright (c) 2021-2026 RustCrypto Developers + . + 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. + +Files: vendor/crypto-primes-0.7.2/* +Copyright: 2022-* Bogdan Opanchuk +License: Apache-2.0 or Expat + Copyright 2022-* Bogdan Opanchuk + . + 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. + +Files: vendor/ctutils-0.4.2/* +Copyright: 2025-2026 The RustCrypto Project Developers +License: Apache-2.0 or Expat + Copyright (c) 2025-2026 The RustCrypto Project Developers + . + 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. + +Files: vendor/deadpool-0.13.1/* + vendor/deadpool-postgres-0.14.2/* + vendor/deadpool-runtime-0.3.1/* +Copyright: 2019 Michael P. Jung +License: Apache-2.0 or Expat + The MIT License (MIT) + . + Copyright (c) 2019 Michael P. Jung + . + 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. + +Files: vendor/der-0.7.10/* +Copyright: 2020-2023 The RustCrypto Project Developers +License: Apache-2.0 or Expat + Copyright (c) 2020-2023 The RustCrypto Project Developers + . + 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. + +Files: vendor/digest-0.10.7/* + vendor/hmac-0.12.1/* + vendor/hmac-0.13.0/* +Copyright: 2017 Artyom Pavlov +License: Apache-2.0 or Expat + Copyright (c) 2017 Artyom Pavlov + . + 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. + +Files: vendor/digest-0.11.3/* +Copyright: 2017-2025 RustCrypto Developers + 2017 Artyom Pavlov +License: Apache-2.0 or Expat + Copyright (c) 2017-2025 RustCrypto Developers + Copyright (c) 2017 Artyom Pavlov + . + 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. + +Files: vendor/dotenvy-0.15.7/* +Copyright: 2014 Santiago Lapresta and contributors +License: Expat + # The MIT License (MIT) + . + Copyright (c) 2014 Santiago Lapresta and contributors + . + 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. + +Files: vendor/either-1.18.0/* +Copyright: 2015 +License: Apache-2.0 or Expat + Copyright (c) 2015 + . + 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. + +Files: vendor/encoding_rs-0.8.41/* +Copyright: WHATWG (Apple, Google, Mozilla, Microsoft). + Mozilla Foundation +License: Apache-2.0 or Expat, and BSD-3-Clause + // Copyright © WHATWG (Apple, Google, Mozilla, Microsoft). + // + // Redistribution and use in source and binary forms, with or without + // modification, are permitted provided that the following conditions are met: + // + // 1. Redistributions of source code must retain the above copyright notice, this + // list of conditions and the following disclaimer. + // + // 2. Redistributions in binary form must reproduce the above copyright notice, + // this list of conditions and the following disclaimer in the documentation + // and/or other materials provided with the distribution. + // + // 3. Neither the name of the copyright holder nor the names of its + // contributors may be used to endorse or promote products derived from + // this software without specific prior written permission. + // + // THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + // AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + // IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + // DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + // FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + // DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + // SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + // CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + // OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + // OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + . + /// The PUA code points special-cased in the GB18030 encoder. + pub(crate) static GB18030_2022_OVERRIDE_PUA: [u16; 18] = [ + 0xE78D, 0xE78E, 0xE78F, 0xE790, 0xE791, 0xE792, 0xE793, 0xE794, 0xE795, 0xE796, 0xE81E, 0xE826, + 0xE82B, 0xE82C, 0xE832, 0xE843, 0xE854, 0xE864, + ]; + . + /// The bytes corresponding to the PUA code points special-cased in the GB18030 encoder. + pub(crate) static GB18030_2022_OVERRIDE_BYTES: [[u8; 2]; 18] = [ + [0xA6, 0xD9], + [0xA6, 0xDA], + [0xA6, 0xDB], + [0xA6, 0xDC], + [0xA6, 0xDD], + [0xA6, 0xDE], + [0xA6, 0xDF], + [0xA6, 0xEC], + [0xA6, 0xED], + [0xA6, 0xF3], + [0xFE, 0x59], + [0xFE, 0x61], + [0xFE, 0x66], + [0xFE, 0x67], + [0xFE, 0x6D], + [0xFE, 0x7E], + [0xFE, 0x90], + [0xFE, 0xA0], + ]; + . + Copyright © WHATWG (Apple, Google, Mozilla, Microsoft). + . + Redistribution and use in source and binary forms, with or without + modification, are permitted provided that the following conditions are met: + . + 1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + . + 2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + . + 3. Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + . + THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + . + Copyright Mozilla Foundation + . + 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. + +Files: vendor/endi-1.1.1/* + vendor/zmij-1.0.23/* + vendor/zvariant_utils-4.2.0/* +Copyright: the authors of endi, zmij, zvariant_utils +License: Expat + 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. + +Files: vendor/enumflags2-0.7.12/* +Copyright: 2017-2023 Maik Klein, Maja Kądziołka +License: Apache-2.0 or Expat + Copyright (c) 2017-2023 Maik Klein, Maja Kądziołka + . + 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. + +Files: vendor/enumflags2_derive-0.7.12/* +Copyright: 2017 Maik Klein +License: Apache-2.0 or Expat + Copyright (c) 2017 Maik Klein + . + 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. + +Files: vendor/equivalent-1.0.2/* +Copyright: 2016--2023 +License: Apache-2.0 or Expat + Copyright (c) 2016--2023 + . + 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. + +Files: vendor/errno-0.3.14/* +Copyright: 2014 Chris Wong +License: Apache-2.0 or Expat + Copyright (c) 2014 Chris Wong + . + 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. + +Files: vendor/fallible-iterator-0.2.0/* +Copyright: 2015 The rust-openssl-verify Developers +License: Apache-2.0 or Expat + Copyright (c) 2015 The rust-openssl-verify Developers + . + 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. + +Files: vendor/field-offset-0.3.6/* +Copyright: 2016-2021 Diggory Blake, and other contributors. +License: Apache-2.0 or Expat + MIT License + . + Copyright (c) 2016-2021 Diggory Blake, and other contributors. + . + 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. + +Files: vendor/fixed_decimal-0.7.2/* + vendor/icu_collections-2.3.0/* + vendor/icu_decimal-2.3.0/* + vendor/icu_decimal_data-2.3.0/* + vendor/icu_locale_core-2.3.0/* + vendor/icu_locale_fallback-2.3.0/* + vendor/icu_locale_fallback_data-2.3.0/* + vendor/icu_normalizer-2.3.0/* + vendor/icu_normalizer_data-2.3.0/* + vendor/icu_properties-2.3.0/* + vendor/icu_properties_data-2.3.0/* + vendor/icu_provider-2.3.1/* + vendor/litemap-0.8.3/* + vendor/potential_utf-0.1.6/* + vendor/tinystr-0.8.4/* + vendor/writeable-0.6.4/* + vendor/yoke-0.8.3/* + vendor/yoke-derive-0.8.2/* + vendor/zerofrom-0.1.8/* + vendor/zerofrom-derive-0.1.7/* + vendor/zerotrie-0.2.5/* + vendor/zerovec-0.11.8/* + vendor/zerovec-derive-0.11.6/* +Copyright: 2020-2024 Unicode, Inc. +License: Unicode-3.0 + UNICODE LICENSE V3 + . + COPYRIGHT AND PERMISSION NOTICE + . + Copyright © 2020-2024 Unicode, Inc. + . + NOTICE TO USER: Carefully read the following legal agreement. BY + DOWNLOADING, INSTALLING, COPYING OR OTHERWISE USING DATA FILES, AND/OR + SOFTWARE, YOU UNEQUIVOCALLY ACCEPT, AND AGREE TO BE BOUND BY, ALL OF THE + TERMS AND CONDITIONS OF THIS AGREEMENT. IF YOU DO NOT AGREE, DO NOT + DOWNLOAD, INSTALL, COPY, DISTRIBUTE OR USE THE DATA FILES OR SOFTWARE. + . + Permission is hereby granted, free of charge, to any person obtaining a + copy of data files and any associated documentation (the "Data Files") or + software and any associated documentation (the "Software") to deal in the + Data Files or Software without restriction, including without limitation + the rights to use, copy, modify, merge, publish, distribute, and/or sell + copies of the Data Files or Software, and to permit persons to whom the + Data Files or Software are furnished to do so, provided that either (a) + this copyright and permission notice appear with all copies of the Data + Files or Software, or (b) this copyright and permission notice appear in + associated Documentation. + . + THE DATA FILES AND SOFTWARE ARE 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 OF + THIRD PARTY RIGHTS. + . + IN NO EVENT SHALL THE COPYRIGHT HOLDER OR HOLDERS INCLUDED IN THIS NOTICE + BE LIABLE FOR ANY CLAIM, OR ANY SPECIAL INDIRECT OR CONSEQUENTIAL DAMAGES, + OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, + WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, + ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THE DATA + FILES OR SOFTWARE. + . + Except as contained in this notice, the name of a copyright holder shall + not be used in advertising or otherwise to promote the sale, use or other + dealings in these Data Files or Software without prior written + authorization of the copyright holder. + . + SPDX-License-Identifier: Unicode-3.0 + . + — + . + Portions of ICU4X may have been adapted from ICU4C and/or ICU4J. + ICU 1.8.1 to ICU 57.1 © 1995-2016 International Business Machines Corporation and others. + +Files: vendor/flagset-0.4.7/* + vendor/fragile-2.1.0/* +Copyright: the authors of flagset, fragile +License: Apache-2.0 + On Debian systems, the complete text of the Apache License, Version 2.0 + can be found in "/usr/share/common-licenses/Apache-2.0". + . + APPENDIX: How to apply the Apache License to your work. + . + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + . + Copyright [yyyy] [name of copyright owner] + . + 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. + +Files: vendor/flate2-1.1.10/* +Copyright: 2014-2026 Alex Crichton +License: Apache-2.0 or Expat + Copyright (c) 2014-2026 Alex Crichton + . + 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. + +Files: vendor/foldhash-0.2.0/* +Copyright: 2024 Orson Peters +License: Zlib + Copyright (c) 2024 Orson Peters + . + This software is provided 'as-is', without any express or implied warranty. In + no event will the authors be held liable for any damages arising from the use of + this software. + . + Permission is granted to anyone to use this software for any purpose, including + commercial applications, and to alter it and redistribute it freely, subject to + the following restrictions: + . + 1. The origin of this software must not be misrepresented; you must not claim + that you wrote the original software. If you use this software in a product, + an acknowledgment in the product documentation would be appreciated but is + not required. + . + 2. Altered source versions must be plainly marked as such, and must not be + misrepresented as being the original software. + . + 3. This notice may not be removed or altered from any source distribution. + +Files: vendor/form_urlencoded-1.2.2/* +Copyright: 2013-2016 The rust-url developers +License: Apache-2.0 or Expat + Copyright (c) 2013-2016 The rust-url developers + . + 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. + +Files: vendor/futures-0.3.34/* + vendor/futures-channel-0.3.34/* + vendor/futures-core-0.3.34/* + vendor/futures-executor-0.3.34/* + vendor/futures-io-0.3.34/* + vendor/futures-macro-0.3.34/* + vendor/futures-sink-0.3.34/* + vendor/futures-task-0.3.34/* + vendor/futures-util-0.3.34/* +Copyright: 2016 Alex Crichton + 2017 The Tokio Authors +License: Apache-2.0 or Expat + Copyright (c) 2016 Alex Crichton + Copyright (c) 2017 The Tokio Authors + . + 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. + +Files: vendor/futures-intrusive-0.5.0/* +Copyright: 2019 Matthias Einwag +License: Apache-2.0 or Expat + Copyright (c) 2019 Matthias Einwag + . + 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. + +Files: vendor/generic-array-0.14.7/* +Copyright: 2015 Bartłomiej Kamiński +License: Expat + The MIT License (MIT) + . + Copyright (c) 2015 Bartłomiej Kamiński + . + 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. + +Files: vendor/getrandom-0.3.4/* +Copyright: 2018-2025 The rust-random Project Developers + 2014 The Rust Project Developers +License: Apache-2.0 or Expat + Copyright (c) 2018-2025 The rust-random Project Developers + Copyright (c) 2014 The Rust Project Developers + . + 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. + +Files: vendor/getrandom-0.4.3/* +Copyright: 2018-2026 The rust-random Project Developers + 2014 The Rust Project Developers +License: Apache-2.0 or Expat + Copyright (c) 2018-2026 The rust-random Project Developers + Copyright (c) 2014 The Rust Project Developers + . + 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. + +Files: vendor/gettext-rs-0.8.0/* + vendor/gettext-sys-0.26.0/* +Copyright: 2016 Konstantin V. Salikhov +License: Expat + The MIT License (MIT) + . + Copyright (c) 2016 Konstantin V. Salikhov + . + 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. + +Files: vendor/half-2.7.1/* +Copyright: the authors of half +License: Apache-2.0 or Expat + MIT License + . + 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. + +Files: vendor/hashbrown-0.16.1/* + vendor/hashbrown-0.17.1/* +Copyright: 2016 Amanieu d'Antras +License: Apache-2.0 or Expat + Copyright (c) 2016 Amanieu d'Antras + . + 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. + +Files: vendor/hashlink-0.11.1/* +Copyright: the authors of hashlink +License: Apache-2.0 or Expat + This work is derived in part from the `linked-hash-map` crate, Copyright (c) + 2015 The Rust Project Developers + . + 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. + +Files: vendor/heck-0.5.0/* + vendor/unicode-bidi-0.3.18/* + vendor/unicode-normalization-0.1.25/* + vendor/unicode-properties-0.1.4/* +Copyright: 2015 The Rust Project Developers +License: Apache-2.0 or Expat + Copyright (c) 2015 The Rust Project Developers + . + 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. + +Files: vendor/hex-0.4.3/* +Copyright: 2013-2014 The Rust Project Developers. + 2015-2020 The rust-hex Developers +License: Apache-2.0 or Expat + Copyright (c) 2013-2014 The Rust Project Developers. + Copyright (c) 2015-2020 The rust-hex Developers + . + 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. + +Files: vendor/hkdf-0.12.4/* +Copyright: 2015-2018 Vlad Filippov + 2018-2021 RustCrypto Developers +License: Apache-2.0 or Expat + Copyright (c) 2015-2018 Vlad Filippov + Copyright (c) 2018-2021 RustCrypto Developers + . + 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. + +Files: vendor/http-1.5.0/* +Copyright: 2017 http-rs authors +License: Apache-2.0 or Expat + Copyright (c) 2017 http-rs authors + . + 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. + +Files: vendor/http-body-1.1.0/* + vendor/http-body-util-0.1.5/* +Copyright: 2019-2026 Sean McArthur & Hyper Contributors +License: Expat + Copyright (c) 2019-2026 Sean McArthur & Hyper Contributors + . + 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. + +Files: vendor/httparse-1.10.1/* +Copyright: 2015-2025 Sean McArthur +License: Apache-2.0 or Expat + Copyright (c) 2015-2025 Sean McArthur + . + 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. + +Files: vendor/hyper-1.11.1/* +Copyright: 2014-2026 Sean McArthur +License: Expat + Copyright (c) 2014-2026 Sean McArthur + . + 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. + +Files: vendor/hyper-rustls-0.27.9/* + vendor/rustls-0.23.46/* + vendor/rustls-native-certs-0.8.4/* +Copyright: 2016 Joseph Birr-Pixton +License: Apache-2.0 or ISC or Expat + Copyright (c) 2016 Joseph Birr-Pixton + . + 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. + +Files: vendor/hyper-util-0.1.20/* +Copyright: 2023-2025 Sean McArthur +License: Expat + Copyright (c) 2023-2025 Sean McArthur + . + 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. + +Files: vendor/iana-time-zone-0.1.65/* +Copyright: 2020 Andrew D. Straw +License: Apache-2.0 or Expat + Copyright (c) 2020 Andrew D. Straw + . + 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. + +Files: vendor/idna-1.1.0/* + vendor/percent-encoding-2.3.2/* + vendor/url-2.5.8/* +Copyright: 2013-2025 The rust-url developers +License: Apache-2.0 or Expat + Copyright (c) 2013-2025 The rust-url developers + . + 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. + +Files: vendor/idna_adapter-1.2.2/* +Copyright: The rust-url developers +License: Apache-2.0 or Expat + Copyright (c) The rust-url developers + . + 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. + +Files: vendor/indexmap-2.14.2/* +Copyright: 2016--2017 +License: Apache-2.0 or Expat + Copyright (c) 2016--2017 + . + 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. + +Files: vendor/inout-0.1.4/* +Copyright: 2022 The RustCrypto Project Developers + 2022 Artyom Pavlov +License: Apache-2.0 or Expat + Copyright (c) 2022 The RustCrypto Project Developers + Copyright (c) 2022 Artyom Pavlov + . + 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. + +Files: vendor/lazy_static-1.5.0/* +Copyright: 2010 The Rust Project Developers +License: Apache-2.0 or Expat + Copyright (c) 2010 The Rust Project Developers + . + 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. + +Files: vendor/libc-0.2.189/* +Copyright: The Rust Project Developers +License: Apache-2.0 or Expat + Copyright (c) The Rust Project Developers + . + 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. + +Files: vendor/libgssapi-0.11.0/* + vendor/libgssapi-sys-0.3.4/* +Copyright: the authors of libgssapi, libgssapi-sys +License: Expat + MIT License + . + Copyright (c) [year] [fullname] + . + 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. + +Files: vendor/libm-0.2.16/* +Copyright: 2018 Jorge Aparicio + 2005-2020 Rich Felker, et al. + 1993,2004 Sun Microsystems or + 2003-2011 David Schultz or + 2003-2009 Steven G. Kargl or + 2003-2009 Bruce D. Evans or + 2008 Stephen L. Moshier or + 2017-2018 Arm Limited +License: Expat + rust-lang/libm as a whole is available for use under the MIT license: + . + ------------------------------------------------------------------------------ + 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. + ------------------------------------------------------------------------------ + . + As a contributor, you agree that your code can be used under either the MIT + license or the Apache-2.0 license: + . + ------------------------------------------------------------------------------ + On Debian systems, the complete text of the Apache License, Version 2.0 + can be found in "/usr/share/common-licenses/Apache-2.0". + . + APPENDIX: How to apply the Apache License to your work. + . + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + . + 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. + ------------------------------------------------------------------------------ + . + This Rust library contains the following copyrights: + . + Copyright (c) 2018 Jorge Aparicio + . + Portions of this software are derived from third-party works licensed under + terms compatible with the above MIT license: + . + * musl libc https://www.musl-libc.org/. This library contains the following + copyright: + . + Copyright © 2005-2020 Rich Felker, et al. + . + * The CORE-MATH project https://core-math.gitlabpages.inria.fr/. CORE-MATH + routines are available under the MIT license on a per-file basis. + . + The musl libc COPYRIGHT file also includes the following notice relevant to + math portions of the library: + . + ------------------------------------------------------------------------------ + Much of the math library code (src/math/* and src/complex/*) is + Copyright © 1993,2004 Sun Microsystems or + Copyright © 2003-2011 David Schultz or + Copyright © 2003-2009 Steven G. Kargl or + Copyright © 2003-2009 Bruce D. Evans or + Copyright © 2008 Stephen L. Moshier or + Copyright © 2017-2018 Arm Limited + and labelled as such in comments in the individual source files. All + have been licensed under extremely permissive terms. + ------------------------------------------------------------------------------ + . + Copyright notices are retained in src/* files where relevant. + +Files: vendor/libsqlite3-sys-0.37.0/* +Copyright: 2014 The rusqlite developers +License: Expat + Copyright (c) 2014 The rusqlite developers + . + 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. + +Files: vendor/linux-raw-sys-0.12.1/* + vendor/rustix-1.1.4/* +Copyright: the authors of linux-raw-sys, rustix +License: Apache-2.0 or Apache-2.0 with LLVM exception or Expat + 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. + +Files: vendor/locale_config-0.3.0/* +Copyright: 2016–2019 Jan Hudec + 2016 A.J. Gardner + 2019, Bastien Orivel + 2019, Igor Gnatenko + 2019, Sophie Tauchert <999eagle@999eagle.moe> +License: Expat + The MIT License (MIT) + . + Copyright (c) 2016–2019 Jan Hudec + Copyright (c) 2016 A.J. Gardner + Copyright (c) 2019, Bastien Orivel + Copyright (c) 2019, Igor Gnatenko + Copyright (c) 2019, Sophie Tauchert <999eagle@999eagle.moe> + . + 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. + +Files: vendor/lock_api-0.4.14/* + vendor/parking_lot-0.12.5/* + vendor/parking_lot_core-0.9.12/* + vendor/thread_local-1.1.10/* +Copyright: 2016 The Rust Project Developers +License: Apache-2.0 or Expat + Copyright (c) 2016 The Rust Project Developers + . + 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. + +Files: vendor/matchers-0.2.0/* +Copyright: 2019 Eliza Weisman +License: Expat + Copyright (c) 2019 Eliza Weisman + . + 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. + +Files: vendor/md-5-0.10.6/* + vendor/sha2-0.10.9/* +Copyright: 2006-2009 Graydon Hoare + 2009-2013 Mozilla Foundation + 2016 Artyom Pavlov +License: Apache-2.0 or Expat + Copyright (c) 2006-2009 Graydon Hoare + Copyright (c) 2009-2013 Mozilla Foundation + Copyright (c) 2016 Artyom Pavlov + . + 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. + +Files: vendor/md-5-0.11.0/* + vendor/sha1-0.11.0/* + vendor/sha2-0.11.0/* +Copyright: 2016-2026 The RustCrypto Project Developers + 2016 Artyom Pavlov + 2009-2013 Mozilla Foundation + 2006-2009 Graydon Hoare +License: Apache-2.0 or Expat + Copyright (c) 2016-2026 The RustCrypto Project Developers + Copyright (c) 2016 Artyom Pavlov + Copyright (c) 2009-2013 Mozilla Foundation + Copyright (c) 2006-2009 Graydon Hoare + . + 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. + +Files: vendor/memoffset-0.9.1/* +Copyright: 2017 Gilad Naaman +License: Expat + Copyright (c) 2017 Gilad Naaman + . + 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. + +Files: vendor/mio-1.2.3/* +Copyright: 2014 Carl Lerche and other MIO contributors +License: Expat + Copyright (c) 2014 Carl Lerche and other MIO contributors + . + 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. + +Files: vendor/multiversion-0.9.0/* + vendor/multiversion-macros-0.9.0/* +Copyright: 2019 Caleb Zulawski +License: Apache-2.0 or Expat + Copyright 2019 Caleb Zulawski + . + 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. + +Files: vendor/multiversion_no_op-1.0.0/* + vendor/utf8_iter-1.0.4/* +Copyright: Mozilla Foundation +License: Apache-2.0 or Expat + Copyright Mozilla Foundation + . + 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. + +Files: vendor/nu-ansi-term-0.50.3/* +Copyright: 2014 Benjamin Sago + 2021-2022 The Nushell Project Developers +License: Expat + The MIT License (MIT) + . + Copyright (c) 2014 Benjamin Sago + Copyright (c) 2021-2022 The Nushell Project Developers + . + 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. + +Files: vendor/oo7-0.6.0/* +Copyright: 2022 Bilal Elmoussaoui, Sophie Herold, Maximiliano Sandoval, Dhanuka Warusadura +License: Expat + MIT License + . + Copyright (c) 2022 Bilal Elmoussaoui, Sophie Herold, Maximiliano Sandoval, Dhanuka Warusadura + . + 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. + +Files: vendor/pbkdf2-0.12.2/* +Copyright: 2017 Artyom Pavlov + 2018-2023 The RustCrypto Project Developers +License: Apache-2.0 or Expat + Copyright (c) 2017 Artyom Pavlov + Copyright (c) 2018-2023 The RustCrypto Project Developers + . + 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. + +Files: vendor/pem-rfc7468-1.0.0/* + vendor/pkcs1-0.8.0-rc.4/* +Copyright: 2021-2025 The RustCrypto Project Developers +License: Apache-2.0 or Expat + Copyright (c) 2021-2025 The RustCrypto Project Developers + . + 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. + +Files: vendor/phf-0.13.1/* + vendor/phf_shared-0.13.1/* +Copyright: 2014-2022 Steven Fackler, Yuki Okushi +License: Expat + The MIT License (MIT) + . + Copyright (c) 2014-2022 Steven Fackler, Yuki Okushi + . + 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. + +Files: vendor/postgres-protocol-0.6.12/* + vendor/postgres-types-0.2.14/* + vendor/tokio-postgres-0.7.18/* +Copyright: 2016 Steven Fackler +License: Apache-2.0 or Expat + The MIT License (MIT) + . + Copyright (c) 2016 Steven Fackler + . + 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. + +Files: vendor/pretty-hex-0.4.2/* +Copyright: 2018 Andrei Volnin +License: Expat + MIT License + . + Copyright (c) 2018 Andrei Volnin + . + 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. + +Files: vendor/rand-0.10.2/* + vendor/rand-0.9.5/* + vendor/rand_core-0.9.5/* +Copyright: 2018 Developers of the Rand project + 2014 The Rust Project Developers +License: Apache-2.0 or Expat + Copyright 2018 Developers of the Rand project + Copyright (c) 2014 The Rust Project Developers + . + 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. + +Files: vendor/rand_core-0.10.1/* +Copyright: 2018-2026 The Rand Project Developers +License: Apache-2.0 or Expat + Copyright (c) 2018-2026 The Rand Project Developers + . + 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. + +Files: vendor/rust_xlsxwriter-0.99.1/* +Copyright: 2022-2026 John McNamara +License: Apache-2.0 or Expat + MIT License + . + Copyright 2022-2026 John McNamara + . + 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. + +Files: vendor/rustls-pki-types-1.15.1/* +Copyright: 2023 Dirkjan Ochtman +License: Apache-2.0 or Expat + Copyright (c) 2023 Dirkjan Ochtman + . + 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. + +Files: vendor/rustls-platform-verifier-0.7.0/* +Copyright: 2022 1Password +License: Apache-2.0 or Expat + MIT License + . + Copyright (c) 2022 1Password + . + 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. + +Files: vendor/rustls-webpki-0.103.15/* +Copyright: 2015 Brian Smith. +License: ISC + Except as otherwise noted, this project is licensed under the following + (ISC-style) terms: + . + Copyright 2015 Brian Smith. + . + Permission to use, copy, modify, and/or distribute this software for any + purpose with or without fee is hereby granted, provided that the above + copyright notice and this permission notice appear in all copies. + . + THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHORS DISCLAIM ALL WARRANTIES + WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF + MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR + ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN + ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF + OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + . + The files under third-party/chromium are licensed as described in + third-party/chromium/LICENSE. + +Files: vendor/ryu-1.0.23/* +Copyright: the authors of ryu +License: Apache-2.0 or BSL-1.0 + On Debian systems, the complete text of the Apache License, Version 2.0 + can be found in "/usr/share/common-licenses/Apache-2.0". + +Files: vendor/scopeguard-1.2.0/* +Copyright: 2016-2019 Ulrik Sverdrup "bluss" and scopeguard developers +License: Apache-2.0 or Expat + Copyright (c) 2016-2019 Ulrik Sverdrup "bluss" and scopeguard developers + . + 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. + +Files: vendor/secrecy-0.10.3/* +Copyright: 2019-2024 iqlusion +License: Apache-2.0 or Expat + MIT License + . + Copyright (c) 2019-2024 iqlusion + . + 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. + +Files: vendor/sharded-slab-0.1.7/* +Copyright: 2019 Eliza Weisman +License: Expat + Copyright (c) 2019 Eliza Weisman + . + 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. + +Files: vendor/signal-hook-registry-1.4.8/* +Copyright: 2017 tokio-jsonrpc developers +License: Apache-2.0 or Expat + Copyright (c) 2017 tokio-jsonrpc developers + . + 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. + +Files: vendor/signature-3.0.0/* +Copyright: 2018-2026 RustCrypto Developers +License: Apache-2.0 or Expat + Copyright (c) 2018-2026 RustCrypto Developers + . + 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. + +Files: vendor/simd-adler32-0.3.10/* +Copyright: [2021] [Marvin Countryman] +License: Expat + MIT License + . + Copyright (c) [2021] [Marvin Countryman] + . + 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. + +Files: vendor/simdutf8-0.1.5/* +Copyright: the authors of simdutf8 +License: Apache-2.0 or Expat + MIT License + . + 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. + +Files: vendor/siphasher-1.0.4/* +Copyright: the authors of siphasher +License: Apache-2.0 or Expat + 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. + +Files: vendor/slab-0.4.12/* +Copyright: 2019 Carl Lerche +License: Expat + Copyright (c) 2019 Carl Lerche + . + 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. + +Files: vendor/smallvec-1.16.1/* +Copyright: 2018 The Servo Project Developers +License: Apache-2.0 or Expat + Copyright (c) 2018 The Servo Project Developers + . + 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. + +Files: vendor/spin-0.9.9/* +Copyright: 2014 Mathijs van de Nes +License: Expat + The MIT License (MIT) + . + Copyright (c) 2014 Mathijs van de Nes + . + 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. + +Files: vendor/spki-0.7.3/* +Copyright: 2021-2023 The RustCrypto Project Developers +License: Apache-2.0 or Expat + Copyright (c) 2021-2023 The RustCrypto Project Developers + . + 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. + +Files: vendor/sqlparser-0.63.0/* +Copyright: the authors of sqlparser +License: Apache-2.0 + On Debian systems, the complete text of the Apache License, Version 2.0 + can be found in "/usr/share/common-licenses/Apache-2.0". + . + APPENDIX: How to apply the Apache License to your work. + . + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + . + Copyright [yyyy] [name of copyright owner] + . + 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. + +Files: vendor/sqlx-0.9.0/* + vendor/sqlx-core-0.9.0/* + vendor/sqlx-macros-0.9.0/* + vendor/sqlx-macros-core-0.9.0/* + vendor/sqlx-mysql-0.9.0/* + vendor/sqlx-sqlite-0.9.0/* +Copyright: SQLx Contributors +License: Apache-2.0 or Expat + Copyright (C) SQLx Contributors + Portions of this work Copyright (C) LaunchBadge, LLC + . + 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. + +Files: vendor/stable_deref_trait-1.2.1/* +Copyright: 2017 Robert Grosse +License: Apache-2.0 or Expat + Copyright (c) 2017 Robert Grosse + . + 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. + +Files: vendor/stringprep-0.1.5/* +Copyright: 2017 The rust-stringprep Developers +License: Apache-2.0 or Expat + Copyright (c) 2017 The rust-stringprep Developers + . + 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. + +Files: vendor/subtle-2.6.1/* +Copyright: 2016-2017 Isis Agora Lovecruft, Henry de Valence. All rights reserved. + 2016-2024 Isis Agora Lovecruft. All rights reserved. +License: BSD-3-Clause + Copyright (c) 2016-2017 Isis Agora Lovecruft, Henry de Valence. All rights reserved. + Copyright (c) 2016-2024 Isis Agora Lovecruft. All rights reserved. + . + Redistribution and use in source and binary forms, with or without + modification, are permitted provided that the following conditions are + met: + . + 1. Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + . + 2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + . + 3. Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + . + THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS + IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED + TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A + PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED + TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR + PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF + LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING + NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +Files: vendor/synstructure-0.13.2/* +Copyright: 2016 Nika Layzell +License: Expat + Copyright 2016 Nika Layzell + . + 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. + +Files: vendor/tempfile-3.27.0/* +Copyright: 2015 Steven Allen +License: Apache-2.0 or Expat + Copyright (c) 2015 Steven Allen + . + 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. + +Files: vendor/tiberius-0.13.0/* + vendor/tiberius-macros-0.1.0/* +Copyright: 2021 Tiberius Contributors +License: Apache-2.0 or Expat + Copyright (c) 2021 Tiberius Contributors + . + 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. + +Files: vendor/tinyvec-1.13.3/* +Copyright: the authors of tinyvec +License: Apache-2.0 or Expat or Zlib + 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. + +Files: vendor/tokio-1.53.1/* + vendor/tokio-stream-0.1.19/* + vendor/tokio-util-0.7.19/* +Copyright: Tokio Contributors +License: Expat + MIT License + . + Copyright (c) Tokio Contributors + . + 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. + +Files: vendor/tokio-macros-2.7.2/* +Copyright: 2019 Yoshua Wuyts + Tokio Contributors +License: Expat + MIT License + . + Copyright (c) 2019 Yoshua Wuyts + Copyright (c) Tokio Contributors + . + 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. + +Files: vendor/tokio-postgres-rustls-0.14.0/* +Copyright: 2019 Jasper Hugo +License: Expat + MIT License + . + Copyright (c) 2019 Jasper Hugo + . + 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. + +Files: vendor/tokio-rustls-0.26.5/* +Copyright: 2017 quininer kel +License: Apache-2.0 or Expat + Copyright (c) 2017 quininer kel + . + 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. + +Files: vendor/toml_datetime-1.1.1+spec-1.1.0/* + vendor/toml_edit-0.25.15+spec-1.1.0/* + vendor/toml_parser-1.1.3+spec-1.1.0/* +Copyright: Individual contributors +License: Apache-2.0 or Expat + Copyright (c) Individual contributors + . + 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. + +Files: vendor/tower-service-0.3.3/* +Copyright: 2019 Tower Contributors +License: Expat + Copyright (c) 2019 Tower Contributors + . + 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. + +Files: vendor/tracing-0.1.44/* + vendor/tracing-attributes-0.1.31/* + vendor/tracing-log-0.2.0/* + vendor/tracing-serde-0.2.0/* + vendor/tracing-subscriber-0.3.23/* +Copyright: 2019 Tokio Contributors +License: Expat + Copyright (c) 2019 Tokio Contributors + . + 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. + +Files: vendor/tracing-core-0.1.36/* +Copyright: 2019 Tokio Contributors + 2014 Mathijs van de Nes +License: Expat + Copyright (c) 2019 Tokio Contributors + . + 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. + . + The MIT License (MIT) + . + Copyright (c) 2014 Mathijs van de Nes + . + 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. + +Files: vendor/try-lock-0.2.5/* +Copyright: 2018-2023 Sean McArthur + 2016 Alex Crichton +License: Expat + Copyright (c) 2018-2023 Sean McArthur + Copyright (c) 2016 Alex Crichton + . + 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. + +Files: vendor/typenum-1.20.1/* +Copyright: 2014 Paho Lurie-Gregg +License: Apache-2.0 or Expat + The MIT License (MIT) + . + Copyright (c) 2014 Paho Lurie-Gregg + . + 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. + +Files: vendor/unicode-ident-1.0.24/* +Copyright: 1991-2023 Unicode, Inc. +License: Apache-2.0 or Expat, and Unicode-3.0 + 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. + . + UNICODE LICENSE V3 + . + COPYRIGHT AND PERMISSION NOTICE + . + Copyright © 1991-2023 Unicode, Inc. + . + NOTICE TO USER: Carefully read the following legal agreement. BY + DOWNLOADING, INSTALLING, COPYING OR OTHERWISE USING DATA FILES, AND/OR + SOFTWARE, YOU UNEQUIVOCALLY ACCEPT, AND AGREE TO BE BOUND BY, ALL OF THE + TERMS AND CONDITIONS OF THIS AGREEMENT. IF YOU DO NOT AGREE, DO NOT + DOWNLOAD, INSTALL, COPY, DISTRIBUTE OR USE THE DATA FILES OR SOFTWARE. + . + Permission is hereby granted, free of charge, to any person obtaining a + copy of data files and any associated documentation (the "Data Files") or + software and any associated documentation (the "Software") to deal in the + Data Files or Software without restriction, including without limitation + the rights to use, copy, modify, merge, publish, distribute, and/or sell + copies of the Data Files or Software, and to permit persons to whom the + Data Files or Software are furnished to do so, provided that either (a) + this copyright and permission notice appear with all copies of the Data + Files or Software, or (b) this copyright and permission notice appear in + associated Documentation. + . + THE DATA FILES AND SOFTWARE ARE 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 OF + THIRD PARTY RIGHTS. + . + IN NO EVENT SHALL THE COPYRIGHT HOLDER OR HOLDERS INCLUDED IN THIS NOTICE + BE LIABLE FOR ANY CLAIM, OR ANY SPECIAL INDIRECT OR CONSEQUENTIAL DAMAGES, + OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, + WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, + ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THE DATA + FILES OR SOFTWARE. + . + Except as contained in this notice, the name of a copyright holder shall + not be used in advertising or otherwise to promote the sale, use or other + dealings in these Data Files or Software without prior written + authorization of the copyright holder. + +Files: vendor/unicode_categories-0.1.1/* +Copyright: 2015 The unicode-categories Developers +License: Apache-2.0 or Expat + Copyright (c) 2015 The unicode-categories Developers + . + 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. + +Files: vendor/untrusted-0.9.0/* +Copyright: the authors of untrusted +License: ISC + // Copyright 2015-2016 Brian Smith. + // + // Permission to use, copy, modify, and/or distribute this software for any + // purpose with or without fee is hereby granted, provided that the above + // copyright notice and this permission notice appear in all copies. + // + // THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHORS DISCLAIM ALL WARRANTIES + // WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF + // MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR + // ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + // WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN + // ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF + // OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +Files: vendor/uuid-1.26.1/* +Copyright: 2014 The Rust Project Developers + 2018 Ashley Mannix, Christopher Armstrong, Dylan DPC, Hunar Roop Kahlon +License: Apache-2.0 or Expat + Copyright (c) 2014 The Rust Project Developers + Copyright (c) 2018 Ashley Mannix, Christopher Armstrong, Dylan DPC, Hunar Roop Kahlon + . + 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. + +Files: vendor/want-0.3.1/* +Copyright: 2018-2019 Sean McArthur +License: Expat + Copyright (c) 2018-2019 Sean McArthur + . + 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. + +Files: vendor/webpki-roots-1.0.9/* +Copyright: the authors of webpki-roots +License: CDLA-Permissive-2.0 + # Community Data License Agreement - Permissive - Version 2.0 + . + This is the Community Data License Agreement - Permissive, Version + 2.0 (the "agreement"). Data Provider(s) and Data Recipient(s) agree + as follows: + . + ## 1. Provision of the Data + . + 1.1. A Data Recipient may use, modify, and share the Data made + available by Data Provider(s) under this agreement if that Data + Recipient follows the terms of this agreement. + . + 1.2. This agreement does not impose any restriction on a Data + Recipient's use, modification, or sharing of any portions of the + Data that are in the public domain or that may be used, modified, + or shared under any other legal exception or limitation. + . + ## 2. Conditions for Sharing Data + . + 2.1. A Data Recipient may share Data, with or without modifications, so + long as the Data Recipient makes available the text of this agreement + with the shared Data. + . + ## 3. No Restrictions on Results + . + 3.1. This agreement does not impose any restriction or obligations + with respect to the use, modification, or sharing of Results. + . + ## 4. No Warranty; Limitation of Liability + . + 4.1. All Data Recipients receive the Data subject to the following + terms: + . + THE DATA IS PROVIDED ON AN "AS IS" BASIS, WITHOUT REPRESENTATIONS, + 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. + . + NO DATA PROVIDER SHALL HAVE ANY LIABILITY FOR ANY DIRECT, INDIRECT, + INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING + WITHOUT LIMITATION LOST PROFITS), HOWEVER CAUSED AND ON ANY THEORY OF + LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING + NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE DATA OR RESULTS, + EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. + . + ## 5. Definitions + . + 5.1. "Data" means the material received by a Data Recipient under + this agreement. + . + 5.2. "Data Provider" means any person who is the source of Data + provided under this agreement and in reliance on a Data Recipient's + agreement to its terms. + . + 5.3. "Data Recipient" means any person who receives Data directly + or indirectly from a Data Provider and agrees to the terms of this + agreement. + . + 5.4. "Results" means any outcome obtained by computational analysis + of Data, including for example machine learning models and models' + insights. + +Files: vendor/whoami-2.1.3/* +Copyright: the authors of whoami +License: Apache-2.0 or BSL-1.0 or Expat + MIT License + . + 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. + +Files: vendor/winnow-0.7.15/* + vendor/winnow-1.0.4/* +Copyright: the authors of winnow +License: Expat + 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. + +Files: vendor/x509-cert-0.2.5/* +Copyright: 2021 The RustCrypto Project Developers +License: Apache-2.0 or Expat + Copyright (c) 2021 The RustCrypto Project Developers + . + 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. + +Files: vendor/zbus-5.19.0/* + vendor/zbus_macros-5.19.0/* + vendor/zbus_names-4.3.4/* + vendor/zvariant-5.15.0/* + vendor/zvariant_derive-5.15.0/* +Copyright: 2024 Zeeshan Ali Khan & zbus contributors +License: Expat + Copyright (c) 2024 Zeeshan Ali Khan & zbus contributors + . + 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. + +Files: vendor/zcheapstr-1.1.0/* +Copyright: 2026 Zeeshan Ali Khan & zcheapstr contributors +License: Expat + Copyright (c) 2026 Zeeshan Ali Khan & zcheapstr contributors + . + 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. + +Files: vendor/zerocopy-0.8.57/* + vendor/zerocopy-derive-0.8.57/* +Copyright: 2023 The Fuchsia Authors +License: Apache-2.0 or BSD-2-Clause or Expat + Copyright 2023 The Fuchsia Authors + . + 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. + +Files: vendor/zeroize-1.9.0/* +Copyright: 2018-2026 The RustCrypto Project Developers +License: Apache-2.0 or Expat + Copyright (c) 2018-2026 The RustCrypto Project Developers + . + 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. + +Files: vendor/zip-8.6.0/* +Copyright: 2014 Mathijs van de Nes +License: Expat + The MIT License (MIT) + . + Copyright (c) 2014 Mathijs van de Nes + . + 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. + +Files: vendor/zlib-rs-0.6.7/* +Copyright: 2024 Trifecta Tech Foundation +License: Zlib + (C) 2024 Trifecta Tech Foundation + . + This software is provided 'as-is', without any express or implied + warranty. In no event will the authors be held liable for any damages + arising from the use of this software. + . + Permission is granted to anyone to use this software for any purpose, + including commercial applications, and to alter it and redistribute it + freely, subject to the following restrictions: + . + 1. The origin of this software must not be misrepresented; you must not + claim that you wrote the original software. If you use this software + in a product, an acknowledgment in the product documentation would be + appreciated but is not required. + . + 2. Altered source versions must be plainly marked as such, and must not be + misrepresented as being the original software. + . + 3. This notice may not be removed or altered from any source distribution. + +Files: vendor/zopfli-0.8.3/* +Copyright: 2011 Google Inc. +License: Apache-2.0 + On Debian systems, the complete text of the Apache License, Version 2.0 + can be found in "/usr/share/common-licenses/Apache-2.0". + . + APPENDIX: How to apply the Apache License to your work. + . + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + . + Copyright 2011 Google Inc. + . + 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. diff --git a/linux/build-aux/debian/rules b/linux/build-aux/debian/rules new file mode 100755 index 0000000000..384c395658 --- /dev/null +++ b/linux/build-aux/debian/rules @@ -0,0 +1,48 @@ +#!/usr/bin/make -f + +include /usr/share/dpkg/buildopts.mk + +# rustc-1.97 and cargo-1.97 install rustc, rustdoc and cargo here, and +# Meson looks for cargo on PATH only. 1.97 is the workspace rust-version. +export PATH := /usr/lib/rust-1.97/bin:$(PATH) + +# Launchpad builds have no network. The crates come from the vendor +# component tarball, which dpkg-source unpacks into vendor/. +export CARGO_NET_OFFLINE = true + +# Cargo starts one rustc per CPU unless told otherwise, and the largest +# optimized crates take up to 3 GB each, so the jobs follow the memory as +# well as DEB_BUILD_OPTIONS: one per 2 GiB. +memory_jobs := $(shell awk '/^MemAvailable:/ { jobs = int($$2 / 2097152); print (jobs < 1 ? 1 : jobs) }' /proc/meminfo) +parallel_jobs := $(or $(DEB_BUILD_OPTION_PARALLEL),1) +export CARGO_BUILD_JOBS := $(shell if [ $(memory_jobs) -lt $(parallel_jobs) ]; then echo $(memory_jobs); else echo $(parallel_jobs); fi) + +# With the release profile's fat LTO, rustc 1.97 takes more than 7 GB to +# link tablepro and an 8 GB machine kills it. With thin LTO the whole build +# stays under 4 GB. The .rpm does the same. +export CARGO_PROFILE_RELEASE_LTO = thin +# The release profile carries no debug info, which leaves the dbgsym +# package empty; line tables are enough to symbolize a backtrace. +export CARGO_PROFILE_RELEASE_DEBUG = line-tables-only +export DEB_BUILD_MAINT_OPTIONS = hardening=+all + +%: + dh $@ --buildsystem=meson + +# dh_clean deletes *.orig files, and every vendored crate ships a +# Cargo.toml.orig that its .cargo-checksum.json lists. +override_dh_clean: + dh_clean -Xvendor/ + +# Meson runs cargo from the build directory under this tree, and Cargo +# reads .cargo/config.toml from the directories above where it runs. +override_dh_auto_configure: + mkdir -p .cargo + cp vendor/cargo-config.toml .cargo/config.toml + dh_auto_configure -- -Dprofile=default -Dkerberos=true + +# The gtk suite opens windows on a display and owns D-Bus names. +override_dh_auto_test: +ifeq (,$(filter nocheck,$(DEB_BUILD_OPTIONS) $(DEB_BUILD_PROFILES))) + xvfb-run -a dbus-run-session -- dh_auto_test +endif diff --git a/linux/build-aux/debian/source/format b/linux/build-aux/debian/source/format new file mode 100644 index 0000000000..163aaf8d82 --- /dev/null +++ b/linux/build-aux/debian/source/format @@ -0,0 +1 @@ +3.0 (quilt) diff --git a/linux/build-aux/debian/source/lintian-overrides b/linux/build-aux/debian/source/lintian-overrides new file mode 100644 index 0000000000..c4edf6306c --- /dev/null +++ b/linux/build-aux/debian/source/lintian-overrides @@ -0,0 +1,3 @@ +# cargo vendor ships each crate whole. These are the Windows test fixtures +# of libloading, which the Linux build never reads. +source-contains-prebuilt-windows-binary [vendor/libloading-*/tests/nagisa*.dll] diff --git a/linux/build-aux/flatpak/app.tablepro.TablePro.Devel.json b/linux/build-aux/flatpak/app.tablepro.TablePro.Devel.json new file mode 100644 index 0000000000..95f2470097 --- /dev/null +++ b/linux/build-aux/flatpak/app.tablepro.TablePro.Devel.json @@ -0,0 +1,69 @@ +{ + "id": "app.tablepro.TablePro.Devel", + "runtime": "org.gnome.Platform", + "runtime-version": "51", + "sdk": "org.gnome.Sdk", + "sdk-extensions": [ + "org.freedesktop.Sdk.Extension.rust-stable", + "org.freedesktop.Sdk.Extension.llvm22" + ], + "command": "tablepro", + "finish-args": [ + "--share=ipc", + "--share=network", + "--socket=fallback-x11", + "--socket=wayland", + "--socket=ssh-auth", + "--device=dri", + "--filesystem=home", + "--filesystem=/media", + "--filesystem=/run/media", + "--filesystem=/mnt", + "--filesystem=/run/.heim_org.h5l.kcm-socket", + "--env=KRB5_CONFIG=/app/etc/krb5.conf" + ], + "build-options": { + "append-path": "/usr/lib/sdk/rust-stable/bin:/usr/lib/sdk/llvm22/bin", + "prepend-ld-library-path": "/usr/lib/sdk/llvm22/lib", + "build-args": [ + "--share=network" + ], + "env": { + "LIBCLANG_PATH": "/usr/lib/sdk/llvm22/lib" + }, + "test-args": [ + "--socket=x11", + "--share=network" + ] + }, + "cleanup": [ + "/share/man" + ], + "modules": [ + { + "name": "tablepro", + "buildsystem": "meson", + "run-tests": true, + "config-opts": [ + "-Dprofile=development", + "-Dflatpak=true" + ], + "sources": [ + { + "type": "dir", + "path": "../../", + "skip": [ + ".flatpak-builder", + "_build", + "_flatpak", + "_packages", + "flatpak_app", + "repo", + "target", + "build-aux/flatpak/.flatpak-builder" + ] + } + ] + } + ] +} diff --git a/linux/build-aux/flatpak/krb5.conf b/linux/build-aux/flatpak/krb5.conf new file mode 100644 index 0000000000..8ca0765bb4 --- /dev/null +++ b/linux/build-aux/flatpak/krb5.conf @@ -0,0 +1,7 @@ +[libdefaults] + default_ccache_name = KCM: + kcm_socket = /run/.heim_org.h5l.kcm-socket + dns_lookup_realm = true + dns_lookup_kdc = true + dns_canonicalize_hostname = fallback + rdns = false diff --git a/linux/build-aux/flatpak/lint-exceptions.json b/linux/build-aux/flatpak/lint-exceptions.json new file mode 100644 index 0000000000..7d54cd998b --- /dev/null +++ b/linux/build-aux/flatpak/lint-exceptions.json @@ -0,0 +1,6 @@ +{ + "app.tablepro.TablePro.Devel": [ + "finish-args-home-filesystem-access", + "finish-args-has-socket-ssh-auth" + ] +} diff --git a/linux/build-aux/meson.build b/linux/build-aux/meson.build new file mode 100644 index 0000000000..81968e9c9d --- /dev/null +++ b/linux/build-aux/meson.build @@ -0,0 +1,42 @@ +python = find_program('python3', required: false) + +# Keeps __pycache__ out of the source tree. +python_env = environment() +python_env.set('PYTHONDONTWRITEBYTECODE', '1') + +if python.found() + test( + 'packaging-scripts', + python, + args: ['-m', 'unittest', 'discover', '-s', meson.current_source_dir() / 'tests'], + env: python_env, + suite: 'packaging', + ) + + test( + 'license-metadata', + python, + args: [ + meson.current_source_dir() / 'packaging' / 'license_metadata.py', + 'check', + '--licenses', meson.project_source_root() / 'data' / 'licenses' / 'third-party.json', + '--license-file', meson.project_source_root() / 'LICENSE', + '--copyright', meson.current_source_dir() / 'debian' / 'copyright', + '--spec', meson.current_source_dir() / 'rpm' / 'tablepro.spec', + ], + env: python_env, + suite: 'packaging', + ) + + test( + 'release-metadata', + python, + args: [ + meson.current_source_dir() / 'packaging' / 'check_packaging.py', + '--source-root', meson.project_source_root(), + '--version', meson.project_version(), + ], + env: python_env, + suite: 'packaging', + ) +endif diff --git a/linux/build-aux/packaging/add-rust-toolchain-ppa.sh b/linux/build-aux/packaging/add-rust-toolchain-ppa.sh new file mode 100755 index 0000000000..9c54149b85 --- /dev/null +++ b/linux/build-aux/packaging/add-rust-toolchain-ppa.sh @@ -0,0 +1,38 @@ +#!/bin/sh +# Makes rustc-X.Y and cargo-X.Y installable the way Launchpad finds them +# for the PPA build: from the series' own archive when it has them, and +# otherwise from ppa:rust-toolchain/staging, which the TablePro PPA +# depends on. Run as root in an Ubuntu container; installs nothing. +# Usage: add-rust-toolchain-ppa.sh +set -eu + +if [ $# -ne 1 ]; then + echo "usage: add-rust-toolchain-ppa.sh " >&2 + exit 2 +fi +here="$(cd "$(dirname "$0")" && pwd)" + +apt-get update +if apt-cache show "rustc-$1" >/dev/null 2>&1; then + exit 0 +fi + +# The key is committed, so no run trusts a key it fetched. apt checks +# InRelease against it, which makes plain HTTP as safe as the archive's +# own sources, and the base image has no CA bundle for HTTPS. +# shellcheck source=/dev/null +. /etc/os-release +mkdir -p /etc/apt/keyrings +cp "$here/rust-toolchain-staging.asc" /etc/apt/keyrings/rust-toolchain-staging.asc +cat > /etc/apt/sources.list.d/rust-toolchain-staging.sources </dev/null 2>&1; then + echo "add-rust-toolchain-ppa.sh: neither $VERSION_CODENAME nor ppa:rust-toolchain/staging has rustc-$1" >&2 + exit 1 +fi diff --git a/linux/build-aux/packaging/build-deb.sh b/linux/build-aux/packaging/build-deb.sh new file mode 100755 index 0000000000..ac457c0c6d --- /dev/null +++ b/linux/build-aux/packaging/build-deb.sh @@ -0,0 +1,103 @@ +#!/bin/sh +# Builds the binary packages from a source package the way Launchpad does, +# and checks the source and binary packages with lintian. Each step runs +# in a fresh container of the source's Ubuntu series: +# +# 1. with network, it downloads the build dependencies the .dsc declares +# from the archive and, where the archive lacks the toolchain, +# ppa:rust-toolchain/staging; +# 2. with no network, it installs only those, unpacks the source and runs +# every Meson suite inside dpkg-buildpackage, as a user that is not root; +# 3. it runs lintian, which fails on a warning. +# +# The steps share a container volume that is removed at the end, so the +# build never writes into a container's own layer. +# Usage: build-deb.sh +# CONTAINER_ENGINE picks docker (the default) or podman. +set -eu + +here="$(cd "$(dirname "$0")" && pwd)" + +download() { + rust="$(grep -o 'rustc-[0-9][0-9.]*' "$1" | head -n 1)" + "$here/add-rust-toolchain-ppa.sh" "${rust#rustc-}" + mkdir -p /build/debs/partial + apt-get build-dep -y --download-only -o Dir::Cache::Archives=/build/debs "$1" +} + +build() { + apt-get install -y --no-install-recommends /build/debs/*.deb + useradd --create-home builder + mkdir /build/work + chown builder /build/work + # runuser opens a PAM session, which gives the build the limits of a + # login that is not root, 1024 open files among them. Launchpad passes + # the builder's CPU count the same way as here. + status=0 + # shellcheck disable=SC2016 # the inner sh expands $1 + runuser -u builder -- env HOME=/home/builder DEB_BUILD_OPTIONS="parallel=$(nproc)" \ + sh -euc 'cd /build/work && dpkg-source -x "$1" src && cd src && dpkg-buildpackage -b -us -uc' sh "$1" || + status=$? + # Meson prints only the end of a failed suite's log, so the whole log + # is kept beside the packages. + if [ "$status" -ne 0 ]; then + find /build/work/src -path '*/meson-logs/testlog.txt' -exec cp {} /out/ \; + exit "$status" + fi + for file in /build/work/*.deb /build/work/*.ddeb /build/work/*.buildinfo /build/work/*.changes; do + if [ -e "$file" ]; then + cp "$file" /out/ + fi + done +} + +check() { + apt-get update + apt-get install -y --no-install-recommends lintian + version="$(basename "$1" _source.changes)" + lintian --fail-on error,warning --display-info "$1" /out/"$version"_*.changes +} + +if [ "${1:-}" = "--step" ]; then + export DEBIAN_FRONTEND=noninteractive TMPDIR=/build/tmp + mkdir -p "$TMPDIR" + chmod 1777 "$TMPDIR" + case "$2" in + download) download "$3" ;; + build) build "$3" ;; + check) check "$3" ;; + esac + exit 0 +fi + +if [ $# -ne 2 ]; then + echo "usage: build-deb.sh " >&2 + exit 2 +fi +engine="${CONTAINER_ENGINE:-docker}" +source_dir="$(cd "$(dirname "$1")" && pwd)" +dsc="$(basename "$1")" +changes="${dsc%.dsc}_source.changes" +series="$(sed -n 's/^Distribution: //p' "$source_dir/$changes")" +mkdir -p "$2" +out="$(cd "$2" && pwd)" + +volume="tablepro-deb-$$" +"$engine" volume create "$volume" >/dev/null +trap '"$engine" volume rm -f "$volume" >/dev/null' EXIT + +step() { + if [ "$1" = offline ]; then + shift + set -- --network none "ubuntu:$series" /packaging/build-deb.sh --step "$@" + else + set -- "ubuntu:$series" /packaging/build-deb.sh --step "$@" + fi + "$engine" run --rm -v "$volume:/build" -v "$here:/packaging:ro" -v "$source_dir:/source:ro" \ + -v "$out:/out" "$@" +} + +step download "/source/$dsc" +step offline build "/source/$dsc" +step check "/source/$changes" +ls -l "$out" diff --git a/linux/build-aux/packaging/build-packages.sh b/linux/build-aux/packaging/build-packages.sh new file mode 100755 index 0000000000..ddbc4df20d --- /dev/null +++ b/linux/build-aux/packaging/build-packages.sh @@ -0,0 +1,68 @@ +#!/bin/sh +# Builds the Ubuntu and Fedora packages from the committed tree the way CI +# does, each step in a fresh container: the source tarballs in +# ubuntu:26.04; the Debian source package for each Ubuntu series and its +# binaries, built offline by build-deb.sh; the .rpm in fedora:44; and each +# package installed and started in another fresh container. The steps +# write to a container volume that is removed at the end, and the packages +# land in . +# Usage: build-packages.sh deb|rpm|all [series...] +# The series default to resolute. CONTAINER_ENGINE picks docker (the +# default) or podman. +set -eu + +usage() { + echo "usage: build-packages.sh deb|rpm|all [series...]" >&2 + exit 2 +} + +engine="${CONTAINER_ENGINE:-docker}" +[ $# -ge 2 ] || usage +which="$1" +case "$which" in +deb | rpm | all) ;; +*) usage ;; +esac +mkdir -p "$2" +out="$(cd "$2" && pwd)" +shift 2 +if [ $# -eq 0 ]; then + set -- resolute +fi + +here="$(cd "$(dirname "$0")" && pwd)" +root="$(cd "$here/../.." && pwd)" +repo="$(git -C "$root" rev-parse --show-toplevel)" +git_dir="$(git -C "$root" rev-parse --path-format=absolute --git-common-dir)" + +volume="tablepro-packages-$$" +"$engine" volume create "$volume" >/dev/null +trap '"$engine" volume rm -f "$volume" >/dev/null' EXIT + +# Paths stay the same inside the containers, so a worktree's .git file +# still points at its repository. Not read-only: meson dist refreshes the +# git index before it archives. +run() { + image="$1" + shift + "$engine" run --rm -e TMPDIR=/build -v "$volume:/build" \ + -v "$repo:$repo" -v "$git_dir:$git_dir" -v "$out:$out" \ + -w "$root" "$image" "$@" +} + +run ubuntu:26.04 "$here/make-sources.sh" --install-deps "$out/sources" +dist="$(find "$out/sources" -name 'tablepro-*.tar.xz' ! -name '*-vendor.tar.xz')" +vendor="$(find "$out/sources" -name 'tablepro-*-vendor.tar.xz')" + +if [ "$which" != rpm ]; then + run ubuntu:26.04 "$here/build-source.sh" --install-deps "$dist" "$vendor" "$out/source" "$@" + for changes in "$out"/source/*_source.changes; do + series="$(sed -n 's/^Distribution: //p' "$changes")" + "$here/build-deb.sh" "${changes%_source.changes}.dsc" "$out/deb/$series" + run "ubuntu:$series" "$here/smoke-test.sh" "$(find "$out/deb/$series" -name 'tablepro_*.deb')" + done +fi +if [ "$which" != deb ]; then + run fedora:44 "$here/build-rpm.sh" "$dist" "$vendor" "$out/rpm" + run fedora:44 "$here/smoke-test.sh" "$(find "$out/rpm" -name 'tablepro-[0-9]*.rpm' ! -name '*.src.rpm')" +fi diff --git a/linux/build-aux/packaging/build-rpm.sh b/linux/build-aux/packaging/build-rpm.sh new file mode 100755 index 0000000000..623548178f --- /dev/null +++ b/linux/build-aux/packaging/build-rpm.sh @@ -0,0 +1,46 @@ +#!/bin/sh +# Builds the source and binary RPMs from the two release tarballs, as root +# in a fresh fedora:44, with only the BuildRequires the spec declares, and +# checks them with rpmlint. COPR builds the same spec in mock from the +# release (build-aux/rpm/copr-sources.sh). +# Usage: build-rpm.sh +set -eu + +if [ $# -ne 3 ]; then + echo "usage: build-rpm.sh " >&2 + exit 2 +fi +mkdir -p "$3" +out="$(cd "$3" && pwd)" + +name="$(basename "$1" .tar.xz)" +top="$(mktemp -d)" +trap 'rm -rf "$top"' EXIT +mkdir "$top/SOURCES" "$top/SPECS" +cp "$1" "$2" "$top/SOURCES/" + +# rpmlint reads the spec under en_US.UTF-8 and reports a missing locale as +# an error. +dnf install -y --setopt=install_weak_deps=False rpm-build rpmlint glibc-langpack-en tar xz + +# Outside SPECS, where rpmbuild --rebuild removes the spec it unpacks, so +# rpmlint still has this one to read. +spec="$top/tablepro.spec" +tar -xOf "$1" "$name/build-aux/rpm/tablepro.spec" > "$spec" +rpmbuild --define "_topdir $top" -bs "$spec" +srpm="$(find "$top/SRPMS" -name '*.src.rpm')" + +dnf builddep -y --setopt=install_weak_deps=False "$srpm" +# COPR builds with no network. Offline, Cargo fails here too unless it +# reads the vendored crates from the config %cargo_prep writes. +# Meson prints only the end of a failed suite's log, so the whole log is +# kept beside the packages. +if ! CARGO_NET_OFFLINE=true rpmbuild --define "_topdir $top" --rebuild "$srpm"; then + find "$top/BUILD" -path '*/meson-logs/testlog.txt' -exec cp {} "$out/" \; + exit 1 +fi + +cp "$srpm" "$top"/RPMS/*/*.rpm "$out/" +ls -l "$out" +# After the copy, so a lint failure keeps the packages to look at. +rpmlint --strict "$spec" "$srpm" "$top"/RPMS/*/*.rpm diff --git a/linux/build-aux/packaging/build-source.sh b/linux/build-aux/packaging/build-source.sh new file mode 100755 index 0000000000..9758b9d033 --- /dev/null +++ b/linux/build-aux/packaging/build-source.sh @@ -0,0 +1,98 @@ +#!/bin/sh +# Builds the Debian source package for each Ubuntu series named, unsigned, +# from the two release tarballs: the meson dist tarball is the orig tarball +# and the vendor tarball its vendor component. release-linux.yml signs the +# packages with debsign before it uploads them to the PPA. +# +# --install-deps first installs, as root in a throwaway ubuntu:26.04, the +# tools this needs. --revision counts uploads of one upstream version to +# the PPA, 1 when not given. --debian takes the packaging from that +# directory instead of the tarball's build-aux/debian, for a packaging fix +# to a released version. +# Usage: build-source.sh [--install-deps] [--revision N] [--debian DIR] ... +set -eu + +usage() { + echo "usage: build-source.sh [--install-deps] [--revision N] [--debian DIR] ..." >&2 + exit 2 +} + +revision=1 +debian="" +while [ $# -gt 0 ]; do + case "$1" in + --install-deps) + export DEBIAN_FRONTEND=noninteractive + apt-get update + apt-get install -y --no-install-recommends devscripts distro-info dpkg-dev xz-utils + shift + ;; + --revision) + [ $# -ge 2 ] || usage + revision="$2" + shift 2 + ;; + --debian) + [ $# -ge 2 ] || usage + debian="$(cd "$2" && pwd)" + shift 2 + ;; + *) break ;; + esac +done +[ $# -ge 4 ] || usage +case "$revision" in + "" | *[!0-9]* | 0*) usage ;; +esac +dist="$(cd "$(dirname "$1")" && pwd)/$(basename "$1")" +vendor="$(cd "$(dirname "$2")" && pwd)/$(basename "$2")" +mkdir -p "$3" +out="$(cd "$3" && pwd)" +shift 3 + +upstream="$(basename "$dist" .tar.xz)" +upstream="${upstream#tablepro-}" +# Debian sorts a pre-release below its release through a tilde, so +# 0.2.0-rc.1 becomes 0.2.0~rc.1. +debian_upstream="$(printf '%s\n' "$upstream" | sed 's/-/~/')" + +work="$(mktemp -d)" +trap 'rm -rf "$work"' EXIT +cp "$dist" "$work/tablepro_$debian_upstream.orig.tar.xz" +cp "$vendor" "$work/tablepro_$debian_upstream.orig-vendor.tar.xz" + +# The tree dpkg-source would unpack from the two tarballs, so it finds no +# upstream change to record. +src="$work/tablepro-$debian_upstream" +mkdir "$src" +tar -xf "$dist" -C "$src" --strip-components=1 +tar -xf "$vendor" -C "$src" +cp -R "${debian:-$src/build-aux/debian}" "$src/debian" +maintainer="$(sed -n 's/^Maintainer: //p' "$src/debian/control")" +if [ "$revision" = 1 ]; then + message="New upstream release. Notes: https://github.com/TableProApp/TablePro/blob/linux-v$upstream/linux/CHANGELOG.md" +else + message="Packaging update of the linux-v$upstream release, upload $revision." +fi + +for series in "$@"; do + release="$(ubuntu-distro-info --series "$series" --release | cut -d ' ' -f 1)" + rm -f "$src/debian/changelog" + ( + cd "$src" + DEBEMAIL="$maintainer" dch --create --package tablepro \ + --newversion "$debian_upstream-0ppa$revision~$release" \ + --distribution "$series" --force-distribution --urgency medium \ + "$message" + # -sa ships the orig tarballs with every upload; Launchpad accepts + # the same files again for each series. + dpkg-buildpackage -S -sa -d -nc -us -uc -I -i + ) +done + +for file in "$work"/*; do + if [ -f "$file" ]; then + cp "$file" "$out/" + fi +done +ls -l "$out" diff --git a/linux/build-aux/packaging/check_packaging.py b/linux/build-aux/packaging/check_packaging.py new file mode 100644 index 0000000000..fa6cfe11db --- /dev/null +++ b/linux/build-aux/packaging/check_packaging.py @@ -0,0 +1,255 @@ +#!/usr/bin/env python3 +# Checks that the version Meson builds is the one the metainfo releases, the +# RPM spec packages and the man page names, that the spec collects +# translations exactly when po/LINGUAS ships one, and that the Debian +# package builds with the Rust the workspace names as its rust-version. The +# release workflow adds the tag and its date, and then CHANGELOG.md must +# carry that version's notes, unless it is a pre-release, and the spec must +# be the version's first package release. +# +# Once CHANGELOG.md has a heading for the version, the release commit has +# set its dates, and the heading, the spec's %changelog entry and the man +# page must carry the metainfo's release date. + +import argparse +import re +import sys +import tomllib +import xml.etree.ElementTree as ElementTree +from datetime import date, datetime +from pathlib import Path + +METAINFO = Path("data/app.tablepro.TablePro.metainfo.xml.in.in") +SPEC = Path("build-aux/rpm/tablepro.spec") +LINGUAS = Path("po/LINGUAS") +CHANGELOG = Path("CHANGELOG.md") +CARGO = Path("Cargo.toml") +CONTROL = Path("build-aux/debian/control") +RULES = Path("build-aux/debian/rules") +MAN = Path("data/tablepro.1") + +# X.Y.Z, or a pre-release such as X.Y.Z-rc.1 with no '-' after the first, +# so the package version below stays a plain substitution. +RELEASE_VERSION = re.compile(r"^\d+\.\d+\.\d+(?:-[0-9A-Za-z]+(?:\.[0-9A-Za-z]+)*)?$") +SPEC_VERSION = re.compile(r"^Version:\s*(\S+)\s*$", re.MULTILINE) +SPEC_CHANGELOG = re.compile(r"^%changelog\s*\n\*[^\n]*\s-\s(\S+)\s*$", re.MULTILINE) +SPEC_RELEASE = re.compile(r"^Release:\s*(\S+)\s*$", re.MULTILINE) +SPEC_ENTRY = re.compile(r"^\*\s+(\w{3}\s+\w{3}\s+\d{1,2}\s+\d{4})\s.*\s-\s(\S+)\s*$", re.MULTILINE) +FIRST_PACKAGE_RELEASE = "1%{?dist}" +MAN_TITLE = re.compile(r'^\.TH\s+\S+\s+\S+\s+"?([^"\s]*)"?\s+"TablePro ([^"]*)"', re.MULTILINE) +FIND_LANG = re.compile(r"^%find_lang\b", re.MULTILINE) +CONTROL_TOOLCHAIN = re.compile(r"\b(cargo|rustc)-(\d+\.\d+)\b") +RULES_TOOLCHAIN = re.compile(r"/usr/lib/rust-(\d+\.\d+)/bin\b") + + +def package_version(version): + # RPM forbids '-' in Version and git forbids '~' in a tag. Debian and + # Fedora both sort X.Y.Z~rc.1 below X.Y.Z. + return version.replace("-", "~", 1) + + +def newest_release(metainfo): + releases = ElementTree.parse(metainfo).getroot().find("releases") + if releases is None: + return None + return releases.find("release") + + +def linguas(path): + languages = [] + for line in path.read_text(encoding="utf-8").splitlines(): + line = line.split("#", 1)[0].strip() + languages.extend(line.split()) + return languages + + +def release_notes(changelog, version): + notes = [] + heading = f"## [{version}]" + inside = False + for line in changelog.read_text(encoding="utf-8").splitlines(): + if line.startswith("## ["): + inside = line.startswith(heading) + elif inside: + notes.append(line) + return "\n".join(notes).strip() + + +def release_heading_date(changelog, version): + """The date on the version's heading: None without a heading, "" when it has no date.""" + # towncrier links the version to its GitHub release. + heading = re.compile( + rf"^## \[{re.escape(version)}\](?:\([^)\s]*\))?(?: - (\d{{4}}-\d{{2}}-\d{{2}}))?\s*$" + ) + for line in changelog.read_text(encoding="utf-8").splitlines(): + match = heading.match(line) + if match: + return match.group(1) or "" + return None + + +def spec_entry_date(spec, release): + """The date of the %changelog entry for release, None without one, "" when the date is bogus.""" + _, _, log = spec.partition("\n%changelog") + for match in SPEC_ENTRY.finditer(log): + if match.group(2) != release: + continue + written = " ".join(match.group(1).split()) + try: + parsed = datetime.strptime(written, "%a %b %d %Y").date() + except ValueError: + return "" + # rpmbuild warns about a weekday that does not match the date, + # and strptime ignores it. + if parsed.strftime("%a") != written.split()[0]: + return "" + return parsed.isoformat() + return None + + +def man_title(root): + """The date and version on the man page's .TH line, or None without one.""" + match = MAN_TITLE.search((root / MAN).read_text(encoding="utf-8")) + return (match.group(1), match.group(2)) if match else None + + +def release_date_problems(root, version, spec, released, spec_opens_with_version): + found = [] + title = man_title(root) + if title is not None and title[0] != released: + found.append(f"{MAN} is dated {title[0] or 'nothing'}, the metainfo {released}") + heading = release_heading_date(root / CHANGELOG, version) + if heading == "": + found.append(f"{CHANGELOG} has no YYYY-MM-DD date on ## [{version}]") + elif heading is not None and heading != released: + found.append(f"{CHANGELOG} dates {version} {heading}, the metainfo {released}") + + # A spec whose %changelog does not open with the version is reported + # once, by the version check. + if not spec_opens_with_version: + return found + first = f"{package_version(version)}-1" + entry = spec_entry_date(spec, first) + if entry is None: + found.append(f"{SPEC} has no %changelog entry for {first}") + elif entry == "": + found.append(f"the %changelog entry for {first} in {SPEC} has no valid date") + elif entry != released: + found.append(f"the %changelog entry for {first} in {SPEC} is dated {entry}, the metainfo {released}") + return found + + +def toolchain_problems(root): + package = tomllib.loads((root / CARGO).read_text(encoding="utf-8")).get("workspace", {}).get("package", {}) + rust_version = package.get("rust-version") + if rust_version is None: + return [f"{CARGO} names no workspace rust-version"] + found = [] + expected = [("cargo", rust_version), ("rustc", rust_version)] + named = sorted(set(CONTROL_TOOLCHAIN.findall((root / CONTROL).read_text(encoding="utf-8")))) + if named != expected: + names = ", ".join(f"{name}-{number}" for name, number in named) or "no versioned rustc or cargo" + found.append( + f"{CONTROL} build-depends on {names}, not cargo-{rust_version} and rustc-{rust_version}, " + f"the rust-version in {CARGO}" + ) + paths = sorted(set(RULES_TOOLCHAIN.findall((root / RULES).read_text(encoding="utf-8")))) + if paths != [rust_version]: + found.append(f"{RULES} must put /usr/lib/rust-{rust_version}/bin on PATH, and only that toolchain") + return found + + +def problems(root, version, tag=None, tag_date=None): + found = [] + if not RELEASE_VERSION.match(version): + found.append(f"version {version} is neither X.Y.Z nor a pre-release such as X.Y.Z-rc.1") + if tag is not None: + if tag != f"linux-v{version}": + found.append(f"tag {tag} does not name version {version}; expected linux-v{version}") + # A pre-release keeps the fragments; its notes are a towncrier draft of them. + if "-" not in version and not release_notes(root / CHANGELOG, version): + found.append(f"{CHANGELOG} has no notes under ## [{version}]") + + packaged = package_version(version) + spec = (root / SPEC).read_text(encoding="utf-8") + changelog = SPEC_CHANGELOG.search(spec) + spec_opens_with_version = changelog is not None and changelog.group(1).startswith(f"{packaged}-") + release = newest_release(root / METAINFO) + if release is None: + found.append(f"{METAINFO} has no ") + else: + if release.get("version") != version: + found.append( + f"the newest release in {METAINFO} is {release.get('version')}, not {version}" + ) + released = release.get("date", "") + dated = True + try: + date.fromisoformat(released) + except ValueError: + dated = False + found.append(f"the newest release in {METAINFO} has no YYYY-MM-DD date") + if tag_date is not None and released != tag_date: + found.append( + f"the newest release in {METAINFO} is dated {released or 'nothing'}, " + f"the tagged commit {tag_date}" + ) + released_at_commit = tag is not None or release_heading_date(root / CHANGELOG, version) is not None + if dated and release.get("version") == version and released_at_commit: + found.extend(release_date_problems(root, version, spec, released, spec_opens_with_version)) + + versions = SPEC_VERSION.findall(spec) + if versions != [packaged]: + found.append(f"{SPEC} packages version {', '.join(versions) or 'nothing'}, not {packaged}") + if not spec_opens_with_version: + found.append(f"the first %changelog entry in {SPEC} is not for {packaged}") + # A packaging fix after the release raises Release on the branch; a + # new version starts again from 1. + if tag is not None: + releases = SPEC_RELEASE.findall(spec) + if releases != [FIRST_PACKAGE_RELEASE]: + found.append( + f"{SPEC} has Release {', '.join(releases) or 'nothing'}, not {FIRST_PACKAGE_RELEASE} " + f"for a new version" + ) + + title = man_title(root) + if title is None: + found.append(f'{MAN} has no .TH line naming "TablePro X.Y.Z"') + elif title[1] != version: + found.append(f"{MAN} names version {title[1] or 'nothing'}, not {version}") + + if bool(linguas(root / LINGUAS)) != bool(FIND_LANG.search(spec)): + found.append( + f"{SPEC} must run %find_lang and list %{{name}}.lang exactly when {LINGUAS} names a language" + ) + found.extend(toolchain_problems(root)) + return found + + +def main(argv=None): + parser = argparse.ArgumentParser( + description="Check that the release metadata agree with the version Meson builds." + ) + parser.add_argument("--source-root", required=True, type=Path) + parser.add_argument("--version", required=True) + parser.add_argument("--tag") + parser.add_argument("--tag-date") + parser.add_argument("--notes", type=Path, help="write the version's CHANGELOG.md notes here") + args = parser.parse_args(argv) + + try: + found = problems(args.source_root, args.version, args.tag, args.tag_date) + if args.notes is not None and not found: + args.notes.write_text( + release_notes(args.source_root / CHANGELOG, args.version) + "\n", encoding="utf-8" + ) + except (OSError, ElementTree.ParseError, tomllib.TOMLDecodeError) as error: + found = [str(error)] + for problem in found: + print(f"check_packaging.py: {problem}", file=sys.stderr) + return 1 if found else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/linux/build-aux/packaging/fetch-release-sources.sh b/linux/build-aux/packaging/fetch-release-sources.sh new file mode 100755 index 0000000000..1baa2a25f3 --- /dev/null +++ b/linux/build-aux/packaging/fetch-release-sources.sh @@ -0,0 +1,48 @@ +#!/bin/sh +# Downloads the source and vendor tarballs of a published Linux release into +# , checked against the release's SHA256SUMS. A packaging fix +# builds from these rather than from a new meson dist: Launchpad refuses an +# orig tarball whose bytes differ from the one it already has. +# +# --install-deps first installs curl, as root in a throwaway ubuntu:26.04. +# Usage: fetch-release-sources.sh [--install-deps] +set -eu + +usage() { + echo "usage: fetch-release-sources.sh [--install-deps] " >&2 + exit 2 +} + +if [ "${1:-}" = "--install-deps" ]; then + shift + export DEBIAN_FRONTEND=noninteractive + apt-get update + apt-get install -y --no-install-recommends ca-certificates curl +fi +[ $# -eq 3 ] || usage +repository="$1" +tag="$2" +case "$repository" in + */*/* | *[!0-9A-Za-z._/-]* | /* | */) usage ;; + */*) ;; + *) usage ;; +esac +version="${tag#linux-v}" +case "$version" in + "$tag" | "" | *[!0-9.]*) + echo "fetch-release-sources.sh: not the tag of a Linux release: $tag" >&2 + exit 2 + ;; +esac +mkdir -p "$3" +cd "$3" + +url="https://github.com/$repository/releases/download/$tag" +for file in "tablepro-$version.tar.xz" "tablepro-$version-vendor.tar.xz" SHA256SUMS; do + curl --fail --silent --show-error --location --proto '=https' --output "$file" "$url/$file" +done +# Both tarballs were just downloaded, so --ignore-missing skips only the +# release's other files. +sha256sum --check --strict --ignore-missing SHA256SUMS +rm SHA256SUMS +ls -l diff --git a/linux/build-aux/packaging/license_metadata.py b/linux/build-aux/packaging/license_metadata.py new file mode 100644 index 0000000000..554a9259ec --- /dev/null +++ b/linux/build-aux/packaging/license_metadata.py @@ -0,0 +1,398 @@ +#!/usr/bin/env python3 +# Writes build-aux/debian/copyright and the RPM spec's License tag from +# cargo-about's data/licenses/third-party.json: the license expression each +# crate declares, and the text of the license TablePro uses it under. +# cargo-about templates cannot indent a license text into a deb822 field, +# hence a script. + +import argparse +import json +import re +import sys +import textwrap +import xml.etree.ElementTree as ElementTree +from pathlib import Path + +DEP5_FORMAT = "https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/" +UPSTREAM_LICENSE = "AGPL-3.0-or-later" +UPSTREAM_COPYRIGHT = "TablePro Authors" +METAINFO = Path("data/app.tablepro.TablePro.metainfo.xml.in.in") + +APACHE_TITLE = re.compile(r"^\s*Apache License\s*$") +APACHE_TERMS = "TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION" +APACHE_END = "END OF TERMS AND CONDITIONS" +# lintian rejects a copyright file that carries the Apache 2.0 terms, which +# every Debian system has under common-licenses. +APACHE_POINTER = [ + "On Debian systems, the complete text of the Apache License, Version 2.0", + 'can be found in "/usr/share/common-licenses/Apache-2.0".', +] +COMMON_LICENSES = {"CC0-1.0": "CC0 1.0 Universal license"} + +NOTICE = re.compile(r"^(?:copyright\b|©|\(c\)\s*\d)", re.IGNORECASE) +NOTICE_PREFIX = re.compile(r"^(?:copyright\b\s*)?(?:\(c\)|©)?\s*", re.IGNORECASE) +NOT_A_NOTICE = re.compile( + r"\[(?:yyyy|year)\]|\{yyyy\}|" + r"|^copyright\s+(?:notices?|holders?|owners?|license|and\s+permission|statement)\b" + r"|:$", + re.IGNORECASE, +) + +# DEP-5 asks for the short name Expat where an MIT text is this one. The +# holders are the part SPDX lets vary. +EXPAT_TERMS = ( + '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." +) +EXPAT = re.compile( + r"\s+".join(map(re.escape, EXPAT_TERMS.split())).replace( + r"AUTHORS\s+OR\s+COPYRIGHT\s+HOLDERS", r"[^.]+?" + ), + re.IGNORECASE, +) +# The X11 license is the Expat text with this clause added. +X11_CLAUSE = re.compile(r"shall\s+not\s+be\s+used\s+in\s+advertising", re.IGNORECASE) + +SPEC_LICENSE = re.compile(r"^License:([ \t]+)(?:%\{shrink:\n(?:[^\n]*\n)*?\}|[^\n]*)$", re.MULTILINE) +EXPRESSION_TOKEN = re.compile(r"\(|\)|[^\s()]+") +LICENSE_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9.+-]*$") + + +class MetadataError(Exception): + pass + + +# An SPDX expression as ("license", id), ("with", id, exception), +# ("and", terms) or ("or", terms). +def parse_expression(text): + tokens = EXPRESSION_TOKEN.findall(text.replace("/", " OR ")) + position = 0 + + def peek(): + return tokens[position] if position < len(tokens) else None + + def take(): + nonlocal position + token = peek() + if token is None: + raise MetadataError(f"the license expression {text!r} ends early") + position += 1 + return token + + def operand(): + token = take() + if token == "(": + node = disjunction() + if take() != ")": + raise MetadataError(f"the license expression {text!r} has an unclosed parenthesis") + elif LICENSE_ID.match(token) and token.upper() not in {"AND", "OR", "WITH"}: + if token in {"Unknown", "Ignore"}: + raise MetadataError(f"cargo-about could not read the license expression {text!r}") + node = ("license", token) + else: + raise MetadataError(f"the license expression {text!r} has {token!r} where a license belongs") + if (peek() or "").upper() == "WITH": + take() + if node[0] != "license": + raise MetadataError(f"the license expression {text!r} applies WITH to an expression") + exception = take() + if not LICENSE_ID.match(exception): + raise MetadataError(f"the license expression {text!r} has {exception!r} where an exception belongs") + node = ("with", node[1], exception) + return node + + def chain(operator, part): + terms = [part()] + while (peek() or "").upper() == operator: + take() + terms.append(part()) + return combine(operator.lower(), terms) + + def conjunction(): + return chain("AND", operand) + + def disjunction(): + return chain("OR", conjunction) + + node = disjunction() + if peek() is not None: + raise MetadataError(f"the license expression {text!r} has {peek()!r} after its end") + return node + + +# Flat, each operand once and sorted, so "MIT OR Apache-2.0" and +# "Apache-2.0 OR MIT" are one term. +def combine(kind, terms): + flat = [] + for term in terms: + for part in term[1] if term[0] == kind else (term,): + if part not in flat: + flat.append(part) + if len(flat) == 1: + return flat[0] + return (kind, tuple(sorted(flat, key=sort_key))) + + +def sort_key(node): + return spdx(node).replace("(", "").replace(")", "").lower() + + +def spdx(node, nested=False): + if node[0] == "license": + return node[1] + if node[0] == "with": + return f"{node[1]} WITH {node[2]}" + joined = f" {node[0].upper()} ".join(spdx(term, nested=True) for term in node[1]) + return f"({joined})" if nested else joined + + +def is_expat(text): + text = text.replace("“", '"').replace("”", '"') + return bool(EXPAT.search(text)) and not X11_CLAUSE.search(text) + + +def dep5_id(license_id, expat): + if license_id == "MIT" and expat: + return "Expat" + for suffix, replacement in (("-or-later", "+"), ("-only", "")): + if license_id.endswith(suffix): + return license_id.removesuffix(suffix) + replacement + return license_id + + +def dep5(node, expat=False): + if node[0] == "license": + return dep5_id(node[1], expat) + if node[0] == "with": + # DEP-5 writes an exception as " with exception". + return f"{dep5_id(node[1], expat)} with {node[2].removesuffix('-exception')} exception" + if node[0] == "or": + for term in node[1]: + if term[0] == "and" and any(part[0] == "or" for part in term[1]): + raise MetadataError(f"DEP-5 cannot write {spdx(node)}") + return " or ".join(dep5(term, expat) for term in node[1]) + # "and" binds tighter than "or" in DEP-5 unless a comma precedes it. + separator = ", and " if any(term[0] == "or" for term in node[1]) else " and " + return separator.join(dep5(term, expat) for term in node[1]) + + +def load(path): + with open(path, encoding="utf-8") as handle: + data = json.load(handle) + return data["licenses"], data["crates"] + + +def metainfo_license(path): + found = ElementTree.parse(path).getroot().findtext("metadata_license") + if not found: + raise MetadataError(f"{path} has no metadata_license") + return found.strip() + + +def crate_texts(licenses, crates): + texts = {(crate["name"], crate["version"]): [] for crate in crates} + for index, license in enumerate(licenses): + for used_by in license["used_by"]: + key = (used_by["crate"]["name"], used_by["crate"]["version"]) + if key not in texts: + raise MetadataError(f"{key[0]} {key[1]} has a license text but no license expression") + if index not in texts[key]: + texts[key].append(index) + for (name, version), indices in texts.items(): + if not indices: + raise MetadataError(f"{name} {version} has a license expression but no license text") + return texts + + +def debian_text(text): + lines = text.split("\n") + if APACHE_TERMS not in text: + return lines + terms = next(i for i, line in enumerate(lines) if APACHE_TERMS in line) + titles = [i for i in range(terms) if APACHE_TITLE.match(lines[i])] + ends = [i for i in range(terms, len(lines)) if APACHE_END in lines[i]] + if not titles or not ends: + raise MetadataError("an Apache 2.0 text has no title or end line to cut at") + return lines[: titles[-1]] + APACHE_POINTER + lines[ends[0] + 1 :] + + +def notices(texts): + found = [] + for text in texts: + for line in text.split("\n"): + line = " ".join(line.split()) + if not NOTICE.match(line) or NOT_A_NOTICE.search(line): + continue + notice = NOTICE_PREFIX.sub("", line) + if notice and notice not in found: + found.append(notice) + return found + + +def unnamed_holders(crates): + names = ", ".join(dict.fromkeys(name for name, _ in crates)) + return textwrap.wrap(f"the authors of {names}", width=72, break_on_hyphens=False) + + +def field(name, lines): + first, *rest = lines + out = [f"{name}: {first}".rstrip()] + for line in rest: + line = line.expandtabs().replace("\f", "").rstrip() + out.append(f" {line}" if line else " .") + return out + + +def text_block(texts): + lines = [] + for text in texts: + body = debian_text(text) + while body and not body[0].strip(): + body.pop(0) + while body and not body[-1].strip(): + body.pop() + if lines: + lines.append("") + lines.extend(body) + return lines + + +def common_license_pointer(license_id): + return [ + f"On Debian systems, the complete text of the {COMMON_LICENSES[license_id]}", + f'can be found in "/usr/share/common-licenses/{license_id}".', + ] + + +def render_copyright(licenses, crates, upstream_text, metadata_license): + paragraphs = [ + field("Format", [DEP5_FORMAT]) + + field("Upstream-Name", ["TablePro"]) + + field("Upstream-Contact", ["https://github.com/TableProApp/TablePro/issues"]) + + field("Source", ["https://github.com/TableProApp/TablePro"]) + + field( + "Comment", + [ + "build-aux/packaging/license_metadata.py writes this file from", + "data/licenses/third-party.json. The vendor/ paragraphs cover the crates", + "linked into tablepro and tablepro-askpass. Each names every license", + "the crate offers and holds the text of the one TablePro uses, as the", + "crate ships it.", + ], + ), + field("Files", ["*"]) + + field("Copyright", [UPSTREAM_COPYRIGHT]) + + field("License", [dep5_id(UPSTREAM_LICENSE, False)] + text_block([upstream_text])), + ] + if metadata_license != UPSTREAM_LICENSE: + if metadata_license not in COMMON_LICENSES: + raise MetadataError(f"the metainfo is under {metadata_license}, which has no text here") + paragraphs.append( + field("Files", [METAINFO.as_posix()]) + + field("Copyright", [UPSTREAM_COPYRIGHT]) + + field("License", [dep5_id(metadata_license, False)] + common_license_pointer(metadata_license)) + ) + + expressions = {(crate["name"], crate["version"]): crate["license"] for crate in crates} + groups = {} + for crate, indices in crate_texts(licenses, crates).items(): + texts = [] + for index in indices: + if licenses[index]["text"] not in texts: + texts.append(licenses[index]["text"]) + mit = [licenses[index]["text"] for index in indices if licenses[index]["id"] == "MIT"] + expat = bool(mit) and all(is_expat(text) for text in mit) + synopsis = dep5(parse_expression(expressions[crate]), expat) + groups.setdefault((synopsis, tuple(texts)), []).append(crate) + + for (synopsis, texts), group in sorted(groups.items(), key=lambda item: sorted(item[1])[0]): + group = sorted(group) + paragraphs.append( + field("Files", [f"vendor/{name}-{version}/*" for name, version in group]) + + field("Copyright", notices(texts) or unnamed_holders(group)) + + field("License", [synopsis] + text_block(texts)) + ) + + return "\n\n".join("\n".join(paragraph) for paragraph in paragraphs) + "\n" + + +def rpm_license(crates, metadata_license): + upstream = [("license", UPSTREAM_LICENSE)] + if metadata_license != UPSTREAM_LICENSE: + upstream.append(("license", metadata_license)) + terms = [] + for crate in crates: + node = parse_expression(crate["license"]) + for term in node[1] if node[0] == "and" else (node,): + if term not in upstream and term not in terms: + terms.append(term) + return [spdx(term, nested=True) for term in upstream + sorted(terms, key=sort_key)] + + +def apply_spec_license(spec, terms): + if len(SPEC_LICENSE.findall(spec)) != 1: + raise MetadataError("the spec must have exactly one License tag") + lines = [f" {term} AND" for term in terms[:-1]] + [f" {terms[-1]}"] + block = "%{shrink:\n" + "\n".join(lines) + "\n}" + return SPEC_LICENSE.sub(lambda match: f"License:{match.group(1)}{block}", spec) + + +def expected(args): + licenses, crates = load(args.licenses) + upstream = Path(args.license_file).read_text(encoding="utf-8") + # LICENSE sits at the source root, beside data/. + metadata_license = metainfo_license(Path(args.license_file).parent / METAINFO) + spec = Path(args.spec).read_text(encoding="utf-8") + return { + Path(args.copyright): render_copyright(licenses, crates, upstream, metadata_license), + Path(args.spec): apply_spec_license(spec, rpm_license(crates, metadata_license)), + } + + +def main(argv=None): + parser = argparse.ArgumentParser( + description="Write, or check, the package license metadata generated from third-party.json." + ) + parser.add_argument("command", choices=["write", "check"]) + parser.add_argument("--licenses", required=True) + parser.add_argument("--license-file", required=True) + parser.add_argument("--copyright", required=True) + parser.add_argument("--spec", required=True) + args = parser.parse_args(argv) + + try: + outputs = expected(args) + except (OSError, KeyError, ValueError, ElementTree.ParseError, MetadataError) as error: + print(f"license_metadata.py: {error}", file=sys.stderr) + return 1 + + stale = [] + for path, content in outputs.items(): + current = path.read_text(encoding="utf-8") if path.exists() else None + if current == content: + continue + if args.command == "write": + path.write_text(content, encoding="utf-8") + else: + stale.append(path) + + for path in stale: + print( + f"license_metadata.py: {path} does not match data/licenses; " + "run build-aux/update-third-party-licenses.sh", + file=sys.stderr, + ) + return 1 if stale else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/linux/build-aux/packaging/make-sources.sh b/linux/build-aux/packaging/make-sources.sh new file mode 100755 index 0000000000..1a6ff0a6b3 --- /dev/null +++ b/linux/build-aux/packaging/make-sources.sh @@ -0,0 +1,67 @@ +#!/bin/sh +# Writes the two release source tarballs into : +# tablepro-X.Y.Z.tar.xz from meson dist, and tablepro-X.Y.Z-vendor.tar.xz, +# which holds vendor/ with every crate in Cargo.lock and +# vendor/cargo-config.toml, so the packages build offline. +# +# --install-deps first installs, as root in a throwaway ubuntu:26.04, git +# and the build dependencies in build-aux/debian/control, the toolchain +# from ppa:rust-toolchain/staging among them. +# Usage: make-sources.sh [--install-deps] +set -eu + +here="$(cd "$(dirname "$0")" && pwd)" +root="$(cd "$here/../.." && pwd)" + +container="" +if [ "${1:-}" = "--install-deps" ]; then + shift + container=1 + export DEBIAN_FRONTEND=noninteractive + apt-get update + apt-get install -y --no-install-recommends ca-certificates git xz-utils + # The checkout belongs to another user, which git refuses to read. + git config --global --add safe.directory '*' + rust="$(sed -n 's/^rust-version = "\(.*\)"$/\1/p' "$root/Cargo.toml")" + "$here/add-rust-toolchain-ppa.sh" "$rust" + apt-get build-dep -y --no-install-recommends -P nocheck "$root/build-aux" + PATH="/usr/lib/rust-$rust/bin:$PATH" +fi + +if [ $# -ne 1 ]; then + echo "usage: make-sources.sh [--install-deps] " >&2 + exit 2 +fi +mkdir -p "$1" +out="$(cd "$1" && pwd)" + +work="$(mktemp -d)" +trap 'rm -rf "$work"' EXIT +# In a container the crates go to the work directory, which build-packages.sh +# puts on the build volume, rather than the container's own layer. +if [ -n "$container" ]; then + export CARGO_HOME="$work/cargo-home" +fi + +meson setup "$work/build" "$root" +meson dist -C "$work/build" --no-tests --formats xztar +version="$(meson introspect --projectinfo "$work/build" \ + | python3 -c 'import json, sys; print(json.load(sys.stdin)["version"])')" +cp "$work/build/meson-dist/tablepro-$version.tar.xz" "$out/" + +# cargo vendor prints the source replacement on stdout, naming the +# directory as it was given, and may prune that directory. Run from its +# parent so the config says "vendor", and move the config in afterwards. +mkdir "$work/vendor-root" +( + cd "$work/vendor-root" + cargo vendor --locked --versioned-dirs --manifest-path "$root/Cargo.toml" vendor +) > "$work/cargo-config.toml" +mv "$work/cargo-config.toml" "$work/vendor-root/vendor/cargo-config.toml" + +mtime="$(git -C "$root" log -1 --format=%ct)" +XZ_OPT=-T0 tar -C "$work/vendor-root" --sort=name --owner=0 --group=0 --numeric-owner \ + --mtime="@$mtime" -cJf "$out/tablepro-$version-vendor.tar.xz" vendor + +echo "$out/tablepro-$version.tar.xz" +echo "$out/tablepro-$version-vendor.tar.xz" diff --git a/linux/build-aux/packaging/rust-toolchain-staging.asc b/linux/build-aux/packaging/rust-toolchain-staging.asc new file mode 100644 index 0000000000..e0f7822cc7 --- /dev/null +++ b/linux/build-aux/packaging/rust-toolchain-staging.asc @@ -0,0 +1,29 @@ +-----BEGIN PGP PUBLIC KEY BLOCK----- + +xsFNBGjJs8EBEADndS3zC4w58Tz/d0gn/fE+5fE66I5o+Qj7hq/isEcAPNJxT6ul +MN7jwoq5etbbq0ZQjl5bTMWAnMWSlR2GaTMQWo8dQ63iJ3ZbGB6uNus38jCyQONl +LhHnCFwDRqNJXYmyRlLuFgJ4geD+KfmJV63PIVoZ8/LCfgj3QERNbqyJckJT8ELq +7B4cs1CEA5p6u8PMFfbhC5iXWwCWmmJHr0BDgI4bxeugA6aGbVJ0CL1r21xAC2xO +RY5gx93X3gd89dP8DyJQiDU4C+r3adMVFOIX80YtwTSYgfSVPHeAJllLBQgl+zz+ +hMhOm9Tsi/ow6TkpOdodT5tj3ef2ewqAoTadShS9g7Pk+Cc0yvT++QF1HrNailAd +a1/V5yxCLG3ul1hyKFSPnYC8ToKzBdvUs9nhzaTl4nAWKWAhfR3A/dneOVXZggdJ +6B6oMrdbVdIhzEeTL+L9/o1HfHW9OJtlKGxOqJaK8C70+awDPodnnvkjdZ1iGXmy +9wMJ3+og5I5TayKvXfYW0+IaBC7OFKKcNZ9yX3skvIKhUzGFZ+mVzqOeNcnIJJ4H +2geov0RYe0+Nkycow7ZzJjEfSs5yS6FyWvgOfCajhZhj6Ll0aO5tNKgnSxbRqX88 +Zp3U3kaceSj3atq+91NrNA/fZb2HtrKSnfngMsCvpgxyCcWwg0DGjOCTOwARAQAB +zSBMYXVuY2hwYWQgUFBBIGZvciBSdXN0IFRvb2xjaGFpbsLBjgQTAQoAOBYhBK8k +EPizRnHdjDMXh6H73OzcGTzhBQJoybPBAhsDBQsJCAcCBhUKCQgLAgQWAgMBAh4B +AheAAAoJEKH73OzcGTzh7k8P/irfZ3x0dbcvPs2c3TyYp9+IxEDG5zDGll616Iot +eWiH+lWBe3hg4i2DTj0YI/dCRDM9a81aANy+EjOa4+STl1qp7sWBYMJsP5CDrB0Q +er9EcvoPdFY2sfc7vvgDPmDmcA87SknE+ywTbAZ5r6NRl7GmOzJozQsM3QgV/3AX +x4DZlGkn3lAAiBpzPgknZ7aFkG599NnXrKlzW/jI1j4omMS7P6PlymKKJnoiEJ9K +UORV6MKluaGM8b//AvCUAHUqDaEJKjkci+KdikCCY5xGyRmf6iNASSFhIwT6SGsq +i+McGr75OGnvVsW+qphAVwVIWpOEMy3h1gOtLJVxllvNkjUGAJVjU+V36VMuxWeu +F/71YPFf/CXRO/EDC7a3VehvFq0QCUORCFzHsOJSYQD+xOv3/DtV/rnNR7fCyruc +mqZTT5ofJkzgAyGGDz5tKDo+YLQ2fWO23qrrKBlobszlqstt6d2vIChv8+jt5WWs +GczveZNz9gZmPQ7xm8s1C7eDEC5cec+ur8h+DAulqGrDwsKXWTZGJFjeki+ltwac +0lRNiuE6xIJmg8kjeWyQOAKNuhFcHqXMEq0p65IBLyNsuDYSH2UnSUDc664aJgjw +iB0Dip3rIBqSmI5VacgTN183kmJtaQ9O360+YodVg5zSybLgfMJYqe+S8+DTXTWU +uixg +=zc2+ +-----END PGP PUBLIC KEY BLOCK----- diff --git a/linux/build-aux/packaging/smoke-test.sh b/linux/build-aux/packaging/smoke-test.sh new file mode 100755 index 0000000000..929bde5529 --- /dev/null +++ b/linux/build-aux/packaging/smoke-test.sh @@ -0,0 +1,66 @@ +#!/bin/sh +# Installs a built package, as root in a fresh Ubuntu or Fedora container, +# with only the dependencies it declares, and checks that it starts and +# that the files it installed validate. +# Usage: smoke-test.sh +set -eu + +if [ $# -ne 1 ]; then + echo "usage: smoke-test.sh " >&2 + exit 2 +fi +package="$(cd "$(dirname "$1")" && pwd)/$(basename "$1")" + +case "$package" in +*.deb) + export DEBIAN_FRONTEND=noninteractive + apt-get update + apt-get install -y --no-install-recommends "$package" + tools="desktop-file-utils appstream libglib2.0-bin xvfb xauth dbus-daemon" + install_tools() { + # shellcheck disable=SC2086 # one word per package + apt-get install -y --no-install-recommends $tools + } + ;; +*.rpm) + dnf install -y --setopt=install_weak_deps=False "$package" + tools="desktop-file-utils appstream xorg-x11-server-Xvfb xorg-x11-xauth dbus-daemon" + install_tools() { + # shellcheck disable=SC2086 # one word per package + dnf install -y --setopt=install_weak_deps=False $tools + } + ;; +*) + echo "smoke-test.sh: $package is neither a .deb nor an .rpm" >&2 + exit 2 + ;; +esac + +# Before anything else is installed, so the package's own dependencies are +# all it has. The binary opens its settings before it reads the command +# line, so this also proves the schema was installed and compiled. +help="$(tablepro --help)" +case "$help" in +*--help-gapplication*) ;; +*) + echo "smoke-test.sh: tablepro --help did not list the GApplication options:" >&2 + echo "$help" >&2 + exit 1 + ;; +esac +test -x /usr/libexec/tablepro-askpass + +install_tools +desktop-file-validate /usr/share/applications/app.tablepro.TablePro.desktop +appstreamcli validate --no-net /usr/share/metainfo/app.tablepro.TablePro.metainfo.xml +glib-compile-schemas --strict --dry-run /usr/share/glib-2.0/schemas + +# A window that is still up after the timeout has found its GTK modules, +# icon theme and resources. +status=0 +xvfb-run -a dbus-run-session -- timeout 15 tablepro || status=$? +if [ "$status" -ne 124 ]; then + echo "smoke-test.sh: tablepro exited with status $status before the 15 second timeout" >&2 + exit 1 +fi +echo "smoke-test.sh: $(basename "$package") installs, starts and validates" diff --git a/linux/build-aux/rpm/copr-sources.sh b/linux/build-aux/rpm/copr-sources.sh new file mode 100755 index 0000000000..6b36b93e87 --- /dev/null +++ b/linux/build-aux/rpm/copr-sources.sh @@ -0,0 +1,30 @@ +#!/bin/sh +# The COPR package's custom source method: COPR runs this in a Fedora +# chroot, unprivileged and with network, and builds the source RPM from +# the spec and sources it leaves in the working directory. +# release-linux.yml posts the release tag to the project's custom webhook, +# and COPR hands the body over as ./hook_payload. +# +# COPR runs its own copy of this file. After a change, a maintainer runs: +# copr-cli edit-package-custom /tablepro --name tablepro \ +# --script linux/build-aux/rpm/copr-sources.sh \ +# --script-builddeps 'curl rpmdevtools' +set -eu + +tag="$(cat hook_payload)" +case "$tag" in + *[!0-9A-Za-z.-]*) + echo "copr-sources.sh: not a release tag: $tag" >&2 + exit 1 + ;; + linux-v[0-9]*) ;; + *) + echo "copr-sources.sh: not a Linux release tag: $tag" >&2 + exit 1 + ;; +esac + +curl --fail --silent --show-error --location --proto '=https' \ + --output tablepro.spec \ + "https://raw.githubusercontent.com/TableProApp/TablePro/$tag/linux/build-aux/rpm/tablepro.spec" +spectool --get-files --sources tablepro.spec diff --git a/linux/build-aux/rpm/tablepro.spec b/linux/build-aux/rpm/tablepro.spec new file mode 100644 index 0000000000..de607a433b --- /dev/null +++ b/linux/build-aux/rpm/tablepro.spec @@ -0,0 +1,157 @@ +# Line tables, as the .deb keeps, are enough to symbolize a backtrace. +# Full debuginfo adds to what each rustc of the app and the final link +# need, and an 8 GB builder ran out of memory with it. +%global rustflags_debuginfo 1 +# Fedora's one codegen unit puts the whole app crate through LLVM as one +# module, and its unit test build passed 7 GB that way. Cargo's release +# default of 16 splits the work, and thin LTO still optimizes across it. +%global rustflags_codegen_units 16 + +%bcond check 1 + +Name: tablepro +Version: 0.1.0 +Release: 1%{?dist} +Summary: Work with your databases +# build-aux/update-third-party-licenses.sh writes this tag from the +# license expression of each crate the binaries link, every choice kept. +License: %{shrink: + AGPL-3.0-or-later AND + CC0-1.0 AND + Apache-2.0 AND + (Apache-2.0 OR Apache-2.0 WITH LLVM-exception OR MIT) AND + (Apache-2.0 OR BSD-2-Clause OR MIT) AND + (Apache-2.0 OR BSL-1.0) AND + (Apache-2.0 OR BSL-1.0 OR MIT) AND + (Apache-2.0 OR ISC) AND + (Apache-2.0 OR ISC OR MIT) AND + (Apache-2.0 OR ISC OR MIT-0) AND + (Apache-2.0 OR MIT) AND + (Apache-2.0 OR MIT OR Zlib) AND + BSD-3-Clause AND + CDLA-Permissive-2.0 AND + ISC AND + MIT AND + (MIT OR Unlicense) AND + Unicode-3.0 AND + Zlib +} +URL: https://tablepro.app +# A pre-release packages as 0.2.0~rc.1 and is tagged linux-v0.2.0-rc.1. +Source0: https://github.com/TableProApp/TablePro/releases/download/linux-v%{version_no_tilde}/%{name}-%{version_no_tilde}.tar.xz +Source1: https://github.com/TableProApp/TablePro/releases/download/linux-v%{version_no_tilde}/%{name}-%{version_no_tilde}-vendor.tar.xz + +BuildRequires: cargo >= 1.97 +BuildRequires: rust >= 1.97 +BuildRequires: cargo-rpm-macros >= 26 +BuildRequires: meson >= 1.4 +BuildRequires: gcc +BuildRequires: clang-devel +BuildRequires: gettext +BuildRequires: desktop-file-utils +BuildRequires: /usr/bin/appstreamcli +BuildRequires: /usr/bin/glib-compile-schemas +BuildRequires: /usr/bin/xmllint +BuildRequires: pkgconfig(glib-2.0) >= 2.88 +BuildRequires: pkgconfig(gio-2.0) >= 2.88 +BuildRequires: pkgconfig(gtk4) >= 4.22 +BuildRequires: pkgconfig(libadwaita-1) >= 1.9 +BuildRequires: pkgconfig(gtksourceview-5) >= 5.18 +# The SQLite driver and the query history link the system library +# (sqlx's sqlite-unbundled) rather than a copy in the binary, so SQLite +# security fixes arrive through Fedora's sqlite package. +BuildRequires: pkgconfig(sqlite3) >= 3.46 +BuildRequires: pkgconfig(mit-krb5-gssapi) +%if %{with check} +# The gtk suite opens windows on a display and owns D-Bus names, and the +# catalogue test needs a real locale. +BuildRequires: /usr/bin/xvfb-run +BuildRequires: /usr/bin/xauth +BuildRequires: /usr/bin/dbus-run-session +BuildRequires: adwaita-icon-theme +BuildRequires: glibc-langpack-en +BuildRequires: openssh-clients >= 8.7 +%endif + +Requires: hicolor-icon-theme +# GTK's renderer loads libGLESv2 at runtime through libepoxy, which aborts +# without it, and neither gtk4 nor libepoxy declares it: GNOME Shell pulls +# it in, but a minimal install, a toolbox or another desktop may not. +%if %{__isa_bits} == 64 +Requires: libGLESv2.so.2()(64bit) +%else +Requires: libGLESv2.so.2 +%endif +# SSH tunnels run through the system OpenSSH client, and every master +# passes ForkAfterAuthentication, which OpenSSH 8.7 added. +Requires: openssh-clients >= 8.7 +# Saved passwords live in the Secret Service. Without a provider every +# connect asks for the password and Remember is hidden. +Recommends: (gnome-keyring or kf6-kwallet or keepassxc) +# kinit, for SQL Server sign-in with Kerberos. +Suggests: krb5-workstation + +%description +TablePro connects to PostgreSQL, MySQL, SQLite, SQL Server and ClickHouse. +Browse tables, edit rows in place, run SQL with syntax highlighting, and +reach servers behind an SSH bastion. + +%prep +%autosetup -n %{name}-%{version_no_tilde} -a1 +# Writes .cargo/config.toml with the vendored crates as the only source. +# Cargo reads it from the parent of the Meson build directory it runs in. +%cargo_prep -v vendor +# Some crates ship executable .rs files, whose leading #![...] attribute +# brp-mangle-shebangs then rejects as a shebang in the debugsource package. +find vendor -type f -name '*.rs' -perm /111 -exec chmod a-x {} + + +%build +# Cargo starts a rustc per CPU, and one rustc of the app takes several +# GB, so the jobs are also capped at one per 4 GB. %%check builds the +# tests the same. +%constrain_build -m 4096 +export CARGO_BUILD_JOBS=%{_smp_build_ncpus} +# Fedora's rustc runs out of 8 GB in the release profile's fat LTO link of +# this binary; thin LTO fits a builder. %%check repeats it, since meson +# test rebuilds the binary first. +export CARGO_PROFILE_RELEASE_LTO=thin +%meson -Dprofile=default -Dkerberos=true +%meson_build +# These cover the whole workspace, test-only crates included: cargo2rpm +# 0.4 drops the value of its --package option. +%{cargo_license_summary} +%{cargo_license} > LICENSE.dependencies +%{cargo_vendor_manifest} + +%install +%meson_install + +%check +desktop-file-validate %{buildroot}%{_datadir}/applications/app.tablepro.TablePro.desktop +appstreamcli validate --no-net %{buildroot}%{_metainfodir}/app.tablepro.TablePro.metainfo.xml +%if %{with check} +# The same RUSTFLAGS and LTO as %%build, or cargo rebuilds every crate. +%set_build_flags +export CARGO_PROFILE_RELEASE_LTO=thin +export CARGO_BUILD_JOBS=%{_smp_build_ncpus} +# %%meson_test expands to lines of its own; shrink joins them to this one. +%{shrink:xvfb-run -a dbus-run-session -- %meson_test} +%endif + +%files +%license LICENSE data/licenses/THIRD-PARTY-NOTICE.md +%license LICENSE.dependencies cargo-vendor.txt +%doc CHANGELOG.md +%{_bindir}/tablepro +%{_libexecdir}/tablepro-askpass +%{_datadir}/applications/app.tablepro.TablePro.desktop +%{_datadir}/dbus-1/services/app.tablepro.TablePro.service +%{_datadir}/glib-2.0/schemas/app.tablepro.TablePro.gschema.xml +%{_datadir}/icons/hicolor/scalable/apps/app.tablepro.TablePro.svg +%{_datadir}/icons/hicolor/symbolic/apps/app.tablepro.TablePro-symbolic.svg +%{_mandir}/man1/tablepro.1* +%{_metainfodir}/app.tablepro.TablePro.metainfo.xml + +%changelog +* Wed Oct 14 2026 TablePro Authors - 0.1.0-1 +- First package diff --git a/linux/build-aux/tests/test_check_packaging.py b/linux/build-aux/tests/test_check_packaging.py new file mode 100644 index 0000000000..13e1d5789b --- /dev/null +++ b/linux/build-aux/tests/test_check_packaging.py @@ -0,0 +1,373 @@ +import contextlib +import io +import sys +import tempfile +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "packaging")) + +import check_packaging # noqa: E402 + +METAINFO = """ + + @app-id@ + + {releases} + + +""" + +SPEC = """Name: tablepro +Version: {version} +Release: {release} +{find_lang} +%changelog +{later}* {spec_date} TablePro Authors - {changelog}-1 +- First package +""" + +PACKAGING_FIX = """* Mon Oct 19 2026 TablePro Authors - 1.2.0-2 +- Recommend a keyring + +""" + + +CARGO = """[workspace.package] +edition = "2024" +rust-version = "{rust_version}" +""" + +CONTROL = """Source: tablepro +Build-Depends: + debhelper-compat (= 13), + {toolchain} + meson (>= 1.4), + +Package: tablepro +Depends: + openssh-client (>= 1:8.7), +""" + +RULES = """export PATH := {rust_bin}:$(PATH) +""" + +MAN = """.TH TABLEPRO 1 {man_date} "TablePro {man_version}" "User Commands" +.SH NAME +tablepro \\- work with your databases +""" + +NOTES = """# Changelog + +## [Unreleased] + +### Added + +- Something not yet released. + +## [1.2.0] - 2026-10-14 + +### Added + +- Packages for Ubuntu and Fedora. + +## [1.1.0] - 2026-09-01 + +- Older notes. +""" + +LINKED = "## [1.2.0](https://github.com/TableProApp/TablePro/releases/tag/linux-v1.2.0) - 2026-10-14" + + +class ProblemsTests(unittest.TestCase): + def tree( + self, + releases='', + version="1.2.0", + changelog="1.2.0", + find_lang="", + linguas="# comment\n", + notes=NOTES, + rust_version="1.97", + toolchain="cargo-1.97,\n rustc-1.97,", + rust_bin="/usr/lib/rust-1.97/bin", + release="1%{?dist}", + spec_date="Wed Oct 14 2026", + later="", + man_date="2026-10-14", + man_version="1.2.0", + ): + workdir = tempfile.TemporaryDirectory() + self.addCleanup(workdir.cleanup) + root = Path(workdir.name) + for path, content in ( + (check_packaging.METAINFO, METAINFO.format(releases=releases)), + ( + check_packaging.SPEC, + SPEC.format( + version=version, + changelog=changelog, + find_lang=find_lang, + release=release, + spec_date=spec_date, + later=later, + ), + ), + (check_packaging.LINGUAS, linguas), + (check_packaging.CHANGELOG, notes), + (check_packaging.CARGO, CARGO.format(rust_version=rust_version)), + (check_packaging.CONTROL, CONTROL.format(toolchain=toolchain)), + (check_packaging.RULES, RULES.format(rust_bin=rust_bin)), + (check_packaging.MAN, MAN.format(man_date=man_date, man_version=man_version)), + ): + (root / path).parent.mkdir(parents=True, exist_ok=True) + (root / path).write_text(content, encoding="utf-8") + return root + + def test_agreeing_metadata_passes(self): + self.assertEqual(check_packaging.problems(self.tree(), "1.2.0"), []) + + def test_the_release_gate_passes_for_its_own_tag_and_date(self): + found = check_packaging.problems(self.tree(), "1.2.0", "linux-v1.2.0", "2026-10-14") + + self.assertEqual(found, []) + + def test_a_tag_for_another_version_fails(self): + found = check_packaging.problems(self.tree(), "1.2.0", "linux-v1.2.1", "2026-10-14") + + self.assertEqual(len(found), 1) + self.assertIn("linux-v1.2.1", found[0]) + + def test_the_newest_release_must_be_the_built_version(self): + releases = '' + + found = check_packaging.problems(self.tree(releases=releases), "1.2.0") + + self.assertEqual(len(found), 1) + self.assertIn("is 1.1.0, not 1.2.0", found[0]) + + def test_an_undated_release_fails(self): + found = check_packaging.problems(self.tree(releases=''), "1.2.0") + + self.assertEqual(len(found), 1) + self.assertIn("no YYYY-MM-DD date", found[0]) + + def test_a_release_dated_before_the_tag_fails(self): + root = self.tree( + releases='', + notes=NOTES.replace("## [1.2.0] - 2026-10-14", "## [1.2.0] - 2026-10-01"), + spec_date="Thu Oct 01 2026", + man_date="2026-10-01", + ) + + found = check_packaging.problems(root, "1.2.0", "linux-v1.2.0", "2026-10-14") + + self.assertEqual(len(found), 1) + self.assertIn("dated 2026-10-01", found[0]) + + def test_a_metainfo_without_releases_fails(self): + found = check_packaging.problems(self.tree(releases=""), "1.2.0") + + self.assertEqual(len(found), 1) + self.assertIn("no ", found[0]) + + def test_the_spec_must_package_the_built_version(self): + found = check_packaging.problems(self.tree(version="1.1.0"), "1.2.0") + + self.assertEqual(len(found), 1) + self.assertIn("packages version 1.1.0", found[0]) + + def test_the_spec_changelog_must_open_with_the_built_version(self): + found = check_packaging.problems(self.tree(changelog="1.1.0"), "1.2.0") + + self.assertEqual(len(found), 1) + self.assertIn("%changelog", found[0]) + + def test_find_lang_follows_linguas(self): + cases = [ + ("", "# none yet\n", 0), + ("%find_lang %{name}\n", "de\nfr # French\n", 0), + ("", "de\n", 1), + ("%find_lang %{name}\n", "\n", 1), + ] + for find_lang, linguas, expected in cases: + with self.subTest(find_lang=find_lang, linguas=linguas): + found = check_packaging.problems(self.tree(find_lang=find_lang, linguas=linguas), "1.2.0") + + self.assertEqual(len(found), expected) + + def test_a_version_rpm_or_git_cannot_carry_fails(self): + for version in ("1.2", "1.2.0~rc1", "1.2.0-rc-1", "1.2.0-", "1.2.0-rc..1"): + with self.subTest(version=version): + found = check_packaging.problems(self.tree(), version) + + self.assertTrue(any("is neither X.Y.Z nor a pre-release" in problem for problem in found)) + + def test_a_pre_release_packages_with_a_tilde(self): + self.assertEqual(check_packaging.package_version("1.2.0-rc.1"), "1.2.0~rc.1") + self.assertEqual(check_packaging.package_version("1.2.0"), "1.2.0") + + def test_a_pre_release_tag_passes_when_the_spec_packages_the_tilde_version(self): + root = self.tree( + releases='', + version="1.2.0~rc.1", + changelog="1.2.0~rc.1", + man_version="1.2.0-rc.1", + ) + + self.assertEqual(check_packaging.problems(root, "1.2.0-rc.1", "linux-v1.2.0-rc.1", "2026-10-14"), []) + + def test_a_spec_with_the_upstream_pre_release_fails(self): + root = self.tree( + releases='', + version="1.2.0-rc.1", + changelog="1.2.0~rc.1", + man_version="1.2.0-rc.1", + ) + + found = check_packaging.problems(root, "1.2.0-rc.1") + + self.assertEqual(len(found), 1) + self.assertIn("packages version 1.2.0-rc.1, not 1.2.0~rc.1", found[0]) + + def test_the_debian_package_builds_with_the_workspace_rust_version(self): + cases = [ + ({"rust_version": "1.98"}, 2), + ({"toolchain": "cargo-1.97,\n rustc-1.96,"}, 1), + ({"toolchain": "cargo,\n rustc,"}, 1), + ({"rust_bin": "/usr/lib/rust-1.96/bin"}, 1), + ({"rust_bin": "/usr/bin"}, 1), + ] + for changes, expected in cases: + with self.subTest(changes=changes): + found = check_packaging.problems(self.tree(**changes), "1.2.0") + + self.assertEqual(len(found), expected, found) + + def test_a_workspace_without_a_rust_version_fails(self): + root = self.tree() + (root / check_packaging.CARGO).write_text("[workspace]\n", encoding="utf-8") + + found = check_packaging.problems(root, "1.2.0") + + self.assertEqual(found, ["Cargo.toml names no workspace rust-version"]) + + def test_a_release_needs_its_changelog_notes(self): + for notes in ( + NOTES.replace("## [1.2.0] - 2026-10-14", "## [1.2.1]"), + "## [1.2.0] - 2026-10-14\n\n## [1.1.0]\n- x\n", + ): + with self.subTest(notes=notes): + found = check_packaging.problems(self.tree(notes=notes), "1.2.0", "linux-v1.2.0", "2026-10-14") + + self.assertEqual(len(found), 1) + self.assertIn("no notes under ## [1.2.0]", found[0]) + + def test_a_towncrier_heading_carries_the_release_date(self): + root = self.tree(notes=NOTES.replace("## [1.2.0] - 2026-10-14", LINKED)) + + self.assertEqual(check_packaging.problems(root, "1.2.0", "linux-v1.2.0", "2026-10-14"), []) + self.assertEqual( + check_packaging.release_notes(root / check_packaging.CHANGELOG, "1.2.0"), + "### Added\n\n- Packages for Ubuntu and Fedora.", + ) + + root = self.tree(notes=NOTES.replace("## [1.2.0] - 2026-10-14", LINKED.replace("10-14", "10-13"))) + + self.assertEqual( + check_packaging.problems(root, "1.2.0"), ["CHANGELOG.md dates 1.2.0 2026-10-13, the metainfo 2026-10-14"] + ) + + def test_the_changelog_is_only_required_for_a_release(self): + self.assertEqual(check_packaging.problems(self.tree(notes="# Changelog\n"), "1.2.0"), []) + + def test_the_changelog_heading_carries_the_release_date(self): + cases = [ + ("## [1.2.0] - 2026-10-13", "CHANGELOG.md dates 1.2.0 2026-10-13, the metainfo 2026-10-14"), + ("## [1.2.0]", "CHANGELOG.md has no YYYY-MM-DD date on ## [1.2.0]"), + ] + for heading, expected in cases: + with self.subTest(heading=heading): + root = self.tree(notes=NOTES.replace("## [1.2.0] - 2026-10-14", heading)) + + self.assertEqual(check_packaging.problems(root, "1.2.0"), [expected]) + + def test_the_spec_first_package_carries_the_release_date(self): + cases = [ + ("Tue Oct 13 2026", "is dated 2026-10-13, the metainfo 2026-10-14"), + ("Thu Oct 14 2026", "has no valid date"), + ("Wed Oct 32 2026", "has no valid date"), + ] + for spec_date, expected in cases: + with self.subTest(spec_date=spec_date): + found = check_packaging.problems(self.tree(spec_date=spec_date), "1.2.0") + + self.assertEqual(len(found), 1, found) + self.assertIn(expected, found[0]) + + def test_the_spec_needs_an_entry_for_the_first_package(self): + found = check_packaging.problems(self.tree(changelog="1.1.0", later=PACKAGING_FIX), "1.2.0") + + self.assertEqual(found, ["build-aux/rpm/tablepro.spec has no %changelog entry for 1.2.0-1"]) + + def test_dates_wait_for_the_release_commit(self): + notes = "# Changelog\n\n## [Unreleased]\n\n- Something.\n" + + found = check_packaging.problems(self.tree(notes=notes, spec_date="Tue Oct 13 2026"), "1.2.0") + + self.assertEqual(found, []) + + def test_a_packaging_fix_after_the_release_passes(self): + root = self.tree(release="2%{?dist}", later=PACKAGING_FIX) + + self.assertEqual(check_packaging.problems(root, "1.2.0"), []) + + def test_a_release_tag_needs_the_first_package_release(self): + root = self.tree(release="2%{?dist}", later=PACKAGING_FIX) + + found = check_packaging.problems(root, "1.2.0", "linux-v1.2.0", "2026-10-14") + + self.assertEqual(len(found), 1, found) + self.assertIn("has Release 2%{?dist}, not 1%{?dist}", found[0]) + + def test_the_man_page_names_the_built_version(self): + found = check_packaging.problems(self.tree(man_version="1.1.0"), "1.2.0") + + self.assertEqual(found, ["data/tablepro.1 names version 1.1.0, not 1.2.0"]) + + def test_a_man_page_without_a_title_line_fails(self): + root = self.tree() + (root / check_packaging.MAN).write_text(".SH NAME\n", encoding="utf-8") + + found = check_packaging.problems(root, "1.2.0") + + self.assertEqual(found, ['data/tablepro.1 has no .TH line naming "TablePro X.Y.Z"']) + + def test_the_man_page_carries_the_release_date(self): + found = check_packaging.problems(self.tree(man_date="2026-09-01"), "1.2.0") + + self.assertEqual(found, ["data/tablepro.1 is dated 2026-09-01, the metainfo 2026-10-14"]) + + def test_main_writes_the_release_notes(self): + root = self.tree() + notes = root / "notes.md" + + status = check_packaging.main( + ["--source-root", str(root), "--version", "1.2.0", "--tag", "linux-v1.2.0", "--notes", str(notes)] + ) + + self.assertEqual(status, 0) + self.assertEqual(notes.read_text(encoding="utf-8"), "### Added\n\n- Packages for Ubuntu and Fedora.\n") + + def test_main_reports_on_stderr_and_exits_1(self): + stderr = io.StringIO() + with contextlib.redirect_stderr(stderr): + failed = check_packaging.main(["--source-root", str(self.tree(version="1.1.0")), "--version", "1.1.0"]) + passed = check_packaging.main(["--source-root", str(self.tree()), "--version", "1.2.0"]) + + self.assertEqual((failed, passed), (1, 0)) + self.assertIn("check_packaging.py: the newest release", stderr.getvalue()) + + +if __name__ == "__main__": + unittest.main() diff --git a/linux/build-aux/tests/test_copr_sources.py b/linux/build-aux/tests/test_copr_sources.py new file mode 100644 index 0000000000..a84a820c15 --- /dev/null +++ b/linux/build-aux/tests/test_copr_sources.py @@ -0,0 +1,39 @@ +import subprocess +import tempfile +import unittest +from pathlib import Path + +SCRIPT = Path(__file__).resolve().parent.parent / "rpm" / "copr-sources.sh" + + +def run(payload): + with tempfile.TemporaryDirectory() as work: + Path(work, "hook_payload").write_text(payload) + result = subprocess.run( + ["sh", str(SCRIPT)], cwd=work, capture_output=True, text=True, check=False + ) + return result, sorted(path.name for path in Path(work).iterdir()) + + +class CoprSourcesTest(unittest.TestCase): + def test_other_releases_of_the_repository_are_refused(self): + for tag in ["v0.40.0", "plugin-trino-v1.0.14", "linux-vnext", ""]: + with self.subTest(tag=tag): + result, files = run(tag) + + self.assertEqual(result.returncode, 1) + self.assertIn("not a Linux release tag", result.stderr) + self.assertEqual(files, ["hook_payload"]) + + def test_a_tag_that_would_change_the_url_is_refused(self): + for tag in ["linux-v0.1.0/../../main", "linux-v0.1.0?x", "linux-v0.1.0 x"]: + with self.subTest(tag=tag): + result, files = run(tag) + + self.assertEqual(result.returncode, 1) + self.assertIn("not a release tag", result.stderr) + self.assertEqual(files, ["hook_payload"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/linux/build-aux/tests/test_license_metadata.py b/linux/build-aux/tests/test_license_metadata.py new file mode 100644 index 0000000000..fef146de32 --- /dev/null +++ b/linux/build-aux/tests/test_license_metadata.py @@ -0,0 +1,405 @@ +import contextlib +import io +import json +import sys +import tempfile +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "packaging")) + +import license_metadata # noqa: E402 + +APACHE = """ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + Copyright [yyyy] [name of copyright owner] +""" + +MIT_ALICE = """Copyright (c) 2019 Alice + +Permission is hereby granted, free of charge, to any person obtaining a copy. +The above copyright notice and this permission notice shall be included. +""" + +MIT_NO_NOTICE = "Permission is hereby granted, free of charge.\n" + +EXPAT_ALICE = """Copyright (c) 2019 Alice + +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 +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. +""" + +UNICODE = "COPYRIGHT AND PERMISSION NOTICE\n\nCopyright © 1991-2024 Unicode, Inc.\n" + + +X11 = EXPAT_ALICE + "Except as contained in this notice, the name of Alice shall not be used in advertising.\n" + + +def crate(name, version): + return {"crate": {"name": name, "version": version}} + + +def entry(spdx_id, text, *crates): + return {"id": spdx_id, "name": spdx_id, "text": text, "used_by": list(crates)} + + +def declared(licenses, **expressions): + crates = [] + for license in licenses: + for used_by in license["used_by"]: + name, version = used_by["crate"]["name"], used_by["crate"]["version"] + record = {"name": name, "version": version, "license": expressions.get(name, license["id"])} + if record not in crates: + crates.append(record) + return crates + + +def paragraphs(copyright_text): + parsed = [] + for block in copyright_text.strip("\n").split("\n\n"): + fields = {} + current = None + for line in block.split("\n"): + if line.startswith(" "): + fields[current].append(line[1:]) + else: + current, _, value = line.partition(": ") + fields[current] = [value] + parsed.append(fields) + return parsed + + +def files_paragraph(rendered, pattern): + matches = [p for p in paragraphs(rendered) if pattern in " ".join(p.get("Files", []))] + if len(matches) != 1: + raise AssertionError(f"{len(matches)} paragraphs name {pattern}") + return matches[0] + + +class ParseExpressionTests(unittest.TestCase): + def test_commuted_choices_are_one_term(self): + self.assertEqual( + license_metadata.parse_expression("MIT OR Apache-2.0"), + license_metadata.parse_expression("Apache-2.0 OR MIT"), + ) + + def test_and_binds_tighter_than_or(self): + parsed = license_metadata.parse_expression("MIT OR Apache-2.0 AND ISC") + + self.assertEqual(license_metadata.spdx(parsed), "(Apache-2.0 AND ISC) OR MIT") + + def test_nested_operators_flatten(self): + parsed = license_metadata.parse_expression("(MIT OR (Apache-2.0 OR MIT)) AND (Zlib AND ISC)") + + self.assertEqual(license_metadata.spdx(parsed), "(Apache-2.0 OR MIT) AND ISC AND Zlib") + + def test_an_exception_stays_with_its_license(self): + parsed = license_metadata.parse_expression("Apache-2.0 WITH LLVM-exception OR MIT") + + self.assertEqual(parsed, ("or", (("with", "Apache-2.0", "LLVM-exception"), ("license", "MIT")))) + + def test_a_slash_is_read_as_or(self): + self.assertEqual( + license_metadata.parse_expression("MIT/Apache-2.0"), + license_metadata.parse_expression("MIT OR Apache-2.0"), + ) + + def test_a_malformed_or_unread_expression_is_refused(self): + malformed = ("", "MIT OR", "(MIT", "MIT)", "MIT Apache-2.0", "MIT WITH (", "(MIT OR ISC) WITH LLVM-exception", "Unknown") + for text in malformed: + with self.subTest(text=text), self.assertRaises(license_metadata.MetadataError): + license_metadata.parse_expression(text) + + +class Dep5Tests(unittest.TestCase): + def dep5(self, text, expat=True): + return license_metadata.dep5(license_metadata.parse_expression(text), expat) + + def test_choices_are_written_with_or(self): + self.assertEqual(self.dep5("Unlicense OR MIT"), "Expat or Unlicense") + + def test_mit_is_expat_only_when_the_text_is(self): + self.assertEqual(self.dep5("MIT", expat=False), "MIT") + self.assertTrue(license_metadata.is_expat(EXPAT_ALICE)) + self.assertFalse(license_metadata.is_expat(MIT_ALICE)) + self.assertFalse(license_metadata.is_expat(X11)) + + def test_an_and_of_choices_takes_commas(self): + self.assertEqual( + self.dep5("(MIT OR Apache-2.0) AND Unicode-3.0"), + "Apache-2.0 or Expat, and Unicode-3.0", + ) + self.assertEqual(self.dep5("ISC AND Zlib"), "ISC and Zlib") + + def test_or_later_and_only_take_the_dep5_suffix(self): + self.assertEqual(self.dep5("LGPL-2.1-or-later OR GPL-2.0-only"), "GPL-2.0 or LGPL-2.1+") + + def test_an_exception_is_written_the_dep5_way(self): + self.assertEqual(self.dep5("Apache-2.0 WITH LLVM-exception"), "Apache-2.0 with LLVM exception") + + def test_a_choice_that_dep5_cannot_group_is_refused(self): + with self.assertRaises(license_metadata.MetadataError): + self.dep5("MIT OR (ISC AND (Zlib OR Apache-2.0))") + + +class RenderCopyrightTests(unittest.TestCase): + def render(self, licenses, crates=None, upstream="GNU AFFERO GENERAL PUBLIC LICENSE\n\nTerms.\n", metainfo="AGPL-3.0-or-later"): + if crates is None: + crates = declared(licenses) + return license_metadata.render_copyright(licenses, crates, upstream, metainfo) + + def test_header_comes_first_and_upstream_covers_every_file(self): + rendered = self.render([entry("MIT", MIT_ALICE, crate("a", "1.0.0"))]) + header, upstream = paragraphs(rendered)[:2] + + self.assertEqual(header["Format"], [license_metadata.DEP5_FORMAT]) + self.assertEqual(upstream["Files"], ["*"]) + self.assertEqual(upstream["Copyright"], ["TablePro Authors"]) + self.assertEqual( + upstream["License"], + ["AGPL-3.0+", "GNU AFFERO GENERAL PUBLIC LICENSE", ".", "Terms."], + ) + + def test_the_metainfo_gets_its_own_license(self): + rendered = self.render([entry("MIT", MIT_ALICE, crate("a", "1.0.0"))], metainfo="CC0-1.0") + paragraph = files_paragraph(rendered, "metainfo") + + self.assertEqual(paragraph["Files"], [license_metadata.METAINFO.as_posix()]) + self.assertEqual(paragraph["License"][0], "CC0-1.0") + self.assertIn("/usr/share/common-licenses/CC0-1.0", "\n".join(paragraph["License"])) + + def test_a_metainfo_license_with_no_text_is_refused(self): + with self.assertRaises(license_metadata.MetadataError): + self.render([entry("MIT", MIT_ALICE, crate("a", "1.0.0"))], metainfo="FSFAP") + + def test_a_crate_names_every_license_it_offers_with_the_text_it_is_used_under(self): + licenses = [entry("MIT", EXPAT_ALICE, crate("memchr", "2.7.0"))] + crates = declared(licenses, memchr="Unlicense OR MIT") + paragraph = files_paragraph(self.render(licenses, crates), "vendor/memchr-2.7.0/*") + + self.assertEqual(paragraph["License"][0], "Expat or Unlicense") + self.assertIn("Permission is hereby granted", "\n".join(paragraph["License"])) + + def test_an_mit_text_that_is_not_expat_keeps_the_name_mit(self): + licenses = [entry("MIT", X11, crate("a", "1.0.0"))] + paragraph = files_paragraph(self.render(licenses), "vendor/a-1.0.0/*") + + self.assertEqual(paragraph["License"][0], "MIT") + + def test_crates_with_one_text_and_different_expressions_get_their_own_paragraphs(self): + licenses = [entry("MIT", MIT_ALICE, crate("a", "1.0.0"), crate("b", "2.1.0"))] + rendered = self.render(licenses, declared(licenses, b="MIT OR Apache-2.0")) + + self.assertEqual(files_paragraph(rendered, "vendor/a-1.0.0/*")["Files"], ["vendor/a-1.0.0/*"]) + self.assertEqual(files_paragraph(rendered, "vendor/b-2.1.0/*")["Files"], ["vendor/b-2.1.0/*"]) + + def test_a_crate_with_a_text_and_no_expression_or_the_reverse_is_refused(self): + licenses = [entry("MIT", MIT_ALICE, crate("a", "1.0.0"))] + extra = {"name": "b", "version": "1.0.0", "license": "MIT"} + for crates in ([], declared(licenses) + [extra]): + with self.subTest(crates=crates), self.assertRaises(license_metadata.MetadataError): + self.render(licenses, crates) + + def test_every_crate_is_named_once_by_its_versioned_vendor_directory(self): + licenses = [ + entry("MIT", MIT_ALICE, crate("a", "1.0.0"), crate("b", "2.1.0")), + entry("MIT", MIT_NO_NOTICE, crate("c", "0.3.0"), crate("c", "0.4.1")), + ] + rendered = self.render(licenses) + + patterns = [] + for paragraph in paragraphs(rendered)[2:]: + patterns.extend(" ".join(paragraph["Files"]).split()) + self.assertEqual( + sorted(patterns), + ["vendor/a-1.0.0/*", "vendor/b-2.1.0/*", "vendor/c-0.3.0/*", "vendor/c-0.4.1/*"], + ) + + def test_crates_sharing_a_text_share_a_paragraph(self): + rendered = self.render([entry("MIT", MIT_ALICE, crate("a", "1.0.0"), crate("b", "2.1.0"))]) + + paragraph = files_paragraph(rendered, "vendor/a-1.0.0/*") + self.assertEqual(paragraph["Files"], ["vendor/a-1.0.0/*", "vendor/b-2.1.0/*"]) + + def test_a_crate_under_two_licenses_lists_both_with_both_texts(self): + licenses = [ + entry("MIT", MIT_ALICE, crate("unicode-ident", "1.0.24")), + entry("Unicode-3.0", UNICODE, crate("unicode-ident", "1.0.24")), + ] + crates = declared(licenses, **{"unicode-ident": "(MIT OR Apache-2.0) AND Unicode-3.0"}) + paragraph = files_paragraph(self.render(licenses, crates), "vendor/unicode-ident-1.0.24/*") + + self.assertEqual(paragraph["License"][0], "Apache-2.0 or MIT, and Unicode-3.0") + body = "\n".join(paragraph["License"][1:]) + self.assertIn("Permission is hereby granted", body) + self.assertIn("Copyright © 1991-2024 Unicode, Inc.", body) + + def test_one_text_used_for_several_licenses_appears_once(self): + licenses = [ + entry("ISC", MIT_ALICE, crate("aws-lc-sys", "0.45.0")), + entry("MIT", MIT_ALICE, crate("aws-lc-sys", "0.45.0")), + ] + crates = declared(licenses, **{"aws-lc-sys": "ISC AND MIT"}) + paragraph = files_paragraph(self.render(licenses, crates), "vendor/aws-lc-sys-0.45.0/*") + + self.assertEqual(paragraph["License"][0], "ISC and MIT") + self.assertEqual("\n".join(paragraph["License"]).count("Permission is hereby granted"), 1) + + def test_copyright_takes_the_notices_without_their_prefix(self): + licenses = [ + entry("MIT", MIT_ALICE, crate("a", "1.0.0")), + entry("Unicode-3.0", UNICODE, crate("a", "1.0.0")), + ] + paragraph = files_paragraph(self.render(licenses, declared(licenses, a="MIT AND Unicode-3.0")), "vendor/a-1.0.0/*") + + self.assertEqual(paragraph["Copyright"], ["2019 Alice", "1991-2024 Unicode, Inc."]) + + def test_a_text_without_a_notice_credits_the_crates_authors(self): + licenses = [entry("MIT", MIT_NO_NOTICE, crate("serde", "1.0.0"), crate("syn", "1.0.0"), crate("syn", "2.0.0"))] + paragraph = files_paragraph(self.render(licenses), "vendor/serde-1.0.0/*") + + self.assertEqual(paragraph["Copyright"], ["the authors of serde, syn"]) + + def test_the_apache_terms_point_at_common_licenses(self): + licenses = [entry("Apache-2.0", APACHE + "\nCopyright 2011 Google Inc.\n", crate("ryu", "1.0.0"))] + rendered = self.render(licenses) + paragraph = files_paragraph(rendered, "vendor/ryu-1.0.0/*") + + self.assertNotIn(license_metadata.APACHE_TERMS, rendered) + self.assertNotIn("1. Definitions.", rendered) + body = "\n".join(paragraph["License"][1:]) + self.assertIn("/usr/share/common-licenses/Apache-2.0", body) + self.assertIn("APPENDIX: How to apply the Apache License to your work.", body) + self.assertEqual(paragraph["Copyright"], ["2011 Google Inc."]) + + def test_an_apache_text_with_no_end_line_is_refused(self): + broken = APACHE.replace("END OF TERMS AND CONDITIONS", "") + + with self.assertRaises(license_metadata.MetadataError): + self.render([entry("Apache-2.0", broken, crate("ryu", "1.0.0"))]) + + def test_blank_lines_and_tabs_are_deb822_safe(self): + licenses = [entry("MIT", "Copyright 2020 Bob\n\n\tIndented\fline\n\n", crate("a", "1.0.0"))] + rendered = self.render(licenses) + lines = files_paragraph(rendered, "vendor/a-1.0.0/*")["License"] + + self.assertEqual(lines, ["MIT", "Copyright 2020 Bob", ".", " Indentedline"]) + self.assertNotIn("\t", rendered) + self.assertTrue(rendered.endswith("\n") and not rendered.endswith("\n\n")) + + +class RpmLicenseTests(unittest.TestCase): + def test_upstream_first_then_each_crate_expression_once_with_every_choice(self): + crates = [ + {"name": "a", "version": "1.0.0", "license": "MIT OR Apache-2.0"}, + {"name": "b", "version": "1.0.0", "license": "Apache-2.0 OR MIT"}, + {"name": "c", "version": "1.0.0", "license": "Unlicense OR MIT"}, + {"name": "d", "version": "1.0.0", "license": "(MIT OR Apache-2.0) AND Unicode-3.0"}, + {"name": "e", "version": "1.0.0", "license": "MIT"}, + {"name": "f", "version": "1.0.0", "license": "AGPL-3.0-or-later"}, + ] + + self.assertEqual( + license_metadata.rpm_license(crates, "CC0-1.0"), + ["AGPL-3.0-or-later", "CC0-1.0", "(Apache-2.0 OR MIT)", "MIT", "(MIT OR Unlicense)", "Unicode-3.0"], + ) + + def test_the_metainfo_license_is_left_out_when_it_is_the_upstream_one(self): + crates = [{"name": "a", "version": "1.0.0", "license": "MIT"}] + + self.assertEqual(license_metadata.rpm_license(crates, "AGPL-3.0-or-later"), ["AGPL-3.0-or-later", "MIT"]) + + def test_a_one_line_license_tag_becomes_a_block_and_a_block_is_replaced(self): + spec = "Name: tablepro\nLicense: AGPL-3.0-or-later\nURL: x\n" + block = "Name: tablepro\nLicense: %{shrink:\n AGPL-3.0-or-later AND\n (Apache-2.0 OR MIT)\n}\nURL: x\n" + + self.assertEqual( + license_metadata.apply_spec_license(spec, ["AGPL-3.0-or-later", "(Apache-2.0 OR MIT)"]), + block, + ) + self.assertEqual( + license_metadata.apply_spec_license(block, ["AGPL-3.0-or-later", "MIT"]), + "Name: tablepro\nLicense: %{shrink:\n AGPL-3.0-or-later AND\n MIT\n}\nURL: x\n", + ) + + def test_a_spec_without_exactly_one_license_tag_is_refused(self): + for spec in ("Name: tablepro\n", "License: A\nLicense: B\n"): + with self.subTest(spec=spec), self.assertRaises(license_metadata.MetadataError): + license_metadata.apply_spec_license(spec, ["MIT"]) + + +class CommandTests(unittest.TestCase): + def setUp(self): + workdir = tempfile.TemporaryDirectory() + self.addCleanup(workdir.cleanup) + self.dir = Path(workdir.name) + licenses = [entry("MIT", MIT_ALICE, crate("a", "1.0.0"))] + (self.dir / "third-party.json").write_text( + json.dumps({"licenses": licenses, "crates": declared(licenses)}), + encoding="utf-8", + ) + (self.dir / "LICENSE").write_text("AGPL text\n", encoding="utf-8") + metainfo = self.dir / license_metadata.METAINFO + metainfo.parent.mkdir(parents=True) + metainfo.write_text( + "CC0-1.0\n", encoding="utf-8" + ) + (self.dir / "tablepro.spec").write_text("License: AGPL-3.0-or-later\n", encoding="utf-8") + (self.dir / "copyright").write_text("stale\n", encoding="utf-8") + + def run_command(self, command): + self.stderr = io.StringIO() + with contextlib.redirect_stderr(self.stderr): + return license_metadata.main( + [ + command, + "--licenses", str(self.dir / "third-party.json"), + "--license-file", str(self.dir / "LICENSE"), + "--copyright", str(self.dir / "copyright"), + "--spec", str(self.dir / "tablepro.spec"), + ] + ) + + def test_check_fails_on_stale_files_and_passes_after_write(self): + self.assertEqual(self.run_command("check"), 1) + self.assertIn("update-third-party-licenses.sh", self.stderr.getvalue()) + self.assertEqual((self.dir / "copyright").read_text(encoding="utf-8"), "stale\n") + + self.assertEqual(self.run_command("write"), 0) + + self.assertEqual(self.run_command("check"), 0) + self.assertEqual( + (self.dir / "tablepro.spec").read_text(encoding="utf-8"), + "License: %{shrink:\n AGPL-3.0-or-later AND\n CC0-1.0 AND\n MIT\n}\n", + ) + self.assertIn("Files: vendor/a-1.0.0/*", (self.dir / "copyright").read_text(encoding="utf-8")) + + +if __name__ == "__main__": + unittest.main() diff --git a/linux/build-aux/tests/test_regression_names.py b/linux/build-aux/tests/test_regression_names.py new file mode 100644 index 0000000000..a60232dc57 --- /dev/null +++ b/linux/build-aux/tests/test_regression_names.py @@ -0,0 +1,112 @@ +import re +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +MAP = ROOT / "docs" / "regression-tests.md" + +ROW = re.compile(r"^\|(.+)\|\s*$") +LINK = re.compile(r"^\[[^\]]+\]\(([^)#]+)\)$") +TICKED = re.compile(r"^`([^`]+)`$") +GUARD_NAME = re.compile(r"\bfn\s+(f\d{3}_\w+)") +JOB = re.compile(r"^ ([A-Za-z0-9_-]+):\s*$") + + +def sections(): + found = {} + current = None + for line in MAP.read_text(encoding="utf-8").splitlines(): + if line.startswith("## "): + current = line[3:].strip() + found[current] = [] + continue + match = ROW.match(line) + if current is None or not match: + continue + cells = [cell.strip() for cell in match.group(1).split("|")] + if set(cells[0]) <= set("-: ") or cells[0] in ("Finding", "Helper"): + continue + found[current].append(cells) + return found + + +def ticked(cell): + match = TICKED.match(cell) + if not match: + raise AssertionError(f"not a backticked name: {cell}") + return match.group(1) + + +def linked(cell): + match = LINK.match(cell) + if not match: + raise AssertionError(f"not a link to a file: {cell}") + return (MAP.parent / match.group(1)).resolve() + + +def defines(path, name): + source = path.read_text(encoding="utf-8") + return re.search(rf"\bfn\s+{re.escape(name)}\b", source) is not None + + +def finding_prefix(finding): + return "f" + finding.removeprefix("F-") + "_" + + +class RegressionNamesTest(unittest.TestCase): + @classmethod + def setUpClass(cls): + found = sections() + cls.findings = [cells[0] for cells in found["Findings"]] + cls.guards = [(cells[0], cells[1], ticked(cells[2]), linked(cells[3])) for cells in found["Guards"]] + cls.retired = [(ticked(cells[2]), linked(cells[3])) for cells in found["Retired helpers"]] + cls.tests = [guard for guard in cls.guards if guard[1] != "ci-job"] + cls.jobs = [guard for guard in cls.guards if guard[1] == "ci-job"] + + def test_the_map_names_findings_and_guards(self): + self.assertTrue(self.findings) + self.assertTrue(self.tests) + + def test_every_finding_has_a_guard(self): + guarded = {guard[0] for guard in self.guards} + for finding in self.findings: + with self.subTest(finding=finding): + self.assertTrue(finding in guarded, f"{finding} has no guard") + for finding in guarded: + with self.subTest(finding=finding): + self.assertTrue(finding in self.findings, f"{finding} guards a finding the page does not list") + + def test_every_test_carries_its_finding(self): + for finding, _, name, _ in self.tests: + with self.subTest(name=name): + self.assertTrue(name.startswith(finding_prefix(finding)), f"{name} is listed under {finding}") + + def test_every_listed_test_is_defined_in_its_file(self): + for name, path in [(guard[2], guard[3]) for guard in self.tests] + self.retired: + with self.subTest(name=name): + self.assertTrue(path.is_file(), f"{path} does not exist") + self.assertTrue(defines(path, name), f"no fn {name} in {path.relative_to(ROOT)}") + + def test_every_finding_named_test_is_listed(self): + listed = {(name, path) for _, _, name, path in self.tests} + for path in sorted((ROOT / "crates").rglob("*.rs")): + for name in GUARD_NAME.findall(path.read_text(encoding="utf-8")): + with self.subTest(name=name): + self.assertTrue( + (name, path.resolve()) in listed, + f"{path.relative_to(ROOT)} defines {name}, which docs/regression-tests.md does not list", + ) + + def test_every_listed_ci_job_exists(self): + workflows = {path for _, _, _, path in self.jobs} + if not all(path.is_file() for path in workflows): + self.skipTest("the workflow is outside this source tree") + for _, _, job, path in self.jobs: + with self.subTest(job=job): + lines = path.read_text(encoding="utf-8").split("\njobs:\n", 1)[-1].splitlines() + ids = {match.group(1) for match in map(JOB.match, lines) if match} + self.assertTrue(job in ids, f"no job {job} in {path.name}") + + +if __name__ == "__main__": + unittest.main() diff --git a/linux/build-aux/update-third-party-licenses.sh b/linux/build-aux/update-third-party-licenses.sh new file mode 100755 index 0000000000..e12fbb491f --- /dev/null +++ b/linux/build-aux/update-third-party-licenses.sh @@ -0,0 +1,52 @@ +#!/bin/sh +# Writes data/licenses from Cargo.lock with cargo-about, then the Debian +# copyright file and the RPM spec's License tag from data/licenses. CI runs +# it and fails when the committed files differ, so run it after every +# change to Cargo.lock and commit what it writes. +set -eu + +expected_version="cargo-about 0.9.2" +root="$(cd "$(dirname "$0")/.." && pwd)" + +# Another version can render the same lock file differently, which CI +# would report as drift. +version="$(cargo about --version)" +if [ "$version" != "$expected_version" ]; then + echo "update-third-party-licenses.sh: needs $expected_version, found $version" >&2 + exit 1 +fi + +cargo fetch --locked --manifest-path "$root/Cargo.toml" + +# The app manifest, not the workspace, so the test-only crates and their +# dependencies stay out: tablepro-ssh, which builds the askpass helper, +# is one of the app's own dependencies. Not --offline: the git entries +# in about.toml fetch a license file from the crate's repository. +generate() { + cargo about generate --locked --fail \ + --config "$root/about.toml" \ + --manifest-path "$root/crates/app/Cargo.toml" \ + --output-file "$2" \ + "$1" +} + +# A crate with no license file cargo-about can read gets the SPDX template +# text, which names no copyright holder, and --fail lets that through. +fallbacks="$(mktemp)" +trap 'rm -f "$fallbacks"' EXIT +generate "$root/build-aux/about/fallbacks.hbs" "$fallbacks" +if grep -q '[^[:space:]]' "$fallbacks"; then + echo "update-third-party-licenses.sh: no license file read for these crates; add or fix their [.clarify] entry in about.toml:" >&2 + cat "$fallbacks" >&2 + exit 1 +fi + +for output in third-party.json THIRD-PARTY-NOTICE.md; do + generate "$root/build-aux/about/$output.hbs" "$root/data/licenses/$output" +done + +python3 "$root/build-aux/packaging/license_metadata.py" write \ + --licenses "$root/data/licenses/third-party.json" \ + --license-file "$root/LICENSE" \ + --copyright "$root/build-aux/debian/copyright" \ + --spec "$root/build-aux/rpm/tablepro.spec" diff --git a/linux/changelog.d/.gitkeep b/linux/changelog.d/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/linux/changelog.d/3413.added.md b/linux/changelog.d/3413.added.md new file mode 100644 index 0000000000..f9f732d9b9 --- /dev/null +++ b/linux/changelog.d/3413.added.md @@ -0,0 +1 @@ +Open SQLite files from the file manager's Open With, or with `tablepro file.sqlite3`. diff --git a/linux/changelog.d/3464.added.1.md b/linux/changelog.d/3464.added.1.md new file mode 100644 index 0000000000..06e0ef581e --- /dev/null +++ b/linux/changelog.d/3464.added.1.md @@ -0,0 +1 @@ +SSH tunnels through the system OpenSSH client, with your agent, `~/.ssh/config`, jump hosts and host key checks. diff --git a/linux/changelog.d/3464.added.10.md b/linux/changelog.d/3464.added.10.md new file mode 100644 index 0000000000..c3bbfb59c6 --- /dev/null +++ b/linux/changelog.d/3464.added.10.md @@ -0,0 +1 @@ +Desktop notification when a query, save or export finishes in a tab you are not looking at. diff --git a/linux/changelog.d/3464.added.11.md b/linux/changelog.d/3464.added.11.md new file mode 100644 index 0000000000..fcdfe9f7f7 --- /dev/null +++ b/linux/changelog.d/3464.added.11.md @@ -0,0 +1 @@ +Troubleshooting in About TablePro, with the versions to paste into a bug report. diff --git a/linux/changelog.d/3464.added.2.md b/linux/changelog.d/3464.added.2.md new file mode 100644 index 0000000000..a57354fd0e --- /dev/null +++ b/linux/changelog.d/3464.added.2.md @@ -0,0 +1 @@ +SQL Server sign-in with Kerberos, using the ticket from `kinit`. diff --git a/linux/changelog.d/3464.added.3.md b/linux/changelog.d/3464.added.3.md new file mode 100644 index 0000000000..28943d8c1c --- /dev/null +++ b/linux/changelog.d/3464.added.3.md @@ -0,0 +1 @@ +A window per connection, with its table, structure and SQL editor tabs reopened at the next launch. diff --git a/linux/changelog.d/3464.added.4.md b/linux/changelog.d/3464.added.4.md new file mode 100644 index 0000000000..b7a2517226 --- /dev/null +++ b/linux/changelog.d/3464.added.4.md @@ -0,0 +1 @@ +Table grid with filters, sorting and paging, editing in place, new and deleted rows, and undo until you save. diff --git a/linux/changelog.d/3464.added.5.md b/linux/changelog.d/3464.added.5.md new file mode 100644 index 0000000000..b7ba3acb5f --- /dev/null +++ b/linux/changelog.d/3464.added.5.md @@ -0,0 +1 @@ +Structure tab to change columns, indexes and foreign keys, with the SQL shown before it runs. diff --git a/linux/changelog.d/3464.added.6.md b/linux/changelog.d/3464.added.6.md new file mode 100644 index 0000000000..478656f59f --- /dev/null +++ b/linux/changelog.d/3464.added.6.md @@ -0,0 +1 @@ +SQL editor with completion, formatting, find and replace, a result per statement, and Stop. diff --git a/linux/changelog.d/3464.added.7.md b/linux/changelog.d/3464.added.7.md new file mode 100644 index 0000000000..bd30e1e035 --- /dev/null +++ b/linux/changelog.d/3464.added.7.md @@ -0,0 +1 @@ +Query history with full-text search, and saved queries. diff --git a/linux/changelog.d/3464.added.8.md b/linux/changelog.d/3464.added.8.md new file mode 100644 index 0000000000..28c0a01bda --- /dev/null +++ b/linux/changelog.d/3464.added.8.md @@ -0,0 +1 @@ +Export to CSV, JSON, SQL, Excel, Markdown, HTML and XML, CSV import, and Copy As. diff --git a/linux/changelog.d/3464.added.9.md b/linux/changelog.d/3464.added.9.md new file mode 100644 index 0000000000..eb6705103d --- /dev/null +++ b/linux/changelog.d/3464.added.9.md @@ -0,0 +1 @@ +Keyboard navigation of the grid, and names and states for screen readers. diff --git a/linux/changelog.d/3464.added.md b/linux/changelog.d/3464.added.md new file mode 100644 index 0000000000..4249dd51f2 --- /dev/null +++ b/linux/changelog.d/3464.added.md @@ -0,0 +1 @@ +Saved connections with groups, colours, TLS options and read-only mode, and passwords kept in the system keyring. diff --git a/linux/clippy.toml b/linux/clippy.toml new file mode 100644 index 0000000000..4598cd680a --- /dev/null +++ b/linux/clippy.toml @@ -0,0 +1,17 @@ +allow-unwrap-in-tests = true +allow-expect-in-tests = true +allow-panic-in-tests = true +allow-dbg-in-tests = true +allow-print-in-tests = true + +# A serde_json map sorts its keys, which would reorder a document the +# server sent. JSON a row holds stays as JsonText, and JSON the app +# writes goes out in the order it is written. +disallowed-types = [ + { path = "serde_json::Map", reason = "key order is not preserved; keep JSON as JsonText" }, +] + +disallowed-methods = [ + { path = "serde_json::Value::as_object", reason = "key order is not preserved; keep JSON as JsonText" }, + { path = "serde_json::Value::as_object_mut", reason = "key order is not preserved; keep JSON as JsonText" }, +] diff --git a/linux/crates/app/Cargo.toml b/linux/crates/app/Cargo.toml new file mode 100644 index 0000000000..e29064f3fc --- /dev/null +++ b/linux/crates/app/Cargo.toml @@ -0,0 +1,112 @@ +[package] +name = "tablepro" +edition.workspace = true +rust-version.workspace = true +license.workspace = true +publish.workspace = true + +[lib] +name = "tablepro_app" + +[[test]] +name = "startup" +harness = false + +[[test]] +name = "i18n_catalog" +harness = false + +[[test]] +name = "logging" +harness = false + +# A panic hook is process-wide and the crash ends the process, so each +# case runs in a child of the binary. +[[test]] +name = "crash_report" +harness = false + +# These start the application, which GTK allows only on the process's +# main thread, so they run without the test harness. +[[test]] +name = "application_uniqueness" +harness = false + +[[test]] +name = "application_lifecycle" +harness = false + +# D-Bus starts the binary, which opens its windows on GTK's main thread. +[[test]] +name = "open_files" +harness = false + +[[test]] +name = "application_session_restore" +harness = false + +[[test]] +name = "accessibility" +harness = false + +[features] +default = ["kerberos"] +kerberos = ["tablepro-driver-mssql/kerberos"] + +[dependencies] +tablepro-core.workspace = true +tablepro-driver-clickhouse.workspace = true +tablepro-driver-mssql.workspace = true +tablepro-driver-mysql.workspace = true +tablepro-driver-postgres.workspace = true +tablepro-driver-sqlite.workspace = true +tablepro-net.workspace = true +tablepro-session.workspace = true +tablepro-ssh.workspace = true +tablepro-storage.workspace = true +async-trait.workspace = true +chrono.workspace = true +futures.workspace = true +serde.workspace = true +serde_json.workspace = true +thiserror.workspace = true +gio.workspace = true +gio-unix.workspace = true +gtk4.workspace = true +libadwaita.workspace = true +sourceview5.workspace = true +glib.workspace = true +relm4.workspace = true +# CLOCK_BOOTTIME, which std does not expose, tells a suspend apart. +rustix = { workspace = true, features = ["time"] } +secrecy.workspace = true +tokio.workspace = true +tokio-util = { workspace = true, features = ["rt"] } +tracing.workspace = true +tracing-subscriber.workspace = true +uuid.workspace = true +gettext-rs = { version = "0.8", features = ["gettext-system"] } +# Counts grouped the way the user's locale groups digits. +icu_decimal = { version = "2.3", features = ["alloc"] } +icu_locale_core = { version = "2.3", features = ["alloc"] } +sqlformat = "0.5" + +[dev-dependencies] +proptest.workspace = true +tablepro-session = { workspace = true, features = ["fakes"] } +tablepro-test-fixtures.workspace = true +tablepro-test-support.workspace = true +tempfile.workspace = true +tokio = { workspace = true, features = ["macros", "rt", "test-util"] } +# tokio-postgres logs through `log`, so the logging tests drive that +# bridge the way it does. +log = "0.4" +tracing-log = "0.2" +# Real key presses through XTEST, which GDK reads as it reads a keyboard. +x11rb = { version = "0.14", features = ["xtest"] } + +[build-dependencies] +glib-build-tools.workspace = true + +[lints] +workspace = true diff --git a/linux/crates/app/build.rs b/linux/crates/app/build.rs new file mode 100644 index 0000000000..2c67658efd --- /dev/null +++ b/linux/crates/app/build.rs @@ -0,0 +1,90 @@ +use std::env; +use std::fs; +use std::path::PathBuf; +use std::process::ExitCode; + +#[path = "src/application/metainfo_release.rs"] +mod metainfo_release; + +const METAINFO_TEMPLATE: &str = "../../data/app.tablepro.TablePro.metainfo.xml.in.in"; +/// The fallback `config::APP_ID` takes when Meson sets nothing. +const DEFAULT_APP_ID: &str = "app.tablepro.TablePro.Devel"; + +fn main() -> ExitCode { + let metainfo_dir = match render_metainfo() { + Ok(dir) => dir, + Err(message) => { + println!("cargo::error={message}"); + return ExitCode::FAILURE; + } + }; + glib_build_tools::compile_resources( + &[PathBuf::from("../../data/resources"), metainfo_dir], + "../../data/resources/resources.gresource.xml", + "tablepro.gresource", + ); + ExitCode::SUCCESS +} + +/// The About dialog reads the metainfo from the GResource. A Meson build +/// passes the copy it installs, translations merged; a plain cargo build +/// renders the template with the same values, untranslated. +fn render_metainfo() -> Result { + println!("cargo::rerun-if-env-changed=TABLEPRO_METAINFO"); + let out_dir = env::var_os("OUT_DIR").ok_or("OUT_DIR is not set")?; + let rendered = match env::var_os("TABLEPRO_METAINFO") { + Some(merged) => { + let merged = PathBuf::from(merged); + println!("cargo::rerun-if-changed={}", merged.display()); + fs::read_to_string(&merged).map_err(|error| format!("could not read {}: {error}", merged.display()))? + } + None => render_template()?, + }; + + let dir = PathBuf::from(out_dir).join("metainfo"); + fs::create_dir_all(&dir).map_err(|error| format!("could not create {}: {error}", dir.display()))?; + let path = dir.join("metainfo.xml"); + // A rewrite, even of the same bytes, makes cargo run this script and + // rebuild the crate on every later build. + if fs::read_to_string(&path).ok().as_deref() != Some(rendered.as_str()) { + fs::write(&path, rendered).map_err(|error| format!("could not write {}: {error}", path.display()))?; + } + Ok(dir) +} + +fn render_template() -> Result { + println!("cargo::rerun-if-changed={METAINFO_TEMPLATE}"); + println!("cargo::rerun-if-env-changed=TABLEPRO_APP_ID"); + let app_id = env::var("TABLEPRO_APP_ID").unwrap_or_else(|_| DEFAULT_APP_ID.to_owned()); + let package = env::var("CARGO_PKG_NAME").map_err(|error| format!("CARGO_PKG_NAME: {error}"))?; + let template = fs::read_to_string(METAINFO_TEMPLATE) + .map_err(|error| format!("could not read {METAINFO_TEMPLATE}: {error}"))?; + let version = metainfo_release::newest_release(&template) + .ok_or_else(|| format!("{METAINFO_TEMPLATE} lists no release with a version"))?; + let rendered = template + .replace("@app-id@", &app_id) + .replace("@gettext-package@", &package) + .replace("@version@", version); + if let Some(name) = unfilled_placeholder(&rendered) { + return Err(format!( + "{METAINFO_TEMPLATE} uses @{name}@, which build.rs does not fill" + )); + } + Ok(rendered) +} + +/// The name of a Meson `@name@` placeholder still in `text`. +fn unfilled_placeholder(text: &str) -> Option<&str> { + let mut rest = text; + while let Some(start) = rest.find('@') { + let after = &rest[start + 1..]; + let length = after + .find(|c: char| !(c.is_ascii_alphanumeric() || c == '-' || c == '_')) + .unwrap_or(after.len()); + if length > 0 && after[length..].starts_with('@') { + return Some(&after[..length]); + } + rest = after; + } + None +} diff --git a/linux/crates/app/clippy.toml b/linux/crates/app/clippy.toml new file mode 100644 index 0000000000..4d4d39d206 --- /dev/null +++ b/linux/crates/app/clippy.toml @@ -0,0 +1,63 @@ +allow-unwrap-in-tests = true +allow-expect-in-tests = true +allow-panic-in-tests = true +allow-dbg-in-tests = true +allow-print-in-tests = true + +# A serde_json map sorts its keys, which would reorder a document the +# server sent. JSON a row holds stays as JsonText, and JSON the app +# writes goes out in the order it is written. +disallowed-types = [ + { path = "serde_json::Map", reason = "key order is not preserved; keep JSON as JsonText" }, +] + +disallowed-methods = [ + { path = "serde_json::Value::as_object", reason = "key order is not preserved; keep JSON as JsonText" }, + { path = "serde_json::Value::as_object_mut", reason = "key order is not preserved; keep JSON as JsonText" }, + + # Catalogue text goes into DDL verbatim, so only a driver may build + # one from a string. User input goes through SqlDialect::parse_type + # or parse_expression first. + { path = "tablepro_core::column::SqlTypeExpr::from_catalog_text", reason = "catalog text only; validate user input with SqlDialect::parse_type/parse_expression" }, + { path = "tablepro_core::column::SqlExpression::from_catalog_text", reason = "catalog text only; validate user input with SqlDialect::parse_type/parse_expression" }, + + # Background work goes through Tasks, which holds the one handle + # AppRuntime created. Reaching for the ambient runtime hides where + # work runs and panics on the GTK thread, which has no runtime. + { path = "tokio::runtime::Handle::current", reason = "take the handle from Tasks" }, + { path = "tokio::runtime::Handle::try_current", reason = "take the handle from Tasks" }, + { path = "tokio::runtime::Handle::block_on", reason = "blocking a runtime thread deadlocks it; use spawn_blocking_task" }, + { path = "tokio::runtime::Runtime::enter", reason = "take the handle from Tasks" }, + { path = "tokio::runtime::Handle::enter", reason = "take the handle from Tasks" }, + + # Background work goes through Tasks so there is one place that + # decides where it runs. These reach for whichever runtime happens + # to be ambient, which on the GTK thread is none at all. + { path = "tokio::spawn", reason = "use Tasks::spawn_task" }, + { path = "tokio::task::spawn_blocking", reason = "use Tasks::spawn_blocking_task" }, + { path = "tokio::runtime::Handle::spawn", reason = "use Tasks::spawn_task" }, + { path = "tokio::runtime::Handle::spawn_blocking", reason = "use Tasks::spawn_blocking_task" }, + { path = "relm4::spawn", reason = "use Tasks::spawn_task" }, + { path = "relm4::spawn_blocking", reason = "use Tasks::spawn_blocking_task" }, + { path = "relm4::spawn_local", reason = "use glib::spawn_future_local" }, + + # Blocking the GTK main loop freezes every window, and quitting from + # under it skips the shutdown that flushes pending writes. + { path = "gtk4::glib::MainContext::block_on", reason = "await in glib::spawn_future_local instead of blocking the main loop" }, + { path = "gio::prelude::ApplicationExt::quit", reason = "use TableProApplication::request_quit, which closes each window through its prompts" }, + + # A window can close while work it started is still on its way back. + # These panic on a component that has shut down; deliver drops the + # message instead. + { path = "relm4::ComponentSender::input", reason = "use crate::runtime::DeliverInput::deliver" }, + { path = "relm4::FactorySender::input", reason = "use crate::runtime::DeliverInput::deliver" }, + + # TableProApplication owns the windows and the app actions. Relm4's + # own application builds a second one that knows neither. + { path = "relm4::RelmApp::run", reason = "open windows with TableProApplication::open_window" }, + { path = "relm4::main_application", reason = "take the application from the window or its WindowHost" }, + { path = "relm4::main_adw_application", reason = "take the application from the window or its WindowHost" }, + { path = "relm4::actions::RelmActionGroup::register_for_main_application", reason = "add actions with add_action_entries on the window or the application" }, + { path = "relm4::ComponentBuilder::transient_for", reason = "relm4 applies it only from RelmApp; present dialogs on their parent window" }, + { path = "relm4::ComponentBuilder::transient_for_native", reason = "relm4 applies it only from RelmApp; present dialogs on their parent window" }, +] diff --git a/linux/crates/app/meson.build b/linux/crates/app/meson.build new file mode 100644 index 0000000000..b3db68b011 --- /dev/null +++ b/linux/crates/app/meson.build @@ -0,0 +1,103 @@ +cargo_options = [ + '--manifest-path', meson.project_source_root() / 'Cargo.toml', + '--locked', +] + +if profile == 'development' + cargo_profile_dir = 'debug' +else + cargo_options += ['--release'] + cargo_profile_dir = 'release' +endif + +if not get_option('kerberos') + cargo_options += ['--no-default-features'] +endif + +cargo_built = cargo_target_dir / cargo_profile_dir + +# The About dialog embeds the metainfo Meson installs, with its +# translations, rather than build.rs rendering the template untranslated. +app_env = cargo_env +app_env.set('TABLEPRO_METAINFO', metainfo_file.full_path()) + +cargo_build_command = ' '.join( + [cargo.full_path(), 'build', '-p', 'tablepro', '-p', 'tablepro-ssh'] + cargo_options, +) + +custom_target( + 'cargo-build', + build_always_stale: true, + build_by_default: true, + console: true, + output: ['tablepro', 'tablepro-askpass'], + env: app_env, + depends: metainfo_file, + command: [ + 'sh', + '-c', + cargo_build_command + + ' && cp ' + cargo_built / 'tablepro' + ' @OUTPUT0@' + + ' && cp ' + cargo_built / 'tablepro-askpass' + ' @OUTPUT1@', + ], + install: true, + install_dir: [bindir, libexecdir], +) + +test_env = cargo_env +if profile != 'development' + # With --release every test binary goes through the release profile's + # fat LTO, which takes gigabytes and minutes per binary and runs a + # package build out of memory. The installed binaries keep it. + test_env.set('CARGO_PROFILE_RELEASE_LTO', 'off') +endif + +# The workspace suite excludes the app, whose tests need a display and +# the GTK test accessibility backend. +test( + 'workspace', + cargo, + args: ['test'] + cargo_options + ['--workspace', '--exclude', 'tablepro'], + env: test_env, + suite: 'unit', + is_parallel: false, + timeout: 3600, +) + +gtk_env = app_env +if profile != 'development' + gtk_env.set('CARGO_PROFILE_RELEASE_LTO', 'off') +endif +gtk_env.set('GSK_RENDERER', 'cairo') +gtk_env.set('GTK_A11Y', 'test') +# The key tests press real keys through XTEST, which only an X11 display +# hands to GDK. +gtk_env.set('GDK_BACKEND', 'x11') +# meson devenv sets GSETTINGS_SCHEMA_DIR and meson test does not, so the +# suite points at the build tree itself. +gtk_env.set('GSETTINGS_SCHEMA_DIR', meson.project_build_root() / 'data') +# Inside Flatpak GLib sends notifications through the portal; the suite +# answers them with a freedesktop server of its own on its own bus. +gtk_env.set('GNOTIFICATION_BACKEND', 'freedesktop') + +# A session bus of the suite's own, so its names and notifications never +# reach the desktop it runs on, and a build sandbox without a bus still +# has one. A build that skips the tests need not install dbus. +gtk_program = cargo +gtk_args = ['test'] + cargo_options + ['-p', 'tablepro'] +dbus_run_session = find_program('dbus-run-session', required: false) +if dbus_run_session.found() + gtk_program = dbus_run_session + gtk_args = ['--', cargo.full_path()] + gtk_args +endif + +test( + 'app', + gtk_program, + args: gtk_args, + env: gtk_env, + depends: [compiled_schemas, metainfo_file], + suite: 'gtk', + is_parallel: false, + timeout: 3600, +) diff --git a/linux/crates/app/src/application/about_dialog.rs b/linux/crates/app/src/application/about_dialog.rs new file mode 100644 index 0000000000..4627e18f2d --- /dev/null +++ b/linux/crates/app/src/application/about_dialog.rs @@ -0,0 +1,199 @@ +use libadwaita as adw; +use tablepro_core::DriverRegistry; + +use super::debug_info::debug_info; +use super::runtime_versions::RuntimeVersions; +use super::third_party_licenses; +use crate::config::{self, Profile}; +use crate::i18n::gettext; + +const DEVELOPERS: &[&str] = &["TablePro Authors https://github.com/TableProApp/TablePro/graphs/contributors"]; +const DEBUG_INFO_FILENAME: &str = "tablepro-debug-info.txt"; + +/// The name, icon, developer, links and license come from the metainfo +/// the GResource embeds, so the dialog and the software centre agree. +pub fn build_about_dialog(drivers: &DriverRegistry) -> adw::AboutDialog { + let dialog = adw::AboutDialog::from_appdata(&metainfo_resource(), release_notes_version(config::profile())); + // The metainfo gives the latest release, which a development build is + // not: its version carries the commit. + dialog.set_version(config::VERSION); + dialog.set_developers(DEVELOPERS); + // Translators: your names, one per line, as "Name " or "Name https://site". + dialog.set_translator_credits(&gettext("translator-credits")); + dialog.set_debug_info(&debug_info(&RuntimeVersions::current(), drivers)); + dialog.set_debug_info_filename(DEBUG_INFO_FILENAME); + match third_party_licenses::embedded_legal_sections() { + Ok(sections) => third_party_licenses::add_legal_sections(&dialog, §ions), + Err(error) => tracing::warn!(%error, "the About dialog lists no third-party licenses"), + } + dialog +} + +fn metainfo_resource() -> String { + format!("{}/metainfo.xml", config::RESOURCE_BASE_PATH) +} + +/// A development version has no release of its own in the metainfo, and +/// libadwaita reports a version it cannot find as a critical. +fn release_notes_version(profile: Profile) -> Option<&'static str> { + match profile { + Profile::Default => Some(config::VERSION), + Profile::Development => None, + } +} + +#[cfg(test)] +mod tests { + use gtk4 as gtk; + + use super::*; + use crate::application::metainfo_release::newest_release; + + fn embedded_metainfo() -> String { + crate::register_test_resources(); + let bytes = gio::resources_lookup_data(&metainfo_resource(), gio::ResourceLookupFlags::NONE) + .expect("the metainfo is embedded at the resource base path"); + String::from_utf8(bytes.to_vec()).expect("the metainfo is UTF-8") + } + + /// The text between the element `open` starts and the next `close`. + fn metainfo_text<'a>(xml: &'a str, open: &str, close: &str) -> &'a str { + let start = xml.find(open).unwrap_or_else(|| panic!("{open} is in the metainfo")); + let content = &xml[start..]; + let content = &content[content.find('>').expect("the element's start tag closes") + 1..]; + let end = content.find(close).unwrap_or_else(|| panic!("{close} follows {open}")); + content[..end].trim() + } + + #[gtk4::test] + fn about_dialog_is_built_from_metainfo() { + adw::init().expect("libadwaita initialises under the test backend"); + let xml = embedded_metainfo(); + let homepage = metainfo_text(&xml, ""); + let bugtracker = metainfo_text(&xml, ""); + let help = metainfo_text(&xml, ""); + let developer = metainfo_text(metainfo_text(&xml, ""), ""); + assert!(!homepage.is_empty() && !bugtracker.is_empty() && !help.is_empty() && !developer.is_empty()); + + let dialog = build_about_dialog(&DriverRegistry::new()); + + assert_eq!(dialog.website(), homepage); + assert_eq!(dialog.issue_url(), bugtracker); + assert_eq!(dialog.support_url(), help); + assert_eq!(dialog.developer_name(), developer); + assert_eq!(dialog.application_icon(), config::APP_ID); + assert_eq!(dialog.version(), config::VERSION); + assert_eq!(dialog.license_type(), gtk::License::Agpl30); + assert_eq!(dialog.application_name(), "TablePro"); + assert_eq!( + dialog.developers().iter().map(|name| name.as_str()).collect::>(), + DEVELOPERS + ); + assert_eq!(dialog.debug_info_filename(), DEBUG_INFO_FILENAME); + assert!(dialog.debug_info().contains(config::APP_ID), "{}", dialog.debug_info()); + } + + #[test] + fn embedded_metainfo_names_the_build_app_id() { + let xml = embedded_metainfo(); + + assert_eq!(metainfo_text(&xml, ""), config::APP_ID); + assert_eq!( + metainfo_text(&xml, ""), + format!("{}.desktop", config::APP_ID) + ); + assert!( + !xml.contains("@app-id@") && !xml.contains("@gettext-package@") && !xml.contains("@version@"), + "{xml}" + ); + } + + #[test] + fn embedded_metainfo_pins_project_links_to_the_release_tag() { + let xml = embedded_metainfo(); + let version = newest_release(&xml).expect("the metainfo lists a release"); + let tree = format!("https://github.com/TableProApp/TablePro/blob/linux-v{version}/linux"); + let screenshots = + format!("https://raw.githubusercontent.com/TableProApp/TablePro/linux-v{version}/linux/data/screenshots/"); + let releases = metainfo_text(&xml, ""); + + assert_eq!( + metainfo_text(&xml, ""), + format!("{tree}/po/README.md") + ); + assert_eq!( + metainfo_text(&xml, ""), + format!("{tree}/CONTRIBUTING.md") + ); + assert_eq!( + metainfo_text(releases, ""), + format!("https://github.com/TableProApp/TablePro/releases/tag/linux-v{version}") + ); + for image in xml.split("') + .and_then(|(_, rest)| rest.split_once("")) + .map(|(url, _)| url.trim()); + assert!(url.is_some_and(|url| url.starts_with(&screenshots)), "{image}"); + } + } + + /// AppStream takes these from the desktop entry, and a metainfo copy + /// would replace the entry's translated keywords and its MIME types. + #[test] + fn categories_keywords_and_media_types_come_from_the_desktop_entry() { + let xml = embedded_metainfo(); + + for element in [""); + let mut listed: Vec = screenshots + .split("") + .filter(|entry| !entry.trim().is_empty()) + .map(|entry| { + assert!(entry.contains(""); + assert!(url.starts_with("https://"), "{url}"); + let name = url.rsplit('/').next().unwrap_or_default(); + assert!( + url.ends_with(&format!("/linux/data/screenshots/{name}")), + "{url} is not under the repository's data/screenshots" + ); + name.to_owned() + }) + .collect(); + listed.sort(); + + let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../data/screenshots"); + let mut committed: Vec = std::fs::read_dir(&dir) + .expect("data/screenshots is readable") + .map(|entry| { + entry + .expect("a readable entry") + .file_name() + .to_string_lossy() + .into_owned() + }) + .collect(); + committed.sort(); + + assert_eq!(listed, committed); + assert!(!listed.is_empty()); + for name in &listed { + let bytes = std::fs::read(dir.join(name)).expect("the screenshot is readable"); + assert!(bytes.starts_with(b"\x89PNG\r\n\x1a\n"), "{name} is not a PNG"); + } + } + + #[test] + fn release_notes_version_absent_in_development_profile() { + assert_eq!(release_notes_version(Profile::Development), None); + assert_eq!(release_notes_version(Profile::Default), Some(config::VERSION)); + } +} diff --git a/linux/crates/app/src/application/accelerator.rs b/linux/crates/app/src/application/accelerator.rs new file mode 100644 index 0000000000..c4d8025a83 --- /dev/null +++ b/linux/crates/app/src/application/accelerator.rs @@ -0,0 +1,9 @@ +use super::{AcceleratorScope, ShortcutListing}; + +#[derive(Debug, Clone, Copy)] +pub struct Accelerator { + pub action: &'static str, + pub triggers: &'static [&'static str], + pub scope: AcceleratorScope, + pub listing: ShortcutListing, +} diff --git a/linux/crates/app/src/application/accelerator_scope.rs b/linux/crates/app/src/application/accelerator_scope.rs new file mode 100644 index 0000000000..b3cfd71b07 --- /dev/null +++ b/linux/crates/app/src/application/accelerator_scope.rs @@ -0,0 +1,22 @@ +use gtk4 as gtk; + +use super::ShortcutOwner; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AcceleratorScope { + /// Installed with `set_accels_for_action`. GtkWindow runs these in a + /// capture-phase, global controller, so they reach the window before + /// the focused widget does. + Application, + /// Installed by `local_shortcut_controller` on the owner's widget. + Local { + owner: ShortcutOwner, + phase: gtk::PropagationPhase, + }, + /// Bound by the owner's own controller, which reads its keys from the + /// table. + Component(ShortcutOwner), + /// Bound by a GTK or libadwaita class, or by AdwApplication. Listed + /// and checked for collisions, never installed. + Stock, +} diff --git a/linux/crates/app/src/application/accelerators.rs b/linux/crates/app/src/application/accelerators.rs new file mode 100644 index 0000000000..b194fa618b --- /dev/null +++ b/linux/crates/app/src/application/accelerators.rs @@ -0,0 +1,735 @@ +use gtk4 as gtk; +use gtk4::gdk; +use gtk4::prelude::*; + +use super::{Accelerator, AcceleratorScope, ShortcutListing, ShortcutOwner, ShortcutSection}; + +use AcceleratorScope::{Application, Component, Local, Stock}; +use ShortcutListing::{Listed, Unlisted}; +use ShortcutOwner::{ + BrowseGrid, BrowseTab, CellEditor, FilterStrip, GridCell, HistoryRow, PickerRow, SidebarList, SqlEditor, + SqlEditorTab, StructureTab, +}; +use ShortcutSection::{BrowseTable, DataGrid, General, TableStructure, Tabs}; + +const BUBBLE: gtk::PropagationPhase = gtk::PropagationPhase::Bubble; +/// GtkTextView binds Ctrl+/ to select-all, and a bubble controller on +/// the view runs before that binding only while it was added last. +/// Capture runs before every controller the view has. +const CAPTURE: gtk::PropagationPhase = gtk::PropagationPhase::Capture; + +const fn application(action: &'static str, triggers: &'static [&'static str], section: ShortcutSection) -> Accelerator { + Accelerator { + action, + triggers, + scope: Application, + listing: Listed(section), + } +} + +const fn stock(action: &'static str, triggers: &'static [&'static str], listing: ShortcutListing) -> Accelerator { + Accelerator { + action, + triggers, + scope: Stock, + listing, + } +} + +const fn local( + owner: ShortcutOwner, + phase: gtk::PropagationPhase, + action: &'static str, + triggers: &'static [&'static str], + listing: ShortcutListing, +) -> Accelerator { + Accelerator { + action, + triggers, + scope: Local { owner, phase }, + listing, + } +} + +const fn component( + owner: ShortcutOwner, + action: &'static str, + triggers: &'static [&'static str], + listing: ShortcutListing, +) -> Accelerator { + Accelerator { + action, + triggers, + scope: Component(owner), + listing, + } +} + +/// Every key the app binds or relies on. History is on Ctrl+Shift+H, not +/// Ctrl+H: an application accelerator runs before the focused widget, and +/// Ctrl+H is find and replace in a text editor. +pub const ACCELERATORS: &[Accelerator] = &[ + application("app.new-window", &["n"], General), + application("app.preferences", &["comma"], General), + application("win.show-history", &["h"], General), + // As in GNOME Files. + application("win.show-sidebar", &["F9"], General), + application("app.quit", &["q"], General), + // AdwApplication adds the action and its accelerator once it finds + // shortcuts-dialog.ui under the resource base path. + stock("app.shortcuts", &["question"], Listed(General)), + stock("dialog.close", &["Escape"], Listed(General)), + application("win.new-editor-tab", &["t"], Tabs), + application("win.close-current", &["w"], Tabs), + application("win.reopen-closed-tab", &["t"], Tabs), + // AdwTabView's own controller, which runs in the capture phase. The + // workspace removes its Ctrl+Home and Ctrl+End pairs, so the lists + // and the editor inside the tabs keep those keys. + stock( + "tab-view.select-next", + &["Tab", "Page_Down"], + Listed(Tabs), + ), + stock( + "tab-view.select-previous", + &["Tab", "Page_Up"], + Listed(Tabs), + ), + stock("tab-view.move-next", &["Page_Down"], Listed(Tabs)), + stock("tab-view.move-previous", &["Page_Up"], Listed(Tabs)), + stock( + "tab-view.select-number", + &[ + "1", "2", "3", "4", "5", "6", "7", "8", "9", + ], + Unlisted, + ), + stock("tab-view.select-tenth", &["0"], Unlisted), + local(BrowseTab, BUBBLE, "browse.filter", &["f"], Listed(BrowseTable)), + // F5 as in GNOME Files, and Ctrl+R as the GNOME HIG names Reload. + local( + BrowseTab, + BUBBLE, + "browse.refresh", + &["F5", "r"], + Listed(BrowseTable), + ), + local(BrowseTab, BUBBLE, "browse.save", &["s"], Listed(BrowseTable)), + local(BrowseTab, BUBBLE, "browse.undo", &["z"], Listed(BrowseTable)), + local( + BrowseTab, + BUBBLE, + "browse.redo", + &["z", "y"], + Listed(BrowseTable), + ), + // GtkListBase binds Page Up and Page Down with no modifier, Ctrl, + // Shift and Ctrl+Shift to move within the rows on screen, so paging + // takes Alt, which no widget in the tab binds. + local( + BrowseTab, + BUBBLE, + "browse.previous-page", + &["Page_Up"], + Listed(BrowseTable), + ), + local( + BrowseTab, + BUBBLE, + "browse.next-page", + &["Page_Down"], + Listed(BrowseTable), + ), + stock("list.select-all", &["a"], Listed(BrowseTable)), + stock("list.unselect-all", &["a"], Listed(BrowseTable)), + stock("list.first-row", &["Home"], Listed(BrowseTable)), + stock("list.last-row", &["End"], Listed(BrowseTable)), + stock( + "list.extend-selection", + &["Pointer_Button1"], + Listed(BrowseTable), + ), + stock( + "list.toggle-selection", + &["Pointer_Button1"], + Listed(BrowseTable), + ), + stock("list.move", &["Up", "Down", "Left", "Right"], Listed(DataGrid)), + component(GridCell, "cell.edit", &["F2", "Return"], Listed(DataGrid)), + component(GridCell, "cell.toggle", &["space"], Listed(DataGrid)), + component(GridCell, "cell.menu", &["Menu", "F10"], Listed(DataGrid)), + component(GridCell, "cell.set-null", &["BackSpace"], Listed(DataGrid)), + stock("text.activate", &["Return"], Listed(DataGrid)), + component(CellEditor, "cell-editor.cancel", &["Escape"], Listed(DataGrid)), + component(CellEditor, "cell-editor.next", &["Tab"], Listed(DataGrid)), + component(CellEditor, "cell-editor.previous", &["Tab"], Listed(DataGrid)), + component( + CellEditor, + "cell-editor.save-text", + &["Return"], + Listed(DataGrid), + ), + component(CellEditor, "cell-editor.pick-date", &["space"], Unlisted), + local( + BrowseGrid, + BUBBLE, + "browse.insert-row", + &["n"], + Listed(DataGrid), + ), + local(BrowseGrid, BUBBLE, "browse.delete-rows", &["Delete"], Listed(DataGrid)), + local( + BrowseGrid, + BUBBLE, + "browse.copy-rows", + &["c"], + Listed(DataGrid), + ), + local(BrowseGrid, BUBBLE, "browse.paste-rows", &["v"], Unlisted), + local( + BrowseGrid, + BUBBLE, + "browse.clear-selection", + &["Escape"], + Listed(DataGrid), + ), + local( + SqlEditor, + CAPTURE, + "editor.run", + &["Return"], + Listed(ShortcutSection::SqlEditor), + ), + local( + SqlEditor, + CAPTURE, + "editor.run-statement", + &["Return"], + Listed(ShortcutSection::SqlEditor), + ), + // Enabled only while a query runs, so otherwise Escape goes on to the + // completion popup. + local( + SqlEditor, + CAPTURE, + "editor.cancel", + &["Escape"], + Listed(ShortcutSection::SqlEditor), + ), + // GtkPaned's own binding, a signal rather than an action. Tab indents + // in the editor and Ctrl+Tab switches tabs, so this is its way out. + stock("paned.cycle-child-focus", &["F6"], Listed(ShortcutSection::SqlEditor)), + local( + SqlEditor, + CAPTURE, + "editor.format", + &["f"], + Listed(ShortcutSection::SqlEditor), + ), + local( + SqlEditor, + CAPTURE, + "editor.toggle-comment", + &["slash"], + Listed(ShortcutSection::SqlEditor), + ), + local( + SqlEditorTab, + BUBBLE, + "editor.open-file", + &["o"], + Listed(ShortcutSection::SqlEditor), + ), + local( + SqlEditorTab, + BUBBLE, + "editor.save", + &["s"], + Listed(ShortcutSection::SqlEditor), + ), + local( + SqlEditorTab, + BUBBLE, + "editor.save-as", + &["s"], + Listed(ShortcutSection::SqlEditor), + ), + // GtkSearchEntry binds Ctrl+G and Ctrl+Shift+G itself, and the find + // bar follows its next-match and previous-match signals. + local( + SqlEditorTab, + BUBBLE, + "editor.find", + &["f"], + Listed(ShortcutSection::SqlEditor), + ), + local( + SqlEditorTab, + BUBBLE, + "editor.find-replace", + &["h"], + Listed(ShortcutSection::SqlEditor), + ), + local( + SqlEditorTab, + BUBBLE, + "editor.find-next", + &["g"], + Listed(ShortcutSection::SqlEditor), + ), + local( + SqlEditorTab, + BUBBLE, + "editor.find-previous", + &["g"], + Listed(ShortcutSection::SqlEditor), + ), + local( + StructureTab, + BUBBLE, + "structure.save", + &["s"], + Listed(TableStructure), + ), + local(FilterStrip, BUBBLE, "filter.close", &["Escape"], Unlisted), + local( + SidebarList, + BUBBLE, + "sidebar.context-menu", + &["Menu", "F10"], + Unlisted, + ), + local( + HistoryRow, + BUBBLE, + "history-row.menu", + &["Menu", "F10"], + Unlisted, + ), + local(PickerRow, BUBBLE, "picker-row.menu", &["Menu", "F10"], Unlisted), +]; + +pub fn install_application_accelerators(app: &impl IsA) { + for entry in ACCELERATORS.iter().filter(|entry| entry.scope == Application) { + app.set_accels_for_action(entry.action, entry.triggers); + } +} + +/// The owner's Local entries, each through the action it names, so a +/// disabled action lets its key go on. +pub fn local_shortcut_controller(owner: ShortcutOwner) -> gtk::ShortcutController { + let controller = gtk::ShortcutController::new(); + controller.set_scope(gtk::ShortcutScope::Local); + controller.set_name(Some(controller_name(owner))); + for entry in ACCELERATORS { + let Local { owner: of, phase } = entry.scope else { + continue; + }; + if of != owner { + continue; + } + controller.set_propagation_phase(phase); + for trigger in entry.triggers { + if let Some(trigger) = shortcut_trigger(&[trigger]) { + controller.add_shortcut(gtk::Shortcut::new( + Some(trigger), + Some(gtk::NamedAction::new(entry.action)), + )); + } + } + } + controller +} + +/// The name `local_shortcut_controller` gives the owner's controller. +pub const fn controller_name(owner: ShortcutOwner) -> &'static str { + match owner { + BrowseTab => "tablepro-browse-tab-shortcuts", + BrowseGrid => "tablepro-browse-grid-shortcuts", + GridCell => "tablepro-grid-cell-shortcuts", + CellEditor => "tablepro-cell-editor-shortcuts", + SqlEditor => "tablepro-sql-editor-shortcuts", + SqlEditorTab => "tablepro-sql-editor-tab-shortcuts", + StructureTab => "tablepro-structure-tab-shortcuts", + FilterStrip => "tablepro-filter-strip-shortcuts", + SidebarList => "tablepro-sidebar-list-shortcuts", + HistoryRow => "tablepro-history-row-shortcuts", + PickerRow => "tablepro-picker-row-shortcuts", + } +} + +pub fn triggers_for(action: &str) -> &'static [&'static str] { + ACCELERATORS + .iter() + .find(|entry| entry.action == action) + .map_or(&[], |entry| entry.triggers) +} + +/// The keys for a component's own controller, so what it binds is what +/// the table lists. +pub fn component_trigger(action: &str) -> Option { + shortcut_trigger(triggers_for(action)) +} + +/// For a component that reads its keys from an event controller. +pub fn component_matches(action: &str, keyval: gdk::Key, state: gdk::ModifierType) -> bool { + let modifiers = state & gtk::accelerator_get_default_mod_mask(); + let keyval = keyval.to_lower(); + triggers_for(action) + .iter() + .filter_map(|accel| parse(accel)) + .any(|(key, mods)| mods == modifiers && (key == keyval || keypad_twins(key).contains(&keyval))) +} + +/// GtkPopoverMenu shows an application accelerator by itself; a Local or +/// Component one has to be put on the item. +pub fn menu_item(label: &str, detailed_action: &str) -> gio::MenuItem { + let item = gio::MenuItem::new(Some(label), Some(detailed_action)); + let shown = ACCELERATORS + .iter() + .find(|entry| entry.action == detailed_action && matches!(entry.scope, Local { .. } | Component(_))) + .and_then(|entry| entry.triggers.first()); + if let Some(accel) = shown { + item.set_attribute_value("accel", Some(&accel.to_variant())); + } + item +} + +/// One trigger for all of `accels`, with the keypad twin of each key, as +/// GTK's own classes bind them. +fn shortcut_trigger(accels: &[&str]) -> Option { + accels + .iter() + .filter_map(|accel| parse(accel)) + .flat_map(|(key, modifiers)| { + std::iter::once(key) + .chain(keypad_twins(key).iter().copied()) + .map(move |key| gtk::KeyvalTrigger::new(key, modifiers).upcast::()) + }) + .reduce(|first, second| gtk::AlternativeTrigger::new(first, second).upcast()) +} + +fn parse(accel: &str) -> Option<(gdk::Key, gdk::ModifierType)> { + let parsed = gtk::accelerator_parse(accel); + if parsed.is_none() { + tracing::error!(accel, "an accelerator in the table does not parse; it is not bound"); + } + parsed +} + +pub(crate) fn keypad_twins(key: gdk::Key) -> &'static [gdk::Key] { + match key { + gdk::Key::Return => &[gdk::Key::ISO_Enter, gdk::Key::KP_Enter], + gdk::Key::space => &[gdk::Key::KP_Space], + gdk::Key::Delete => &[gdk::Key::KP_Delete], + _ => &[], + } +} + +#[cfg(test)] +mod tests { + use std::collections::{HashMap, HashSet}; + + use libadwaita as adw; + + use super::*; + use crate::test_support::shortcut_keys::{Keystroke, keystroke, keystrokes_with_twins, shortcuts, trigger_keys}; + + /// Keys a window runs before the focused widget sees them: the + /// application's, and the stock ones AdwApplication and AdwTabView + /// add in the same capture phase. + fn reaches_window_first(entry: &Accelerator) -> bool { + entry.scope == Application || entry.action == "app.shortcuts" || entry.action.starts_with("tab-view.") + } + + fn owner_of(entry: &Accelerator) -> Option { + match entry.scope { + Local { owner, .. } | Component(owner) => Some(owner), + Application | Stock => None, + } + } + + /// The dialog shows these through `action-name`, so it shows the + /// accelerator GTK has installed. + fn shown_by_action_name(entry: &Accelerator) -> bool { + entry.scope == Application || entry.action == "app.shortcuts" + } + + #[gtk4::test] + fn every_trigger_parses() { + for entry in ACCELERATORS { + assert!(!entry.triggers.is_empty(), "{} has no keys", entry.action); + for accel in entry.triggers { + assert!(gtk::accelerator_parse(*accel).is_some(), "{accel} of {}", entry.action); + assert!( + gtk::ShortcutTrigger::parse_string(accel).is_some(), + "{accel} of {}", + entry.action + ); + } + } + } + + #[gtk4::test] + fn no_local_or_component_trigger_shadows_an_application_trigger() { + let first: HashSet = ACCELERATORS + .iter() + .filter(|entry| reaches_window_first(entry)) + .flat_map(|entry| entry.triggers.iter().map(|accel| keystroke(accel))) + .collect(); + assert!(first.contains(&keystroke("w"))); + + for entry in ACCELERATORS { + let Some(owner) = owner_of(entry) else { + continue; + }; + for accel in entry.triggers { + for key in keystrokes_with_twins(accel) { + assert!( + !first.contains(&key), + "{accel} of {} is taken before it reaches {owner:?}", + entry.action + ); + } + } + } + } + + #[gtk4::test] + fn application_scope_avoids_text_editing_and_editor_keys() { + let reserved: HashSet = ["z", "y", "a", "c", "v", "x", "f", "g", "h", "slash"] + .iter() + .map(|key| keystroke(&format!("{key}"))) + .chain( + ["z", "g"] + .iter() + .map(|key| keystroke(&format!("{key}"))), + ) + .collect(); + let with_any_modifier = [ + gdk::Key::F5, + gdk::Key::Escape, + gdk::Key::Return, + gdk::Key::Delete, + gdk::Key::F2, + gdk::Key::BackSpace, + gdk::Key::Home, + gdk::Key::End, + gdk::Key::Tab, + gdk::Key::Page_Up, + gdk::Key::Page_Down, + gdk::Key::Up, + gdk::Key::Down, + gdk::Key::Left, + gdk::Key::Right, + ]; + + for entry in ACCELERATORS.iter().filter(|entry| entry.scope == Application) { + for accel in entry.triggers { + let key = keystroke(accel); + assert!(!reserved.contains(&key), "{} takes {accel}", entry.action); + assert!(!with_any_modifier.contains(&key.0), "{} takes {accel}", entry.action); + } + } + } + + #[gtk4::test] + fn no_duplicate_trigger_within_an_owner() { + let mut seen: HashMap<(Option, Keystroke), &str> = HashMap::new(); + for entry in ACCELERATORS { + let group = match owner_of(entry) { + Some(owner) => Some(owner), + None if reaches_window_first(entry) => None, + None => continue, + }; + for accel in entry.triggers { + for key in keystrokes_with_twins(accel) { + if let Some(other) = seen.insert((group, key), entry.action) { + panic!("{accel} is both {other} and {}", entry.action); + } + } + } + } + } + + #[test] + fn each_action_has_one_entry() { + let mut seen = HashSet::new(); + for entry in ACCELERATORS { + assert!(seen.insert(entry.action), "{} has two entries", entry.action); + } + } + + #[test] + fn each_local_owner_runs_in_one_phase() { + let mut phases: HashMap = HashMap::new(); + for entry in ACCELERATORS { + if let Local { owner, phase } = entry.scope { + let first = *phases.entry(owner).or_insert(phase); + assert_eq!(first, phase, "{owner:?} mixes phases at {}", entry.action); + } + } + } + + #[gtk4::test] + fn local_shortcut_controller_names_each_trigger() { + let mut owners: Vec = Vec::new(); + for entry in ACCELERATORS { + if let Local { owner, .. } = entry.scope + && !owners.contains(&owner) + { + owners.push(owner); + } + } + for owner in owners { + let controller = local_shortcut_controller(owner); + let mut expected = Vec::new(); + for entry in ACCELERATORS { + if let Local { owner: of, phase } = entry.scope + && of == owner + { + assert_eq!(controller.propagation_phase(), phase, "{owner:?}"); + for accel in entry.triggers { + expected.push((entry.action.to_owned(), keystrokes_with_twins(accel))); + } + } + } + + let found: Vec<(String, Vec)> = shortcuts(&controller) + .iter() + .map(|shortcut| { + let action = shortcut + .action() + .and_downcast::() + .map(|action| action.action_name().to_string()) + .unwrap_or_default(); + let keys = shortcut + .trigger() + .map(|trigger| trigger_keys(&trigger)) + .unwrap_or_default(); + (action, keys) + }) + .collect(); + + assert_eq!(found, expected, "{owner:?}"); + assert_eq!(controller.scope(), gtk::ShortcutScope::Local); + assert_eq!(controller.name().as_deref(), Some(controller_name(owner))); + } + } + + #[gtk4::test] + fn menu_item_shows_local_and_component_keys() { + let accel = |item: &gio::MenuItem| { + item.attribute_value("accel", None) + .and_then(|value| value.get::()) + }; + + assert_eq!( + accel(&menu_item("Set to NULL", "cell.set-null")).as_deref(), + Some("BackSpace") + ); + assert_eq!(accel(&menu_item("Edit Cell", "cell.edit")).as_deref(), Some("F2")); + assert_eq!( + accel(&menu_item("New Window", "app.new-window")), + None, + "GTK shows its own" + ); + assert_eq!(accel(&menu_item("Copy", "cell.copy")), None); + } + + #[gtk4::test] + fn component_matches_reads_the_table_and_keypad_twins() { + let none = gdk::ModifierType::empty(); + + assert!(component_matches("cell-editor.pick-date", gdk::Key::space, none)); + assert!(component_matches("cell-editor.pick-date", gdk::Key::KP_Space, none)); + assert!(component_matches( + "cell-editor.pick-date", + gdk::Key::space, + gdk::ModifierType::LOCK_MASK + )); + assert!(!component_matches( + "cell-editor.pick-date", + gdk::Key::space, + gdk::ModifierType::CONTROL_MASK + )); + assert!(!component_matches("cell-editor.pick-date", gdk::Key::Return, none)); + } + + #[gtk4::test] + fn shortcuts_dialog_matches_accelerator_table() { + adw::init().expect("libadwaita initialises under the test backend"); + crate::register_test_resources(); + let builder = + gtk::Builder::from_resource(&format!("{}/shortcuts-dialog.ui", crate::config::RESOURCE_BASE_PATH)); + let sections = [ + (ShortcutSection::General, "section-general"), + (ShortcutSection::Tabs, "section-tabs"), + (ShortcutSection::BrowseTable, "section-browse-table"), + (ShortcutSection::DataGrid, "section-data-grid"), + (ShortcutSection::SqlEditor, "section-sql-editor"), + (ShortcutSection::TableStructure, "section-table-structure"), + ]; + let mut placed: Vec<(adw::ShortcutsItem, ShortcutSection)> = Vec::new(); + for (section, id) in sections { + let list = builder + .object::(id) + .unwrap_or_else(|| panic!("the dialog has no {id}")); + for position in 0..list.n_items() { + let item = list + .item(position) + .and_downcast::() + .expect("a shortcut item"); + placed.push((item, section)); + } + } + let objects = builder.objects(); + assert_eq!( + objects + .iter() + .filter(|object| object.is::()) + .count(), + sections.len(), + "a section the table does not know" + ); + assert_eq!( + objects + .iter() + .filter(|object| object.is::()) + .count(), + placed.len(), + "an item outside the sections" + ); + + let mut listed = Vec::new(); + for entry in ACCELERATORS { + let Listed(section) = entry.listing else { + continue; + }; + let id = entry.action.replace('.', "-"); + let item = builder + .object::(&id) + .unwrap_or_else(|| panic!("the dialog does not list {}", entry.action)); + let found = placed + .iter() + .find(|(placed, _)| placed == &item) + .map(|(_, section)| *section); + assert_eq!(found, Some(section), "{} is in another section", entry.action); + if shown_by_action_name(entry) { + assert_eq!(item.action_name(), entry.action); + assert_eq!(item.accelerator(), "", "{}", entry.action); + } else { + assert_eq!(item.accelerator(), entry.triggers.join(" "), "{}", entry.action); + assert_eq!(item.action_name(), "", "{}", entry.action); + } + listed.push(item); + } + for (item, _) in &placed { + assert!( + listed.contains(item), + "the dialog lists {:?}, which the table does not", + item.title() + ); + } + } +} diff --git a/linux/crates/app/src/application/app_actions.rs b/linux/crates/app/src/application/app_actions.rs new file mode 100644 index 0000000000..638a6686bf --- /dev/null +++ b/linux/crates/app/src/application/app_actions.rs @@ -0,0 +1,76 @@ +use gtk4::glib; +use gtk4::prelude::*; +use gtk4::subclass::prelude::*; +use libadwaita::prelude::*; +use uuid::Uuid; + +use super::TableProApplication; +use super::about_dialog::build_about_dialog; +use crate::runtime::DeliverInput; +use crate::ui::{PreferencesDialog, SessionWindowInput}; + +pub fn install_app_actions(app: &TableProApplication) { + let new_window = gio::ActionEntry::builder("new-window") + .activate(|app: &TableProApplication, _, _| app.open_launch_windows()) + .build(); + let quit = gio::ActionEntry::builder("quit") + .activate(|app: &TableProApplication, _, _| app.request_quit()) + .build(); + let preferences = gio::ActionEntry::builder("preferences") + .activate(|app: &TableProApplication, _, _| present_preferences(app)) + .build(); + let about = gio::ActionEntry::builder("about") + .activate(|app: &TableProApplication, _, _| present_about(app)) + .build(); + // A notification's default action, with the window and the tab it + // is about. + let focus_tab = gio::ActionEntry::builder("focus-tab") + .parameter_type(Some(&<(u32, String)>::static_variant_type())) + .activate(|app: &TableProApplication, _, target| focus_tab(app, target)) + .build(); + app.add_action_entries([new_window, quit, preferences, about, focus_tab]); +} + +/// Bring the tab forward in its window. A notification can outlive both, +/// and is clicked after a restart too, so one that names neither brings +/// TablePro forward instead. +fn focus_tab(app: &TableProApplication, target: Option<&glib::Variant>) { + let named = target + .and_then(|target| target.get::<(u32, String)>()) + .and_then(|(window_id, tab)| Some((window_id, Uuid::parse_str(&tab).ok()?))); + let sender = named.and_then(|(window_id, tab)| Some((window_id, tab, app.imp().windows.sender(window_id)?))); + match sender { + Some((window_id, tab, sender)) => { + sender.deliver(SessionWindowInput::FocusTab(tab)); + app.present_window(window_id); + } + None => { + tracing::warn!(?target, "a notification named a window or tab that is gone"); + match app.active_window() { + Some(window) => window.present(), + None => app.activate(), + } + } + } +} + +fn present_preferences(app: &TableProApplication) { + let Some(services) = app.services() else { + return; + }; + PreferencesDialog::new( + &services.settings, + &services.storage, + services.history(), + &services.tasks, + services.file_revealer.clone(), + ) + .present(app.active_window().as_ref()); +} + +fn present_about(app: &TableProApplication) { + let Some(services) = app.services() else { + return; + }; + build_about_dialog(&services.drivers).present(app.active_window().as_ref()); +} diff --git a/linux/crates/app/src/application/app_services.rs b/linux/crates/app/src/application/app_services.rs new file mode 100644 index 0000000000..7d55615207 --- /dev/null +++ b/linux/crates/app/src/application/app_services.rs @@ -0,0 +1,255 @@ +use std::cell::OnceCell; +use std::path::Path; +use std::rc::Rc; +use std::sync::Arc; + +use gtk4::glib::prelude::IsA; +use tablepro_core::{ConnectEnvironment, DriverRegistry, LivenessPolicy}; +use tablepro_session::connect::StoreConnectionWrites; +use tablepro_session::operation::{DeadlinePolicy, DefaultDeadlines}; +use tablepro_session::runtime::{ProcessHold, Tasks}; +use tablepro_session::{ConnectServices, Connector, DriverConnector, SessionRegistry}; +use tablepro_ssh::SshServices; +use tablepro_storage::{AppSettings, QueryHistory, SecretStore, StoragePaths}; + +use crate::config; +use crate::runtime::GioProcessHold; +use crate::services::active_session::ActiveSession; +use crate::services::connection_deletion::ConnectionDeletion; +use crate::services::history_preferences::HistoryPreferences; +use crate::services::history_service::HistoryService; +use crate::services::saved_connection_editor::SavedConnectionEditor; +use crate::services::saved_connection_list::SavedConnectionList; +use crate::services::session_context::{CredentialSourceFactory, KeyringSources, SessionContext}; +use crate::storage::{AppStorage, SharedStorage}; +use crate::ui::credential_prompt::{GtkCredentialPrompter, PromptQueue}; +use crate::ui::file_reveal::{FileLauncherRevealer, FileRevealer}; +use crate::workspace::OpenSessions; + +/// What every window of the application shares. GTK-thread only. +pub struct AppServices { + pub drivers: Arc, + pub connector: Arc, + pub deadlines: Arc, + pub liveness: LivenessPolicy, + pub sessions: Rc, + pub settings: Rc, + pub storage: SharedStorage, + pub tasks: Tasks, + /// The saved connections every picker shows, following the store. + pub saved_connections: Rc, + /// Edits, copies and regroups saved connections with their secrets. + pub saved_connection_editor: SavedConnectionEditor, + /// Shows a file selected in the file manager. + pub(crate) file_revealer: Rc, + /// Where each session's secrets come from. + credentials: Rc, + /// Bound to the application once it exists, which is after this. + application_hold: Rc, + /// What keeps the process alive while a close or a save finishes. + hold: Rc, + /// Which sessions the windows show, for the next launch to reopen. + open_sessions: Rc, + history: OnceCell, + ssh: Option, +} + +/// What `AppServices::new` builds from, for a caller that brings its own +/// connector and storage. +pub struct AppServicesParts { + pub drivers: DriverRegistry, + pub connector: Arc, + pub deadlines: Arc, + pub liveness: LivenessPolicy, + pub settings: Rc, + pub storage: AppStorage, + pub tasks: Tasks, +} + +impl AppServices { + pub fn new(parts: AppServicesParts) -> Self { + let AppServicesParts { + drivers, + connector, + deadlines, + liveness, + settings, + storage, + tasks, + } = parts; + let application_hold = Rc::new(GioProcessHold::default()); + let open_sessions = OpenSessions::new(storage.workspaces().clone(), application_hold.clone()); + let saved_connections = SavedConnectionList::follow(storage.connections()); + let saved_connection_editor = + SavedConnectionEditor::new(storage.connections().clone(), storage.secrets().clone(), tasks.clone()); + Self { + drivers: Arc::new(drivers), + connector, + deadlines, + liveness, + sessions: SessionRegistry::new(), + settings, + credentials: Rc::new(KeyringSources::new(storage.secrets().clone())), + storage: Rc::new(storage), + tasks, + saved_connections, + saved_connection_editor, + file_revealer: Rc::new(FileLauncherRevealer), + hold: application_hold.clone(), + application_hold, + open_sessions, + history: OnceCell::new(), + ssh: None, + } + } + + /// A second launch builds this too before it hands its command line + /// to the running instance, so what only the primary may do waits + /// for `start`. + pub fn production(drivers: DriverRegistry, settings: Rc, paths: StoragePaths, tasks: Tasks) -> Self { + let secrets = Arc::new(SecretStore::new(config::secret_schema())); + let storage = AppStorage::new(paths, secrets, &tasks); + let ssh = SshServices::system(storage.paths().runtime.clone(), Path::new(config::LIBEXECDIR)); + let liveness = LivenessPolicy::DESKTOP; + let deadlines: Arc = Arc::new(DefaultDeadlines::new(liveness)); + let connector = DriverConnector::new( + ConnectServices { + environment: ConnectEnvironment::new(config::VERSION), + ssh: ssh.clone(), + liveness, + }, + deadlines.clone(), + ); + let mut services = Self::new(AppServicesParts { + drivers, + connector: Arc::new(connector), + deadlines, + liveness, + settings, + storage, + tasks, + }); + services.ssh = Some(ssh); + services + } + + /// The work only the primary instance does, from its startup. + /// + /// A missing askpass helper is logged rather than fatal: only SSH + /// needs it, and the connect that tries says so in its own error. The + /// sweep ends masters a crashed instance left behind, which would + /// otherwise keep their tunnels open until the next reboot. + pub(crate) fn start(&self) { + drop(self.history()); + let Some(ssh) = self.ssh.clone() else { + return; + }; + if let Err(error) = ssh.verify_askpass_helper() { + tracing::warn!(%error, "SSH connections will fail until the askpass helper is installed"); + } + drop(self.tasks.spawn_task(async move { + let ended = tablepro_ssh::sweep_stale_masters(&ssh).await; + if ended > 0 { + tracing::info!(ended, "ended SSH masters a previous instance left running"); + } + })); + } + + /// The database migrates and prunes on open, which is too slow to + /// hold up the first window. The menu items that need it stay + /// disabled until the service says how the open went. + pub(crate) fn history(&self) -> HistoryService { + self.history + .get_or_init(|| { + let preferences = HistoryPreferences::follow(&self.settings); + HistoryService::start(self.storage.paths().clone(), preferences, &self.tasks) + }) + .clone() + } + + /// The OpenSSH client and its helpers. `None` in a test that brings + /// none. + pub(crate) fn ssh(&self) -> Option { + self.ssh.clone() + } + + pub(crate) fn hold(&self) -> Rc { + self.hold.clone() + } + + /// What deletes a saved connection and everything kept for it, with + /// `history` as the history database, or `None` when it did not + /// open. + pub(crate) fn connection_deletion(&self, history: Option) -> ConnectionDeletion { + ConnectionDeletion { + secrets: self.storage.secrets().clone(), + connections: Arc::new(StoreConnectionWrites::new( + self.storage.connections().clone(), + self.tasks.clone(), + )), + column_widths: self.storage.column_widths().clone(), + filters: self.storage.filter_settings().clone(), + workspaces: self.storage.workspaces().clone(), + history, + spawner: self.tasks.clone(), + } + } + + pub(crate) fn open_sessions(&self) -> &Rc { + &self.open_sessions + } + + pub(crate) fn bind_application(&self, application: &impl IsA) { + self.application_hold.bind(application); + } + + /// A test reads the holds, or stands in for the keyring. + #[cfg(test)] + pub(crate) fn with_hold(mut self, hold: Rc) -> Self { + self.open_sessions = OpenSessions::new(self.storage.workspaces().clone(), hold.clone()); + self.hold = hold; + self + } + + #[cfg(test)] + pub(crate) fn with_ssh(mut self, ssh: SshServices) -> Self { + self.ssh = Some(ssh); + self + } + + #[cfg(test)] + pub(crate) fn with_credentials(mut self, credentials: Rc) -> Self { + self.credentials = credentials; + self + } + + /// One window's view of the shared services: its own session slot, + /// and its own credential prompts, which it shows over itself. + pub(crate) fn window_context(&self) -> (Rc, PromptQueue) { + let (prompter, prompts) = GtkCredentialPrompter::new(); + let context = SessionContext { + drivers: self.drivers.clone(), + connector: self.connector.clone(), + deadlines: self.deadlines.clone(), + prompter: Arc::new(prompter), + secrets: self.storage.secrets().clone(), + sessions: self.sessions.clone(), + hold: self.hold(), + tasks: self.tasks.clone(), + liveness: self.liveness, + credentials: self.credentials.clone(), + active: ActiveSession::new(), + }; + (Rc::new(context), prompts) + } +} + +impl std::fmt::Debug for AppServices { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("AppServices") + .field("drivers", &self.drivers.len()) + .field("sessions", &self.sessions) + .field("history", &self.history.get().is_some()) + .finish_non_exhaustive() + } +} diff --git a/linux/crates/app/src/application/app_window_host.rs b/linux/crates/app/src/application/app_window_host.rs new file mode 100644 index 0000000000..0d11383211 --- /dev/null +++ b/linux/crates/app/src/application/app_window_host.rs @@ -0,0 +1,34 @@ +use gtk4 as gtk; +use gtk4::glib; +use gtk4::prelude::*; + +use super::{TableProApplication, WindowHost, WindowLaunch}; + +/// The application keeps every window's controller, so a window that +/// held it strongly would keep it alive past its own shutdown. +#[derive(Debug)] +pub struct AppWindowHost { + app: glib::WeakRef, +} + +impl AppWindowHost { + pub fn new(app: &TableProApplication) -> Self { + Self { app: app.downgrade() } + } +} + +impl WindowHost for AppWindowHost { + fn present_window(&self, window_id: u32) -> bool { + self.app.upgrade().is_some_and(|app| app.present_window(window_id)) + } + + fn open_window(&self, launch: WindowLaunch) { + if let Some(app) = self.app.upgrade() { + app.open_window(launch); + } + } + + fn application(&self) -> Option { + self.app.upgrade().map(|app| app.upcast()) + } +} diff --git a/linux/crates/app/src/application/crash_notice.rs b/linux/crates/app/src/application/crash_notice.rs new file mode 100644 index 0000000000..29ff4b724c --- /dev/null +++ b/linux/crates/app/src/application/crash_notice.rs @@ -0,0 +1,62 @@ +use std::path::PathBuf; + +use gtk4::glib; +use gtk4::prelude::*; +use gtk4::subclass::prelude::*; + +use super::TableProApplication; +use crate::ui::SessionWindow; + +impl TableProApplication { + /// Looks off the GTK thread for a report the last run left when it + /// crashed, and has a window say so. + pub(super) fn look_for_crash_report(&self) { + let Some(services) = self.services() else { + return; + }; + let dir = services.storage.paths().crashes_dir(); + let looking = services + .tasks + .spawn_blocking_task(move || crate::crash_report::take_unannounced(&dir)); + let app = self.downgrade(); + glib::spawn_future_local(async move { + let report = match looking.await { + Ok(Ok(Some(report))) => report, + Ok(Ok(None)) => return, + Ok(Err(error)) => { + tracing::warn!(%error, "could not look for a crash report"); + return; + } + Err(failure) => { + tracing::warn!(%failure, "the crash report lookup failed"); + return; + } + }; + if let Some(app) = app.upgrade() { + app.announce_crash(report); + } + }); + } + + /// The window in front says it, or the first one to open when the + /// windows of a restored session are still on their way. + fn announce_crash(&self, report: PathBuf) { + let window = self.active_window().and_downcast::().or_else(|| { + self.windows() + .into_iter() + .find_map(|window| window.downcast::().ok()) + }); + match window { + Some(window) => window.announce_crash(report), + None => { + self.imp().crash_report.replace(Some(report)); + } + } + } + + pub(super) fn hand_over_crash_report(&self, window: &SessionWindow) { + if let Some(report) = self.imp().crash_report.take() { + window.announce_crash(report); + } + } +} diff --git a/linux/crates/app/src/application/debug_info.rs b/linux/crates/app/src/application/debug_info.rs new file mode 100644 index 0000000000..3bdfb2e5a7 --- /dev/null +++ b/linux/crates/app/src/application/debug_info.rs @@ -0,0 +1,103 @@ +use tablepro_core::DriverRegistry; + +use super::runtime_versions::RuntimeVersions; +use crate::config::{self, Profile}; + +/// The Troubleshooting text of the About dialog. A user pastes it into a +/// public bug report, so it takes nothing that could hold a connection, +/// a host, a user name or a path. +pub fn debug_info(versions: &RuntimeVersions, drivers: &DriverRegistry) -> String { + let mut drivers: Vec<_> = drivers + .iter() + .map(|driver| (driver.id(), driver.display_name())) + .collect(); + // The registry is a hash map; a report reads the same each time. + drivers.sort_unstable(); + + let mut lines = vec![ + format!("TablePro {}", config::VERSION), + format!("Application ID: {}", config::APP_ID), + format!("Profile: {}", profile_name(config::profile())), + String::new(), + format!("GTK: {}", dotted(versions.gtk)), + format!("libadwaita: {}", dotted(versions.adwaita)), + format!("GtkSourceView: {}", dotted(versions.sourceview)), + format!("Sandbox: {}", if versions.flatpak { "Flatpak" } else { "none" }), + String::new(), + "Drivers:".to_owned(), + ]; + lines.extend(drivers.into_iter().map(|(id, name)| format!("- {id}: {name}"))); + lines.push(String::new()); + lines.join("\n") +} + +fn profile_name(profile: Profile) -> &'static str { + match profile { + Profile::Default => "default", + Profile::Development => "development", + } +} + +fn dotted((major, minor, micro): (u32, u32, u32)) -> String { + format!("{major}.{minor}.{micro}") +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use super::*; + + #[test] + fn debug_info_lists_versions_and_drivers_only() { + let versions = RuntimeVersions { + gtk: (4, 22, 1), + adwaita: (1, 9, 2), + sourceview: (5, 18, 0), + flatpak: true, + }; + let mut drivers = DriverRegistry::new(); + drivers.register(Arc::new(tablepro_driver_sqlite::SqliteDriver)); + drivers.register(Arc::new(tablepro_driver_postgres::PgDriver)); + + let expected = format!( + "TablePro {version}\n\ + Application ID: {app_id}\n\ + Profile: {profile}\n\ + \n\ + GTK: 4.22.1\n\ + libadwaita: 1.9.2\n\ + GtkSourceView: 5.18.0\n\ + Sandbox: Flatpak\n\ + \n\ + Drivers:\n\ + - postgres: PostgreSQL\n\ + - sqlite: SQLite\n", + version = config::VERSION, + app_id = config::APP_ID, + profile = profile_name(config::profile()), + ); + assert_eq!(debug_info(&versions, &drivers), expected); + } + + #[test] + fn debug_info_says_when_there_is_no_sandbox() { + let versions = RuntimeVersions { + gtk: (4, 22, 0), + adwaita: (1, 9, 0), + sourceview: (5, 18, 0), + flatpak: false, + }; + + let text = debug_info(&versions, &DriverRegistry::new()); + + assert!(text.contains("\nSandbox: none\n"), "{text}"); + assert!(text.ends_with("Drivers:\n"), "{text}"); + } + + #[test] + fn profile_names_match_the_meson_option() { + assert_eq!(profile_name(Profile::Default), "default"); + assert_eq!(profile_name(Profile::Development), "development"); + } +} diff --git a/linux/crates/app/src/application/imp.rs b/linux/crates/app/src/application/imp.rs new file mode 100644 index 0000000000..bdcb53208b --- /dev/null +++ b/linux/crates/app/src/application/imp.rs @@ -0,0 +1,170 @@ +use std::cell::{Cell, OnceCell, RefCell}; +use std::ops::ControlFlow; +use std::rc::Rc; + +use gtk4 as gtk; +use gtk4::glib; +use gtk4::prelude::*; +use gtk4::subclass::prelude::*; +use libadwaita as adw; +use libadwaita::subclass::prelude::*; + +use super::session_signals::SessionSignals; +use super::{AppServices, WindowRegistry, install_app_actions, install_application_accelerators}; +use crate::workspace::CloseDecision; + +pub(super) const NEW_WINDOW_OPTION: &str = "new-window"; + +#[derive(Default)] +pub struct TableProApplication { + pub(super) services: OnceCell>, + pub(super) windows: WindowRegistry, + /// The quit in progress, if any. + pub(super) quit: RefCell>>, + /// The window the quit is waiting on. + pub(super) quit_asking: Cell>, + /// Only the primary instance has sessions to tell. + pub(super) session_signals: RefCell>, + /// Only the first activate restores the last session. + pub(super) restore: Cell, + /// The restore waiting on the record, which a quit approved + /// meanwhile waits for too. + pub(super) restoring: RefCell>>, + /// Files handed over while the record is read, opened after the + /// windows it restores. + pub(super) pending_files: RefCell>, + /// A crash report found before any window opened. + pub(super) crash_report: RefCell>, +} + +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +pub(super) enum RestoreState { + #[default] + NotAttempted, + /// The record is being read, and the windows open once it is. + Loading, + Done, + /// An approved quit is writing the record, or waiting out a read of + /// it whose windows it keeps from opening. A launch meanwhile asks + /// for it to be restored after: read earlier, it would race the + /// write, and the quit then empties the list it restored into. + Quitting { + relaunched: bool, + }, +} + +#[glib::object_subclass] +impl ObjectSubclass for TableProApplication { + const NAME: &'static str = "TpApplication"; + type Type = super::TableProApplication; + type ParentType = adw::Application; +} + +impl ObjectImpl for TableProApplication { + fn constructed(&self) { + self.parent_constructed(); + // GApplication parses the command line before it emits + // handle-local-options, so an option added there is unknown. + self.obj().add_main_option( + NEW_WINDOW_OPTION, + glib::Char::from(b'n'), + glib::OptionFlags::NONE, + glib::OptionArg::None, + &crate::i18n::gettext("Open a new window"), + None, + ); + } +} + +impl ApplicationImpl for TableProApplication { + fn startup(&self) { + self.parent_startup(); + let app = self.obj(); + install_app_actions(&app); + install_application_accelerators(&*app); + if let Some(services) = self.services.get() { + services.start(); + app.look_for_crash_report(); + self.session_signals + .replace(Some(SessionSignals::start(&services.sessions))); + } + app.connect_query_end(|app| { + app.present_inhibiting_window(); + }); + } + + fn activate(&self) { + self.parent_activate(); + let app = self.obj(); + if let Some(window) = app.active_window() { + window.present(); + return; + } + app.open_launch_windows(); + } + + /// The files from Open With in the file manager, or from the command + /// line. GApplication opens them instead of activating, so no picker + /// window comes with them. + fn open(&self, files: &[gio::File], _hint: &str) { + match self.restore.get() { + RestoreState::Done => self.obj().open_files(files), + // The record is read before a file's window adds to it, or + // that write would replace the sessions still to restore. + RestoreState::NotAttempted => { + self.pending_files.borrow_mut().extend_from_slice(files); + self.obj().restore_session(); + } + RestoreState::Loading => self.pending_files.borrow_mut().extend_from_slice(files), + RestoreState::Quitting { .. } => { + self.pending_files.borrow_mut().extend_from_slice(files); + self.restore.set(RestoreState::Quitting { relaunched: true }); + } + } + } + + /// `--new-window` on a second launch opens the window in the running + /// instance and exits. On the first launch it falls through to + /// activate, which opens the one window either way. + fn handle_local_options(&self, options: &glib::VariantDict) -> ControlFlow { + if !options.contains(NEW_WINDOW_OPTION) { + return ControlFlow::Continue(()); + } + let app = self.obj(); + if let Err(error) = app.register(gio::Cancellable::NONE) { + tracing::error!(%error, "could not register with the session bus"); + return ControlFlow::Break(glib::ExitCode::FAILURE); + } + if app.is_remote() { + app.activate_action("new-window", None); + return ControlFlow::Break(glib::ExitCode::SUCCESS); + } + ControlFlow::Continue(()) + } + + fn shutdown(&self) { + // The windows go with the process, which is not the user closing + // them, so the next launch still reopens their sessions. + if let Some(services) = self.services.get() { + services.open_sessions().freeze_for_quit(); + } + self.windows.clear(); + drop(self.session_signals.take()); + drop(self.quit.take()); + self.parent_shutdown(); + } +} + +impl GtkApplicationImpl for TableProApplication { + fn window_removed(&self, window: >k::Window) { + let id = window + .downcast_ref::() + .map(|window| window.id()); + self.parent_window_removed(window); + if let Some(id) = id { + self.windows.remove(id); + } + } +} + +impl AdwApplicationImpl for TableProApplication {} diff --git a/linux/crates/app/src/application/legal_section.rs b/linux/crates/app/src/application/legal_section.rs new file mode 100644 index 0000000000..0b7a3414ef --- /dev/null +++ b/linux/crates/app/src/application/legal_section.rs @@ -0,0 +1,49 @@ +use gtk4 as gtk; + +/// One block on the Legal page of the About dialog. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LegalSection { + pub title: String, + pub license_type: gtk::License, + /// Set only for `gtk::License::Custom`: for a known type the dialog + /// names the license and links to it. + pub license_text: Option, +} + +impl LegalSection { + /// The dialog sets a custom license as Pango markup, and a copyright + /// line such as `Name ` would blank the section. + pub fn license_markup(&self) -> Option { + self.license_text.as_deref().map(glib::markup_escape_text) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn license_markup_escapes_the_text() { + let section = LegalSection { + title: "a".to_owned(), + license_type: gtk::License::Custom, + license_text: Some("Copyright 2020 A & B".to_owned()), + }; + + assert_eq!( + section.license_markup().as_deref(), + Some("Copyright 2020 A <a@example.org> & B") + ); + } + + #[test] + fn license_markup_is_absent_without_a_text() { + let section = LegalSection { + title: "a".to_owned(), + license_type: gtk::License::MitX11, + license_text: None, + }; + + assert_eq!(section.license_markup(), None); + } +} diff --git a/linux/crates/app/src/application/metainfo_release.rs b/linux/crates/app/src/application/metainfo_release.rs new file mode 100644 index 0000000000..3c785a3583 --- /dev/null +++ b/linux/crates/app/src/application/metainfo_release.rs @@ -0,0 +1,69 @@ +//! build.rs includes this file too, so the release it fills into the +//! metainfo's URLs and the one the tests expect are read the same way. + +/// AppStream lists releases newest first, so this is the first +/// `` element's version, whatever order its attributes are in. +pub(crate) fn newest_release(xml: &str) -> Option<&str> { + let tag = xml + .match_indices("' || c == '/'))?; + attribute(&tag[..tag.find('>')?], "version") +} + +fn attribute<'a>(attributes: &'a str, name: &str) -> Option<&'a str> { + let mut rest = attributes; + loop { + let (key, value) = rest.split_once('=')?; + let value = value.trim_start(); + let quote = value.chars().next().filter(|c| matches!(c, '"' | '\''))?; + let value = &value[1..]; + let end = value.find(quote)?; + if key.trim() == name { + return Some(&value[..end]); + } + rest = &value[end + 1..]; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_first_release_is_the_newest() { + let xml = r#" + + + "#; + + assert_eq!(newest_release(xml), Some("1.1.0")); + } + + #[test] + fn the_version_can_come_after_other_attributes() { + assert_eq!( + newest_release(r#""#), + Some("1.0.0") + ); + assert_eq!( + newest_release(""), + Some("1.0.0") + ); + assert_eq!( + newest_release(r#""#), + Some("1.0.0") + ); + } + + #[test] + fn a_release_without_a_version_names_none() { + assert_eq!(newest_release(""), None); + assert_eq!(newest_release(r#""#), None); + assert_eq!( + newest_release(r#""#), + None, + "an older release stood in for the newest" + ); + } +} diff --git a/linux/crates/app/src/application/mod.rs b/linux/crates/app/src/application/mod.rs new file mode 100644 index 0000000000..85a4ffe6e4 --- /dev/null +++ b/linux/crates/app/src/application/mod.rs @@ -0,0 +1,217 @@ +//! The GApplication: one process per profile, the actions every window +//! shares, and the windows themselves. + +mod about_dialog; +mod accelerator; +mod accelerator_scope; +mod accelerators; +mod app_actions; +mod app_services; +mod app_window_host; +mod crash_notice; +mod debug_info; +mod imp; +mod legal_section; +#[cfg(test)] +mod metainfo_release; +mod open_files; +mod quit_sequence; +mod runtime_versions; +mod session_signals; +mod shortcut_listing; +mod shortcut_owner; +mod shortcut_section; +mod third_party_licenses; +mod window_host; +mod window_launch; +mod window_registry; + +use std::rc::Rc; + +use gtk4 as gtk; +use gtk4::glib; +use gtk4::prelude::*; +use gtk4::subclass::prelude::*; +use libadwaita as adw; +use tablepro_session::SessionKey; + +pub use accelerator::Accelerator; +pub use accelerator_scope::AcceleratorScope; +#[cfg(test)] +pub(crate) use accelerators::keypad_twins; +pub use accelerators::{ + ACCELERATORS, component_matches, component_trigger, controller_name, install_application_accelerators, + local_shortcut_controller, menu_item, triggers_for, +}; +pub use app_actions::install_app_actions; +pub use app_services::{AppServices, AppServicesParts}; +pub use app_window_host::AppWindowHost; +pub use quit_sequence::QuitSequence; +pub use shortcut_listing::ShortcutListing; +pub use shortcut_owner::ShortcutOwner; +pub use shortcut_section::ShortcutSection; +pub use window_host::WindowHost; +pub use window_launch::WindowLaunch; +pub(crate) use window_registry::WindowRegistry; + +use crate::config; +use crate::ui::{SessionWindow, SessionWindowInit}; +use crate::workspace::OpenSessionsLoad; + +glib::wrapper! { + pub struct TableProApplication(ObjectSubclass) + @extends adw::Application, gtk::Application, gio::Application, + @implements gio::ActionGroup, gio::ActionMap; +} + +impl TableProApplication { + pub fn new(services: AppServices) -> Self { + // GApplication answers --version from this, before it registers + // on the bus, so the running instance never sees the call. + let app: Self = glib::Object::builder() + .property("application-id", config::APP_ID) + .property("resource-base-path", config::RESOURCE_BASE_PATH) + .property("flags", gio::ApplicationFlags::HANDLES_OPEN) + .property("version", config::VERSION) + .build(); + services.bind_application(&app); + // Only `new` sets it, and it has just built the object. + let _ = app.imp().services.set(Rc::new(services)); + app + } + + /// `None` only for an application built without `new`. + pub fn services(&self) -> Option> { + self.imp().services.get().cloned() + } + + /// Open a window on `launch` and show it. `None` when the + /// application has not started yet, since GTK takes windows only + /// after startup. + pub fn open_window(&self, launch: WindowLaunch) -> Option { + let Some(services) = self.services() else { + tracing::warn!("no window opened: the application has no services"); + return None; + }; + let window = SessionWindow::new(); + // The window gets its id when it is added, and it claims its + // session under that id from start on. + self.add_window(&window); + if window.application().is_none() { + tracing::warn!("no window opened: the application has not started"); + window.destroy(); + return None; + } + let host: Rc = Rc::new(AppWindowHost::new(self)); + self.imp().windows.insert(window.id(), window.clone()); + window.start(SessionWindowInit { services, host, launch }); + self.hand_over_crash_report(&window); + window.present(); + Some(window.upcast()) + } + + /// False when no window has that id. + pub fn present_window(&self, window_id: u32) -> bool { + let Some(window) = self.window_by_id(window_id) else { + return false; + }; + window.present(); + true + } + + /// What activate and the new-window action open: the sessions open + /// at the last quit the first time, a picker after. A picker opened + /// first would write its session over the ones still to reopen. + fn open_launch_windows(&self) { + let imp = self.imp(); + match imp.restore.get() { + imp::RestoreState::NotAttempted => self.restore_session(), + imp::RestoreState::Loading => {} + imp::RestoreState::Quitting { .. } => imp.restore.set(imp::RestoreState::Quitting { relaunched: true }), + imp::RestoreState::Done => { + self.open_window(WindowLaunch::Picker); + } + } + } + + /// Open the windows whose sessions were open when the application + /// last quit, then any files handed over meanwhile, or the picker + /// when that opens nothing. + fn restore_session(&self) { + let imp = self.imp(); + let services = match self.services() { + Some(services) if services.settings.restore_session() => services, + _ => { + imp.restore.set(imp::RestoreState::Done); + self.finish_restore(Vec::new()); + return; + } + }; + imp.restore.set(imp::RestoreState::Loading); + // No window holds the application while the record is read. + let hold = services.hold().acquire(); + let loading = services.open_sessions().load(); + let app = self.clone(); + let restoring = glib::spawn_future_local(async move { + let loaded = loading.await; + // A quit approved during the read leaves the record for the + // next launch and opens nothing. + if app.imp().restore.get() == imp::RestoreState::Loading { + let keys = match loaded { + OpenSessionsLoad::Restored(record) => services.open_sessions().restore(record), + OpenSessionsLoad::Empty => Vec::new(), + }; + app.imp().restore.set(imp::RestoreState::Done); + app.finish_restore(keys); + } + drop(hold); + }); + imp.restoring.replace(Some(restoring)); + } + + fn finish_restore(&self, keys: Vec) { + let files = self.imp().pending_files.take(); + if keys.is_empty() && files.is_empty() { + self.open_window(WindowLaunch::Picker); + } + for key in keys { + self.open_window(WindowLaunch::Restore(key)); + } + self.open_files(&files); + } + + /// Ask every window to close, in turn. While one is asking, another + /// request brings that window forward instead of starting over. + pub fn request_quit(&self) { + let imp = self.imp(); + if imp.quit.borrow().is_some() { + if let Some(asking) = imp.quit_asking.get() { + self.present_window(asking); + } + return; + } + let app = self.clone(); + let running = glib::spawn_future_local(async move { + let decision = QuitSequence::run(app.clone()).await; + app.imp().quit.take(); + decision + }); + imp.quit.replace(Some(running)); + } + + /// Bring forward the first window that holds the session back, so + /// the user sees what a logout would lose. Returns its id. + pub(crate) fn present_inhibiting_window(&self) -> Option { + let imp = self.imp(); + let window = self + .windows() + .into_iter() + .filter_map(|window| window.downcast::().ok()) + .find(|window| imp.windows.holds_inhibit(window.id()))?; + window.present(); + Some(window.id()) + } +} + +#[cfg(test)] +mod tests; diff --git a/linux/crates/app/src/application/open_files.rs b/linux/crates/app/src/application/open_files.rs new file mode 100644 index 0000000000..4996de17cf --- /dev/null +++ b/linux/crates/app/src/application/open_files.rs @@ -0,0 +1,74 @@ +use gtk4::glib; +use gtk4::prelude::*; +use tablepro_session::SessionKey; + +use super::{TableProApplication, WindowLaunch}; +use crate::services::file_resolution::{FileTarget, ResolvedFile, resolve_file_blocking}; +use crate::ui::{SessionWindow, SessionWindowInput}; + +impl TableProApplication { + /// Open each file the file manager or the command line hands over: + /// in the window that has it open already, in the active window + /// while it only shows the picker, or in a new window. + pub fn open_files(&self, files: &[gio::File]) { + for file in files { + self.open_file(file.clone()); + } + } + + /// Finding the window that has the file open means canonicalizing + /// its path and matching it against the saved connections, which + /// read the disk, so the file is resolved on the spawner first. + fn open_file(&self, file: gio::File) { + let Some(services) = self.services() else { + tracing::warn!("no file opened: the application has no services"); + return; + }; + let (path, uri) = (file.path(), file.uri().to_string()); + let connections = services.storage.connections().clone(); + let drivers = services.drivers.clone(); + let resolving = services + .tasks + .spawn_blocking_task(move || resolve_file_blocking(path, uri, &connections, &drivers)); + // An instance D-Bus started for this file would otherwise exit + // before its window opens. + let hold = self.hold(); + let app = self.downgrade(); + glib::spawn_future_local(async move { + let key = resolving.await.ok().and_then(|resolved| session_key(&resolved)); + if let Some(app) = app.upgrade() { + app.place_file(file, key); + } + drop(hold); + }); + } + + fn place_file(&self, file: gio::File, key: Option) { + let owner = key.and_then(|key| self.services()?.sessions.window_for(&key)); + if owner.is_some_and(|owner| self.present_window(owner)) { + return; + } + let idle = self + .active_window() + .and_downcast::() + .filter(SessionWindow::is_idle); + match idle { + Some(window) => { + window.send(SessionWindowInput::OpenFile(file)); + window.present(); + } + None => { + self.open_window(WindowLaunch::OpenFile(file)); + } + } + } +} + +/// The session a resolved file opens, so its window can be found. +fn session_key(resolved: &ResolvedFile) -> Option { + match &resolved.target { + Ok(FileTarget::Saved(saved)) => Some(SessionKey::Saved(saved.id)), + Ok(FileTarget::File(identity)) => Some(SessionKey::File(identity.uri.clone())), + Err(_) => None, + } +} diff --git a/linux/crates/app/src/application/quit_sequence.rs b/linux/crates/app/src/application/quit_sequence.rs new file mode 100644 index 0000000000..b7c683a7bc --- /dev/null +++ b/linux/crates/app/src/application/quit_sequence.rs @@ -0,0 +1,119 @@ +use futures::channel::oneshot; +use gtk4 as gtk; +use gtk4::prelude::*; +use gtk4::subclass::prelude::*; + +use super::imp::RestoreState; +use super::{AppServices, TableProApplication}; +use crate::runtime::{DeliverInput, Delivery}; +use crate::ui::SessionWindowInput; +use crate::workspace::{CloseDecision, CloseReason}; + +/// Asks each window in turn whether it may close, the way closing it by +/// hand would. +/// +/// It never calls `g_application_quit`, which ignores the hold count: +/// the process ends once the last window is gone and the last save, +/// close and teardown hold has dropped. +/// +/// The open sessions stay recorded through the quit, so the next launch +/// reopens the windows it closes. The record is written before the +/// process may end, and a cancelled quit keeps only the windows still +/// showing or connecting their session. +/// +/// An approved quit can leave the process running with no window while +/// a session closes, and a second launch then activates it. That +/// activate restores the record as a new process would, after the +/// record is written if it lands while the quit writes it. A quit +/// approved while the record is still read keeps the windows it names +/// from opening. +pub struct QuitSequence; + +impl QuitSequence { + pub async fn run(app: TableProApplication) -> CloseDecision { + let services = app.services(); + // Taken before the first window closes: the last one going would + // end the process before the record is written. + let hold = services.as_ref().map(|services| services.hold().acquire()); + if let Some(services) = &services { + services.open_sessions().freeze_for_quit(); + } + let mut asked: Vec = Vec::new(); + let decision = loop { + let Some(window) = app.windows().into_iter().find(|window| !asked.contains(window)) else { + break CloseDecision::Approved; + }; + asked.push(window.clone()); + if ask(&app, &window).await == CloseDecision::Cancelled { + break CloseDecision::Cancelled; + } + }; + app.imp().quit_asking.set(None); + if let Some(services) = &services { + match decision { + CloseDecision::Approved => end_approved(&app, services).await, + CloseDecision::Cancelled => services.open_sessions().thaw(|key| { + services + .sessions + .window_for(key) + .is_some_and(|window_id| app.imp().windows.keeps_session(window_id)) + }), + } + } + drop(hold); + decision + } +} + +/// Keep the record for the next launch. Only the keys of a finished +/// restore are written: before it, the keys in memory are empty and the +/// record on disk still names the sessions to reopen. +async fn end_approved(app: &TableProApplication, services: &AppServices) { + let imp = app.imp(); + let before = imp.restore.get(); + let restorable = matches!(before, RestoreState::Done | RestoreState::Loading); + if restorable { + imp.restore.set(RestoreState::Quitting { relaunched: false }); + } + match before { + RestoreState::Done => services.open_sessions().flush().await, + RestoreState::Loading => { + // Handed over before the quit, so their windows would open + // after it. + imp.pending_files.take(); + if let Some(loading) = imp.restoring.take() { + let _ = loading.await; + } + } + RestoreState::NotAttempted | RestoreState::Quitting { .. } => {} + } + services.open_sessions().end_quit(); + if restorable { + let relaunched = imp.restore.get() == RestoreState::Quitting { relaunched: true }; + imp.restore.set(RestoreState::NotAttempted); + if relaunched { + app.restore_session(); + } + } +} + +async fn ask(app: &TableProApplication, window: >k::Window) -> CloseDecision { + let sender = window + .downcast_ref::() + .and_then(|window| app.imp().windows.sender(window.id())); + let Some(sender) = sender else { + // Not one of the session windows, so it has nothing to ask. + window.close(); + return CloseDecision::Approved; + }; + if let Some(window) = window.downcast_ref::() { + app.imp().quit_asking.set(Some(window.id())); + } + window.present(); + let (reply, answer) = oneshot::channel(); + if sender.try_deliver(SessionWindowInput::CloseRequested(CloseReason::Quit(reply))) == Delivery::ReceiverGone { + return CloseDecision::Approved; + } + // A window torn down before it answered has nothing left to keep. + answer.await.unwrap_or(CloseDecision::Approved) +} diff --git a/linux/crates/app/src/application/runtime_versions.rs b/linux/crates/app/src/application/runtime_versions.rs new file mode 100644 index 0000000000..45f9421d1d --- /dev/null +++ b/linux/crates/app/src/application/runtime_versions.rs @@ -0,0 +1,47 @@ +use std::path::Path; + +use gtk4 as gtk; +use libadwaita as adw; + +/// The toolkit libraries the process loaded, which can be newer than the +/// ones it was built against. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct RuntimeVersions { + pub gtk: (u32, u32, u32), + pub adwaita: (u32, u32, u32), + pub sourceview: (u32, u32, u32), + pub flatpak: bool, +} + +impl RuntimeVersions { + /// GTK thread only, once GTK is initialised: the bindings assert both. + pub fn current() -> Self { + Self { + gtk: (gtk::major_version(), gtk::minor_version(), gtk::micro_version()), + adwaita: (adw::major_version(), adw::minor_version(), adw::micro_version()), + sourceview: ( + sourceview5::major_version(), + sourceview5::minor_version(), + sourceview5::micro_version(), + ), + // Flatpak writes this file at the root of every sandbox. + flatpak: Path::new("/.flatpak-info").exists(), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[gtk4::test] + fn current_versions_meet_the_platform_floor() { + adw::init().expect("libadwaita initialises under the test backend"); + + let versions = RuntimeVersions::current(); + + assert!(versions.gtk >= (4, 22, 0), "{:?}", versions.gtk); + assert!(versions.adwaita >= (1, 9, 0), "{:?}", versions.adwaita); + assert!(versions.sourceview >= (5, 18, 0), "{:?}", versions.sourceview); + } +} diff --git a/linux/crates/app/src/application/session_signals.rs b/linux/crates/app/src/application/session_signals.rs new file mode 100644 index 0000000000..c499295244 --- /dev/null +++ b/linux/crates/app/src/application/session_signals.rs @@ -0,0 +1,100 @@ +use std::rc::Rc; +use std::time::Duration; + +use tablepro_session::registry::RegistrySubscription; +use tablepro_session::{MonitorSignal, SessionRegistry}; + +use crate::runtime::{ + ClockSource, GioReachability, NetworkWatch, ReachabilityFanout, ReachabilityProbe, ResumeDetector, SystemClocks, +}; + +/// What tells the open sessions about the world outside their +/// connections: whether the network reaches each server, and that the +/// computer woke from suspend. It lives from startup to shutdown. +#[derive(Debug)] +pub(super) struct SessionSignals { + _network: NetworkWatch, + _live_count: RegistrySubscription, + _fanout: Rc, + _resume: Rc, +} + +impl SessionSignals { + pub(super) fn start(sessions: &Rc) -> Self { + let monitor = gio::NetworkMonitor::default(); + Self::start_with( + sessions, + monitor.clone(), + Rc::new(GioReachability::new(monitor)), + Box::new(SystemClocks), + ) + } + + fn start_with( + sessions: &Rc, + monitor: gio::NetworkMonitor, + probe: Rc, + clocks: Box, + ) -> Self { + let fanout = ReachabilityFanout::new(sessions.clone(), probe); + let network = NetworkWatch::start(monitor, fanout.clone(), sessions); + let resume = ResumeDetector::new(clocks); + // Held weakly: the registry keeps this closure among its + // observers, and the fanout keeps the registry. + let registry = Rc::downgrade(sessions); + let rechecks = Rc::downgrade(&fanout); + let on_resume: Rc = Rc::new(move |slept| { + if let Some(sessions) = registry.upgrade() { + for (_, session) in sessions.attached_sessions() { + session.signal(MonitorSignal::Resumed { slept }); + } + } + if let Some(fanout) = rechecks.upgrade() { + fanout.reevaluate_all(); + } + }); + let live_count = resume.follow(sessions, on_resume); + Self { + _network: network, + _live_count: live_count, + _fanout: fanout, + _resume: resume, + } + } +} + +#[cfg(test)] +mod tests { + use tablepro_test_support::CallCounters; + + use super::*; + use crate::test_support::{FakeClocks, FakeProbe, attach_remote, wait_until}; + + #[gtk4::test] + fn a_resume_probes_every_session_and_rechecks_its_server() { + let runtime = crate::test_support::test_runtime(); + let registry = SessionRegistry::new(); + let probe = Rc::new(FakeProbe::default()); + let clocks = FakeClocks::default(); + let signals = SessionSignals::start_with( + ®istry, + gio::NetworkMonitor::default(), + probe.clone(), + Box::new(clocks.clone()), + ); + let attached = attach_remote(®istry, "db.example.com", Vec::new(), runtime.tasks()); + crate::test_support::drain_main_context(); + let checked = probe.asked().len(); + let probes = || CallCounters::get(&attached.fake.connection.counters().probe); + let probed = probes(); + + clocks.advance(600, 0); + + wait_until(Duration::from_secs(10), || probes() > probed) + .expect("the session to probe its connection after the sleep"); + wait_until(Duration::from_secs(1), || probe.asked().len() > checked) + .expect("the server to be checked again after the sleep"); + drop(signals); + runtime.shutdown(); + } +} diff --git a/linux/crates/app/src/application/shortcut_listing.rs b/linux/crates/app/src/application/shortcut_listing.rs new file mode 100644 index 0000000000..5f3a794d82 --- /dev/null +++ b/linux/crates/app/src/application/shortcut_listing.rs @@ -0,0 +1,7 @@ +use super::ShortcutSection; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ShortcutListing { + Listed(ShortcutSection), + Unlisted, +} diff --git a/linux/crates/app/src/application/shortcut_owner.rs b/linux/crates/app/src/application/shortcut_owner.rs new file mode 100644 index 0000000000..4cf3d2c92c --- /dev/null +++ b/linux/crates/app/src/application/shortcut_owner.rs @@ -0,0 +1,22 @@ +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum ShortcutOwner { + BrowseTab, + /// The browse tab's column view. + BrowseGrid, + /// A focused grid cell, through the grid's capture-phase controller. + GridCell, + /// A cell's open inline, multi-line or date editor. + CellEditor, + /// The SQL editor's source view. + SqlEditor, + /// The whole SQL editor tab, its find bar included. + SqlEditorTab, + StructureTab, + FilterStrip, + /// A row of the sidebar's table list. + SidebarList, + /// A row of the query history dialog. + HistoryRow, + /// A saved connection's row in the picker. + PickerRow, +} diff --git a/linux/crates/app/src/application/shortcut_section.rs b/linux/crates/app/src/application/shortcut_section.rs new file mode 100644 index 0000000000..219b422a26 --- /dev/null +++ b/linux/crates/app/src/application/shortcut_section.rs @@ -0,0 +1,10 @@ +/// A section of the shortcuts dialog. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum ShortcutSection { + General, + Tabs, + BrowseTable, + DataGrid, + SqlEditor, + TableStructure, +} diff --git a/linux/crates/app/src/application/tests.rs b/linux/crates/app/src/application/tests.rs new file mode 100644 index 0000000000..1947712960 --- /dev/null +++ b/linux/crates/app/src/application/tests.rs @@ -0,0 +1,851 @@ +use std::cell::Cell; +use std::ops::ControlFlow; +use std::rc::Rc; +use std::sync::Arc; +use std::time::Duration; + +use libadwaita as adw; +use libadwaita::prelude::*; +use tablepro_core::{DriverRegistry, LivenessPolicy}; +use tablepro_session::SessionKey; +use tablepro_session::fakes::{FakeConnector, FakeEstablish}; +use tablepro_session::operation::DefaultDeadlines; +use tablepro_storage::StoragePaths; +use tablepro_storage::document::{decode_document, encode_document}; +use tablepro_test_support::{ConnectionScript, FakeDriver, FakeReply, FakeSecretVault}; + +use super::*; +use crate::storage::AppStorage; +use crate::test_support::{MemorySettings, finish_on_main, wait_until}; +use crate::workspace::{CloseDecision, InhibitReason, InhibitTarget, OpenSessionsRecord, WindowInhibitor}; + +const WAIT: Duration = Duration::from_secs(10); + +/// What fake services need kept alive for as long as the test runs. +struct Backing { + runtime: Option, + _settings: MemorySettings, + dir: tempfile::TempDir, +} + +impl Drop for Backing { + fn drop(&mut self) { + if let Some(runtime) = self.runtime.take() { + runtime.shutdown(); + } + } +} + +fn fake_services() -> (AppServices, Backing) { + adw::init().expect("libadwaita initialises under the test backend"); + crate::register_test_resources(); + if let Some(settings) = gtk4::Settings::default() { + settings.set_gtk_enable_animations(false); + } + let runtime = crate::test_support::test_runtime(); + let settings = MemorySettings::new(); + let dir = tempfile::tempdir().expect("a temporary directory"); + let paths = StoragePaths::under(dir.path(), "tablepro", crate::config::APP_ID); + let storage = AppStorage::new(paths, Arc::new(FakeSecretVault::new()), &runtime.tasks()); + let liveness = LivenessPolicy::DESKTOP; + // Files open through a fake driver, so a restored file connects. + let mut script = ConnectionScript::default(); + script.probe.always(FakeReply::ok(())); + script.list_schemas.always(FakeReply::ok(Vec::new())); + script.list_objects.always(FakeReply::ok(Vec::new())); + let mut drivers = DriverRegistry::new(); + drivers.register(Arc::new(FakeDriver::connecting( + &FakeDriver::FILE_CAPABILITIES, + script.clone(), + ))); + let services = AppServices::new(AppServicesParts { + drivers, + connector: Arc::new(FakeConnector::new([]).always(FakeEstablish::Connect(script))), + deadlines: Arc::new(DefaultDeadlines::new(liveness)), + liveness, + settings: settings.get().clone(), + storage, + tasks: runtime.tasks(), + }); + let backing = Backing { + runtime: Some(runtime), + _settings: settings, + dir, + }; + (services, backing) +} + +/// An application over fakes, under an id of its own so it never meets +/// another test's application, or a TablePro the developer has open, on +/// the session bus. +struct TestApp { + app: TableProApplication, + _backing: Backing, +} + +impl TestApp { + fn new(name: &str) -> Self { + let (services, backing) = fake_services(); + let app = TableProApplication::new(services); + app.set_application_id(Some(&format!("{}.Test.{name}", crate::config::APP_ID))); + Self { app, _backing: backing } + } + + fn started(name: &str) -> Self { + let test = Self::new(name); + test.app + .register(gio::Cancellable::NONE) + .expect("register the application"); + test + } + + fn window(&self) -> adw::ApplicationWindow { + self.app.open_window(WindowLaunch::Picker).expect("a window") + } + + fn services(&self) -> Rc { + self.app.services().expect("the application's services") + } + + /// A database file in the test's directory, by the key it opens as. + fn database(&self, name: &str) -> SessionKey { + std::fs::write(self._backing.dir.path().join(name), b"").expect("create the database"); + self.file_key(name) + } + + /// The key a file in the test's directory opens as, whether or not + /// it is there. + fn file_key(&self, name: &str) -> SessionKey { + let dir = std::fs::canonicalize(self._backing.dir.path()).expect("the canonical directory"); + let uri = glib::filename_to_uri(dir.join(name), None).expect("a file URI"); + SessionKey::File(uri.to_string()) + } + + fn record_path(&self) -> std::path::PathBuf { + self.services() + .storage + .paths() + .workspaces_dir() + .join(OpenSessionsRecord::FILE_NAME) + } + + /// Write `keys` as the sessions open at the last quit. + fn record(&self, keys: &[SessionKey]) { + let path = self.record_path(); + std::fs::create_dir_all(path.parent().expect("a parent")).expect("the workspaces directory"); + let bytes = encode_document(&OpenSessionsRecord::from_keys(keys)).expect("encode"); + std::fs::write(&path, bytes).expect("write the record"); + } + + /// The sessions recorded on disk, once every write has landed. + fn recorded(&self) -> Vec { + finish_on_main(WAIT, self.services().open_sessions().settled()).expect("the writes to land"); + let path = self.record_path(); + let bytes = std::fs::read(&path).expect("the record"); + decode_document::(&path, &bytes) + .expect("a record") + .keys() + } + + /// The window that claims `key`. + fn window_for(&self, key: &SessionKey) -> gtk4::ApplicationWindow { + let id = self + .services() + .sessions + .window_for(key) + .expect("a window claims the key"); + self.app + .windows() + .into_iter() + .filter_map(|window| window.downcast::().ok()) + .find(|window| window.id() == id) + .expect("the window") + } + + /// A quit polled once before anything else runs, so it finds the + /// restore where it is now. + fn quit_now(&self) -> std::pin::Pin + use<>>> { + let mut quit = Box::pin(QuitSequence::run(self.app.clone())); + let polled = quit + .as_mut() + .poll(&mut std::task::Context::from_waker(std::task::Waker::noop())); + assert!(polled.is_pending(), "the quit ended in its first poll"); + quit + } + + /// Wait for every key to show its workspace in a window of its own. + fn wait_for_sessions(&self, keys: &[SessionKey]) { + let services = self.services(); + wait_until(WAIT, || { + services.sessions.live_count() == keys.len() + && keys.iter().all(|key| services.sessions.window_for(key).is_some()) + }) + .expect("the restored sessions to connect"); + } +} + +impl Drop for TestApp { + fn drop(&mut self) { + for window in self.app.windows() { + window.destroy(); + } + crate::test_support::drain_main_context(); + if self.app.is_registered() { + crate::test_support::end_application(&self.app); + } + } +} + +/// Grants every inhibition, as a session manager would. +struct GrantingSession; + +impl InhibitTarget for GrantingSession { + fn inhibit(&self, _flags: gtk4::ApplicationInhibitFlags, _reason: &str) -> u32 { + 1 + } + + fn uninhibit(&self, _cookie: u32) {} +} + +#[gtk4::test] +fn new_builds_on_the_configured_id_and_resources() { + let (services, _backing) = fake_services(); + + let app = TableProApplication::new(services); + + assert_eq!(app.application_id().as_deref(), Some(crate::config::APP_ID)); + assert_eq!( + app.resource_base_path().as_deref(), + Some(crate::config::RESOURCE_BASE_PATH) + ); + assert!(app.services().is_some()); + assert!(glib::Object::new::().services().is_none()); +} + +#[gtk4::test] +fn new_window_option_registered_before_parse() { + let test = TestApp::new("Parse"); + let seen = Rc::new(Cell::new(false)); + let sink = seen.clone(); + test.app.connect_handle_local_options(move |_, options| { + sink.set(options.contains("new-window")); + ControlFlow::Break(glib::ExitCode::SUCCESS) + }); + + let code = test.app.run_with_args(&["tablepro", "--new-window"]); + + assert_eq!(code, glib::ExitCode::SUCCESS, "GApplication refused --new-window"); + assert!(seen.get(), "handle-local-options did not see --new-window"); +} + +#[gtk4::test] +fn handle_local_options_new_window_on_primary_continues() { + let test = TestApp::new("Primary"); + let options = glib::VariantDict::new(None); + options.insert_value("new-window", &true.to_variant()); + + let flow = test.app.imp().handle_local_options(&options); + + assert!(matches!(flow, ControlFlow::Continue(())), "{flow:?}"); + assert!(test.app.is_registered() && !test.app.is_remote()); +} + +#[gtk4::test] +fn handle_local_options_without_new_window_does_not_register() { + let test = TestApp::new("Plain"); + + let flow = test.app.imp().handle_local_options(&glib::VariantDict::new(None)); + + assert!(matches!(flow, ControlFlow::Continue(())), "{flow:?}"); + assert!(!test.app.is_registered()); +} + +#[gtk4::test] +fn startup_installs_the_app_actions_and_their_accels() { + let test = TestApp::started("Actions"); + + for action in ["new-window", "quit", "preferences", "about"] { + assert!(test.app.lookup_action(action).is_some(), "app.{action} is missing"); + } +} + +#[gtk4::test] +fn installed_application_accels_match_table() { + use crate::test_support::shortcut_keys::keystroke; + let test = TestApp::started("Accels"); + let installed = |action: &str| -> Vec<_> { + test.app + .accels_for_action(action) + .iter() + .map(|accel| keystroke(accel)) + .collect() + }; + + let application: Vec<_> = ACCELERATORS + .iter() + .filter(|entry| entry.scope == AcceleratorScope::Application) + .collect(); + assert!(application.iter().any(|entry| entry.action == "win.close-current")); + for entry in application { + let table: Vec<_> = entry.triggers.iter().map(|accel| keystroke(accel)).collect(); + assert_eq!(installed(entry.action), table, "{}", entry.action); + } + let shortcuts: Vec<_> = triggers_for("app.shortcuts") + .iter() + .map(|accel| keystroke(accel)) + .collect(); + assert_eq!(shortcuts, [keystroke("question")]); + assert_eq!(installed("app.shortcuts"), shortcuts); +} + +#[gtk4::test] +fn activate_opens_one_picker_and_then_presents_it() { + let test = TestApp::started("Activate"); + + test.app.activate(); + test.app.activate(); + wait_until(WAIT, || test.app.windows().len() == 1).expect("the picker"); + test.app.activate(); + let _ = wait_until(Duration::from_millis(300), || test.app.windows().len() > 1); + + assert_eq!(test.app.windows().len(), 1); + assert_eq!(test.app.imp().windows.len(), 1); +} + +#[gtk4::test] +fn activate_restores_the_recorded_sessions_in_order() { + let test = TestApp::started("Restore"); + let (first, second) = (test.database("first.db"), test.database("second.db")); + test.record(&[first.clone(), second.clone()]); + + test.app.activate(); + test.wait_for_sessions(&[first.clone(), second.clone()]); + + let services = test.services(); + let ids = [&first, &second].map(|key| services.sessions.window_for(key).expect("a window")); + assert!(ids[0] < ids[1], "the windows opened out of the recorded order: {ids:?}"); + assert_eq!( + test.app.windows().len(), + 2, + "a picker opened beside the restored windows" + ); + assert_eq!(services.open_sessions().keys(), [first, second]); +} + +#[gtk4::test] +fn activate_restores_nothing_when_restore_session_false() { + let test = TestApp::started("NoRestore"); + let first = test.database("first.db"); + test.record(std::slice::from_ref(&first)); + test.services() + .settings + .set_restore_session(false) + .expect("turn restoring off"); + + test.app.activate(); + + assert_eq!(test.app.windows().len(), 1, "the picker did not open at once"); + let _ = wait_until(Duration::from_millis(500), || { + test.services().sessions.window_for(&first).is_some() + }); + assert_eq!( + test.services().sessions.window_for(&first), + None, + "a session was restored" + ); + assert_eq!(test.app.windows().len(), 1); +} + +#[gtk4::test] +fn second_activate_does_not_restore_again() { + let test = TestApp::started("RestoreOnce"); + let first = test.database("first.db"); + test.record(std::slice::from_ref(&first)); + test.app.activate(); + test.wait_for_sessions(std::slice::from_ref(&first)); + for window in test.app.windows() { + window.destroy(); + } + assert!(test.recorded().is_empty(), "a closed window's session stayed recorded"); + test.record(std::slice::from_ref(&first)); + + test.app.activate(); + + assert_eq!(test.app.windows().len(), 1, "the picker did not open at once"); + let _ = wait_until(Duration::from_millis(500), || { + test.services().sessions.window_for(&first).is_some() + }); + assert_eq!( + test.services().sessions.window_for(&first), + None, + "the session was restored twice" + ); +} + +/// A launch for a file skips activate, and the file's window must not +/// write over the sessions recorded at the last quit. +#[gtk4::test] +fn an_open_before_any_activate_restores_the_record_and_keeps_it() { + let test = TestApp::started("OpenRestores"); + let (first, second) = (test.database("first.db"), test.database("second.db")); + test.record(std::slice::from_ref(&first)); + let SessionKey::File(uri) = &second else { + unreachable!("a database file opens as a file key"); + }; + + test.app.open(&[gio::File::for_uri(uri)], ""); + test.wait_for_sessions(&[first.clone(), second.clone()]); + + assert_eq!( + test.app.windows().len(), + 2, + "a picker opened beside the restored and opened windows" + ); + let mut recorded = test.recorded(); + recorded.sort_by_key(|key| key == &second); + assert_eq!(recorded, [first, second]); +} + +#[gtk4::test] +fn an_activate_while_the_record_loads_opens_nothing_more() { + let test = TestApp::started("RestoreLoading"); + let first = test.database("first.db"); + test.record(std::slice::from_ref(&first)); + + test.app.activate(); + test.app.activate(); + test.wait_for_sessions(std::slice::from_ref(&first)); + let _ = wait_until(Duration::from_millis(300), || test.app.windows().len() > 1); + + assert_eq!( + test.app.windows().len(), + 1, + "a picker opened beside the restored window" + ); +} + +#[gtk4::test] +fn quit_keeps_keys_window_close_removes() { + let test = TestApp::started("QuitRecord"); + let (closed, kept) = (test.database("closed.db"), test.database("kept.db")); + test.record(&[closed.clone(), kept.clone()]); + test.app.activate(); + test.wait_for_sessions(&[closed.clone(), kept.clone()]); + let services = test.services(); + + test.window_for(&closed).close(); + wait_until(WAIT, || services.sessions.window_for(&closed).is_none()).expect("the window to close"); + let decision = finish_on_main(WAIT, QuitSequence::run(test.app.clone())).expect("the quit to finish"); + + assert_eq!(decision, CloseDecision::Approved); + assert!(test.app.windows().is_empty()); + assert_eq!(test.recorded(), [kept]); +} + +#[gtk4::test] +fn cancelled_quit_thaws_to_live_keys() { + let test = TestApp::started("QuitCancelled"); + let (first, second) = (test.database("first.db"), test.database("second.db")); + test.record(&[first.clone(), second.clone()]); + test.app.activate(); + test.wait_for_sessions(&[first.clone(), second.clone()]); + // Whichever window the quit asks first closes and raises a dialog + // over the other, which takes that window's close request and so + // cancels the quit. + let windows: Vec = test + .app + .windows() + .into_iter() + .filter_map(|window| window.downcast::().ok()) + .collect(); + let [one, other] = windows.as_slice() else { + panic!("{} windows, not two", windows.len()); + }; + let raised = Rc::new(Cell::new(false)); + for (closing, kept) in [(one, other), (other, one)] { + let (kept, raised) = (kept.downgrade(), raised.clone()); + closing.connect_close_request(move |_| { + if let Some(kept) = kept.upgrade() + && !raised.replace(true) + { + adw::AlertDialog::new(Some("Busy"), None).present(Some(&kept)); + } + glib::Propagation::Proceed + }); + } + + let decision = finish_on_main(WAIT, QuitSequence::run(test.app.clone())).expect("the quit to finish"); + + assert_eq!(decision, CloseDecision::Cancelled); + assert_eq!(test.app.windows().len(), 1); + let services = test.services(); + let open: Vec = [first, second] + .into_iter() + .filter(|key| services.sessions.window_for(key).is_some()) + .collect(); + assert_eq!(open.len(), 1, "{open:?}"); + assert_eq!(services.open_sessions().keys(), open); + assert_eq!(test.recorded(), open); +} + +#[gtk4::test] +fn an_activate_after_an_approved_quit_restores_its_record() { + let test = TestApp::started("QuitThenActivate"); + let (first, second) = (test.database("first.db"), test.database("second.db")); + test.record(&[first.clone(), second.clone()]); + test.app.activate(); + test.wait_for_sessions(&[first.clone(), second.clone()]); + let services = test.services(); + // A session still closing keeps the process up once its window is gone. + let closing = services.hold().acquire(); + let decision = finish_on_main(WAIT, QuitSequence::run(test.app.clone())).expect("the quit to finish"); + assert_eq!(decision, CloseDecision::Approved); + assert!(test.app.windows().is_empty()); + + test.app.activate(); + test.wait_for_sessions(&[first.clone(), second.clone()]); + + assert_eq!( + test.app.windows().len(), + 2, + "a picker opened beside the restored windows" + ); + test.window_for(&first).close(); + wait_until(WAIT, || services.sessions.window_for(&first).is_none()).expect("the window to close"); + assert_eq!( + test.recorded(), + [second], + "a window closed after the restore stayed recorded" + ); + drop(closing); +} + +/// A launch that lands while an approved quit writes the record waits +/// for the write, then reopens what it wrote, once. +#[gtk4::test] +fn an_activate_during_the_quit_flush_restores_the_record_once_written() { + let test = TestApp::started("QuitFlushActivate"); + let (first, second) = (test.database("first.db"), test.database("second.db")); + test.record(&[first.clone(), second.clone()]); + test.app.activate(); + test.wait_for_sessions(&[first.clone(), second.clone()]); + let services = test.services(); + let closing = services.hold().acquire(); + let decision = Rc::new(std::cell::RefCell::new(None)); + let (app, slot) = (test.app.clone(), decision.clone()); + glib::spawn_future_local(async move { + slot.replace(Some(QuitSequence::run(app).await)); + }); + + let mut activated_during_flush = false; + wait_until(WAIT, || { + let flushing = + decision.borrow().is_none() && test.app.windows().is_empty() && test.app.imp().quit_asking.get().is_none(); + if flushing && !activated_during_flush { + test.app.activate(); + test.app.activate(); + activated_during_flush = true; + } + decision.borrow().is_some() + }) + .expect("the quit to finish"); + + assert!( + activated_during_flush, + "the quit ended before a launch could land in its flush" + ); + assert_eq!(*decision.borrow(), Some(CloseDecision::Approved)); + test.wait_for_sessions(&[first.clone(), second.clone()]); + let _ = wait_until(Duration::from_millis(300), || test.app.windows().len() > 2); + assert_eq!( + test.app.windows().len(), + 2, + "a picker opened beside the restored windows" + ); + assert_eq!(services.open_sessions().keys(), [first, second]); + drop(closing); +} + +/// The desktop file's New Window starts TablePro over D-Bus with that +/// action and no activate, and its window must not write over the +/// sessions recorded at the last quit. +#[gtk4::test] +fn a_new_window_before_any_activate_restores_the_record() { + let test = TestApp::started("NewWindowRestores"); + let first = test.database("first.db"); + test.record(std::slice::from_ref(&first)); + + test.app.activate_action("new-window", None); + test.wait_for_sessions(std::slice::from_ref(&first)); + + let _ = wait_until(Duration::from_millis(300), || test.app.windows().len() > 1); + assert_eq!( + test.app.windows().len(), + 1, + "a picker opened beside the restored window" + ); + assert_eq!(test.recorded(), [first]); +} + +/// `--new-window` from a second launch reaches a process an approved +/// quit left running, which has nothing restored yet. +#[gtk4::test] +fn a_new_window_after_an_approved_quit_restores_its_record() { + let test = TestApp::started("QuitThenNewWindow"); + let (first, second) = (test.database("first.db"), test.database("second.db")); + test.record(&[first.clone(), second.clone()]); + test.app.activate(); + test.wait_for_sessions(&[first.clone(), second.clone()]); + let services = test.services(); + let closing = services.hold().acquire(); + let decision = finish_on_main(WAIT, QuitSequence::run(test.app.clone())).expect("the quit to finish"); + assert_eq!(decision, CloseDecision::Approved); + + test.app.activate_action("new-window", None); + test.wait_for_sessions(&[first.clone(), second.clone()]); + + assert_eq!( + test.app.windows().len(), + 2, + "a picker opened beside the restored windows" + ); + assert_eq!(test.recorded(), [first, second]); + drop(closing); +} + +/// Over D-Bus, a quit can reach a process that nothing launched yet. +#[gtk4::test] +fn a_quit_before_any_launch_keeps_the_record() { + let test = TestApp::started("QuitBeforeLaunch"); + let first = test.database("first.db"); + test.record(std::slice::from_ref(&first)); + + let decision = finish_on_main(WAIT, QuitSequence::run(test.app.clone())).expect("the quit to finish"); + + assert_eq!(decision, CloseDecision::Approved); + assert_eq!(test.recorded(), [first]); +} + +/// With no window yet, the quit can only come over D-Bus. +#[gtk4::test] +fn a_quit_while_the_record_loads_keeps_it_and_opens_nothing() { + let test = TestApp::started("QuitWhileLoading"); + let (first, second) = (test.database("first.db"), test.database("second.db")); + test.record(std::slice::from_ref(&first)); + let SessionKey::File(uri) = &second else { + unreachable!("a database file opens as a file key"); + }; + + test.app.activate(); + test.app.open(&[gio::File::for_uri(uri)], ""); + let decision = finish_on_main(WAIT, test.quit_now()).expect("the quit to finish"); + + assert_eq!(decision, CloseDecision::Approved); + let _ = wait_until(Duration::from_millis(500), || !test.app.windows().is_empty()); + assert!(test.app.windows().is_empty(), "a window opened after the quit"); + assert_eq!(test.recorded(), std::slice::from_ref(&first)); + test.app.activate(); + test.wait_for_sessions(std::slice::from_ref(&first)); + let _ = wait_until(Duration::from_millis(300), || test.app.windows().len() > 1); + assert_eq!( + test.app.windows().len(), + 1, + "a file handed over before the quit opened after it" + ); +} + +/// The read the quit waited out opens nothing, and the launch restores +/// the record once. +#[gtk4::test] +fn a_launch_during_a_quit_over_the_loading_record_restores_it_once() { + let test = TestApp::started("QuitLoadingRelaunch"); + let first = test.database("first.db"); + test.record(std::slice::from_ref(&first)); + test.app.activate(); + let quit = test.quit_now(); + + test.app.activate(); + let decision = finish_on_main(WAIT, quit).expect("the quit to finish"); + + assert_eq!(decision, CloseDecision::Approved); + test.wait_for_sessions(std::slice::from_ref(&first)); + let _ = wait_until(Duration::from_millis(300), || test.app.windows().len() > 1); + assert_eq!(test.app.windows().len(), 1, "the record was restored twice"); + assert_eq!(test.services().open_sessions().keys(), [first]); +} + +#[gtk4::test] +fn a_cancelled_quit_forgets_a_restore_that_failed_during_it() { + let test = TestApp::started("QuitFailedRestore"); + let gone = test.file_key("gone.db"); + test.record(std::slice::from_ref(&gone)); + let services = test.services(); + // The restore fails while the quit waits on another window. + services.open_sessions().freeze_for_quit(); + test.app.activate(); + wait_until(WAIT, || services.sessions.window_for(&gone).is_some()).expect("the failed connect's claim"); + // The window's close request dismisses the dialog instead, which + // cancels the quit. + adw::AlertDialog::new(Some("Busy"), None).present(Some(&test.window_for(&gone))); + + let decision = finish_on_main(WAIT, QuitSequence::run(test.app.clone())).expect("the quit to finish"); + + assert_eq!(decision, CloseDecision::Cancelled); + assert!( + services.sessions.window_for(&gone).is_some(), + "the failed connect's window closed" + ); + assert!(test.recorded().is_empty(), "the failed restore stayed recorded"); +} + +#[gtk4::test] +fn a_quit_without_close_requests_keeps_the_sessions_recorded() { + let test = TestApp::new("ShutdownRecord"); + let open = test.database("open.db"); + test.record(std::slice::from_ref(&open)); + let services = test.services(); + let connected = Rc::new(Cell::new(false)); + let (app, seen, key) = (test.app.downgrade(), connected.clone(), open.clone()); + let deadline = std::time::Instant::now() + WAIT; + glib::timeout_add_local(Duration::from_millis(20), move || { + let Some(app) = app.upgrade() else { + return glib::ControlFlow::Break; + }; + seen.set(services.sessions.live_count() == 1 && services.sessions.window_for(&key).is_some()); + if seen.get() || std::time::Instant::now() > deadline { + #[expect( + clippy::disallowed_methods, + reason = "GTK quits this way when the session ends, without asking the windows" + )] + app.quit(); + return glib::ControlFlow::Break; + } + glib::ControlFlow::Continue + }); + + let code = test.app.run_with_args(&["tablepro"]); + + assert_eq!(code, glib::ExitCode::SUCCESS); + assert!(connected.get(), "the recorded session never opened"); + assert_eq!(test.recorded(), [open]); +} + +#[gtk4::test] +fn new_window_action_opens_another_window() { + let test = TestApp::started("NewWindow"); + test.app.activate(); + wait_until(WAIT, || test.app.windows().len() == 1).expect("the picker"); + + test.app.activate_action("new-window", None); + + assert_eq!(test.app.windows().len(), 2); + assert_eq!(test.app.imp().windows.len(), 2); +} + +#[gtk4::test] +fn a_window_before_startup_is_refused() { + let test = TestApp::new("Early"); + + assert!(test.app.open_window(WindowLaunch::Picker).is_none()); + assert_eq!(test.app.imp().windows.len(), 0); +} + +#[gtk4::test] +fn a_removed_window_takes_its_controller_with_it() { + let test = TestApp::started("Removed"); + let window = test.window(); + assert_eq!(test.app.imp().windows.len(), 1); + + window.destroy(); + + assert_eq!(test.app.imp().windows.len(), 0); +} + +#[gtk4::test] +fn present_window_finds_the_window_by_id() { + let test = TestApp::started("Present"); + let window = test.window(); + window.set_visible(false); + + assert!(test.app.present_window(window.id())); + assert!(window.is_visible()); + assert!(!test.app.present_window(window.id() + 100)); +} + +#[gtk4::test] +fn query_end_presents_inhibiting_window() { + let test = TestApp::started("QueryEnd"); + let idle = test.window(); + let busy = test.window(); + for window in [&idle, &busy] { + window.set_visible(false); + } + let mut inhibitor = WindowInhibitor::new(GrantingSession); + inhibitor.update(Some(InhibitReason::UnsavedChanges)); + test.app + .imp() + .windows + .get(busy.id()) + .expect("the busy window") + .set_inhibitor(inhibitor); + + test.app.emit_by_name::<()>("query-end", &[]); + + assert!(busy.is_visible(), "the window holding logout back stayed hidden"); + assert!(!idle.is_visible(), "a window with nothing to lose was brought forward"); + assert_eq!(test.app.present_inhibiting_window(), Some(busy.id())); +} + +#[gtk4::test] +fn quit_closes_every_window_through_its_close_flow() { + let test = TestApp::started("Quit"); + let first = test.window(); + let second = test.window(); + let requests = Rc::new(Cell::new(0)); + for window in [&first, &second] { + let requests = requests.clone(); + window.connect_close_request(move |_| { + requests.set(requests.get() + 1); + glib::Propagation::Proceed + }); + } + + let decision = finish_on_main(WAIT, QuitSequence::run(test.app.clone())).expect("the quit to finish"); + + assert_eq!(decision, CloseDecision::Approved); + assert_eq!(requests.get(), 2, "a window closed without a close request"); + assert!(!first.is_visible() && !second.is_visible()); + assert!(test.app.windows().is_empty()); + assert_eq!(test.app.imp().windows.len(), 0); + assert_eq!(test.app.imp().quit_asking.get(), None); +} + +#[gtk4::test] +fn request_quit_runs_once_and_clears_when_done() { + let test = TestApp::started("RequestQuit"); + test.window(); + + test.app.request_quit(); + assert!(test.app.imp().quit.borrow().is_some()); + wait_until(WAIT, || test.app.imp().quit.borrow().is_none()).expect("the quit to finish"); + + assert!(test.app.windows().is_empty()); +} + +#[gtk4::test] +fn a_second_quit_request_brings_the_asking_window_forward() { + let test = TestApp::started("QuitAgain"); + let asking = test.window(); + asking.set_visible(false); + let pending = glib::spawn_future_local(std::future::pending::()); + test.app.imp().quit.replace(Some(pending)); + test.app.imp().quit_asking.set(Some(asking.id())); + + test.app.request_quit(); + + assert!(asking.is_visible(), "the window the quit waits on stayed hidden"); + assert_eq!(test.app.windows().len(), 1, "a second quit started over"); + if let Some(pending) = test.app.imp().quit.take() { + pending.abort(); + } +} diff --git a/linux/crates/app/src/application/third_party_licenses.rs b/linux/crates/app/src/application/third_party_licenses.rs new file mode 100644 index 0000000000..0dfbc40726 --- /dev/null +++ b/linux/crates/app/src/application/third_party_licenses.rs @@ -0,0 +1,294 @@ +use std::collections::{BTreeMap, BTreeSet}; + +use gtk4 as gtk; +use libadwaita as adw; +use serde::Deserialize; +use thiserror::Error; + +use super::legal_section::LegalSection; +use crate::config; + +#[derive(Debug, Error)] +pub enum ThirdPartyLicensesError { + #[error("could not read the embedded third-party licenses: {0}")] + Lookup(#[source] glib::Error), + #[error("could not parse the embedded third-party licenses: {0}")] + Parse(#[source] serde_json::Error), +} + +/// The shape `build-aux/about/third-party.json.hbs` writes. +#[derive(Deserialize)] +struct Notices { + licenses: Vec, +} + +#[derive(Deserialize)] +struct LicenseRecord { + id: String, + text: String, + used_by: Vec, +} + +#[derive(Deserialize)] +struct UsedBy { + #[serde(rename = "crate")] + krate: CrateRecord, +} + +#[derive(Deserialize)] +struct CrateRecord { + name: String, +} + +#[derive(Default)] +struct LicenseGroup { + crates: BTreeSet, + texts: Vec, +} + +pub fn resource_path() -> String { + format!("{}/licenses/third-party.json", config::RESOURCE_BASE_PATH) +} + +pub fn embedded_legal_sections() -> Result, ThirdPartyLicensesError> { + let json = gio::resources_lookup_data(&resource_path(), gio::ResourceLookupFlags::NONE) + .map_err(ThirdPartyLicensesError::Lookup)?; + legal_sections(&json) +} + +/// One section per license, titled with the crates under it. +pub fn legal_sections(json: &[u8]) -> Result, ThirdPartyLicensesError> { + let notices: Notices = serde_json::from_slice(json).map_err(ThirdPartyLicensesError::Parse)?; + // cargo-about gives each distinct text of a license its own entry, + // since the copyright lines differ from crate to crate. + let mut groups: BTreeMap = BTreeMap::new(); + for record in notices.licenses { + let group = groups.entry(record.id).or_default(); + group + .crates + .extend(record.used_by.into_iter().map(|used| used.krate.name)); + if !group.texts.contains(&record.text) { + group.texts.push(record.text); + } + } + Ok(groups + .into_iter() + .map(|(id, group)| { + let license_type = gtk_license(&id); + LegalSection { + title: group.crates.into_iter().collect::>().join(", "), + license_type, + license_text: (license_type == gtk::License::Custom).then(|| group.texts.join("\n\n")), + } + }) + .collect()) +} + +pub fn add_legal_sections(dialog: &adw::AboutDialog, sections: &[LegalSection]) { + for section in sections { + dialog.add_legal_section( + §ion.title, + None, + section.license_type, + section.license_markup().as_deref(), + ); + } +} + +/// The licenses GTK can name itself. Any other is shown as its text. +fn gtk_license(spdx_id: &str) -> gtk::License { + match spdx_id { + "MIT" => gtk::License::MitX11, + "Apache-2.0" => gtk::License::Apache20, + "BSD-2-Clause" => gtk::License::Bsd, + "BSD-3-Clause" => gtk::License::Bsd3, + "MPL-2.0" => gtk::License::Mpl20, + "LGPL-2.1-or-later" => gtk::License::Lgpl21, + "0BSD" => gtk::License::_0bsd, + _ => gtk::License::Custom, + } +} + +#[cfg(test)] +mod tests { + use gtk4::prelude::*; + use libadwaita::prelude::AdwDialogExt; + use tablepro_core::DriverRegistry; + + use super::*; + use crate::application::about_dialog::build_about_dialog; + use crate::test_support::{descendants, drain_main_context}; + + const FIXTURE: &str = r#"{ + "licenses": [ + { + "id": "ISC", + "name": "ISC License", + "text": "ISC text one", + "used_by": [{ "crate": { "name": "untrusted", "version": "0.9.0" } }] + }, + { + "id": "ISC", + "name": "ISC License", + "text": "ISC text two", + "used_by": [{ "crate": { "name": "aws-lc-sys", "version": "0.32.0" } }] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "MIT text one", + "used_by": [ + { "crate": { "name": "serde", "version": "1.0.0" } }, + { "crate": { "name": "anyhow", "version": "1.0.0" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "MIT text two", + "used_by": [ + { "crate": { "name": "windows-sys", "version": "0.52.0" } }, + { "crate": { "name": "windows-sys", "version": "0.59.0" } }, + { "crate": { "name": "serde", "version": "1.0.0" } } + ] + }, + { + "id": "Apache-2.0", + "name": "Apache License 2.0", + "text": "Apache text", + "used_by": [{ "crate": { "name": "fragile", "version": "2.1.0" } }] + } + ] + }"#; + + #[test] + fn spdx_maps_to_gtk_license() { + let expected = [ + ("MIT", gtk::License::MitX11), + ("Apache-2.0", gtk::License::Apache20), + ("BSD-2-Clause", gtk::License::Bsd), + ("BSD-3-Clause", gtk::License::Bsd3), + ("MPL-2.0", gtk::License::Mpl20), + ("LGPL-2.1-or-later", gtk::License::Lgpl21), + ("0BSD", gtk::License::_0bsd), + ("Unicode-3.0", gtk::License::Custom), + ("ISC", gtk::License::Custom), + ]; + for (spdx_id, license) in expected { + assert_eq!(gtk_license(spdx_id), license, "{spdx_id}"); + } + } + + #[test] + fn legal_sections_from_fixture() { + let sections = legal_sections(FIXTURE.as_bytes()).expect("the fixture parses"); + + assert_eq!( + sections, + vec![ + LegalSection { + title: "fragile".to_owned(), + license_type: gtk::License::Apache20, + license_text: None, + }, + LegalSection { + title: "aws-lc-sys, untrusted".to_owned(), + license_type: gtk::License::Custom, + license_text: Some("ISC text one\n\nISC text two".to_owned()), + }, + LegalSection { + title: "anyhow, serde, windows-sys".to_owned(), + license_type: gtk::License::MitX11, + license_text: None, + }, + ] + ); + } + + #[test] + fn legal_sections_rejects_malformed_json() { + for json in [&b"{"[..], br#"{ "licenses": [{ "id": "MIT" }] }"#, b"[]"] { + assert!( + matches!(legal_sections(json), Err(ThirdPartyLicensesError::Parse(_))), + "{}", + String::from_utf8_lossy(json) + ); + } + } + + /// Every SPDX id in the embedded file, read without the parser under test. + fn embedded_license_ids() -> BTreeSet { + crate::register_test_resources(); + let json = gio::resources_lookup_data(&resource_path(), gio::ResourceLookupFlags::NONE) + .expect("the third-party licenses are embedded"); + let value: serde_json::Value = serde_json::from_slice(&json).expect("the embedded file is JSON"); + value["licenses"] + .as_array() + .expect("a licenses array") + .iter() + .map(|license| license["id"].as_str().expect("an id").to_owned()) + .collect() + } + + /// What every label of `dialog` reads once it is shown, which is when + /// it builds its pages. + fn presented_labels(dialog: &adw::AboutDialog) -> Vec { + let window = adw::Window::new(); + window.present(); + dialog.present(Some(&window)); + drain_main_context(); + let labels = descendants(dialog) + .into_iter() + .filter_map(|widget| widget.downcast::().ok()) + .map(|label| label.text().to_string()) + .collect(); + dialog.force_close(); + window.destroy(); + labels + } + + #[gtk4::test] + fn custom_license_text_shows_as_written() { + adw::init().expect("libadwaita initialises under the test backend"); + let text = "Copyright 2020 A & B"; + let dialog = adw::AboutDialog::new(); + let section = LegalSection { + title: "example".to_owned(), + license_type: gtk::License::Custom, + license_text: Some(text.to_owned()), + }; + + add_legal_sections(&dialog, &[section]); + + let labels = presented_labels(&dialog); + assert!(labels.iter().any(|label| label == "example"), "{labels:?}"); + assert!(labels.iter().any(|label| label == text), "{labels:?}"); + } + + #[gtk4::test] + fn about_dialog_shows_every_embedded_section() { + adw::init().expect("libadwaita initialises under the test backend"); + crate::register_test_resources(); + let sections = embedded_legal_sections().expect("the embedded licenses parse"); + + let labels = presented_labels(&build_about_dialog(&DriverRegistry::new())); + + for section in §ions { + assert!(labels.contains(§ion.title), "no heading {}", section.title); + if let Some(text) = §ion.license_text { + assert!(labels.contains(text), "no text under {}", section.title); + } + } + } + + #[test] + fn embedded_licenses_give_one_section_per_license() { + let ids = embedded_license_ids(); + assert!(ids.contains("MIT"), "{ids:?}"); + + let sections = embedded_legal_sections().expect("the embedded licenses parse"); + + assert_eq!(sections.len(), ids.len()); + assert!(sections.iter().all(|section| !section.title.is_empty())); + } +} diff --git a/linux/crates/app/src/application/window_host.rs b/linux/crates/app/src/application/window_host.rs new file mode 100644 index 0000000000..e4a0876b66 --- /dev/null +++ b/linux/crates/app/src/application/window_host.rs @@ -0,0 +1,13 @@ +use gtk4 as gtk; + +use super::WindowLaunch; + +/// What a window may ask of the application that hosts it, so a window +/// reaches the others without holding the application. +pub trait WindowHost { + /// False when no window has that id any more. + fn present_window(&self, window_id: u32) -> bool; + fn open_window(&self, launch: WindowLaunch); + /// `None` once the application is gone. + fn application(&self) -> Option; +} diff --git a/linux/crates/app/src/application/window_launch.rs b/linux/crates/app/src/application/window_launch.rs new file mode 100644 index 0000000000..de1311351a --- /dev/null +++ b/linux/crates/app/src/application/window_launch.rs @@ -0,0 +1,13 @@ +use tablepro_session::SessionKey; +use tablepro_storage::SavedConnection; + +/// What a new window opens on. +#[derive(Debug, Clone)] +pub enum WindowLaunch { + Picker, + Connect(Box), + /// A database file, from the file manager or the command line. + OpenFile(gio::File), + /// A session that was open when the application last quit. + Restore(SessionKey), +} diff --git a/linux/crates/app/src/application/window_registry.rs b/linux/crates/app/src/application/window_registry.rs new file mode 100644 index 0000000000..c094d1d328 --- /dev/null +++ b/linux/crates/app/src/application/window_registry.rs @@ -0,0 +1,64 @@ +use std::cell::RefCell; +use std::collections::HashMap; + +use crate::runtime::InputSender; +use crate::ui::{SessionWindow, SessionWindowInput}; + +/// Each window the application opened, by window id. GTK-thread only. +#[derive(Default)] +pub(crate) struct WindowRegistry { + windows: RefCell>, +} + +impl WindowRegistry { + pub(crate) fn insert(&self, id: u32, window: SessionWindow) { + self.windows.borrow_mut().insert(id, window); + } + + /// The window is shut down after the map is released: its teardown + /// may present another window, which reads the map. + pub(crate) fn remove(&self, id: u32) { + let removed = self.windows.borrow_mut().remove(&id); + if let Some(window) = removed { + window.shutdown(); + } + } + + pub(crate) fn sender(&self, id: u32) -> Option> { + self.windows.borrow().get(&id).and_then(SessionWindow::input) + } + + /// Whether the window holds logout back. + pub(crate) fn holds_inhibit(&self, id: u32) -> bool { + self.windows.borrow().get(&id).is_some_and(SessionWindow::is_inhibiting) + } + + pub(crate) fn keeps_session(&self, id: u32) -> bool { + self.windows.borrow().get(&id).is_some_and(SessionWindow::keeps_session) + } + + #[cfg(test)] + pub(crate) fn len(&self) -> usize { + self.windows.borrow().len() + } + + #[cfg(test)] + pub(crate) fn get(&self, id: u32) -> Option { + self.windows.borrow().get(&id).cloned() + } + + pub(crate) fn clear(&self) { + let windows: Vec = self.windows.borrow_mut().drain().map(|(_, window)| window).collect(); + for window in windows { + window.shutdown(); + } + } +} + +impl std::fmt::Debug for WindowRegistry { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("WindowRegistry") + .field("windows", &self.windows.borrow().keys().collect::>()) + .finish() + } +} diff --git a/linux/crates/app/src/config.rs b/linux/crates/app/src/config.rs new file mode 100644 index 0000000000..f10421dfba --- /dev/null +++ b/linux/crates/app/src/config.rs @@ -0,0 +1,113 @@ +pub const APP_ID: &str = default_env(option_env!("TABLEPRO_APP_ID"), "app.tablepro.TablePro.Devel"); +pub const VERSION: &str = default_env(option_env!("TABLEPRO_VERSION"), "unknown"); +pub const PROFILE: &str = default_env(option_env!("TABLEPRO_PROFILE"), "development"); +pub const LOCALEDIR: &str = default_env(option_env!("TABLEPRO_LOCALEDIR"), "/usr/share/locale"); +pub const LIBEXECDIR: &str = default_env(option_env!("TABLEPRO_LIBEXECDIR"), "/usr/libexec"); + +pub const GETTEXT_PACKAGE: &str = env!("CARGO_PKG_NAME"); +pub const RESOURCE_BASE_PATH: &str = "/app/tablepro/TablePro"; + +const DEVELOPMENT_SUFFIX: &str = ".Devel"; + +const fn default_env(value: Option<&'static str>, fallback: &'static str) -> &'static str { + match value { + Some(value) => value, + None => fallback, + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Profile { + Default, + Development, +} + +pub fn profile() -> Profile { + if APP_ID.ends_with(DEVELOPMENT_SUFFIX) { + Profile::Development + } else { + Profile::Default + } +} + +/// Directory name under `$XDG_CONFIG_HOME` and `$XDG_STATE_HOME`. A +/// development build keeps its own so `cargo run` never writes over an +/// installed build's connections, history or workspace state. +pub fn storage_dir_name() -> &'static str { + match profile() { + Profile::Default => "tablepro", + Profile::Development => "tablepro-devel", + } +} + +/// `xdg:schema` attribute for keyring items. It follows the app ID, so +/// a development build never reads an installed build's secrets. +pub fn secret_schema() -> String { + format!("{APP_ID}.Password") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn app_id_is_valid() { + assert!(gtk4::gio::Application::id_is_valid(APP_ID)); + } + + #[test] + fn resource_base_path_is_shared_by_every_profile() { + let app_path = format!("/{}", APP_ID.replace('.', "/")); + let suffix = app_path + .strip_prefix(RESOURCE_BASE_PATH) + .expect("the app ID extends the base ID"); + + assert!(matches!(suffix, "" | "/Devel"), "{suffix}"); + } + + #[test] + fn profile_is_development_exactly_when_the_app_id_is_a_devel_id() { + assert_eq!(profile() == Profile::Development, APP_ID.ends_with(DEVELOPMENT_SUFFIX)); + } + + #[test] + fn storage_dir_name_separates_development_from_installed_builds() { + let expected = match profile() { + Profile::Default => "tablepro", + Profile::Development => "tablepro-devel", + }; + assert_eq!(storage_dir_name(), expected); + assert_ne!("tablepro", "tablepro-devel"); + } + + #[test] + fn release_and_development_schemas_install_side_by_side_on_their_own_paths() { + let dir = tempfile::tempdir().expect("a temporary directory"); + let (release, development) = ("app.tablepro.TablePro", "app.tablepro.TablePro.Devel"); + + if let Err(error) = crate::test_support::settings_schema::compile(dir.path(), &[release, development]) { + panic!("{error}"); + } + + let source = gio::SettingsSchemaSource::from_directory(dir.path(), None, true).expect("the compiled schemas"); + let path = |id: &str| source.lookup(id, false).and_then(|schema| schema.path()); + assert_eq!(path(release).as_deref(), Some("/app/tablepro/TablePro/")); + assert_eq!(path(development).as_deref(), Some("/app/tablepro/TablePro/Devel/")); + } + + #[test] + fn secret_schema_follows_the_app_id() { + assert_eq!(secret_schema(), format!("{APP_ID}.Password")); + } + + #[test] + fn default_env_prefers_the_build_time_value() { + assert_eq!(default_env(Some("set"), "fallback"), "set"); + assert_eq!(default_env(None, "fallback"), "fallback"); + } + + #[test] + fn gettext_package_is_the_package_name() { + assert_eq!(GETTEXT_PACKAGE, "tablepro"); + } +} diff --git a/linux/crates/app/src/crash_report.rs b/linux/crates/app/src/crash_report.rs new file mode 100644 index 0000000000..733a930480 --- /dev/null +++ b/linux/crates/app/src/crash_report.rs @@ -0,0 +1,186 @@ +//! What a crash leaves behind for the user to send, and how the next +//! launch finds it. + +use std::backtrace::Backtrace; +use std::panic::PanicHookInfo; +use std::path::{Path, PathBuf}; +use std::sync::OnceLock; + +use tablepro_storage::StorageError; + +use crate::config; + +/// GTK calls the app back through C, which a panic cannot unwind, so a +/// panic on the GTK thread ends the process. A panic on a runtime thread +/// is caught there and shown as a failed task, so only these are +/// crashes. +const GTK_THREAD: &str = "main"; + +const REPORT_PREFIX: &str = "crash-"; +const REPORT_SUFFIX: &str = ".txt"; +/// Holds the name of the newest report the user was told about. +const ANNOUNCED: &str = "announced"; +/// A launch that crashes every time leaves one report each, so the +/// oldest go once there are more than this. +const KEPT_REPORTS: usize = 20; + +/// Keep the default hook, which writes the panic to stderr and so to +/// the journal, and also write it with a backtrace to a file of its own +/// in `dir`. A GTK callback that panics panics a second time when it +/// cannot unwind, and that goes in the same file. +pub fn install(dir: PathBuf) { + let report = OnceLock::new(); + let default_hook = std::panic::take_hook(); + std::panic::set_hook(Box::new(move |info| { + default_hook(info); + if std::thread::current().name() != Some(GTK_THREAD) { + return; + } + let path = report.get_or_init(|| dir.join(report_name(chrono::Utc::now(), std::process::id()))); + let text = report_text(info, &Backtrace::force_capture(), chrono::Utc::now()); + if let Err(error) = tablepro_storage::fs::append_private_blocking(path, text.as_bytes()) { + tracing::error!(%error, "could not save the crash report"); + } + })); +} + +/// Names sort by time, so the newest report is the last name. +fn report_name(now: chrono::DateTime, pid: u32) -> String { + format!("{REPORT_PREFIX}{}-{pid}{REPORT_SUFFIX}", now.format("%Y%m%dT%H%M%SZ")) +} + +fn report_text(info: &PanicHookInfo<'_>, backtrace: &Backtrace, now: chrono::DateTime) -> String { + let message = info.payload_as_str().unwrap_or("(no message)"); + let location = info + .location() + .map_or_else(|| "an unknown place".to_owned(), ToString::to_string); + let build = if cfg!(debug_assertions) { "debug" } else { "release" }; + format!( + "TablePro {version}\n\ + Application ID: {app_id}\n\ + Profile: {profile}\n\ + Build: {build}\n\ + Time: {time}\n\ + \n\ + Panicked at {location}:\n\ + {message}\n\ + \n\ + Backtrace:\n\ + {backtrace}\n\n", + version = config::VERSION, + app_id = config::APP_ID, + profile = config::PROFILE, + time = now.to_rfc3339_opts(chrono::SecondsFormat::Secs, true), + ) +} + +/// The newest report the user has not been told about, marked as told. +/// Runs at startup, before this run could have written one, and drops +/// the oldest reports past `KEPT_REPORTS`. +pub fn take_unannounced(dir: &Path) -> Result, StorageError> { + let mut reports = match std::fs::read_dir(dir) { + Ok(entries) => entries + .filter_map(Result::ok) + .filter_map(|entry| entry.file_name().into_string().ok()) + .filter(|name| name.starts_with(REPORT_PREFIX) && name.ends_with(REPORT_SUFFIX)) + .collect::>(), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(source) => { + return Err(StorageError::Io { + path: dir.to_owned(), + source, + }); + } + }; + reports.sort_unstable(); + let surplus = reports.len().saturating_sub(KEPT_REPORTS); + for name in reports.drain(..surplus) { + if let Err(error) = std::fs::remove_file(dir.join(&name)) { + tracing::warn!(%error, "could not remove an old crash report"); + } + } + let Some(newest) = reports.pop() else { + return Ok(None); + }; + let marker = dir.join(ANNOUNCED); + let announced = std::fs::read_to_string(&marker).unwrap_or_default(); + if announced.trim() >= newest.as_str() { + return Ok(None); + } + tablepro_storage::fs::write_private_blocking(&marker, newest.as_bytes())?; + Ok(Some(dir.join(newest))) +} + +#[cfg(test)] +mod tests { + use chrono::TimeZone; + + use super::*; + + fn report(dir: &Path, name: &str) { + tablepro_storage::fs::append_private_blocking(&dir.join(name), b"a report").expect("a report"); + } + + #[test] + fn report_names_sort_by_time() { + let earlier = chrono::Utc + .with_ymd_and_hms(2026, 9, 30, 23, 59, 59) + .single() + .expect("a time"); + let later = chrono::Utc + .with_ymd_and_hms(2026, 10, 1, 0, 0, 0) + .single() + .expect("a time"); + + assert_eq!(report_name(later, 42), "crash-20261001T000000Z-42.txt"); + assert!(report_name(earlier, 99_999) < report_name(later, 1)); + } + + #[test] + fn the_newest_report_is_announced_once() { + let dir = tempfile::tempdir().expect("a directory"); + report(dir.path(), "crash-20261001T000000Z-1.txt"); + report(dir.path(), "crash-20261002T000000Z-2.txt"); + + let first = take_unannounced(dir.path()).expect("a listing"); + + assert_eq!(first, Some(dir.path().join("crash-20261002T000000Z-2.txt"))); + assert_eq!(take_unannounced(dir.path()).expect("a listing"), None); + + report(dir.path(), "crash-20261003T000000Z-3.txt"); + assert_eq!( + take_unannounced(dir.path()).expect("a listing"), + Some(dir.path().join("crash-20261003T000000Z-3.txt")) + ); + } + + #[test] + fn no_directory_is_no_report() { + let dir = tempfile::tempdir().expect("a directory"); + + assert_eq!(take_unannounced(&dir.path().join("crashes")).expect("a listing"), None); + } + + #[test] + fn only_the_newest_reports_are_kept() { + let dir = tempfile::tempdir().expect("a directory"); + for day in 1..=KEPT_REPORTS + 3 { + report(dir.path(), &format!("crash-202610{day:02}T000000Z-1.txt")); + } + + take_unannounced(dir.path()).expect("a listing"); + + let left = std::fs::read_dir(dir.path()) + .expect("a listing") + .filter_map(Result::ok) + .filter(|entry| entry.file_name().to_string_lossy().starts_with(REPORT_PREFIX)) + .count(); + assert_eq!(left, KEPT_REPORTS); + assert!(!dir.path().join("crash-20261001T000000Z-1.txt").exists()); + assert!( + dir.path() + .join(format!("crash-202610{:02}T000000Z-1.txt", KEPT_REPORTS + 3)) + .exists() + ); + } +} diff --git a/linux/crates/app/src/i18n.rs b/linux/crates/app/src/i18n.rs new file mode 100644 index 0000000000..5d0a03ed68 --- /dev/null +++ b/linux/crates/app/src/i18n.rs @@ -0,0 +1,158 @@ +use gettextrs::{LocaleCategory, bind_textdomain_codeset, bindtextdomain, setlocale, textdomain}; +use thiserror::Error; + +use crate::config; + +pub use gettextrs::{gettext, ngettext, npgettext, pgettext}; + +#[derive(Debug, Error)] +pub enum I18nError { + #[error("could not bind text domain {package} to {dir}: {source}")] + BindTextDomain { + package: &'static str, + dir: &'static str, + #[source] + source: std::io::Error, + }, + #[error("could not set the text domain codeset to UTF-8: {0}")] + Codeset(#[source] std::io::Error), + #[error("could not select text domain {package}: {source}")] + TextDomain { + package: &'static str, + #[source] + source: std::io::Error, + }, +} + +/// # Safety +/// +/// `setlocale` mutates process-global locale state that other threads read +/// without synchronisation, so this must run before any other thread exists. +pub unsafe fn init() -> Result<(), I18nError> { + unsafe { + setlocale(LocaleCategory::LcAll, ""); + } + bindtextdomain(config::GETTEXT_PACKAGE, config::LOCALEDIR).map_err(|source| I18nError::BindTextDomain { + package: config::GETTEXT_PACKAGE, + dir: config::LOCALEDIR, + source, + })?; + bind_textdomain_codeset(config::GETTEXT_PACKAGE, "UTF-8").map_err(I18nError::Codeset)?; + textdomain(config::GETTEXT_PACKAGE).map_err(|source| I18nError::TextDomain { + package: config::GETTEXT_PACKAGE, + source, + })?; + Ok(()) +} + +/// Translate, then fill `{name}` placeholders. Named rather than +/// positional so a translator can reorder them, and single-pass so a +/// value that itself contains braces is never expanded again. +pub fn gettext_f(msgid: &str, args: &[(&str, &str)]) -> String { + substitute(&gettext(msgid), args) +} + +pub fn ngettext_f(msgid: &str, plural: &str, n: u32, args: &[(&str, &str)]) -> String { + substitute(&ngettext(msgid, plural, n), args) +} + +pub fn pgettext_f(context: &str, msgid: &str, args: &[(&str, &str)]) -> String { + substitute(&pgettext(context, msgid), args) +} + +fn substitute(text: &str, args: &[(&str, &str)]) -> String { + if args.is_empty() { + return text.to_owned(); + } + let mut out = String::with_capacity(text.len()); + let mut rest = text; + while let Some(open) = rest.find('{') { + let Some(close_offset) = rest[open..].find('}') else { + break; + }; + let close = open + close_offset; + let name = &rest[open + 1..close]; + out.push_str(&rest[..open]); + match args.iter().find(|(key, _)| *key == name) { + Some((_, value)) => out.push_str(value), + // An unknown placeholder stays literal: a translator typo + // should show up in the string, not swallow it. + None => out.push_str(&rest[open..=close]), + } + rest = &rest[close + 1..]; + } + out.push_str(rest); + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn substitute_fills_named_placeholders() { + assert_eq!( + substitute("{greeting}, {name}!", &[("greeting", "Hello"), ("name", "Dat")]), + "Hello, Dat!" + ); + } + + #[test] + fn substitute_lets_a_translator_reorder_placeholders() { + let args = [("first", "one"), ("second", "two")]; + + assert_eq!(substitute("{first} {second}", &args), "one two"); + assert_eq!(substitute("{second} {first}", &args), "two one"); + } + + #[test] + fn substitute_never_re_expands_a_value() { + let filled = substitute("{outer}", &[("outer", "{inner}"), ("inner", "boom")]); + + assert_eq!(filled, "{inner}"); + } + + #[test] + fn substitute_keeps_an_unknown_placeholder_literal() { + assert_eq!(substitute("{known} {typo}", &[("known", "ok")]), "ok {typo}"); + } + + #[test] + fn substitute_with_no_args_is_the_identity() { + assert_eq!(substitute("{n} rows", &[]), "{n} rows"); + assert_eq!(substitute("plain", &[]), "plain"); + } + + #[test] + fn substitute_leaves_an_unclosed_brace_alone() { + assert_eq!(substitute("100% {of", &[("of", "x")]), "100% {of"); + } + + #[test] + fn ngettext_f_picks_singular_or_plural_in_the_c_locale() { + assert_eq!(ngettext_f("{n} row", "{n} rows", 1, &[("n", "1")]), "1 row"); + assert_eq!(ngettext_f("{n} row", "{n} rows", 4, &[("n", "4")]), "4 rows"); + } + + #[test] + fn i18n_error_display() { + let error = I18nError::BindTextDomain { + package: "tablepro", + dir: "/usr/share/locale", + source: std::io::Error::from(std::io::ErrorKind::NotFound), + }; + + let message = error.to_string(); + + assert!(message.contains("tablepro")); + assert!(message.contains("/usr/share/locale")); + } + + #[test] + fn i18n_error_keeps_the_io_source() { + let error = I18nError::Codeset(std::io::Error::from(std::io::ErrorKind::InvalidInput)); + + assert!(std::error::Error::source(&error).is_some()); + assert!(error.to_string().contains("UTF-8")); + } +} diff --git a/linux/crates/app/src/lib.rs b/linux/crates/app/src/lib.rs new file mode 100644 index 0000000000..539c047974 --- /dev/null +++ b/linux/crates/app/src/lib.rs @@ -0,0 +1,120 @@ +use std::rc::Rc; +use std::sync::Arc; + +use thiserror::Error; + +use tablepro_core::DriverRegistry; +use tablepro_storage::{AppSettings, StoragePaths}; + +pub mod application; +pub mod config; +pub mod crash_report; +pub mod i18n; +pub mod logging; +mod persistence; +pub mod runtime; +mod services; +pub mod storage; +// Public for the save tests that run a tracker against a real server. +pub use services::{change_tracker, tab_tracker_handle, tracker_edit}; +#[cfg(test)] +mod test_support; +mod ui; +mod workspace; + +use application::{AppServices, TableProApplication}; + +#[derive(Debug, Error)] +pub enum StartupError { + #[error("could not start logging: {0}")] + Logging(#[from] logging::LoggingError), + #[error("could not start the background runtime: {0}")] + Runtime(#[source] std::io::Error), + #[error("could not register the embedded resources: {0}")] + Resources(#[source] glib::Error), + #[error("could not open the settings schema: {0}")] + Settings(#[from] tablepro_storage::SettingsError), +} + +/// Start the application and return its exit code once the last window +/// and the last background close are gone. +pub fn run() -> Result { + // SAFETY: nothing above spawns a thread; the tokio runtime and GTK start later. + let translations = unsafe { i18n::init() }; + logging::init(config::profile())?; + if let Err(error) = translations { + tracing::warn!(%error, "translations unavailable; falling back to the source strings"); + } + + let paths = StoragePaths::from_glib(config::storage_dir_name(), config::APP_ID); + crash_report::install(paths.crashes_dir()); + + let runtime = runtime::AppRuntime::build().map_err(StartupError::Runtime)?; + gio::resources_register_include!("tablepro.gresource").map_err(StartupError::Resources)?; + let settings = Rc::new(AppSettings::open(config::APP_ID)?); + let drivers = build_registry(); + tracing::info!(drivers = drivers.len(), "starting tablepro"); + + let application = TableProApplication::new(AppServices::production(drivers, settings, paths, runtime.tasks())); + let code = gio::prelude::ApplicationExtManual::run(&application); + drop(application); + + // The main loop has returned, so nothing is producing work any + // more. Give what is in flight a moment to finish rather than + // dropping it mid-write. + runtime.shutdown(); + Ok(code) +} + +fn build_registry() -> DriverRegistry { + let mut r = DriverRegistry::new(); + r.register(Arc::new(tablepro_driver_clickhouse::ClickhouseDriver)); + r.register(Arc::new(tablepro_driver_mssql::MssqlDriver)); + r.register(Arc::new(tablepro_driver_mysql::MysqlDriver)); + r.register(Arc::new(tablepro_driver_postgres::PgDriver)); + r.register(Arc::new(tablepro_driver_sqlite::SqliteDriver)); + r +} + +#[cfg(test)] +pub(crate) fn register_test_resources() { + static ONCE: std::sync::Once = std::sync::Once::new(); + ONCE.call_once(|| { + gio::resources_register_include!("tablepro.gresource").expect("embedded resources"); + }); +} + +#[cfg(test)] +mod tests { + use glib::prelude::ObjectExt; + use libadwaita as adw; + + use super::register_test_resources; + use super::*; + + #[test] + fn resources_contain_style_css() { + register_test_resources(); + + let data = gio::resources_lookup_data( + &format!("{}/style.css", config::RESOURCE_BASE_PATH), + gio::ResourceLookupFlags::NONE, + ) + .expect("style.css is embedded at the resource base path"); + + assert!(String::from_utf8_lossy(&data).contains(".tp-cell-modified")); + } + + #[gtk4::test] + fn shortcuts_dialog_resource_builds() { + adw::init().expect("libadwaita initialises under the test backend"); + register_test_resources(); + + let builder = gtk4::Builder::from_resource(&format!("{}/shortcuts-dialog.ui", config::RESOURCE_BASE_PATH)); + let dialog = builder + .object::("shortcuts_dialog") + .expect("shortcuts_dialog is an AdwShortcutsDialog"); + + assert_eq!(dialog.type_().name(), "AdwShortcutsDialog"); + } +} diff --git a/linux/crates/app/src/logging.rs b/linux/crates/app/src/logging.rs new file mode 100644 index 0000000000..211130760a --- /dev/null +++ b/linux/crates/app/src/logging.rs @@ -0,0 +1,267 @@ +use std::error::Error; + +use thiserror::Error as ThisError; +use tracing::Subscriber; +use tracing_subscriber::filter::{FilterExt, LevelFilter, Targets}; +use tracing_subscriber::fmt::MakeWriter; +use tracing_subscriber::layer::SubscriberExt; +use tracing_subscriber::util::SubscriberInitExt; +use tracing_subscriber::{EnvFilter, Layer}; + +use crate::config::Profile; + +/// The libraries under the drivers, by target prefix. Each can log +/// what a user's database was sent or answered: tokio-postgres the text +/// of every statement at debug, sqlx every statement and a slow one as +/// a warning, tiberius rows at trace and server messages as errors, the +/// clickhouse crate exceptions that quote the query, deadpool the errors +/// of the connections it recycles, and sqlparser at debug the text it +/// parses and the tokens and literals it reads. The drivers map and log +/// what they need from these libraries themselves. +const SILENCED_LIBRARIES: &[&str] = &[ + "tokio_postgres", + "deadpool", + "tiberius", + "sqlx", + "clickhouse", + "sqlparser", +]; + +#[derive(Debug, ThisError)] +pub enum LoggingError { + #[error("could not install the tracing subscriber: {0}")] + Install(Box), +} + +/// How a line is written. +/// +/// The journal renders either, but only one of them can be read back by +/// a log shipper without guessing where a field ends. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LogFormat { + Text, + Json, +} + +/// The format `TABLEPRO_LOG_FORMAT` asks for. +/// +/// `None` for a value that names no format, which the caller reports +/// rather than acting on, the same way an unparsable `RUST_LOG` does. +pub fn parse_format(requested: &str) -> Option { + match requested.trim().to_ascii_lowercase().as_str() { + "" | "text" | "plain" => Some(LogFormat::Text), + "json" => Some(LogFormat::Json), + _ => None, + } +} + +/// A development build is noisy on purpose; an installed one keeps the +/// journal readable. +pub fn default_level(profile: Profile) -> &'static str { + match profile { + Profile::Development => "debug", + Profile::Default => "info", + } +} + +/// Logs go to stderr, which the GNOME session journals for both Flatpak +/// and system installs. Panics land there too, so the two interleave in +/// order without a journald writer or a panic hook. +pub fn init(profile: Profile) -> Result<(), LoggingError> { + let requested = std::env::var_os("RUST_LOG"); + let parsed = EnvFilter::try_from_default_env(); + let unparsable = requested.is_some() && parsed.is_err(); + let filter = parsed.unwrap_or_else(|_| EnvFilter::new(default_level(profile))); + + let asked = std::env::var("TABLEPRO_LOG_FORMAT").ok(); + let format = asked.as_deref().and_then(parse_format); + let unknown_format = asked.as_deref().is_some_and(|value| parse_format(value).is_none()); + + // Installing it also installs the `log` bridge, so the libraries + // that log through `log` pass the same filter. + subscriber(filter, format.unwrap_or(LogFormat::Text), std::io::stderr) + .try_init() + .map_err(|error| LoggingError::Install(Box::new(error)))?; + + if unparsable { + tracing::warn!( + "RUST_LOG could not be parsed; falling back to {}", + default_level(profile) + ); + } + if unknown_format { + tracing::warn!("TABLEPRO_LOG_FORMAT names no known format; writing text"); + } + Ok(()) +} + +/// What `init` installs, writing to `writer`. +/// +/// The silenced libraries are filtered beside `filter` rather than +/// inside it, so no `RUST_LOG` directive, however specific, turns them +/// back on. +fn subscriber(filter: EnvFilter, format: LogFormat, writer: W) -> impl Subscriber + Send + Sync + 'static +where + W: for<'writer> MakeWriter<'writer> + Send + Sync + 'static, +{ + let output = tracing_subscriber::fmt::layer().with_writer(writer).with_target(false); + let output = match format { + // One object per line, so a shipper reads a field rather than a + // position in a sentence. + LogFormat::Json => output.json().boxed(), + LogFormat::Text => output.boxed(), + }; + tracing_subscriber::registry().with(output.with_filter(filter.and(libraries_silenced()))) +} + +fn libraries_silenced() -> Targets { + SILENCED_LIBRARIES + .iter() + .fold(Targets::new().with_default(LevelFilter::TRACE), |targets, library| { + targets.with_target(*library, LevelFilter::OFF) + }) +} + +#[cfg(test)] +mod tests { + use std::io::Write; + use std::sync::{Arc, Mutex, PoisonError}; + + use tablepro_core::sql_syntax::{MAX_EXPRESSION_LEN, SqlGrammar, parse_expression}; + + use super::*; + + const SECRET: &str = "ALTER ROLE app PASSWORD 'hunter2'"; + + #[derive(Clone, Default)] + struct Captured(Arc>>); + + impl Captured { + fn text(&self) -> String { + String::from_utf8_lossy(&self.0.lock().unwrap_or_else(PoisonError::into_inner)).into_owned() + } + } + + impl Write for Captured { + fn write(&mut self, bytes: &[u8]) -> std::io::Result { + self.0 + .lock() + .unwrap_or_else(PoisonError::into_inner) + .extend_from_slice(bytes); + Ok(bytes.len()) + } + + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } + } + + impl<'writer> MakeWriter<'writer> for Captured { + type Writer = Self; + + fn make_writer(&'writer self) -> Self::Writer { + self.clone() + } + } + + fn written(rust_log: &str, format: LogFormat, emit: impl FnOnce()) -> String { + let captured = Captured::default(); + let subscriber = subscriber(EnvFilter::new(rust_log), format, captured.clone()); + tracing::subscriber::with_default(subscriber, emit); + captured.text() + } + + #[test] + fn client_library_events_are_dropped_whatever_rust_log_asks() { + for format in [LogFormat::Text, LogFormat::Json] { + let text = written( + "trace,tokio_postgres::simple_query=trace,sqlx::query=trace", + format, + || { + tracing::debug!(target: "tokio_postgres::simple_query", "executing simple query: {SECRET}"); + tracing::warn!(target: "sqlx::query", "slow statement: {SECRET}"); + tracing::trace!(target: "sqlx::explain", "{SECRET}"); + tracing::error!(target: "tiberius::tds::stream::token", message = SECRET); + tracing::debug!(target: "clickhouse::error", "{SECRET}"); + tracing::warn!(target: "deadpool.postgres", "Connection error: {SECRET}"); + tracing::debug!(target: "tablepro::ui::editor", "the run ended"); + }, + ); + + assert!(!text.contains("hunter2"), "{format:?}: {text}"); + assert!(text.contains("the run ended"), "{format:?}: {text}"); + } + } + + #[test] + fn statements_logged_through_log_are_dropped() { + // Fails only when a logger other than the bridge is already set, + // which nothing in this binary does. + let _ = tracing_log::LogTracer::init(); + + let text = written("debug", LogFormat::Text, || { + log::debug!(target: "tokio_postgres::simple_query", "executing simple query: {SECRET}"); + log::debug!(target: "tokio_postgres::prepare", "preparing query s0: {SECRET}"); + log::info!(target: "tokio_postgres::connection", "NOTICE: {SECRET}"); + log::debug!(target: "sqlparser::parser", "Parsing sql '{SECRET}'..."); + log::debug!(target: "sqlparser::dialect", "get_next_precedence_full() {SECRET}"); + log::debug!(target: "tablepro_driver_postgres::pool", "a pooled connection ended"); + }); + + assert!(!text.contains("hunter2"), "{text}"); + assert!(text.contains("a pooled connection ended"), "{text}"); + } + + #[test] + fn a_filter_the_parser_reads_stays_out_of_the_logs() { + let _ = tracing_log::LogTracer::init(); + + let text = written("debug", LogFormat::Text, || { + for grammar in SqlGrammar::ALL { + let parsed = parse_expression(grammar, "api_key = 'hunter2'", MAX_EXPRESSION_LEN); + assert!(parsed.is_ok(), "{grammar:?}: {parsed:?}"); + } + tracing::debug!(target: "tablepro::ui::browse", "the filter applied"); + }); + + assert!(!text.contains("hunter2"), "{text}"); + assert!(text.contains("the filter applied"), "{text}"); + } + + #[test] + fn the_filter_still_drops_what_rust_log_leaves_out() { + let text = written("info", LogFormat::Text, || { + tracing::debug!(target: "tablepro::ui", "noise"); + tracing::info!(target: "tablepro::ui", "kept"); + }); + + assert!(!text.contains("noise"), "{text}"); + assert!(text.contains("kept"), "{text}"); + } + + #[test] + fn default_level_per_profile() { + assert_eq!(default_level(Profile::Development), "debug"); + assert_eq!(default_level(Profile::Default), "info"); + } + + #[test] + fn a_format_is_named_in_any_case_with_any_spacing() { + assert_eq!(parse_format("json"), Some(LogFormat::Json)); + assert_eq!(parse_format(" JSON "), Some(LogFormat::Json)); + assert_eq!(parse_format("text"), Some(LogFormat::Text)); + assert_eq!(parse_format("plain"), Some(LogFormat::Text)); + } + + #[test] + fn an_unset_or_empty_value_is_the_text_default() { + assert_eq!(parse_format(""), Some(LogFormat::Text)); + assert_eq!(parse_format(" "), Some(LogFormat::Text)); + } + + #[test] + fn a_value_naming_no_format_is_refused_rather_than_guessed() { + assert_eq!(parse_format("yaml"), None); + assert_eq!(parse_format("jsonl"), None); + } +} diff --git a/linux/crates/app/src/main.rs b/linux/crates/app/src/main.rs new file mode 100644 index 0000000000..9e7afed97e --- /dev/null +++ b/linux/crates/app/src/main.rs @@ -0,0 +1,17 @@ +use tablepro_app::StartupError; + +fn main() -> glib::ExitCode { + match tablepro_app::run() { + Ok(code) => code, + Err(StartupError::Logging(error)) => { + // Nothing is journalling yet, so this is the one place that + // has to reach the session log by another route. + glib::g_critical!("tablepro", "{error}"); + glib::ExitCode::FAILURE + } + Err(error) => { + tracing::error!(%error, "TablePro could not start"); + glib::ExitCode::FAILURE + } + } +} diff --git a/linux/crates/app/src/persistence/column_width_store.rs b/linux/crates/app/src/persistence/column_width_store.rs new file mode 100644 index 0000000000..6311d3dfa9 --- /dev/null +++ b/linux/crates/app/src/persistence/column_width_store.rs @@ -0,0 +1,170 @@ +use std::rc::Rc; + +use tablepro_session::runtime::Tasks; +use uuid::Uuid; + +use super::StateFile; +use super::column_widths_document::ColumnWidthsDocument; + +/// The widths the user dragged, remembered per connection, schema and +/// table. +#[derive(Clone)] +pub struct ColumnWidthStore { + file: Rc>, +} + +impl ColumnWidthStore { + pub fn load(path: std::path::PathBuf, tasks: &Tasks) -> Self { + Self { + file: Rc::new(StateFile::load(path, tasks)), + } + } + + pub fn width(&self, connection_id: Uuid, schema: Option<&str>, table: &str, column: &str) -> Option { + self.file.read(|document| { + document + .connections + .get(&connection_id.to_string())? + .get(schema_key(schema))? + .get(table)? + .get(column) + .copied() + }) + } + + /// A non-positive width is what an unrealized column reports, and + /// an unchanged one has nothing to save. + pub fn record(&self, connection_id: Uuid, schema: Option<&str>, table: &str, column: &str, width: i32) { + if width <= 0 { + return; + } + self.file.update(|document| { + let slot = document + .connections + .entry(connection_id.to_string()) + .or_default() + .entry(schema_key(schema).to_owned()) + .or_default() + .entry(table.to_owned()) + .or_default() + .entry(column.to_owned()) + .or_default(); + if *slot == width { + return false; + } + *slot = width; + true + }); + } + + /// Drop the column's width, so it opens at its default again. + pub fn forget(&self, connection_id: Uuid, schema: Option<&str>, table: &str, column: &str) { + self.file.update(|document| { + document + .connections + .get_mut(&connection_id.to_string()) + .and_then(|schemas| schemas.get_mut(schema_key(schema))) + .and_then(|tables| tables.get_mut(table)) + .is_some_and(|columns| columns.remove(column).is_some()) + }); + } + + /// Drop everything for a connection the user deleted, so the file + /// does not grow with entries nothing can reach. + pub fn forget_connection(&self, connection_id: Uuid) { + self.file + .update(|document| document.connections.remove(&connection_id.to_string()).is_some()); + } + + pub fn flush(&self) -> impl Future + Send + use<> { + self.file.flush() + } +} + +/// `None` is stored as the empty string so every JSON key is concrete. +fn schema_key(schema: Option<&str>) -> &str { + schema.unwrap_or("") +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_support::paused_tasks; + + fn store(root: &tempfile::TempDir) -> ColumnWidthStore { + ColumnWidthStore::load(root.path().join("column-widths.json"), &paused_tasks()) + } + + #[tokio::test] + async fn widths_keyed_by_schema() { + let root = tempfile::tempdir().expect("tempdir"); + let store = store(&root); + let id = Uuid::new_v4(); + + store.record(id, Some("public"), "users", "name", 240); + store.record(id, Some("audit"), "users", "name", 90); + + assert_eq!(store.width(id, Some("public"), "users", "name"), Some(240)); + assert_eq!(store.width(id, Some("audit"), "users", "name"), Some(90)); + assert_eq!(store.width(id, None, "users", "name"), None); + } + + #[tokio::test] + async fn record_ignores_non_positive_and_unchanged_widths() { + let root = tempfile::tempdir().expect("tempdir"); + let store = store(&root); + let id = Uuid::new_v4(); + store.record(id, None, "users", "name", 240); + + store.record(id, None, "users", "name", 0); + store.record(id, None, "users", "name", -1); + store.record(id, None, "users", "name", 240); + store.flush().await; + + assert_eq!(store.width(id, None, "users", "name"), Some(240)); + } + + #[tokio::test] + async fn forget_drops_one_column() { + let root = tempfile::tempdir().expect("tempdir"); + let store = store(&root); + let id = Uuid::new_v4(); + store.record(id, Some("public"), "users", "name", 240); + store.record(id, Some("public"), "users", "email", 180); + + store.forget(id, Some("public"), "users", "name"); + store.forget(id, Some("public"), "orders", "name"); + + assert_eq!(store.width(id, Some("public"), "users", "name"), None); + assert_eq!(store.width(id, Some("public"), "users", "email"), Some(180)); + } + + #[tokio::test] + async fn forget_connection_removes_the_map() { + let root = tempfile::tempdir().expect("tempdir"); + let store = store(&root); + let kept = Uuid::new_v4(); + let dropped = Uuid::new_v4(); + store.record(kept, None, "users", "name", 100); + store.record(dropped, None, "users", "name", 200); + + store.forget_connection(dropped); + + assert_eq!(store.width(kept, None, "users", "name"), Some(100)); + assert_eq!(store.width(dropped, None, "users", "name"), None); + } + + #[tokio::test] + async fn a_recorded_width_survives_a_reopen() { + let root = tempfile::tempdir().expect("tempdir"); + let path = root.path().join("column-widths.json"); + let id = Uuid::new_v4(); + let store = ColumnWidthStore::load(path.clone(), &paused_tasks()); + store.record(id, Some("public"), "users", "name", 240); + store.flush().await; + + let reopened = ColumnWidthStore::load(path, &paused_tasks()); + + assert_eq!(reopened.width(id, Some("public"), "users", "name"), Some(240)); + } +} diff --git a/linux/crates/app/src/persistence/column_widths_document.rs b/linux/crates/app/src/persistence/column_widths_document.rs new file mode 100644 index 0000000000..42985e9ab6 --- /dev/null +++ b/linux/crates/app/src/persistence/column_widths_document.rs @@ -0,0 +1,24 @@ +use std::collections::HashMap; + +use serde::{Deserialize, Serialize}; +use tablepro_storage::document::VersionedDocument; + +/// Column widths, keyed connection, schema, table, column. +/// +/// The schema is part of the key so `public.users` and `audit.users` do +/// not share one set of widths on a multi-schema database. `None` is +/// stored as the empty string, so every JSON key is concrete. +#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ColumnWidthsDocument { + #[serde(default)] + pub connections: HashMap, +} + +pub type Schemas = HashMap; +pub type Tables = HashMap; +pub type Widths = HashMap; + +impl VersionedDocument for ColumnWidthsDocument { + const KIND: &'static str = "column-widths"; + const VERSION: u32 = 1; +} diff --git a/linux/crates/app/src/persistence/filter_settings_document.rs b/linux/crates/app/src/persistence/filter_settings_document.rs new file mode 100644 index 0000000000..decb964392 --- /dev/null +++ b/linux/crates/app/src/persistence/filter_settings_document.rs @@ -0,0 +1,23 @@ +use std::collections::HashMap; + +use serde::{Deserialize, Serialize}; +use tablepro_core::FilterSet; +use tablepro_storage::document::VersionedDocument; + +/// Per-table filters, keyed connection, schema, table. +/// +/// The same nesting as the column widths, with a `FilterSet` at the +/// leaf instead of a width. +#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)] +pub struct FilterSettingsDocument { + #[serde(default)] + pub connections: HashMap, +} + +pub type Schemas = HashMap; +pub type Tables = HashMap; + +impl VersionedDocument for FilterSettingsDocument { + const KIND: &'static str = "filter-settings"; + const VERSION: u32 = 1; +} diff --git a/linux/crates/app/src/persistence/filter_settings_store.rs b/linux/crates/app/src/persistence/filter_settings_store.rs new file mode 100644 index 0000000000..8a440292bd --- /dev/null +++ b/linux/crates/app/src/persistence/filter_settings_store.rs @@ -0,0 +1,178 @@ +use std::rc::Rc; + +use tablepro_core::FilterSet; +use tablepro_session::runtime::Tasks; +use uuid::Uuid; + +use super::StateFile; +use super::filter_settings_document::FilterSettingsDocument; + +/// The filter each table was left with, remembered per connection, +/// schema and table. +#[derive(Clone)] +pub struct FilterSettingsStore { + file: Rc>, +} + +impl FilterSettingsStore { + pub fn load(path: std::path::PathBuf, tasks: &Tasks) -> Self { + Self { + file: Rc::new(StateFile::load(path, tasks)), + } + } + + pub fn filters(&self, connection_id: Uuid, schema: Option<&str>, table: &str) -> FilterSet { + self.file.read(|document| { + document + .connections + .get(&connection_id.to_string()) + .and_then(|schemas| schemas.get(schema_key(schema))) + .and_then(|tables| tables.get(table)) + .cloned() + .unwrap_or_default() + }) + } + + pub fn set_filters(&self, connection_id: Uuid, schema: Option<&str>, table: &str, set: FilterSet) { + self.file.update(|document| match set.is_empty() { + // Clearing a filter shrinks the file back rather than + // leaving an empty blob behind. + true => prune(document, connection_id, schema, table), + false => { + let slot = document + .connections + .entry(connection_id.to_string()) + .or_default() + .entry(schema_key(schema).to_owned()) + .or_default() + .entry(table.to_owned()) + .or_default(); + if *slot == set { + return false; + } + *slot = set; + true + } + }); + } + + /// Drop everything for a connection the user deleted. + pub fn forget_connection(&self, connection_id: Uuid) { + self.file + .update(|document| document.connections.remove(&connection_id.to_string()).is_some()); + } + + pub fn flush(&self) -> impl Future + Send + use<> { + self.file.flush() + } +} + +/// `None` is stored as the empty string so every JSON key is concrete. +fn schema_key(schema: Option<&str>) -> &str { + schema.unwrap_or("") +} + +/// Remove the entry and any map it leaves empty. +fn prune(document: &mut FilterSettingsDocument, connection_id: Uuid, schema: Option<&str>, table: &str) -> bool { + let connection_key = connection_id.to_string(); + let Some(schemas) = document.connections.get_mut(&connection_key) else { + return false; + }; + let mut removed = false; + if let Some(tables) = schemas.get_mut(schema_key(schema)) { + removed = tables.remove(table).is_some(); + if tables.is_empty() { + schemas.remove(schema_key(schema)); + } + } + if schemas.is_empty() { + document.connections.remove(&connection_key); + } + removed +} + +#[cfg(test)] +mod tests { + use tablepro_core::{Combinator, FilterOp, FilterRule, FilterValue}; + + use super::*; + use crate::test_support::paused_tasks; + + fn sample_set() -> FilterSet { + FilterSet { + combinator: Combinator::Or, + rules: vec![FilterRule { + column: "name".into(), + op: FilterOp::Eq, + value: Some(FilterValue::Single("alice".into())), + }], + extra_sql: None, + } + } + + fn store(root: &tempfile::TempDir) -> FilterSettingsStore { + FilterSettingsStore::load(root.path().join("filter-settings.json"), &paused_tasks()) + } + + #[tokio::test] + async fn filters_are_keyed_by_schema() { + let root = tempfile::tempdir().expect("tempdir"); + let store = store(&root); + let id = Uuid::new_v4(); + + store.set_filters(id, Some("public"), "users", sample_set()); + + assert_eq!(store.filters(id, Some("public"), "users"), sample_set()); + assert!(store.filters(id, Some("audit"), "users").is_empty()); + assert!(store.filters(id, None, "users").is_empty()); + } + + #[tokio::test] + async fn empty_filter_set_prunes_entry() { + let root = tempfile::tempdir().expect("tempdir"); + let path = root.path().join("filter-settings.json"); + let store = FilterSettingsStore::load(path.clone(), &paused_tasks()); + let id = Uuid::new_v4(); + store.set_filters(id, Some("public"), "users", sample_set()); + + store.set_filters(id, Some("public"), "users", FilterSet::default()); + store.flush().await; + + assert!(store.filters(id, Some("public"), "users").is_empty()); + let written = std::fs::read_to_string(&path).expect("the file"); + assert!( + !written.contains("alice"), + "the cleared filter stayed on disk: {written}" + ); + assert!(!written.contains("users"), "an empty map was left behind: {written}"); + } + + #[tokio::test] + async fn forget_connection_removes_both_maps() { + let root = tempfile::tempdir().expect("tempdir"); + let store = store(&root); + let kept = Uuid::new_v4(); + let dropped = Uuid::new_v4(); + store.set_filters(kept, Some("public"), "users", sample_set()); + store.set_filters(dropped, Some("public"), "users", sample_set()); + + store.forget_connection(dropped); + + assert_eq!(store.filters(kept, Some("public"), "users"), sample_set()); + assert!(store.filters(dropped, Some("public"), "users").is_empty()); + } + + #[tokio::test] + async fn a_saved_filter_survives_a_reopen() { + let root = tempfile::tempdir().expect("tempdir"); + let path = root.path().join("filter-settings.json"); + let id = Uuid::new_v4(); + let store = FilterSettingsStore::load(path.clone(), &paused_tasks()); + store.set_filters(id, None, "users", sample_set()); + store.flush().await; + + let reopened = FilterSettingsStore::load(path, &paused_tasks()); + + assert_eq!(reopened.filters(id, None, "users"), sample_set()); + } +} diff --git a/linux/crates/app/src/persistence/mod.rs b/linux/crates/app/src/persistence/mod.rs new file mode 100644 index 0000000000..1c3f1b504d --- /dev/null +++ b/linux/crates/app/src/persistence/mod.rs @@ -0,0 +1,16 @@ +//! The small versioned JSON files the app keeps beside the user's +//! state: column widths and per-table filters. +//! +//! Each is read once at startup and answered from memory after that, so +//! nothing the user does waits on the disk. A file that cannot be read, +//! or that a newer TablePro wrote, is left exactly as it is. + +mod column_width_store; +mod column_widths_document; +mod filter_settings_document; +mod filter_settings_store; +mod state_file; + +pub use column_width_store::ColumnWidthStore; +pub use filter_settings_store::FilterSettingsStore; +pub use state_file::StateFile; diff --git a/linux/crates/app/src/persistence/state_file.rs b/linux/crates/app/src/persistence/state_file.rs new file mode 100644 index 0000000000..340bf75e8f --- /dev/null +++ b/linux/crates/app/src/persistence/state_file.rs @@ -0,0 +1,266 @@ +use std::cell::{Cell, RefCell}; +use std::path::PathBuf; + +use tablepro_session::runtime::{LatestWinsWriter, Tasks}; +use tablepro_storage::document::{VersionedDocument, decode_document, encode_document}; +use tablepro_storage::{DocumentProblem, DocumentProblemKind}; + +/// A small versioned JSON file the app keeps in memory and writes back +/// behind the user. +/// +/// Reads answer from memory, so a drag never waits on the disk. Writes +/// go through a latest-wins writer on a blocking thread, so a burst +/// costs two writes rather than one per event. +pub struct StateFile { + value: RefCell, + persistence: Cell, + writer: LatestWinsWriter, +} + +/// Whether this file may be written back. +/// +/// A file a newer TablePro wrote, or one that cannot be read at all, is +/// left exactly as it is: overwriting it would destroy state the user +/// gets back by upgrading or by fixing permissions. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Persistence { + Enabled, + Suppressed, +} + +impl StateFile +where + D: VersionedDocument + Default + Clone + Send + Sync + 'static, +{ + /// Read the file once at startup. It is a few kilobytes, so this is + /// cheaper than making every later read fallible. + pub fn load(path: PathBuf, tasks: &Tasks) -> Self { + let (value, persistence) = read(&path); + let path_for_writes = path.clone(); + let writer = LatestWinsWriter::spawn(tasks, move |document: &D| write(&path_for_writes, document)); + Self { + value: RefCell::new(value), + persistence: Cell::new(persistence), + writer, + } + } + + pub fn read(&self, with: impl FnOnce(&D) -> R) -> R { + with(&self.value.borrow()) + } + + /// Change the document. `change` returns whether anything moved, so + /// a no-op edit costs no write. + pub fn update(&self, change: impl FnOnce(&mut D) -> bool) { + let snapshot = { + let mut value = self.value.borrow_mut(); + if !change(&mut value) { + return; + } + value.clone() + }; + if self.persistence.get() == Persistence::Enabled { + self.writer.put(snapshot); + } + } + + /// Resolves once everything changed so far is on disk. + pub fn flush(&self) -> impl Future + Send + use { + self.writer.flush() + } +} + +/// Load the document, applying the recovery rule for each way a file +/// can be unusable. +fn read(path: &std::path::Path) -> (D, Persistence) { + let bytes = match std::fs::read(path) { + Ok(bytes) => bytes, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + return (D::default(), Persistence::Enabled); + } + Err(error) => { + return suppress(DocumentProblem::new( + path, + DocumentProblemKind::Unreadable { + detail: error.to_string(), + }, + )); + } + }; + + match decode_document::(path, &bytes) { + Ok(document) => (document, Persistence::Enabled), + // A newer TablePro wrote this. Upgrading gets the state back; + // writing over it does not. + Err(problem) if problem.is_newer_version() => suppress(problem), + Err(problem) => start_over(path, problem), + } +} + +fn suppress(problem: DocumentProblem) -> (D, Persistence) { + tracing::warn!(%problem, "keeping this file as it is and not saving to it this session"); + (D::default(), Persistence::Suppressed) +} + +/// Corrupt, unversioned or too old: nothing can read it and nothing +/// will, so it is moved aside under a stamped name and the app starts +/// from empty. The bytes stay on disk for anyone who wants to look. +fn start_over(path: &std::path::Path, problem: DocumentProblem) -> (D, Persistence) { + match tablepro_storage::fs::move_aside_blocking(path, std::time::SystemTime::now()) { + Ok(moved) => tracing::warn!(%problem, moved = %moved.display(), "moved the unusable file aside"), + Err(error) => { + tracing::warn!(%problem, %error, "could not move the unusable file aside"); + return (D::default(), Persistence::Suppressed); + } + } + (D::default(), Persistence::Enabled) +} + +fn write(path: &std::path::Path, document: &D) { + let bytes = match encode_document(document) { + Ok(bytes) => bytes, + Err(error) => { + tracing::warn!(%error, kind = D::KIND, "could not serialise state"); + return; + } + }; + if let Err(error) = tablepro_storage::fs::write_private_blocking(path, &bytes) { + tracing::warn!(%error, kind = D::KIND, "could not save state"); + } +} + +#[cfg(test)] +mod tests { + use serde::{Deserialize, Serialize}; + + use super::*; + use crate::test_support::paused_tasks; + + #[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)] + struct Sample { + #[serde(default)] + items: Vec, + } + + impl VersionedDocument for Sample { + const KIND: &'static str = "sample"; + const VERSION: u32 = 1; + } + + fn seed(root: &tempfile::TempDir, contents: &str) -> PathBuf { + let path = root.path().join("sample.json"); + std::fs::write(&path, contents).expect("seed"); + path + } + + async fn push(file: &StateFile, item: &str) { + file.update(|sample| { + sample.items.push(item.to_owned()); + true + }); + file.flush().await; + } + + #[tokio::test] + async fn a_missing_file_starts_empty_and_writes_a_versioned_one() { + let root = tempfile::tempdir().expect("tempdir"); + let path = root.path().join("sample.json"); + let file = StateFile::::load(path.clone(), &paused_tasks()); + assert!(!path.exists()); + + push(&file, "one").await; + + let bytes = std::fs::read_to_string(&path).expect("the file was written"); + assert!(bytes.contains("\"version\": 1"), "{bytes}"); + assert!(bytes.contains("one"), "{bytes}"); + } + + #[tokio::test] + async fn a_corrupt_file_is_moved_aside_with_its_bytes_and_the_state_starts_empty() { + let root = tempfile::tempdir().expect("tempdir"); + let original = "{ not json"; + let path = seed(&root, original); + + let file = StateFile::::load(path.clone(), &paused_tasks()); + + assert_eq!(file.read(|sample| sample.items.len()), 0); + let moved: Vec = std::fs::read_dir(root.path()) + .expect("read the directory") + .filter_map(|entry| entry.ok().map(|entry| entry.path())) + .filter(|entry| entry != &path) + .collect(); + assert_eq!(moved.len(), 1, "{moved:?}"); + assert_eq!( + std::fs::read_to_string(&moved[0]).expect("read the moved file"), + original + ); + + push(&file, "one").await; + assert!(path.exists(), "the store refused to write after starting over"); + } + + #[tokio::test] + async fn a_file_with_no_version_is_moved_aside_too() { + let root = tempfile::tempdir().expect("tempdir"); + let path = seed(&root, r#"{"items": ["kept"]}"#); + + let file = StateFile::::load(path, &paused_tasks()); + + assert_eq!(file.read(|sample| sample.items.len()), 0); + } + + #[tokio::test] + async fn a_newer_version_file_is_never_written_after_updates_and_flush() { + let root = tempfile::tempdir().expect("tempdir"); + let original = r#"{"version": 99, "items": ["from the future"]}"#; + let path = seed(&root, original); + + let file = StateFile::::load(path.clone(), &paused_tasks()); + push(&file, "one").await; + + assert_eq!( + std::fs::read_to_string(&path).expect("read"), + original, + "a file from a newer TablePro was overwritten" + ); + } + + #[tokio::test] + async fn an_unreadable_file_is_never_written_either() { + let root = tempfile::tempdir().expect("tempdir"); + let path = seed(&root, r#"{"version": 1, "items": ["kept"]}"#); + // A directory where the file should be is the portable way to + // make a read fail as root as well as as a normal user. + std::fs::remove_file(&path).expect("clear"); + std::fs::create_dir(&path).expect("block the path"); + + let file = StateFile::::load(path.clone(), &paused_tasks()); + push(&file, "one").await; + + assert!(path.is_dir(), "the store wrote over the blocked path"); + } + + #[tokio::test] + async fn an_update_that_changes_nothing_writes_nothing() { + let root = tempfile::tempdir().expect("tempdir"); + let path = root.path().join("sample.json"); + let file = StateFile::::load(path.clone(), &paused_tasks()); + + file.update(|_| false); + file.flush().await; + + assert!(!path.exists(), "a no-op update still wrote the file"); + } + + #[tokio::test] + async fn a_readable_file_round_trips() { + let root = tempfile::tempdir().expect("tempdir"); + let path = root.path().join("sample.json"); + let file = StateFile::::load(path.clone(), &paused_tasks()); + push(&file, "one").await; + + let reopened = StateFile::::load(path, &paused_tasks()); + + assert_eq!(reopened.read(|sample| sample.items.clone()), vec!["one".to_owned()]); + } +} diff --git a/linux/crates/app/src/runtime/app_runtime.rs b/linux/crates/app/src/runtime/app_runtime.rs new file mode 100644 index 0000000000..02b6a5c2da --- /dev/null +++ b/linux/crates/app/src/runtime/app_runtime.rs @@ -0,0 +1,80 @@ +use std::time::Duration; + +use tablepro_session::runtime::Tasks; + +/// How long a shutdown waits for in-flight work before giving up. Long +/// enough for a durable write to finish, short enough that quitting +/// still feels immediate. +const SHUTDOWN_GRACE: Duration = Duration::from_secs(2); + +/// The app's own tokio runtime, built once at startup. +/// +/// Owning it means the app decides how many threads exist and when they +/// stop, instead of leaving that to whichever library starts a runtime +/// first. +pub struct AppRuntime { + runtime: tokio::runtime::Runtime, +} + +impl AppRuntime { + pub fn build() -> std::io::Result { + let runtime = tokio::runtime::Builder::new_multi_thread() + .worker_threads(worker_count()) + .thread_name("tablepro-rt") + .enable_all() + .build()?; + Ok(Self { runtime }) + } + + pub fn tasks(&self) -> Tasks { + Tasks::new(self.runtime.handle().clone()) + } + + /// Let in-flight work finish, then stop. Called after the GTK main + /// loop returns, so nothing is still producing work. + pub fn shutdown(self) { + self.runtime.shutdown_timeout(SHUTDOWN_GRACE); + } +} + +/// A database client is not compute-bound: its work is waiting on +/// sockets and files. More workers than this only adds context +/// switching, and fewer than two lets one blocking call stall the rest. +fn worker_count() -> usize { + std::thread::available_parallelism() + .map(|count| count.get().clamp(2, 4)) + .unwrap_or(2) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn worker_count_clamps() { + let clamp = |available: usize| available.clamp(2, 4); + + assert_eq!(clamp(1), 2); + assert_eq!(clamp(3), 3); + assert_eq!(clamp(16), 4); + assert!((2..=4).contains(&worker_count())); + } + + #[test] + #[expect( + clippy::disallowed_methods, + reason = "the test drives the runtime from outside it, which is the one place block_on cannot deadlock" + )] + fn runtime_threads_are_named_tablepro_rt() { + let runtime = AppRuntime::build().expect("build the runtime"); + let tasks = runtime.tasks(); + + let name = tasks + .handle() + .block_on(tasks.spawn_blocking_task(|| std::thread::current().name().map(str::to_owned))) + .expect("the task"); + + assert_eq!(name.as_deref(), Some("tablepro-rt")); + runtime.shutdown(); + } +} diff --git a/linux/crates/app/src/runtime/clock_sample.rs b/linux/crates/app/src/runtime/clock_sample.rs new file mode 100644 index 0000000000..ca2d585165 --- /dev/null +++ b/linux/crates/app/src/runtime/clock_sample.rs @@ -0,0 +1,10 @@ +use std::time::Duration; + +/// Both clocks read at one moment. Monotonic time stops while the +/// machine is suspended and boot time does not, so the gap between them +/// grows by exactly the time spent asleep. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct ClockSample { + pub boottime: Duration, + pub monotonic: Duration, +} diff --git a/linux/crates/app/src/runtime/clock_source.rs b/linux/crates/app/src/runtime/clock_source.rs new file mode 100644 index 0000000000..4cfe0592ab --- /dev/null +++ b/linux/crates/app/src/runtime/clock_source.rs @@ -0,0 +1,5 @@ +use super::ClockSample; + +pub trait ClockSource { + fn sample(&self) -> ClockSample; +} diff --git a/linux/crates/app/src/runtime/deliver.rs b/linux/crates/app/src/runtime/deliver.rs new file mode 100644 index 0000000000..80e5584e55 --- /dev/null +++ b/linux/crates/app/src/runtime/deliver.rs @@ -0,0 +1,85 @@ +use std::future::Future; + +use tablepro_session::runtime::{ProcessHold, TaskFailure, Tasks}; + +/// Run `work` on the app runtime and hand its result to `deliver` on the +/// GTK thread. +/// +/// Drivers need a tokio context and widgets need the GTK thread, so a +/// view never awaits driver work itself: the work runs on `Tasks` and +/// only the answer crosses back. Aborting the returned handle drops the +/// delivery, never the work, so a save the user started still lands. +pub fn spawn_and_deliver(tasks: &Tasks, work: W, deliver: D) -> glib::JoinHandle<()> +where + T: Send + 'static, + W: Future + Send + 'static, + D: FnOnce(Result) + 'static, +{ + let running = tasks.spawn_task(work); + glib::spawn_future_local(async move { + deliver(running.await); + }) +} + +/// Run `work` on the app runtime with the process held until it ends, +/// so a quit right after a write started waits for it instead of +/// cutting it off. +pub fn spawn_held(tasks: &Tasks, hold: &dyn ProcessHold, work: W) +where + W: Future + Send + 'static, +{ + let token = hold.acquire(); + let running = tasks.spawn_task(work); + glib::spawn_future_local(async move { + if let Err(failure) = running.await { + tracing::warn!(%failure, "a held background task failed"); + } + drop(token); + }); +} + +#[cfg(test)] +mod tests { + use std::cell::RefCell; + use std::rc::Rc; + + use tablepro_session::runtime::CountingHold; + + use super::*; + + #[gtk4::test] + fn held_work_keeps_the_process_until_it_ends() { + let runtime = crate::test_support::test_runtime(); + let hold = CountingHold::new(); + let (finish, finished) = tokio::sync::oneshot::channel::<()>(); + + spawn_held(&runtime.tasks(), &hold, async move { + let _ = finished.await; + }); + crate::test_support::drain_main_context(); + assert_eq!(hold.live(), 1, "the work was not held"); + + let _ = finish.send(()); + crate::test_support::wait_until(std::time::Duration::from_secs(5), || hold.live() == 0) + .expect("the hold to end with the work"); + runtime.shutdown(); + } + + #[gtk4::test] + fn the_result_arrives_on_the_main_thread() { + let runtime = crate::test_support::test_runtime(); + let main_thread = std::thread::current().id(); + let seen = Rc::new(RefCell::new(None)); + let sink = seen.clone(); + + let _delivery = spawn_and_deliver(&runtime.tasks(), async { std::thread::current().id() }, move |worker| { + *sink.borrow_mut() = Some((worker.ok(), std::thread::current().id())); + }); + crate::test_support::wait_until(std::time::Duration::from_secs(5), || seen.borrow().is_some()) + .expect("the delivery"); + + let (worker, delivered_on) = seen.borrow_mut().take().expect("a result"); + assert_ne!(worker, Some(main_thread), "the work ran on the GTK thread"); + assert_eq!(delivered_on, main_thread); + } +} diff --git a/linux/crates/app/src/runtime/deliver_input.rs b/linux/crates/app/src/runtime/deliver_input.rs new file mode 100644 index 0000000000..9f18633d3c --- /dev/null +++ b/linux/crates/app/src/runtime/deliver_input.rs @@ -0,0 +1,85 @@ +/// Whether a message reached the component it was sent to. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Delivery { + Delivered, + /// The component has shut down, so the message was dropped. + ReceiverGone, +} + +/// Send a component its input without panicking once it has shut down. +/// +/// relm4's `ComponentSender::input` and `FactorySender::input` call +/// `expect` on a closed channel, and a window can close while a read or +/// a save it started is still on its way back, which would take every +/// other window down with it. The message is dropped instead. +pub trait DeliverInput { + fn try_deliver(&self, message: M) -> Delivery; + + fn deliver(&self, message: M) { + if self.try_deliver(message) == Delivery::ReceiverGone { + tracing::debug!(target: "component", "message dropped after component shutdown"); + } + } +} + +impl DeliverInput for relm4::Sender { + fn try_deliver(&self, message: M) -> Delivery { + match self.send(message) { + Ok(()) => Delivery::Delivered, + Err(_) => Delivery::ReceiverGone, + } + } +} + +impl DeliverInput for relm4::ComponentSender { + fn try_deliver(&self, message: C::Input) -> Delivery { + self.input_sender().try_deliver(message) + } +} + +impl DeliverInput for relm4::FactorySender { + fn try_deliver(&self, message: C::Input) -> Delivery { + self.input_sender().try_deliver(message) + } +} + +impl DeliverInput for tokio::sync::mpsc::UnboundedSender { + fn try_deliver(&self, message: M) -> Delivery { + match self.send(message) { + Ok(()) => Delivery::Delivered, + Err(_) => Delivery::ReceiverGone, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_message_to_a_live_receiver_arrives() { + let (sender, receiver) = relm4::channel::(); + + sender.deliver(7); + + assert_eq!(receiver.recv_sync(), Some(7)); + } + + #[test] + fn a_message_after_shutdown_is_dropped_without_panicking() { + let (sender, receiver) = relm4::channel::(); + drop(receiver); + + sender.deliver(7); + } + + #[test] + fn deliver_to_dropped_receiver_reports_receiver_gone() { + let (sender, receiver) = tokio::sync::mpsc::unbounded_channel::(); + assert_eq!(sender.try_deliver(1), Delivery::Delivered); + + drop(receiver); + + assert_eq!(sender.try_deliver(2), Delivery::ReceiverGone); + } +} diff --git a/linux/crates/app/src/runtime/detect_resume.rs b/linux/crates/app/src/runtime/detect_resume.rs new file mode 100644 index 0000000000..4464c79929 --- /dev/null +++ b/linux/crates/app/src/runtime/detect_resume.rs @@ -0,0 +1,48 @@ +use std::time::Duration; + +use super::ClockSample; + +/// Below this the gap is scheduling noise, not a suspend. +pub const RESUME_THRESHOLD: Duration = Duration::from_secs(2); + +/// How long the machine slept between two samples, when it slept longer +/// than `threshold`. +pub fn detect_resume(prev: ClockSample, now: ClockSample, threshold: Duration) -> Option { + let awake = now.monotonic.saturating_sub(prev.monotonic); + let slept = now.boottime.saturating_sub(prev.boottime).saturating_sub(awake); + (slept > threshold).then_some(slept) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sample(boottime: u64, monotonic: u64) -> ClockSample { + ClockSample { + boottime: Duration::from_secs(boottime), + monotonic: Duration::from_secs(monotonic), + } + } + + #[test] + fn drift_over_threshold_reports_sleep() { + let slept = detect_resume(sample(100, 90), sample(700, 95), RESUME_THRESHOLD); + + assert_eq!(slept, Some(Duration::from_secs(595))); + } + + #[test] + fn equal_clocks_report_none() { + assert_eq!(detect_resume(sample(100, 90), sample(105, 95), RESUME_THRESHOLD), None); + assert_eq!( + detect_resume(sample(100, 90), sample(107, 95), RESUME_THRESHOLD), + None, + "two seconds is not a sleep" + ); + assert_eq!( + detect_resume(sample(100, 90), sample(90, 80), RESUME_THRESHOLD), + None, + "clocks read out of order are not a sleep" + ); + } +} diff --git a/linux/crates/app/src/runtime/failed_requests.rs b/linux/crates/app/src/runtime/failed_requests.rs new file mode 100644 index 0000000000..b12a1ca329 --- /dev/null +++ b/linux/crates/app/src/runtime/failed_requests.rs @@ -0,0 +1,66 @@ +use std::collections::BTreeSet; + +/// The kinds of request whose last accepted reply was a failure, which +/// a view asks again for once the connection comes back. +#[derive(Debug, Clone)] +pub struct FailedRequests { + kinds: BTreeSet, +} + +impl FailedRequests { + pub fn new() -> Self { + Self { kinds: BTreeSet::new() } + } + + pub fn record(&mut self, kind: K, outcome: &Result) { + match outcome { + Ok(_) => self.kinds.remove(&kind), + Err(_) => self.kinds.insert(kind), + }; + } + + /// A request that was never asked, because the one it waited on + /// failed first. + pub fn insert(&mut self, kind: K) { + self.kinds.insert(kind); + } + + pub fn contains(&self, kind: K) -> bool { + self.kinds.contains(&kind) + } + + pub fn is_empty(&self) -> bool { + self.kinds.is_empty() + } + + /// Every failed kind, in order, leaving none behind: the replies to + /// the requests asked again record them afresh. + pub fn take(&mut self) -> Vec { + std::mem::take(&mut self.kinds).into_iter().collect() + } +} + +impl Default for FailedRequests { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_failure_is_kept_until_the_kind_succeeds() { + let mut failed = FailedRequests::new(); + + failed.record(2, &Err::<(), ()>(())); + failed.record(1, &Err::<(), ()>(())); + assert!(failed.contains(2)); + failed.record(2, &Ok::<(), ()>(())); + + assert!(!failed.contains(2)); + assert_eq!(failed.take(), [1]); + assert!(failed.is_empty()); + } +} diff --git a/linux/crates/app/src/runtime/gio_process_hold.rs b/linux/crates/app/src/runtime/gio_process_hold.rs new file mode 100644 index 0000000000..fc60a0c6cf --- /dev/null +++ b/linux/crates/app/src/runtime/gio_process_hold.rs @@ -0,0 +1,68 @@ +use glib::prelude::*; +use tablepro_session::runtime::{HoldToken, ProcessHold}; + +/// Keeps the GApplication running while a token is alive, so closing +/// the last window does not end the process with a connection still +/// closing behind it. +/// +/// It holds the application weakly: the application owns the services +/// this lives in, and a token taken once the application is gone has +/// nothing left to keep alive. +#[derive(Debug, Clone, Default)] +pub struct GioProcessHold { + application: glib::WeakRef, +} + +impl GioProcessHold { + pub fn new(application: &impl IsA) -> Self { + let hold = Self::default(); + hold.bind(application); + hold + } + + /// Point the hold at `application`. Tokens taken before this hold + /// nothing. + pub fn bind(&self, application: &impl IsA) { + self.application.set(Some(application.upcast_ref())); + } +} + +impl ProcessHold for GioProcessHold { + fn acquire(&self) -> HoldToken { + self.application.upgrade().map_or_else(HoldToken::inert, |application| { + HoldToken::new(gio::prelude::ApplicationExtManual::hold(&application)) + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[gtk4::test] + fn the_hold_lasts_as_long_as_its_token() { + let application = gio::Application::new(None, gio::ApplicationFlags::NON_UNIQUE); + let hold = GioProcessHold::new(&application); + + let token = hold.acquire(); + drop(token); + let again = hold.acquire(); + drop(again); + } + + #[gtk4::test] + fn a_hold_without_an_application_gives_inert_tokens() { + let unbound = GioProcessHold::default(); + drop(unbound.acquire()); + + let application = gio::Application::new(None, gio::ApplicationFlags::NON_UNIQUE); + let hold = GioProcessHold::new(&application); + drop(application); + + assert!( + hold.application.upgrade().is_none(), + "the hold kept the application alive" + ); + drop(hold.acquire()); + } +} diff --git a/linux/crates/app/src/runtime/gio_reachability.rs b/linux/crates/app/src/runtime/gio_reachability.rs new file mode 100644 index 0000000000..d592b1a849 --- /dev/null +++ b/linux/crates/app/src/runtime/gio_reachability.rs @@ -0,0 +1,38 @@ +use futures::FutureExt; +use futures::future::LocalBoxFuture; +use gio::prelude::*; +use tablepro_core::NetworkEndpoint; +use tablepro_session::health::Reachability; + +use super::ReachabilityProbe; + +/// Asks GLib's network monitor, which checks the endpoint's addresses +/// against the routes it knows, through the network portal under +/// Flatpak. It resolves the name but never connects. +#[derive(Debug, Clone)] +pub struct GioReachability { + monitor: gio::NetworkMonitor, +} + +impl GioReachability { + pub fn new(monitor: gio::NetworkMonitor) -> Self { + Self { monitor } + } +} + +impl ReachabilityProbe for GioReachability { + fn can_reach(&self, endpoint: &NetworkEndpoint) -> LocalBoxFuture<'static, Reachability> { + let address = gio::NetworkAddress::new(endpoint.host(), endpoint.port()); + let host = endpoint.host().to_owned(); + self.monitor + .can_reach_future(&address) + .map(move |reached| match reached { + Ok(()) => Reachability::Reachable, + Err(error) => { + tracing::debug!(%host, %error, "the network cannot reach a session's server"); + Reachability::Unreachable + } + }) + .boxed_local() + } +} diff --git a/linux/crates/app/src/runtime/input_queue.rs b/linux/crates/app/src/runtime/input_queue.rs new file mode 100644 index 0000000000..45c4be4477 --- /dev/null +++ b/linux/crates/app/src/runtime/input_queue.rs @@ -0,0 +1,177 @@ +use gtk4::glib; +use gtk4::glib::prelude::*; +use tokio::sync::mpsc::{UnboundedReceiver, UnboundedSender}; + +use super::{DeliverInput, Delivery}; + +/// What a widget's actions, signal handlers and background work send it. +/// It is `Send`, so a task on the runtime can answer through it. +pub type InputSender = UnboundedSender; + +pub fn input_queue() -> (InputSender, UnboundedReceiver) { + tokio::sync::mpsc::unbounded_channel() +} + +/// Hand each message `object` receives to `handle`, one at a time and in +/// order, on the GTK thread, until the returned handle is aborted or +/// `object` is gone. +/// +/// Handling waits for the main loop rather than running inside whatever +/// sent the message, so a signal handler or an action never re-enters +/// the widget while it is in the middle of handling another message. +pub fn pump_inputs( + object: &T, + receiver: UnboundedReceiver, + handle: impl Fn(&T, M) + 'static, +) -> glib::JoinHandle<()> +where + T: IsA, + M: 'static, +{ + pump_inputs_with_leftovers(object, receiver, handle, drop) +} + +/// As [`pump_inputs`], and once the pump stops, aborted or with `object` +/// gone, every message still queued goes to `leftover` rather than being +/// dropped, for one that owns something that has to be closed. +pub fn pump_inputs_with_leftovers( + object: &T, + receiver: UnboundedReceiver, + handle: impl Fn(&T, M) + 'static, + leftover: impl FnMut(M) + 'static, +) -> glib::JoinHandle<()> +where + T: IsA, + M: 'static, +{ + let weak = object.downgrade(); + let mut queue = Leftovers { receiver, leftover }; + glib::spawn_future_local(async move { + while let Some(message) = queue.receiver.recv().await { + let Some(object) = weak.upgrade() else { + (queue.leftover)(message); + return; + }; + handle(&object, message); + } + }) +} + +/// The pump's end of the queue. It drains when the pump's future drops. +struct Leftovers { + receiver: UnboundedReceiver, + leftover: F, +} + +impl Drop for Leftovers { + fn drop(&mut self) { + // Closed first, so a sender gets its message back instead of + // adding to a queue nobody reads. + self.receiver.close(); + while let Ok(message) = self.receiver.try_recv() { + (self.leftover)(message); + } + } +} + +/// A relm4 sender whose messages land in `input`, for a relm4 child that +/// can only forward its output to one. Abort the handle with the widget +/// that owns `input`. +pub fn relm4_bridge(input: InputSender) -> (relm4::Sender, glib::JoinHandle<()>) { + let (sender, receiver) = relm4::channel::(); + let forwarding = glib::spawn_future_local(async move { + while let Some(message) = receiver.recv().await { + if input.try_deliver(message) == Delivery::ReceiverGone { + return; + } + } + }); + (sender, forwarding) +} + +#[cfg(test)] +mod tests { + use std::cell::RefCell; + use std::rc::Rc; + + use super::*; + use crate::test_support::drain_main_context; + + #[gtk4::test] + fn messages_are_handled_in_order_after_the_sender_returns() { + let object = glib::Object::new::(); + let (sender, receiver) = input_queue::(); + let seen = Rc::new(RefCell::new(Vec::new())); + let sink = seen.clone(); + let pump = pump_inputs(&object, receiver, move |_, message| sink.borrow_mut().push(message)); + + sender.deliver(1); + sender.deliver(2); + assert!(seen.borrow().is_empty(), "handled inside the send"); + drain_main_context(); + + assert_eq!(*seen.borrow(), [1, 2]); + pump.abort(); + } + + #[gtk4::test] + fn an_aborted_pump_leaves_the_sender_with_no_receiver() { + let object = glib::Object::new::(); + let (sender, receiver) = input_queue::(); + let pump = pump_inputs(&object, receiver, |_, _| {}); + + // The handle holds the task's source, and with it the receiver, + // until it is dropped too. + pump.abort(); + drop(pump); + drain_main_context(); + + assert_eq!(sender.try_deliver(1), Delivery::ReceiverGone); + } + + #[gtk4::test] + fn messages_still_queued_when_the_pump_stops_go_to_leftover() { + let object = glib::Object::new::(); + let (sender, receiver) = input_queue::(); + let (handled, left) = (Rc::new(RefCell::new(Vec::new())), Rc::new(RefCell::new(Vec::new()))); + let (handled_sink, left_sink) = (handled.clone(), left.clone()); + let pump = pump_inputs_with_leftovers( + &object, + receiver, + move |_, message| handled_sink.borrow_mut().push(message), + move |message| left_sink.borrow_mut().push(message), + ); + sender.deliver(1); + drain_main_context(); + + sender.deliver(2); + sender.deliver(3); + pump.abort(); + drop(pump); + drain_main_context(); + + assert_eq!(*handled.borrow(), [1]); + assert_eq!(*left.borrow(), [2, 3]); + assert_eq!(sender.try_deliver(4), Delivery::ReceiverGone); + assert_eq!( + *left.borrow(), + [2, 3], + "a message sent after the pump stopped was taken" + ); + } + + #[gtk4::test] + fn a_bridge_forwards_until_its_target_is_gone() { + let (input, mut receiver) = input_queue::(); + let (bridge, _forwarding) = relm4_bridge(input); + + bridge.deliver(3); + drain_main_context(); + assert_eq!(receiver.try_recv().ok(), Some(3)); + + drop(receiver); + bridge.deliver(4); + drain_main_context(); + assert_eq!(bridge.try_deliver(5), Delivery::ReceiverGone); + } +} diff --git a/linux/crates/app/src/runtime/mod.rs b/linux/crates/app/src/runtime/mod.rs new file mode 100644 index 0000000000..3c304fb8c6 --- /dev/null +++ b/linux/crates/app/src/runtime/mod.rs @@ -0,0 +1,39 @@ +mod app_runtime; +mod clock_sample; +mod clock_source; +mod deliver; +mod deliver_input; +mod detect_resume; +mod failed_requests; +mod gio_process_hold; +mod gio_reachability; +mod input_queue; +mod network_watch; +mod reachability_fanout; +mod reachability_probe; +mod request_sequencer; +mod request_stamp; +mod request_ticket; +mod resume_detector; +mod stamped; +mod system_clocks; + +pub use app_runtime::AppRuntime; +pub use clock_sample::ClockSample; +pub use clock_source::ClockSource; +pub use deliver::{spawn_and_deliver, spawn_held}; +pub use deliver_input::{DeliverInput, Delivery}; +pub use detect_resume::{RESUME_THRESHOLD, detect_resume}; +pub use failed_requests::FailedRequests; +pub use gio_process_hold::GioProcessHold; +pub use gio_reachability::GioReachability; +pub use input_queue::{InputSender, input_queue, pump_inputs, pump_inputs_with_leftovers, relm4_bridge}; +pub use network_watch::NetworkWatch; +pub use reachability_fanout::ReachabilityFanout; +pub use reachability_probe::ReachabilityProbe; +pub use request_sequencer::RequestSequencer; +pub use request_stamp::RequestStamp; +pub use request_ticket::RequestTicket; +pub use resume_detector::ResumeDetector; +pub use stamped::Stamped; +pub use system_clocks::SystemClocks; diff --git a/linux/crates/app/src/runtime/network_watch.rs b/linux/crates/app/src/runtime/network_watch.rs new file mode 100644 index 0000000000..873bb5ff79 --- /dev/null +++ b/linux/crates/app/src/runtime/network_watch.rs @@ -0,0 +1,116 @@ +use std::rc::Rc; + +use gio::prelude::*; +use gtk4::glib; +use tablepro_session::registry::RegistrySubscription; +use tablepro_session::{RegistryEvent, SessionRegistry}; + +use super::ReachabilityFanout; + +/// Rechecks every session's server when the network changes, and a new +/// session's as soon as it is adopted. +/// +/// `network-changed` carries a global "network available" flag that only +/// says whether there is a default route. It is not used: each session +/// asks about its own server instead. +#[derive(Debug)] +pub struct NetworkWatch { + monitor: gio::NetworkMonitor, + handlers: Vec, + _registry: RegistrySubscription, +} + +impl NetworkWatch { + pub fn start(monitor: gio::NetworkMonitor, fanout: Rc, sessions: &Rc) -> Self { + let changed = { + let fanout = fanout.clone(); + monitor.connect_network_changed(move |_, _| fanout.reevaluate_all()) + }; + let connectivity = { + let fanout = fanout.clone(); + monitor.connect_connectivity_notify(move |_| fanout.reevaluate_all()) + }; + // The registry holds its observers, so this one holds the fanout + // weakly: the fanout holds the registry. + let fanout = Rc::downgrade(&fanout); + let registry = sessions.subscribe(move |event| { + let Some(fanout) = fanout.upgrade() else { + return; + }; + match event { + RegistryEvent::Attached { key, .. } => fanout.reevaluate(key), + RegistryEvent::Released { key } => fanout.forget(key), + RegistryEvent::LiveCountChanged(_) => {} + } + }); + Self { + monitor, + handlers: vec![changed, connectivity], + _registry: registry, + } + } +} + +impl Drop for NetworkWatch { + fn drop(&mut self) { + for handler in self.handlers.drain(..) { + self.monitor.disconnect(handler); + } + } +} + +#[cfg(test)] +mod tests { + use tablepro_core::NetworkEndpoint; + + use super::*; + use crate::test_support::{FakeProbe, attach_remote}; + + fn emit_network_changed(monitor: &gio::NetworkMonitor) { + monitor.emit_by_name::<()>("network-changed", &[&true]); + crate::test_support::drain_main_context(); + } + + #[gtk4::test] + fn attach_triggers_reevaluate() { + let runtime = crate::test_support::test_runtime(); + let registry = SessionRegistry::new(); + let probe = std::rc::Rc::new(FakeProbe::default()); + let fanout = ReachabilityFanout::new(registry.clone(), probe.clone()); + let watch = NetworkWatch::start(gio::NetworkMonitor::default(), fanout, ®istry); + let endpoint = NetworkEndpoint::new("db.example.com", 5432).unwrap(); + + let attached = attach_remote(®istry, "db.example.com", Vec::new(), runtime.tasks()); + crate::test_support::drain_main_context(); + + assert!( + probe.asked().contains(&endpoint), + "the new session's server was not checked" + ); + drop(attached); + drop(watch); + runtime.shutdown(); + } + + #[gtk4::test] + fn a_network_change_rechecks_until_the_watch_is_dropped() { + let runtime = crate::test_support::test_runtime(); + let registry = SessionRegistry::new(); + let probe = std::rc::Rc::new(FakeProbe::default()); + let fanout = ReachabilityFanout::new(registry.clone(), probe.clone()); + let monitor = gio::NetworkMonitor::default(); + let watch = NetworkWatch::start(monitor.clone(), fanout, ®istry); + let _attached = attach_remote(®istry, "db.example.com", Vec::new(), runtime.tasks()); + crate::test_support::drain_main_context(); + + let before = probe.asked().len(); + emit_network_changed(&monitor); + assert!(probe.asked().len() > before, "a network change rechecked nothing"); + + drop(watch); + let after_drop = probe.asked().len(); + emit_network_changed(&monitor); + assert_eq!(probe.asked().len(), after_drop, "a dropped watch still rechecked"); + runtime.shutdown(); + } +} diff --git a/linux/crates/app/src/runtime/reachability_fanout.rs b/linux/crates/app/src/runtime/reachability_fanout.rs new file mode 100644 index 0000000000..1e7cf3d7df --- /dev/null +++ b/linux/crates/app/src/runtime/reachability_fanout.rs @@ -0,0 +1,222 @@ +use std::cell::{Cell, RefCell}; +use std::collections::HashMap; +use std::rc::Rc; +use std::sync::{Arc, Weak}; + +use gtk4::glib; +use tablepro_session::health::ReachabilityTarget; +use tablepro_session::{MonitorSignal, Session, SessionKey, SessionRegistry}; + +use super::ReachabilityProbe; + +/// Checks each attached session's server and tells the session what it +/// found. GTK thread only. +/// +/// Checks overlap when the network changes several times in a row, and +/// can finish in any order, so each is stamped and only the newest +/// answer for a session reaches it. +pub struct ReachabilityFanout { + sessions: Rc, + probe: Rc, + stamps: RefCell>, + last_stamp: Cell, +} + +impl ReachabilityFanout { + pub fn new(sessions: Rc, probe: Rc) -> Rc { + Rc::new(Self { + sessions, + probe, + stamps: RefCell::new(HashMap::new()), + last_stamp: Cell::new(0), + }) + } + + pub fn reevaluate_all(self: &Rc) { + for (key, session) in self.sessions.attached_sessions() { + self.check(key, &session); + } + } + + pub fn reevaluate(self: &Rc, key: &SessionKey) { + if let Some(session) = self.sessions.session(key) { + self.check(key.clone(), &session); + } + } + + /// The session went away, so an answer still on its way has nowhere + /// to go. + pub fn forget(&self, key: &SessionKey) { + self.stamps.borrow_mut().remove(key); + } + + /// A file, a socket or a loopback server needs no network, so it is + /// never checked and never told it is unreachable. + fn check(self: &Rc, key: SessionKey, session: &Arc) { + let ReachabilityTarget::Remote(endpoint) = session.reachability() else { + return; + }; + let stamp = self.last_stamp.get().wrapping_add(1); + self.last_stamp.set(stamp); + self.stamps.borrow_mut().insert(key.clone(), stamp); + let probing = self.probe.can_reach(&endpoint); + let fanout = Rc::downgrade(self); + let session: Weak = Arc::downgrade(session); + glib::spawn_future_local(async move { + let reachability = probing.await; + let Some(fanout) = fanout.upgrade() else { + return; + }; + if !fanout.accept(&key, stamp) { + return; + } + if let Some(session) = session.upgrade() { + session.signal(MonitorSignal::Reachability(reachability)); + } + }); + } + + fn accept(&self, key: &SessionKey, stamp: u64) -> bool { + let mut stamps = self.stamps.borrow_mut(); + if stamps.get(key) != Some(&stamp) { + return false; + } + stamps.remove(key); + true + } +} + +impl std::fmt::Debug for ReachabilityFanout { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ReachabilityFanout") + .field("checking", &self.stamps.borrow().len()) + .finish_non_exhaustive() + } +} + +#[cfg(test)] +mod tests { + use std::time::Duration; + + use tablepro_core::NetworkEndpoint; + use tablepro_session::SessionHealth; + use tablepro_session::fakes::FakeEstablish; + use tablepro_session::health::Reachability; + use tablepro_test_support::{ConnectionScript, FakeReply}; + + use super::*; + use crate::test_support::{Attached, FakeProbe, FakeSession, attach_remote, wait_until}; + + const WAIT: Duration = Duration::from_secs(10); + + fn endpoint(host: &str) -> NetworkEndpoint { + NetworkEndpoint::new(host, 5432).expect("an endpoint") + } + + fn health(attached: &Attached) -> SessionHealth { + attached.fake.session.health().borrow().clone() + } + + #[gtk4::test] + fn loopback_never_probed() { + let runtime = crate::test_support::test_runtime(); + let registry = SessionRegistry::new(); + let probe = Rc::new(FakeProbe::default()); + let fanout = ReachabilityFanout::new(registry.clone(), probe.clone()); + let loopback = attach_remote(®istry, "127.0.0.1", Vec::new(), runtime.tasks()); + let _localhost = attach_remote(®istry, "localhost", Vec::new(), runtime.tasks()); + let _file = { + let fake = FakeSession::start(ConnectionScript::default(), Vec::new(), runtime.tasks()); + let claim = registry + .claim(SessionKey::File("file:///srv/app.db".to_owned()), 1) + .expect("a claim"); + claim.attach(&fake.session); + (fake, claim) + }; + let remote = attach_remote(®istry, "db.example.com", Vec::new(), runtime.tasks()); + + fanout.reevaluate_all(); + fanout.reevaluate(&loopback.key); + crate::test_support::drain_main_context(); + + assert_eq!(probe.asked(), [endpoint("db.example.com")]); + drop(remote); + runtime.shutdown(); + } + + /// The first answer parks the session offline, so only the fanout + /// delivering the second one can bring it back before the offline + /// recheck. + #[gtk4::test] + fn lan_host_reachable_without_default_route_stays_online() { + let runtime = crate::test_support::test_runtime(); + let registry = SessionRegistry::new(); + let probe = FakeProbe::answering(Reachability::Unreachable); + probe.then(Reachability::Reachable); + let fanout = ReachabilityFanout::new(registry.clone(), probe.clone()); + let mut healthy = ConnectionScript::default(); + healthy.probe.always(FakeReply::ok(())); + let lan = attach_remote( + ®istry, + "192.168.1.20", + vec![FakeEstablish::Connect(healthy)], + runtime.tasks(), + ); + + fanout.reevaluate_all(); + crate::test_support::drain_main_context(); + lan.fake.connection.mark_lost(); + wait_until(WAIT, || health(&lan) == SessionHealth::Offline).expect("the session offline"); + assert!( + lan.fake.connector.calls().is_empty(), + "an unreachable server was redialled" + ); + + fanout.reevaluate_all(); + + wait_until(WAIT, || health(&lan) == SessionHealth::Online).expect("the session back online"); + assert_eq!(lan.fake.connector.calls().len(), 1); + assert_eq!(probe.asked(), [endpoint("192.168.1.20"), endpoint("192.168.1.20")]); + runtime.shutdown(); + } + + #[gtk4::test] + fn stale_stamp_dropped() { + let runtime = crate::test_support::test_runtime(); + let registry = SessionRegistry::new(); + let probe = Rc::new(FakeProbe::default()); + let fanout = ReachabilityFanout::new(registry.clone(), probe.clone()); + let refused = FakeEstablish::Fail(tablepro_session::ConnectError::Driver( + tablepro_core::DriverError::Transport(tablepro_core::TransportError::Refused { + endpoint: endpoint("db.example.com"), + }), + )); + let remote = attach_remote( + ®istry, + "db.example.com", + vec![refused.clone(), refused], + runtime.tasks(), + ); + let first = probe.then_held(); + let second = probe.then_held(); + + fanout.reevaluate_all(); + fanout.reevaluate(&remote.key); + crate::test_support::drain_main_context(); + assert_eq!(probe.asked().len(), 2); + let _ = second.send(Reachability::Reachable); + crate::test_support::drain_main_context(); + let _ = first.send(Reachability::Unreachable); + crate::test_support::drain_main_context(); + remote.fake.connection.mark_lost(); + + wait_until(WAIT, || !remote.fake.connector.calls().is_empty()) + .expect("the session to reconnect, as its server is reachable"); + assert_ne!( + health(&remote), + SessionHealth::Offline, + "the older answer overrode the newer one" + ); + runtime.shutdown(); + } +} diff --git a/linux/crates/app/src/runtime/reachability_probe.rs b/linux/crates/app/src/runtime/reachability_probe.rs new file mode 100644 index 0000000000..b2a93bcd85 --- /dev/null +++ b/linux/crates/app/src/runtime/reachability_probe.rs @@ -0,0 +1,12 @@ +use futures::future::LocalBoxFuture; +use tablepro_core::NetworkEndpoint; +use tablepro_session::health::Reachability; + +/// Whether the network can reach one endpoint right now. +/// +/// Answered per endpoint, not from the default route: a LAN server with +/// no internet behind it is reachable, and a host behind a VPN that is +/// down is not, whatever the rest of the network looks like. +pub trait ReachabilityProbe { + fn can_reach(&self, endpoint: &NetworkEndpoint) -> LocalBoxFuture<'static, Reachability>; +} diff --git a/linux/crates/app/src/runtime/request_sequencer.rs b/linux/crates/app/src/runtime/request_sequencer.rs new file mode 100644 index 0000000000..942e60252e --- /dev/null +++ b/linux/crates/app/src/runtime/request_sequencer.rs @@ -0,0 +1,183 @@ +use std::collections::HashMap; +use std::hash::Hash; + +use tokio_util::sync::CancellationToken; + +use super::{RequestStamp, RequestTicket}; + +/// Keeps one request of each kind current. +/// +/// Issuing a request stops the one of its kind still running, so the +/// server drops work nobody will look at, and only the reply to the +/// latest request is accepted. Dropping the sequencer stops them all. +#[derive(Debug)] +pub struct RequestSequencer { + last: u64, + current: HashMap, +} + +impl RequestSequencer { + pub fn new() -> Self { + Self { + last: 0, + current: HashMap::new(), + } + } + + pub fn issue(&mut self, kind: K) -> RequestTicket { + self.last += 1; + let stamp = RequestStamp::new(self.last); + let cancel = CancellationToken::new(); + if let Some((_, previous)) = self.current.insert(kind, (stamp, cancel.clone())) { + previous.cancel(); + } + RequestTicket { stamp, cancel } + } + + /// Whether a reply is the answer to the current request of its kind. + /// Accepting it ends that request, so the same reply twice, or a + /// reply to one a newer request replaced, is refused. + pub fn accept(&mut self, kind: K, stamp: RequestStamp) -> bool { + match self.current.get(&kind) { + Some((current, _)) if *current == stamp => { + self.current.remove(&kind); + true + } + _ => false, + } + } + + pub fn is_current(&self, kind: K, stamp: RequestStamp) -> bool { + self.current.get(&kind).is_some_and(|(current, _)| *current == stamp) + } + + /// Whether a request of this kind is still waiting for its reply. + pub fn is_pending(&self, kind: K) -> bool { + self.current.contains_key(&kind) + } + + /// Stop the request of this kind still running; its reply is then + /// refused. + pub fn cancel(&mut self, kind: K) { + if let Some((_, cancel)) = self.current.remove(&kind) { + cancel.cancel(); + } + } + + pub fn cancel_all(&mut self) { + for (_, (_, cancel)) in self.current.drain() { + cancel.cancel(); + } + } +} + +impl Default for RequestSequencer { + fn default() -> Self { + Self::new() + } +} + +impl Drop for RequestSequencer { + fn drop(&mut self) { + for (_, (_, cancel)) in self.current.drain() { + cancel.cancel(); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] + enum Kind { + Page, + Count, + } + + #[test] + fn issue_cancels_previous_token_of_same_kind() { + let mut sequencer = RequestSequencer::new(); + let first = sequencer.issue(Kind::Page); + let count = sequencer.issue(Kind::Count); + + let second = sequencer.issue(Kind::Page); + + assert!(first.cancel.is_cancelled()); + assert!(!second.cancel.is_cancelled()); + assert!(!count.cancel.is_cancelled(), "a page request stopped the count"); + assert!(second.stamp > first.stamp); + } + + #[test] + fn accept_rejects_superseded_stamp() { + let mut sequencer = RequestSequencer::new(); + let first = sequencer.issue(Kind::Page); + let second = sequencer.issue(Kind::Page); + + assert!(!sequencer.accept(Kind::Page, first.stamp)); + assert!(sequencer.is_current(Kind::Page, second.stamp)); + assert!(sequencer.accept(Kind::Page, second.stamp)); + } + + #[test] + fn duplicate_reply_dropped() { + let mut sequencer = RequestSequencer::new(); + let ticket = sequencer.issue(Kind::Count); + + assert!(sequencer.accept(Kind::Count, ticket.stamp)); + assert!(!sequencer.accept(Kind::Count, ticket.stamp)); + assert!(!sequencer.is_pending(Kind::Count)); + assert!( + !ticket.cancel.is_cancelled(), + "accepting a reply stopped the request it answered" + ); + } + + #[test] + fn a_stamp_is_only_current_for_its_own_kind() { + let mut sequencer = RequestSequencer::new(); + let page = sequencer.issue(Kind::Page); + + assert!(!sequencer.accept(Kind::Count, page.stamp)); + assert!(sequencer.is_pending(Kind::Page)); + } + + #[test] + fn cancel_stops_only_its_kind() { + let mut sequencer = RequestSequencer::new(); + let page = sequencer.issue(Kind::Page); + let count = sequencer.issue(Kind::Count); + + sequencer.cancel(Kind::Count); + + assert!(count.cancel.is_cancelled()); + assert!(!sequencer.accept(Kind::Count, count.stamp)); + assert!(!page.cancel.is_cancelled()); + assert!(sequencer.accept(Kind::Page, page.stamp)); + } + + #[test] + fn cancel_all_stops_every_kind() { + let mut sequencer = RequestSequencer::new(); + let page = sequencer.issue(Kind::Page); + let count = sequencer.issue(Kind::Count); + + sequencer.cancel_all(); + + assert!(page.cancel.is_cancelled() && count.cancel.is_cancelled()); + assert!(!sequencer.accept(Kind::Page, page.stamp)); + } + + #[test] + fn drop_cancels_all() { + let mut sequencer = RequestSequencer::new(); + let page = sequencer.issue(Kind::Page); + let count = sequencer.issue(Kind::Count); + + drop(sequencer); + + assert!(page.cancel.is_cancelled()); + assert!(count.cancel.is_cancelled()); + } +} diff --git a/linux/crates/app/src/runtime/request_stamp.rs b/linux/crates/app/src/runtime/request_stamp.rs new file mode 100644 index 0000000000..da8f860aed --- /dev/null +++ b/linux/crates/app/src/runtime/request_stamp.rs @@ -0,0 +1,10 @@ +/// Which request a reply answers. Each one issued is newer than every +/// one before it, so a reply to an older request can be told apart. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct RequestStamp(u64); + +impl RequestStamp { + pub(super) fn new(value: u64) -> Self { + Self(value) + } +} diff --git a/linux/crates/app/src/runtime/request_ticket.rs b/linux/crates/app/src/runtime/request_ticket.rs new file mode 100644 index 0000000000..6130772ff6 --- /dev/null +++ b/linux/crates/app/src/runtime/request_ticket.rs @@ -0,0 +1,11 @@ +use tokio_util::sync::CancellationToken; + +use super::RequestStamp; + +/// What a request carries while it runs: the stamp its reply comes back +/// with, and the token that stops it once a newer request replaces it. +#[derive(Debug, Clone)] +pub struct RequestTicket { + pub stamp: RequestStamp, + pub cancel: CancellationToken, +} diff --git a/linux/crates/app/src/runtime/resume_detector.rs b/linux/crates/app/src/runtime/resume_detector.rs new file mode 100644 index 0000000000..39ad72e9ff --- /dev/null +++ b/linux/crates/app/src/runtime/resume_detector.rs @@ -0,0 +1,204 @@ +use std::cell::{Cell, RefCell}; +use std::rc::Rc; +use std::time::Duration; + +use gtk4::glib; +use tablepro_session::registry::RegistrySubscription; +use tablepro_session::{RegistryEvent, SessionRegistry}; + +use super::{ClockSample, ClockSource, RESUME_THRESHOLD, detect_resume}; + +/// How often the clocks are compared. A whole-second GLib timer is +/// batched with the system's other wakeups, and the check reads two +/// clocks and nothing else. +const TICK_SECONDS: u32 = 5; + +/// Notices that the machine slept, by the gap that opens between boot +/// time and monotonic time. GTK thread only. +/// +/// logind's PrepareForSleep needs the system bus, which a Flatpak +/// sandbox reaches only with a permission the app does not ask for. The +/// clocks need nothing. +pub struct ResumeDetector { + clocks: Box, + last: Cell, + source: RefCell>, +} + +impl ResumeDetector { + pub fn new(clocks: Box) -> Rc { + let last = clocks.sample(); + Rc::new(Self { + clocks, + last: Cell::new(last), + source: RefCell::new(None), + }) + } + + /// How long the machine slept since the last tick, if it did. + pub fn tick(&self) -> Option { + let now = self.clocks.sample(); + let prev = self.last.replace(now); + detect_resume(prev, now, RESUME_THRESHOLD) + } + + pub fn is_running(&self) -> bool { + self.source.borrow().is_some() + } + + /// Check every few seconds until `stop`. A sleep while stopped is not + /// reported: nothing was open to be told. + pub fn start(self: &Rc, on_resume: Rc) { + if self.is_running() { + return; + } + self.last.set(self.clocks.sample()); + let detector = Rc::downgrade(self); + let source = glib::timeout_add_seconds_local(TICK_SECONDS, move || { + // Never Break: stop() removes the source, and removing one + // that already ended is a GLib critical. + if let Some(slept) = detector.upgrade().and_then(|detector| detector.tick()) { + tracing::info!(slept_secs = slept.as_secs(), "the computer woke from suspend"); + on_resume(slept); + } + glib::ControlFlow::Continue + }); + self.source.replace(Some(source)); + } + + pub fn stop(&self) { + if let Some(source) = self.source.take() { + source.remove(); + } + } + + /// Run while at least one session is open, calling `on_resume` after + /// each sleep. Dropping the subscription stops following, not the + /// detector. + pub fn follow( + self: &Rc, + sessions: &Rc, + on_resume: Rc, + ) -> RegistrySubscription { + if sessions.live_count() > 0 { + self.start(on_resume.clone()); + } + let detector = Rc::downgrade(self); + sessions.subscribe(move |event| { + let RegistryEvent::LiveCountChanged(live) = event else { + return; + }; + let Some(detector) = detector.upgrade() else { + return; + }; + match live { + 0 => detector.stop(), + _ => detector.start(on_resume.clone()), + } + }) + } +} + +impl Drop for ResumeDetector { + fn drop(&mut self) { + self.stop(); + } +} + +impl std::fmt::Debug for ResumeDetector { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ResumeDetector") + .field("last", &self.last.get()) + .field("running", &self.is_running()) + .finish_non_exhaustive() + } +} + +#[cfg(test)] +mod tests { + use tablepro_session::SessionKey; + use tablepro_test_support::ConnectionScript; + + use super::*; + use crate::test_support::{FakeClocks, FakeSession}; + + fn detector() -> (Rc, FakeClocks) { + let clocks = FakeClocks::default(); + clocks.advance(1_000, 1_000); + (ResumeDetector::new(Box::new(clocks.clone())), clocks) + } + + fn ignore_resume() -> Rc { + Rc::new(|_| {}) + } + + #[test] + fn tick_reports_fake_clock_jump() { + let (detector, clocks) = detector(); + + clocks.advance(60, 5); + + assert_eq!(detector.tick(), Some(Duration::from_secs(55))); + assert_eq!(detector.tick(), None, "the same sleep was reported twice"); + } + + #[test] + fn tick_without_jump_reports_none() { + let (detector, clocks) = detector(); + + clocks.advance(5, 5); + + assert_eq!(detector.tick(), None); + } + + #[gtk4::test] + fn start_then_stop_removes_source_without_critical() { + let previous = glib::log_set_always_fatal(glib::LogLevels::LEVEL_CRITICAL); + let (detector, _clocks) = detector(); + + detector.start(ignore_resume()); + assert!(detector.is_running()); + detector.stop(); + detector.stop(); + detector.start(ignore_resume()); + drop(detector); + crate::test_support::drain_main_context(); + + glib::log_set_always_fatal(previous); + } + + #[gtk4::test] + fn a_sleep_while_stopped_is_not_reported() { + let (detector, clocks) = detector(); + detector.start(ignore_resume()); + detector.stop(); + + clocks.advance(3_600, 0); + detector.start(ignore_resume()); + + assert_eq!(detector.tick(), None); + } + + #[gtk4::test] + fn stops_when_live_count_reaches_zero() { + let runtime = crate::test_support::test_runtime(); + let registry = SessionRegistry::new(); + let (detector, _clocks) = detector(); + let _following = detector.follow(®istry, ignore_resume()); + assert!(!detector.is_running(), "running with no session open"); + + let first = FakeSession::start(ConnectionScript::default(), Vec::new(), runtime.tasks()); + let second = FakeSession::start(ConnectionScript::default(), Vec::new(), runtime.tasks()); + let first_claim = registry.claim(SessionKey::File("file:///a.db".to_owned()), 1).unwrap(); + first_claim.attach(&first.session); + assert!(detector.is_running(), "not running with a session open"); + let second_claim = registry.claim(SessionKey::File("file:///b.db".to_owned()), 2).unwrap(); + second_claim.attach(&second.session); + + drop(first_claim); + assert!(detector.is_running(), "stopped with a session still open"); + drop(second_claim); + assert!(!detector.is_running(), "still running with every session closed"); + runtime.shutdown(); + } +} diff --git a/linux/crates/app/src/runtime/stamped.rs b/linux/crates/app/src/runtime/stamped.rs new file mode 100644 index 0000000000..f21b4ea83d --- /dev/null +++ b/linux/crates/app/src/runtime/stamped.rs @@ -0,0 +1,8 @@ +use super::RequestStamp; + +/// A reply and the request it answers. +#[derive(Debug, Clone)] +pub struct Stamped { + pub stamp: RequestStamp, + pub value: T, +} diff --git a/linux/crates/app/src/runtime/system_clocks.rs b/linux/crates/app/src/runtime/system_clocks.rs new file mode 100644 index 0000000000..d83f342eb2 --- /dev/null +++ b/linux/crates/app/src/runtime/system_clocks.rs @@ -0,0 +1,41 @@ +use std::time::Duration; + +use rustix::time::{ClockId, Timespec, clock_gettime}; + +use super::{ClockSample, ClockSource}; + +/// `CLOCK_BOOTTIME` and `CLOCK_MONOTONIC`, read straight from the kernel. +#[derive(Debug, Clone, Copy, Default)] +pub struct SystemClocks; + +impl ClockSource for SystemClocks { + fn sample(&self) -> ClockSample { + ClockSample { + boottime: duration(clock_gettime(ClockId::Boottime)), + monotonic: duration(clock_gettime(ClockId::Monotonic)), + } + } +} + +fn duration(time: Timespec) -> Duration { + let seconds = Duration::from_secs(u64::try_from(time.tv_sec).unwrap_or(0)); + seconds.saturating_add(Duration::from_nanos(u64::try_from(time.tv_nsec).unwrap_or(0))) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn back_to_back_samples_move_forward_and_find_no_sleep() { + let first = SystemClocks.sample(); + let second = SystemClocks.sample(); + + assert!(second.monotonic >= first.monotonic, "{first:?} then {second:?}"); + assert!(second.boottime >= first.boottime, "{first:?} then {second:?}"); + assert_eq!( + crate::runtime::detect_resume(first, second, crate::runtime::RESUME_THRESHOLD), + None + ); + } +} diff --git a/linux/crates/app/src/services/active_session.rs b/linux/crates/app/src/services/active_session.rs new file mode 100644 index 0000000000..f4006a8f69 --- /dev/null +++ b/linux/crates/app/src/services/active_session.rs @@ -0,0 +1,50 @@ +use std::cell::RefCell; +use std::rc::Rc; +use std::sync::Arc; + +use tablepro_session::{ConnectionMetadata, Session}; + +/// The window's current session, shared by every view on the GTK thread. +/// +/// Views hold a clone of this handle rather than the session itself, so +/// a disconnect or a switch to another database reaches all of them at +/// once and none keeps a closed session alive. +#[derive(Debug, Clone, Default)] +pub struct ActiveSession { + current: Rc>>>, +} + +impl ActiveSession { + pub fn new() -> Self { + Self::default() + } + + pub fn get(&self) -> Option> { + self.current.borrow().clone() + } + + pub fn replace(&self, session: Arc) -> Option> { + self.current.borrow_mut().replace(session) + } + + pub fn take(&self) -> Option> { + self.current.borrow_mut().take() + } + + pub fn is_connected(&self) -> bool { + self.current.borrow().is_some() + } + + pub fn metadata(&self) -> Option { + self.current.borrow().as_ref().map(|session| session.metadata().clone()) + } + + /// What the connection enforces. False with no session, because + /// nothing can be written then anyway. + pub fn read_only(&self) -> bool { + self.current + .borrow() + .as_ref() + .is_some_and(|session| session.read_only()) + } +} diff --git a/linux/crates/app/src/services/cell_overlay.rs b/linux/crates/app/src/services/cell_overlay.rs new file mode 100644 index 0000000000..99cd7702c1 --- /dev/null +++ b/linux/crates/app/src/services/cell_overlay.rs @@ -0,0 +1,41 @@ +#[cfg(test)] +use tablepro_core::Value; +use tablepro_core::edit::CellInput; + +use super::change_tracker::CellState; + +/// One cell's pending state, copied out of the tracker so a draw holds +/// no borrow while it updates widgets. Only a pending value is copied: a +/// clean cell is drawn from the row itself. +#[derive(Debug, Clone, PartialEq)] +pub struct CellOverlay { + /// The cell's pending input, `None` when it shows what was read. A + /// draft's cell is always the tracker's. + pub pending: Option, + pub state: CellState, + /// The row produced the statement a save just failed on. + pub error_flash: bool, +} + +/// What tints a cell, without its pending value: every bind asks this +/// of every cell, and copying a value or scanning every edit to tint +/// one would grow with the edits held. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct CellMarks { + pub row_deleted: bool, + pub state: CellState, + pub error_flash: bool, +} + +#[cfg(test)] +impl CellOverlay { + /// The value the cell shows over `fetched`, a cell left to the + /// server reading as NULL. + pub fn shown(self, fetched: Value) -> Value { + match self.pending { + Some(CellInput::Value(value)) => value, + Some(CellInput::Default) => Value::Null, + None => fetched, + } + } +} diff --git a/linux/crates/app/src/services/change_origins.rs b/linux/crates/app/src/services/change_origins.rs new file mode 100644 index 0000000000..8ef43b7680 --- /dev/null +++ b/linux/crates/app/src/services/change_origins.rs @@ -0,0 +1,49 @@ +use tablepro_core::save::{ChangeSet, RowChange}; + +/// How many rows a save adds and removes, which is what it does to the +/// table's row count. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct ChangeOrigins { + pub inserts: u64, + pub deletes: u64, +} + +impl ChangeOrigins { + pub fn of(changes: &ChangeSet) -> Self { + changes.changes.iter().fold(Self::default(), |mut origins, change| { + match change { + RowChange::Insert { .. } => origins.inserts += 1, + RowChange::Delete { .. } => origins.deletes += 1, + RowChange::Update(_) => {} + } + origins + }) + } +} + +#[cfg(test)] +mod tests { + use tablepro_core::Value; + use tablepro_core::save::RowUpdate; + + use super::*; + + #[test] + fn a_save_counts_its_inserts_and_deletes_but_not_its_updates() { + let changes = ChangeSet { + changes: vec![ + RowChange::Insert { cells: Vec::new() }, + RowChange::Update(RowUpdate { + key: vec![Value::Int(1)], + assignments: Vec::new(), + }), + RowChange::Delete { + key: vec![Value::Int(2)], + }, + RowChange::Insert { cells: Vec::new() }, + ], + }; + + assert_eq!(ChangeOrigins::of(&changes), ChangeOrigins { inserts: 2, deletes: 1 }); + } +} diff --git a/linux/crates/app/src/services/change_tracker.rs b/linux/crates/app/src/services/change_tracker.rs new file mode 100644 index 0000000000..5d47ac3cd9 --- /dev/null +++ b/linux/crates/app/src/services/change_tracker.rs @@ -0,0 +1,2189 @@ +//! Pending row edits for one browse tab. Rows are keyed by primary key +//! so an edit survives sort, filter and paging; the tracker is reached +//! only through `TabTrackerHandle`. + +use std::collections::{HashMap, HashSet, VecDeque}; +use std::sync::Arc; + +use tablepro_core::dml::BuildSqlError; +use tablepro_core::edit::CellInput; +use tablepro_core::meta::RowIdentity; +use tablepro_core::save::{ChangeSet, RowChange, RowUpdate}; +use tablepro_core::{ColumnInfo, Value}; + +use super::cell_overlay::{CellMarks, CellOverlay}; +use super::column_relayout::{ColumnRelayout, Misfit}; +use super::save_batch::{SaveBatch, SaveTarget}; +use super::touch::Touch; +use super::tracker_delta::TrackerDelta; +use super::tracker_edit::TrackerEdit; +use super::undo_op::{DeleteStep, UndoOp}; +use crate::workspace::{PendingState, SaveOutcome, SaveRejection, SaveSnapshot, UnknownCause}; + +const UNDO_LIMIT: usize = 50; + +/// Stable identity for a row across sort / filter / page navigation. +/// Persisted rows are keyed by their primary key tuple; draft rows +/// (not yet committed) are keyed by a monotonic local id assigned by +/// the tracker. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum RowKey { + Persisted(Vec), + Draft(u64), +} + +impl RowKey { + /// Build a `Persisted` key from an existing row's PK column + /// values, or `None` if the slice is empty (table has no PK, + /// editing is blocked at the UI level). + pub fn from_pk_values(pk_values: &[Value]) -> Option { + if pk_values.is_empty() { + return None; + } + Some(RowKey::Persisted( + pk_values.iter().cloned().map(KeyValue::new).collect(), + )) + } +} + +/// A key value that hashes and compares. +/// +/// `ValueIdentity` wraps the value itself rather than a mirror of it: +/// a float compares by its bits, so NaN is its own key, and a +/// timestamp compares with its offset. Nothing is flattened to text on +/// the way in, so the value that goes back into the WHERE clause is +/// the one the row was read with. +pub type KeyValue = tablepro_core::value::ValueIdentity; + +/// A draft row collected by the tracker, one cell per column. A cell +/// left to the server is `CellInput::Default`, which is not a NULL. +#[derive(Debug, Clone, PartialEq)] +pub struct DraftRow { + pub draft_id: u64, + pub cells: Vec, + /// The insert or the latest edit to one of its cells. + pub touched: Touch, +} + +/// A pending edit to a fetched cell. +#[derive(Debug, Clone, PartialEq)] +pub struct CellEdit { + /// The fetched value. An edit back to it drops the entry. + pub original: Value, + pub pending: Value, + pub touched: Touch, +} + +/// A fetched row marked for delete, with the row as it was read. +#[derive(Debug, Clone, PartialEq)] +pub struct DeleteMark { + pub original_values: Vec, + pub touched: Touch, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CellState { + Clean, + Modified, + InsertDraft, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RowState { + Clean, + Modified, + PendingDelete, + InsertDraft, +} + +/// Which grid row a change came from, so a save that fails at one +/// statement can scroll to and flash the row the user edited. +#[derive(Debug, Clone)] +pub enum StatementSource { + Insert { draft_id: u64 }, + Update { row_key: RowKey }, + Delete { row_key: RowKey }, +} + +/// A flash `flash_error_row` started. +#[derive(Debug, Clone, PartialEq)] +#[must_use = "the row a flash replaced keeps its mark until it is redrawn"] +pub struct ErrorFlash { + /// Goes back to `clear_error_row_if` when the flash ends. + pub generation: u64, + /// Another row's flash this one cut short. That flash's own timer + /// no longer clears it, so its row needs a redraw now. + pub replaced: Option, +} + +/// The save that is running: where its edits end, and the value it +/// writes to each cell. +#[derive(Debug)] +struct InFlightSave { + snapshot: SaveSnapshot, + /// Until the save settles, each of these cells holds either this + /// value or the one that was fetched, so no later edit to it can be + /// called a return to the cell's value. + written: HashMap<(RowKey, usize), Value>, +} + +/// A pending change the table's columns no longer have a place for. +/// Saved by its old position it would write another column or row, and +/// dropped it would be lost unseen, so it stays pending and refuses +/// every save until it is discarded. +#[derive(Debug, Clone, PartialEq)] +struct SetAside { + touched: Touch, + misfit: Misfit, +} + +#[derive(Debug, Default)] +pub struct TabChangeTracker { + drafts: Vec, + deletes: HashMap, + updates: HashMap<(RowKey, usize), CellEdit>, + undo: VecDeque, + redo: VecDeque, + next_draft_id: u64, + next_touch: Touch, + /// Row behind the statement the last save failed on. + error_row: Option, + // A flash that starts inside an older flash's timeout must not be + // cleared by that older timer. + error_row_gen: u64, + /// The table and columns the tab last read, which a save is planned + /// against. Every column position the tracker holds is one in these. + target: Option, + set_aside: Vec, + saving: Option, + /// A save that may have landed. Saving again could apply it twice, + /// so nothing saves until the tab is reloaded or discarded. + outcome_unknown: Option, +} + +impl TabChangeTracker { + pub fn pending_count(&self) -> usize { + self.drafts.len() + self.deletes.len() + self.updates.len() + self.set_aside.len() + } + + /// An edit the running save holds back comes back as a redraw of its + /// row, so the grid stops showing what was typed into it. + pub fn apply(&mut self, edit: TrackerEdit) -> TrackerDelta { + match edit { + TrackerEdit::DeleteRows { persisted, drafts } => self.delete_rows(persisted, drafts), + edit if self.edit_is_in_flight(&edit) => self.delta(edit.row_keys()), + TrackerEdit::Cell { + row_key, + col, + original, + value, + } => self.track_cell_edit(row_key, col, original, value), + TrackerEdit::DraftCell { draft_id, col, input } => self.track_draft_cell_edit(draft_id, col, input), + TrackerEdit::InsertDrafts { rows } => self.track_insert(rows), + } + } + + /// An edit that would change what the running save already sent. + /// An insert or a delete cannot be sent twice, so a draft or a + /// delete in the save stays as it is until the save settles; a cell + /// edit is a new value, which the next save writes. A Delete is held + /// back row by row, so this says whether any of its rows is. + pub fn edit_is_in_flight(&self, edit: &TrackerEdit) -> bool { + match edit { + TrackerEdit::DraftCell { draft_id, .. } => self.draft_in_flight(*draft_id), + TrackerEdit::Cell { row_key, .. } => self.row_in_flight(row_key), + TrackerEdit::DeleteRows { persisted, drafts } => { + persisted.iter().any(|(row_key, _)| self.row_in_flight(row_key)) + || drafts.iter().any(|draft_id| self.draft_in_flight(*draft_id)) + } + TrackerEdit::InsertDrafts { .. } => false, + } + } + + fn draft_in_flight(&self, draft_id: u64) -> bool { + self.saving.as_ref().is_some_and(|save| { + self.drafts + .iter() + .any(|draft| draft.draft_id == draft_id && save.snapshot.includes(draft.touched)) + }) + } + + fn row_in_flight(&self, row_key: &RowKey) -> bool { + match row_key { + RowKey::Draft(draft_id) => self.draft_in_flight(*draft_id), + RowKey::Persisted(_) => self.saving.as_ref().is_some_and(|save| { + self.deletes + .get(row_key) + .is_some_and(|mark| save.snapshot.includes(mark.touched)) + }), + } + } + + fn delta(&self, changed: Vec) -> TrackerDelta { + TrackerDelta { + changed, + pending_count: self.pending_count(), + ..TrackerDelta::default() + } + } + + fn touch(&mut self) -> Touch { + let touch = self.next_touch; + self.next_touch = touch.next(); + touch + } + + fn push_undo(&mut self, op: UndoOp) { + if self.undo.len() == UNDO_LIMIT { + self.undo.pop_front(); + } + self.undo.push_back(op); + self.redo.clear(); + } + + fn track_cell_edit(&mut self, row_key: RowKey, col: usize, original: Value, value: Value) -> TrackerDelta { + let key = (row_key.clone(), col); + let before = self.updates.get(&key).cloned(); + let in_flight = self.is_being_written(&key); + let original = before.as_ref().map_or(original, |edit| edit.original.clone()); + let after = (in_flight || value != original).then(|| CellEdit { + original, + pending: value, + touched: self.touch(), + }); + self.push_undo(UndoOp::CellEdit { + row_key: row_key.clone(), + col, + before, + after: after.clone(), + }); + self.put_cell_edit(&row_key, col, after) + } + + fn track_insert(&mut self, rows: Vec>) -> TrackerDelta { + if rows.is_empty() { + return self.delta(Vec::new()); + } + let drafts: Vec = rows + .into_iter() + .map(|cells| { + let draft_id = self.next_draft_id; + self.next_draft_id += 1; + DraftRow { + draft_id, + cells, + touched: self.touch(), + } + }) + .collect(); + self.push_undo(UndoOp::Insert { drafts: drafts.clone() }); + self.put_drafts(drafts) + } + + /// Marks the saved rows and drops the drafts. The marks are one undo + /// step; a row the running save is deleting already is held back and + /// only drawn again. + fn delete_rows(&mut self, persisted: Vec<(RowKey, Arc<[Value]>)>, drafts: Vec) -> TrackerDelta { + let mut changed = Vec::new(); + let mut removed_drafts = Vec::new(); + for draft_id in drafts { + if !self.draft_in_flight(draft_id) && self.drop_draft(draft_id) { + removed_drafts.push(draft_id); + } + changed.push(RowKey::Draft(draft_id)); + } + let mut steps = Vec::new(); + for (row_key, original) in persisted { + changed.push(row_key.clone()); + if self.row_in_flight(&row_key) { + continue; + } + let after = DeleteMark { + original_values: original.to_vec(), + touched: self.touch(), + }; + let before = self.deletes.insert(row_key.clone(), after.clone()); + steps.push(DeleteStep { row_key, before, after }); + } + if !steps.is_empty() { + self.push_undo(UndoOp::Delete { steps }); + } + dedup(&mut changed); + TrackerDelta { + removed_drafts, + ..self.delta(changed) + } + } + + // The draft's undo and redo entries go with it, so Ctrl+Z cannot + // bring it back half-edited. + fn drop_draft(&mut self, draft_id: u64) -> bool { + let before = self.drafts.len(); + self.drafts.retain(|d| d.draft_id != draft_id); + if self.drafts.len() == before { + return false; + } + self.updates + .retain(|(k, _), _| !matches!(k, RowKey::Draft(id) if *id == draft_id)); + self.deletes + .retain(|k, _| !matches!(k, RowKey::Draft(id) if *id == draft_id)); + self.undo.retain_mut(|op| op.forget_draft(draft_id)); + self.redo.retain_mut(|op| op.forget_draft(draft_id)); + true + } + + fn track_draft_cell_edit(&mut self, draft_id: u64, col: usize, input: CellInput) -> TrackerDelta { + let Some((before, before_touch)) = self.draft_cell(draft_id, col) else { + return self.delta(Vec::new()); + }; + let after_touch = self.touch(); + self.push_undo(UndoOp::DraftCell { + draft_id, + col, + before, + after: input.clone(), + before_touch, + after_touch, + }); + self.put_draft_cell(draft_id, col, input, after_touch) + } + + fn draft_cell(&self, draft_id: u64, col: usize) -> Option<(CellInput, Touch)> { + let draft = self.drafts.iter().find(|d| d.draft_id == draft_id)?; + Some((draft.cells.get(col)?.clone(), draft.touched)) + } + + fn is_being_written(&self, cell: &(RowKey, usize)) -> bool { + self.saving.as_ref().is_some_and(|save| save.written.contains_key(cell)) + } + + fn put_cell_edit(&mut self, row_key: &RowKey, col: usize, edit: Option) -> TrackerDelta { + let key = (row_key.clone(), col); + // An edit equal to the fetched value is kept only while a save + // writes the cell. Undo and redo wait for the save, so they find + // such an edit no longer changing anything. + let edit = edit.filter(|edit| edit.pending != edit.original || self.is_being_written(&key)); + match edit { + Some(edit) => self.updates.insert(key, edit), + None => self.updates.remove(&key), + }; + self.delta(vec![row_key.clone()]) + } + + /// Undo puts each step's `before` back, last step first; redo puts + /// each `after` back in order. + fn put_delete_marks(&mut self, steps: &[DeleteStep], undo: bool) -> TrackerDelta { + let mut put = |row_key: &RowKey, mark: Option| match mark { + Some(mark) => self.deletes.insert(row_key.clone(), mark), + None => self.deletes.remove(row_key), + }; + if undo { + for step in steps.iter().rev() { + put(&step.row_key, step.before.clone()); + } + } else { + for step in steps { + put(&step.row_key, Some(step.after.clone())); + } + } + self.delta(steps.iter().map(|step| step.row_key.clone()).collect()) + } + + fn put_drafts(&mut self, drafts: Vec) -> TrackerDelta { + let draft_ids: Vec = drafts.iter().map(|draft| draft.draft_id).collect(); + self.drafts.extend(drafts); + TrackerDelta { + inserted_drafts: draft_ids.clone(), + ..self.delta(draft_ids.into_iter().map(RowKey::Draft).collect()) + } + } + + fn remove_drafts(&mut self, drafts: &[DraftRow]) -> TrackerDelta { + let draft_ids: Vec = drafts.iter().map(|draft| draft.draft_id).collect(); + self.drafts.retain(|d| !draft_ids.contains(&d.draft_id)); + TrackerDelta { + removed_drafts: draft_ids.clone(), + ..self.delta(draft_ids.into_iter().map(RowKey::Draft).collect()) + } + } + + fn put_draft_cell(&mut self, draft_id: u64, col: usize, input: CellInput, touched: Touch) -> TrackerDelta { + let Some(draft) = self.drafts.iter_mut().find(|d| d.draft_id == draft_id) else { + return self.delta(Vec::new()); + }; + let Some(cell) = draft.cells.get_mut(col) else { + return self.delta(Vec::new()); + }; + *cell = input; + draft.touched = touched; + self.delta(vec![RowKey::Draft(draft_id)]) + } + + /// Nothing while a save runs: stepping back through edits the save + /// already sent would show a state the database is not left in. + pub fn undo(&mut self) -> Option { + if self.saving.is_some() { + return None; + } + let op = self.undo.pop_back()?; + let delta = match &op { + UndoOp::CellEdit { + row_key, col, before, .. + } => self.put_cell_edit(row_key, *col, before.clone()), + UndoOp::DraftCell { + draft_id, + col, + before, + before_touch, + .. + } => self.put_draft_cell(*draft_id, *col, before.clone(), *before_touch), + UndoOp::Insert { drafts } => self.remove_drafts(drafts), + UndoOp::Delete { steps } => self.put_delete_marks(steps, true), + }; + self.redo.push_back(op); + Some(delta) + } + + pub fn redo(&mut self) -> Option { + if self.saving.is_some() { + return None; + } + let op = self.redo.pop_back()?; + let delta = match &op { + UndoOp::CellEdit { + row_key, col, after, .. + } => self.put_cell_edit(row_key, *col, after.clone()), + UndoOp::DraftCell { + draft_id, + col, + after, + after_touch, + .. + } => self.put_draft_cell(*draft_id, *col, after.clone(), *after_touch), + UndoOp::Insert { drafts } => self.put_drafts(drafts.clone()), + UndoOp::Delete { steps } => self.put_delete_marks(steps, false), + }; + self.undo.push_back(op); + Some(delta) + } + + pub fn can_undo(&self) -> bool { + !self.undo.is_empty() + } + + pub fn can_redo(&self) -> bool { + !self.redo.is_empty() + } + + /// Drop every pending change and the undo history. A save whose + /// outcome was unknown is resolved: the tab reads the table again. + pub fn discard(&mut self) -> TrackerDelta { + self.outcome_unknown = None; + let mut seen = HashSet::new(); + let rows: Vec = self + .updates + .keys() + .map(|(row_key, _)| row_key) + .chain(self.deletes.keys()) + .cloned() + .chain(self.drafts.iter().map(|draft| RowKey::Draft(draft.draft_id))) + .filter(|row_key| seen.insert(row_key.clone())) + .collect(); + let removed_drafts = self.drafts.iter().map(|draft| draft.draft_id).collect(); + self.drafts.clear(); + self.deletes.clear(); + self.updates.clear(); + self.set_aside.clear(); + self.undo.clear(); + self.redo.clear(); + TrackerDelta { + removed_drafts, + ..self.delta(rows) + } + } + + pub fn cell_overlay(&self, row_key: &RowKey, col: usize) -> CellOverlay { + CellOverlay { + pending: self.pending_input(row_key, col), + state: self.cell_state(row_key, col), + error_flash: self.is_error_row(row_key), + } + } + + pub fn cell_marks(&self, row_key: &RowKey, col: usize) -> CellMarks { + CellMarks { + row_deleted: self.deletes.contains_key(row_key), + state: self.cell_state(row_key, col), + error_flash: self.is_error_row(row_key), + } + } + + fn cell_state(&self, row_key: &RowKey, col: usize) -> CellState { + if matches!(row_key, RowKey::Draft(_)) { + return CellState::InsertDraft; + } + if self.updates.contains_key(&(row_key.clone(), col)) { + return CellState::Modified; + } + CellState::Clean + } + + fn pending_input(&self, row_key: &RowKey, col: usize) -> Option { + match row_key { + RowKey::Draft(draft_id) => self.draft_input(*draft_id, col), + RowKey::Persisted(_) => self + .updates + .get(&(row_key.clone(), col)) + .map(|edit| CellInput::Value(edit.pending.clone())), + } + } + + /// A saved row's pending values among its first `width` columns, in + /// column order. + pub fn pending_values(&self, row_key: &RowKey, width: usize) -> Vec<(usize, Value)> { + if self.updates.is_empty() { + return Vec::new(); + } + let mut cell = (row_key.clone(), 0); + (0..width) + .filter_map(|col| { + cell.1 = col; + self.updates.get(&cell).map(|edit| (col, edit.pending.clone())) + }) + .collect() + } + + /// Coarse row state. PendingDelete wins over Modified. + pub fn row_state(&self, row_key: &RowKey) -> RowState { + if matches!(row_key, RowKey::Draft(_)) { + return RowState::InsertDraft; + } + if self.deletes.contains_key(row_key) { + return RowState::PendingDelete; + } + let any_modified = self.updates.keys().any(|(k, _)| k == row_key); + if any_modified { + RowState::Modified + } else { + RowState::Clean + } + } + + pub fn drafts(&self) -> &[DraftRow] { + &self.drafts + } + + /// Mark the row a failed save points at. + pub fn flash_error_row(&mut self, key: RowKey) -> ErrorFlash { + self.error_row_gen = self.error_row_gen.wrapping_add(1); + let replaced = self.error_row.take().filter(|previous| *previous != key); + self.error_row = Some(key); + ErrorFlash { + generation: self.error_row_gen, + replaced, + } + } + + pub fn is_error_row(&self, key: &RowKey) -> bool { + self.error_row.as_ref() == Some(key) + } + + /// Clear the flash unless a newer one has started. Returns whether + /// it cleared, so the caller knows the row needs a redraw. + pub fn clear_error_row_if(&mut self, generation: u64) -> bool { + if self.error_row_gen != generation { + return false; + } + self.error_row = None; + true + } + + /// A draft's values, with a cell left to the server as NULL. + pub fn draft_values(&self, draft_id: u64) -> Option> { + self.drafts + .iter() + .find(|draft| draft.draft_id == draft_id) + .map(|draft| { + draft + .cells + .iter() + .map(|input| input.value().cloned().unwrap_or(Value::Null)) + .collect() + }) + } + + pub fn draft_inputs(&self, draft_id: u64) -> Option> { + self.drafts + .iter() + .find(|draft| draft.draft_id == draft_id) + .map(|draft| draft.cells.clone()) + } + + pub fn draft_input(&self, draft_id: u64, col: usize) -> Option { + self.drafts + .iter() + .find(|draft| draft.draft_id == draft_id) + .and_then(|draft| draft.cells.get(col)) + .cloned() + } + + /// A draft's cells as its INSERT carries them. + pub fn draft_cells(&self, draft_id: u64, columns: &[ColumnInfo]) -> Option> { + self.drafts + .iter() + .find(|draft| draft.draft_id == draft_id) + .map(|draft| insert_cells(columns, draft)) + } + + /// What the user changed, as the change set a save is planned from. + /// + /// One change per row, ordered by when the row was last touched, so + /// the statements run in the order the user made the edits: a row + /// deleted and then replaced by a draft with the same unique value + /// saves, because its DELETE runs first. Rows are named by their + /// primary key in column order, which is the identity `pk_identity` + /// describes, so the keys bind straight back. A draft cell the user + /// left empty asks for the server's default where the column has + /// one, as does any column the server fills. + pub fn change_set(&self, columns: &[ColumnInfo]) -> Result { + let mut rows: Vec<(Touch, RowChange, StatementSource)> = Vec::new(); + for draft in &self.drafts { + rows.push(( + draft.touched, + RowChange::Insert { + cells: insert_cells(columns, draft), + }, + StatementSource::Insert { + draft_id: draft.draft_id, + }, + )); + } + + // One UPDATE per row, setting only the cells that moved, so a + // concurrent edit to a column this user never touched survives. + let mut per_row = HashMap::new(); + for ((row_key, col), edit) in &self.updates { + if self.deletes.contains_key(row_key) { + continue; + } + per_row.entry(row_key).or_insert_with(Vec::new).push((*col, edit)); + } + for (row_key, mut cells) in per_row { + let RowKey::Persisted(key) = row_key else { + continue; + }; + require_key(columns)?; + cells.sort_by_key(|(col, _)| *col); + let touched = cells.iter().map(|(_, edit)| edit.touched).max().unwrap_or_default(); + rows.push(( + touched, + RowChange::Update(RowUpdate { + key: key_values(key), + assignments: cells + .into_iter() + .map(|(col, edit)| (col, edit.pending.clone())) + .collect(), + }), + StatementSource::Update { + row_key: row_key.clone(), + }, + )); + } + + for (row_key, mark) in &self.deletes { + let RowKey::Persisted(key) = row_key else { + continue; + }; + require_key(columns)?; + rows.push(( + mark.touched, + RowChange::Delete { key: key_values(key) }, + StatementSource::Delete { + row_key: row_key.clone(), + }, + )); + } + + // Every edit takes its own touch, so no two rows share one and + // the order does not depend on how the maps iterated. + rows.sort_by_key(|(touched, ..)| *touched); + let (changes, sources) = rows.into_iter().map(|(_, change, source)| (change, source)).unzip(); + Ok(TrackedChanges { + changes: ChangeSet { changes }, + sources, + }) + } + + /// Columns read again move every pending change to the column of + /// the same name. A change they have no place for is set aside. + pub(crate) fn set_target(&mut self, target: SaveTarget) -> TrackerDelta { + let columns = target.columns.clone(); + match self.target.replace(target) { + Some(previous) if previous.columns != columns => { + self.relayout(&ColumnRelayout::new(&previous.columns, &columns)) + } + _ => self.delta(Vec::new()), + } + } + + fn relayout(&mut self, layout: &ColumnRelayout<'_>) -> TrackerDelta { + let mut rows = Vec::new(); + let mut removed_drafts = Vec::new(); + let set_aside = self.set_aside.len(); + for ((row_key, col), edit) in std::mem::take(&mut self.updates) { + match layout.key(&row_key).and_then(|()| layout.cell(col, &edit.pending)) { + Ok(moved) => { + self.updates.insert((row_key.clone(), moved), edit); + } + Err(misfit) => self.set_aside.push(SetAside { + touched: edit.touched, + misfit, + }), + } + rows.push(row_key); + } + for (row_key, mut mark) in std::mem::take(&mut self.deletes) { + match layout.key(&row_key) { + Ok(()) => { + mark.original_values = layout.fetched(&mark.original_values); + self.deletes.insert(row_key.clone(), mark); + } + Err(misfit) => self.set_aside.push(SetAside { + touched: mark.touched, + misfit, + }), + } + rows.push(row_key); + } + for mut draft in std::mem::take(&mut self.drafts) { + match layout.draft(&draft.cells) { + Ok(cells) => { + draft.cells = cells; + self.drafts.push(draft); + } + Err(misfit) => { + removed_drafts.push(draft.draft_id); + self.set_aside.push(SetAside { + touched: draft.touched, + misfit, + }); + } + } + } + if let Some(save) = self.saving.as_mut() { + save.written = std::mem::take(&mut save.written) + .into_iter() + .filter_map(|((row_key, col), value)| { + layout.key(&row_key).ok()?; + Some(((row_key, layout.position(col)?), value)) + }) + .collect(); + } + let undo: Option> = self.undo.iter().map(|op| op.relayout(layout)).collect(); + let redo: Option> = self.redo.iter().map(|op| op.relayout(layout)).collect(); + match (self.set_aside.len() == set_aside, undo, redo) { + (true, Some(undo), Some(redo)) => { + self.undo = undo; + self.redo = redo; + } + // A step back would bring a change to a place it no longer has. + _ => { + self.undo.clear(); + self.redo.clear(); + } + } + dedup(&mut rows); + TrackerDelta { + removed_drafts, + ..self.delta(rows) + } + } + + /// Why the changes set aside cannot be saved, the key first since + /// it strands every edited row. + fn set_aside_rejection(&self) -> Option { + if self.set_aside.is_empty() { + return None; + } + if self.set_aside.iter().any(|change| change.misfit == Misfit::Key) { + return Some(SaveRejection::KeyChanged); + } + let mut columns: Vec = Vec::new(); + for change in &self.set_aside { + if let Misfit::Columns(names) = &change.misfit { + for name in names { + if !columns.contains(name) { + columns.push(name.clone()); + } + } + } + } + Some(SaveRejection::ColumnsChanged(columns)) + } + + pub(crate) fn pending_state(&self) -> PendingState { + if let Some(cause) = &self.outcome_unknown { + return PendingState::OutcomeUnknown(cause.clone()); + } + if self.saving.is_some() { + return PendingState::Saving; + } + match self.pending_count() { + 0 => PendingState::Clean, + _ => PendingState::Unsaved, + } + } + + /// Take what Save writes, and hold it as in flight until it settles. + /// `None` when there is nothing to save. + pub(crate) fn begin_save(&mut self) -> Result, SaveRejection> { + if self.outcome_unknown.is_some() { + return Err(SaveRejection::OutcomeUnknown); + } + if self.saving.is_some() { + return Err(SaveRejection::AlreadySaving); + } + if let Some(rejection) = self.set_aside_rejection() { + return Err(rejection); + } + if self.pending_count() == 0 { + return Ok(None); + } + let Some(target) = self.target.clone() else { + return Err(SaveRejection::ColumnsNotRead); + }; + let changes = self.change_set(&target.columns).map_err(SaveRejection::Build)?; + if changes.changes.is_empty() { + return Ok(None); + } + let snapshot = SaveSnapshot(self.next_touch); + let written = self + .updates + .iter() + .filter(|((row_key, _), _)| matches!(row_key, RowKey::Persisted(_)) && !self.deletes.contains_key(row_key)) + .map(|(cell, edit)| (cell.clone(), edit.pending.clone())) + .collect(); + self.saving = Some(InFlightSave { snapshot, written }); + Ok(Some(SaveBatch { + table: target.table, + columns: target.columns, + changes, + snapshot, + })) + } + + /// End the save `snapshot` began. A commit drops what it wrote and + /// keeps every edit made while it ran, now measured against what the + /// save wrote; a rollback keeps everything, measured against what was + /// fetched; an unknown outcome keeps everything and refuses the next + /// save. + pub(crate) fn settle_save(&mut self, outcome: &SaveOutcome, snapshot: SaveSnapshot) -> TrackerDelta { + let written = self + .saving + .take_if(|save| save.snapshot == snapshot) + .map(|save| save.written) + .unwrap_or_default(); + match outcome { + SaveOutcome::Committed { .. } => { + let mut delta = self.drop_saved(snapshot); + delta.changed.extend(self.rebase(written, true)); + dedup(&mut delta.changed); + delta.pending_count = self.pending_count(); + delta + } + SaveOutcome::RolledBack(_) => { + let rows = self.rebase(written, false); + self.delta(rows) + } + SaveOutcome::Unknown(cause) => { + self.mark_outcome_unknown(cause.clone()); + self.delta(Vec::new()) + } + } + } + + pub(crate) fn mark_outcome_unknown(&mut self, cause: UnknownCause) { + self.outcome_unknown = Some(cause); + } + + /// Edits made to cells a settled save wrote. After a commit the cell + /// holds what the save wrote, which becomes the value an edit is + /// measured against; after a rollback it still holds what was + /// fetched. Either way an edit back to that value is no change. + fn rebase(&mut self, written: HashMap<(RowKey, usize), Value>, committed: bool) -> Vec { + let mut rows = Vec::new(); + for (cell, value) in written { + let Some(edit) = self.updates.get_mut(&cell) else { + continue; + }; + if committed { + edit.original = value; + } + if edit.pending == edit.original { + self.updates.remove(&cell); + rows.push(cell.0); + } + } + dedup(&mut rows); + rows + } + + fn drop_saved(&mut self, snapshot: SaveSnapshot) -> TrackerDelta { + let mut rows = Vec::new(); + let mut removed_drafts = Vec::new(); + self.updates.retain(|(row_key, _), edit| { + let saved = snapshot.includes(edit.touched); + if saved { + rows.push(row_key.clone()); + } + !saved + }); + self.deletes.retain(|row_key, mark| { + let saved = snapshot.includes(mark.touched); + if saved { + rows.push(row_key.clone()); + } + !saved + }); + self.set_aside.retain(|change| !snapshot.includes(change.touched)); + self.drafts.retain(|draft| { + let saved = snapshot.includes(draft.touched); + if saved { + rows.push(RowKey::Draft(draft.draft_id)); + removed_drafts.push(draft.draft_id); + } + !saved + }); + // The history describes edits against rows as they were read, + // and the saved ones are now the rows themselves. + self.undo.clear(); + self.redo.clear(); + dedup(&mut rows); + TrackerDelta { + removed_drafts, + ..self.delta(rows) + } + } +} + +fn dedup(rows: &mut Vec) { + let mut seen = HashSet::new(); + rows.retain(|row_key| seen.insert(row_key.clone())); +} + +/// The tracker's changes in the form a save plans from, with the row +/// each one came from. +#[derive(Debug, Clone, Default)] +pub struct TrackedChanges { + pub changes: ChangeSet, + sources: Vec, +} + +impl TrackedChanges { + /// The grid row behind one change, by its position in `changes`. + pub fn source(&self, change: usize) -> Option { + self.sources.get(change).cloned() + } +} + +/// The identity the tracker's row keys are written in: the primary key +/// columns, in the order the table lists them. +pub fn pk_identity(columns: &[ColumnInfo]) -> RowIdentity { + RowIdentity::UniqueKey { + columns: columns + .iter() + .filter(|column| column.primary_key) + .map(|column| column.name.clone()) + .collect(), + } +} + +fn require_key(columns: &[ColumnInfo]) -> Result<(), BuildSqlError> { + match columns.iter().any(|column| column.primary_key) { + true => Ok(()), + false => Err(BuildSqlError::NoRowIdentity), + } +} + +fn key_values(key: &[KeyValue]) -> Vec { + key.iter().map(|part| part.value().clone()).collect() +} + +/// A column the server always fills is left to it whatever the draft +/// holds: SQL Server IDENTITY and PostgreSQL GENERATED ALWAYS refuse a +/// value. +fn insert_cells(columns: &[ColumnInfo], draft: &DraftRow) -> Vec { + columns + .iter() + .zip(&draft.cells) + .map( + |(column, input)| match column.is_auto_increment || column.is_generated { + true => CellInput::Default, + false => input.clone(), + }, + ) + .collect() +} + +/// What a new row holds in `column` until the user types: the server's +/// value where it has one, else NULL. +pub fn new_draft_cell(column: &ColumnInfo) -> CellInput { + match column.is_auto_increment || column.is_generated || !column.default.is_none() { + true => CellInput::Default, + false => CellInput::Value(Value::Null), + } +} + +#[cfg(test)] +mod tests { + /// A column carrying the server's spelling and the kind it means, + /// which is what these tests exercise. + #[expect( + clippy::disallowed_methods, + reason = "a test fixture stands in for the catalogue, which is where this text comes from in production" + )] + fn test_column(name: &str, type_name: &str) -> tablepro_core::ColumnInfo { + let kind = tablepro_core::column::classify_type_name(type_name); + tablepro_core::ColumnInfo { + name: name.to_owned(), + column_type: tablepro_core::column::ColumnType { + name: type_name.to_owned(), + dynamic_storage: tablepro_core::column::has_dynamic_storage(&kind), + kind, + catalog: Some(tablepro_core::column::CatalogType { + declaration: tablepro_core::column::SqlTypeExpr::from_catalog_text(type_name), + cast_target: tablepro_core::column::SqlTypeExpr::from_catalog_text(type_name), + }), + read_form: tablepro_core::column::ReadForm::Native, + }, + nullable: true, + primary_key: false, + is_auto_increment: false, + is_generated: false, + default: tablepro_core::column::ColumnDefault::None, + comment: None, + } + } + + use super::*; + use tablepro_core::ColumnInfo; + + fn pk_col(name: &str) -> ColumnInfo { + ColumnInfo { + name: name.into(), + column_type: test_column("", "integer").column_type, + nullable: false, + primary_key: true, + is_auto_increment: true, + default: tablepro_core::column::ColumnDefault::None, + is_generated: false, + comment: None, + } + } + + fn data_col(name: &str) -> ColumnInfo { + ColumnInfo { + name: name.into(), + column_type: test_column("", "text").column_type, + nullable: false, + primary_key: false, + is_auto_increment: false, + default: tablepro_core::column::ColumnDefault::None, + is_generated: false, + comment: None, + } + } + + fn rk(values: &[Value]) -> RowKey { + RowKey::from_pk_values(values).unwrap() + } + + fn insert(t: &mut TabChangeTracker, values: Vec) -> u64 { + match t + .track_insert(vec![values.into_iter().map(CellInput::Value).collect()]) + .inserted_drafts + .as_slice() + { + [draft_id] => *draft_id, + other => panic!("an insert adds one draft row: {other:?}"), + } + } + + fn delete(t: &mut TabChangeTracker, row_key: RowKey, original: Vec) -> TrackerDelta { + t.apply(TrackerEdit::DeleteRows { + persisted: vec![(row_key, original.into())], + drafts: Vec::new(), + }) + } + + fn drop_drafts(t: &mut TabChangeTracker, drafts: Vec) -> TrackerDelta { + t.apply(TrackerEdit::DeleteRows { + persisted: Vec::new(), + drafts, + }) + } + + #[test] + fn cell_edit_creates_modified_state() { + let mut t = TabChangeTracker::default(); + let key = rk(&[Value::Int(1)]); + t.track_cell_edit(key.clone(), 1, Value::Text("a".into()), Value::Text("b".into())); + assert_eq!(t.cell_state(&key, 1), CellState::Modified); + assert_eq!(t.row_state(&key), RowState::Modified); + assert_eq!(t.pending_count(), 1); + } + + #[test] + fn cell_edit_back_to_original_drops_modification() { + let mut t = TabChangeTracker::default(); + let key = rk(&[Value::Int(1)]); + t.track_cell_edit(key.clone(), 1, Value::Text("a".into()), Value::Text("b".into())); + // User edits again, this time back to "a" + t.track_cell_edit(key.clone(), 1, Value::Text("a".into()), Value::Text("a".into())); + assert_eq!(t.cell_state(&key, 1), CellState::Clean); + // pending_count drops; undo stack still has 2 ops + assert_eq!(t.pending_count(), 0); + assert!(t.can_undo()); + } + + #[test] + fn insert_assigns_unique_draft_ids() { + let mut t = TabChangeTracker::default(); + let k1 = insert(&mut t, vec![Value::Null, Value::Null]); + let k2 = insert(&mut t, vec![Value::Null, Value::Null]); + assert_ne!(k1, k2); + assert_eq!(t.drafts().len(), 2); + assert_eq!(t.pending_count(), 2); + } + + #[test] + fn cell_marks_tint_a_cell_by_its_own_edit_and_its_rows_delete() { + let mut t = TabChangeTracker::default(); + let (edited, deleted) = (rk(&[Value::Int(1)]), rk(&[Value::Int(2)])); + t.track_cell_edit(edited.clone(), 1, Value::Text("a".into()), Value::Text("b".into())); + delete(&mut t, deleted.clone(), vec![Value::Int(2), Value::Text("c".into())]); + let draft = RowKey::Draft(insert(&mut t, vec![Value::Null, Value::Null])); + + let marks = |key: &RowKey, col| { + let marks = t.cell_marks(key, col); + (marks.row_deleted, marks.state) + }; + assert_eq!(marks(&edited, 1), (false, CellState::Modified)); + assert_eq!(marks(&edited, 0), (false, CellState::Clean)); + assert_eq!(marks(&deleted, 0), (true, CellState::Clean)); + assert_eq!(marks(&draft, 0), (false, CellState::InsertDraft)); + } + + #[test] + fn delete_marks_row_pending() { + let mut t = TabChangeTracker::default(); + let key = rk(&[Value::Int(7)]); + delete(&mut t, key.clone(), vec![Value::Int(7), Value::Text("dave".into())]); + assert_eq!(t.row_state(&key), RowState::PendingDelete); + assert_eq!(t.pending_count(), 1); + } + + #[test] + fn undo_reverts_cell_edit() { + let mut t = TabChangeTracker::default(); + let key = rk(&[Value::Int(1)]); + t.track_cell_edit(key.clone(), 1, Value::Text("a".into()), Value::Text("b".into())); + let undone = t.undo(); + assert_eq!(undone.map(|delta| delta.changed), Some(vec![key.clone()])); + assert_eq!(t.cell_state(&key, 1), CellState::Clean); + assert!(t.can_redo()); + } + + #[test] + fn redo_reapplies_cell_edit() { + let mut t = TabChangeTracker::default(); + let key = rk(&[Value::Int(1)]); + t.track_cell_edit(key.clone(), 1, Value::Text("a".into()), Value::Text("b".into())); + t.undo(); + t.redo(); + assert_eq!(t.cell_state(&key, 1), CellState::Modified); + } + + #[test] + fn undo_stack_caps_at_50() { + let mut t = TabChangeTracker::default(); + let key = rk(&[Value::Int(1)]); + for i in 0..100 { + t.track_cell_edit(key.clone(), 1, Value::Int(i), Value::Int(i + 1)); + } + assert_eq!(t.undo.len(), UNDO_LIMIT); + } + + #[test] + fn clear_removes_all_state() { + let mut t = TabChangeTracker::default(); + let key = rk(&[Value::Int(1)]); + t.track_cell_edit(key.clone(), 1, Value::Text("a".into()), Value::Text("b".into())); + t.track_insert(vec![vec![CellInput::Value(Value::Null)]]); + delete(&mut t, rk(&[Value::Int(2)]), vec![Value::Int(2)]); + t.discard(); + assert_eq!(t.pending_count(), 0); + assert!(!t.can_undo()); + } + + fn postgres() -> tablepro_driver_postgres::PgDialect { + tablepro_driver_postgres::PgDialect::new(&tablepro_core::ServerInfo::new("PostgreSQL", "18.0", 18, 0, 0)) + } + + fn plan(tracker: &TabChangeTracker, columns: &[ColumnInfo]) -> (TrackedChanges, tablepro_core::save::SavePlan) { + let tracked = tracker.change_set(columns).unwrap(); + let table = tablepro_core::meta::TableRef::unqualified("t"); + let plan = + tablepro_core::save::plan_save(&postgres(), &table, columns, &pk_identity(columns), &tracked.changes) + .unwrap(); + (tracked, plan) + } + + fn text(value: &str) -> Value { + Value::Text(value.into()) + } + + fn edit(t: &mut TabChangeTracker, id: i64, from: &str, to: &str) { + t.track_cell_edit(rk(&[Value::Int(id)]), 1, text(from), text(to)); + } + + /// The cell as the grid draws it, over a fetched "a". + fn pending(t: &TabChangeTracker, id: i64) -> Value { + t.cell_overlay(&rk(&[Value::Int(id)]), 1).shown(text("a")) + } + + /// Each change as a verb and the key or draft it names, which is + /// what the save order is about. + fn script(t: &TabChangeTracker, columns: &[ColumnInfo]) -> Vec { + let tracked = t.change_set(columns).unwrap(); + tracked + .changes + .changes + .iter() + .enumerate() + .map(|(index, change)| match (change, tracked.source(index)) { + (RowChange::Insert { .. }, Some(StatementSource::Insert { draft_id })) => format!("insert {draft_id}"), + (RowChange::Update(update), _) => format!("update {:?}", update.key), + (RowChange::Delete { key }, _) => format!("delete {key:?}"), + (change, source) => panic!("{change:?} came from {source:?}"), + }) + .collect() + } + + #[test] + fn undo_after_a_b_c_restores_b() { + let mut t = TabChangeTracker::default(); + edit(&mut t, 1, "a", "b"); + edit(&mut t, 1, "a", "c"); + + let undone = t.undo(); + + assert_eq!(undone.map(|delta| delta.changed), Some(vec![rk(&[Value::Int(1)])])); + assert_eq!(pending(&t, 1), text("b")); + assert_eq!(t.cell_state(&rk(&[Value::Int(1)]), 1), CellState::Modified); + assert_eq!(t.pending_count(), 1); + } + + #[test] + fn undo_after_a_b_a_restores_b() { + let mut t = TabChangeTracker::default(); + edit(&mut t, 1, "a", "b"); + edit(&mut t, 1, "a", "a"); + assert_eq!(t.pending_count(), 0); + + let _ = t.undo(); + + assert_eq!(pending(&t, 1), text("b")); + assert_eq!(t.pending_count(), 1); + } + + #[test] + fn redo_after_undo_reapplies_c() { + let mut t = TabChangeTracker::default(); + edit(&mut t, 1, "a", "b"); + edit(&mut t, 1, "a", "c"); + let _ = t.undo(); + + let redone = t.redo(); + + assert_eq!(redone.map(|delta| delta.pending_count), Some(1)); + assert_eq!(pending(&t, 1), text("c")); + let _ = t.undo(); + let _ = t.undo(); + assert_eq!(pending(&t, 1), text("a")); + assert_eq!(t.pending_count(), 0); + } + + #[test] + fn new_edit_after_undo_clears_redo() { + let mut t = TabChangeTracker::default(); + edit(&mut t, 1, "a", "b"); + edit(&mut t, 1, "a", "c"); + let _ = t.undo(); + + edit(&mut t, 1, "a", "d"); + + assert!(!t.can_redo()); + assert!(t.redo().is_none()); + assert_eq!(pending(&t, 1), text("d")); + let _ = t.undo(); + assert_eq!(pending(&t, 1), text("b")); + } + + #[test] + fn undo_delete_restores_prior_mark_absence() { + let mut t = TabChangeTracker::default(); + let marked_twice = rk(&[Value::Int(1)]); + let marked_once = rk(&[Value::Int(2)]); + delete(&mut t, marked_twice.clone(), vec![Value::Int(1)]); + delete(&mut t, marked_twice.clone(), vec![Value::Int(1)]); + delete(&mut t, marked_once.clone(), vec![Value::Int(2)]); + + let _ = t.undo(); + assert_eq!(t.row_state(&marked_once), RowState::Clean); + let _ = t.undo(); + assert_eq!(t.row_state(&marked_twice), RowState::PendingDelete); + let _ = t.undo(); + assert_eq!(t.row_state(&marked_twice), RowState::Clean); + assert_eq!(t.pending_count(), 0); + } + + #[test] + fn draft_cell_undo_restores_previous_draft_value() { + let mut t = TabChangeTracker::default(); + let draft_id = insert(&mut t, vec![Value::Null, text("a")]); + let _ = t.apply(TrackerEdit::DraftCell { + draft_id, + col: 1, + input: CellInput::Value(text("b")), + }); + let _ = t.apply(TrackerEdit::DraftCell { + draft_id, + col: 1, + input: CellInput::Value(text("c")), + }); + + let undone = t.undo(); + + assert_eq!(undone.map(|delta| delta.changed), Some(vec![RowKey::Draft(draft_id)])); + assert_eq!(t.drafts()[0].cells[1], CellInput::Value(text("b"))); + let redone = t.redo().map(|delta| delta.changed); + assert_eq!(redone, Some(vec![RowKey::Draft(draft_id)])); + assert_eq!(t.drafts()[0].cells[1], CellInput::Value(text("c"))); + } + + #[test] + fn change_set_orders_rows_by_last_touch() { + let mut t = TabChangeTracker::default(); + let columns = vec![pk_col("id"), data_col("name")]; + edit(&mut t, 1, "a", "b"); + let draft_id = insert(&mut t, vec![Value::Null, text("draft")]); + delete(&mut t, rk(&[Value::Int(3)]), vec![Value::Int(3)]); + edit(&mut t, 2, "a", "b"); + // Row 1 moves behind everything it was ahead of. + edit(&mut t, 1, "a", "c"); + + assert_eq!( + script(&t, &columns), + vec![ + format!("insert {draft_id}"), + "delete [Int(3)]".to_owned(), + "update [Int(2)]".to_owned(), + "update [Int(1)]".to_owned(), + ] + ); + + // Undo puts the row's touch back with its value. + let _ = t.undo(); + assert_eq!(script(&t, &columns)[0], "update [Int(1)]"); + } + + #[test] + fn change_set_places_delete_before_later_insert() { + let mut t = TabChangeTracker::default(); + let columns = vec![pk_col("id"), data_col("email")]; + delete(&mut t, rk(&[Value::Int(1)]), vec![Value::Int(1), text("a")]); + let draft_id = insert(&mut t, vec![Value::Null, text("a")]); + + let (tracked, plan) = plan(&t, &columns); + + let sql: Vec<&str> = plan.batch.steps.iter().map(|step| step.statement.sql()).collect(); + assert!(sql[0].starts_with("DELETE"), "{sql:?}"); + assert!(sql[1].starts_with("INSERT"), "{sql:?}"); + assert!(matches!( + plan.origin(1).and_then(|change| tracked.source(change)), + Some(StatementSource::Insert { draft_id: id }) if id == draft_id + )); + } + + #[test] + fn a_draft_edited_after_a_delete_saves_after_it() { + let mut t = TabChangeTracker::default(); + let columns = vec![pk_col("id"), data_col("email")]; + let draft_id = insert(&mut t, vec![Value::Null, Value::Null]); + delete(&mut t, rk(&[Value::Int(1)]), vec![Value::Int(1), text("a")]); + let _ = t.apply(TrackerEdit::DraftCell { + draft_id, + col: 1, + input: CellInput::Value(text("a")), + }); + + assert_eq!( + script(&t, &columns), + vec!["delete [Int(1)]".to_owned(), format!("insert {draft_id}")] + ); + } + + #[test] + fn undo_and_redo_of_a_draft_edit_put_its_touch_back() { + let mut t = TabChangeTracker::default(); + let columns = vec![pk_col("id"), data_col("email")]; + let draft_id = insert(&mut t, vec![Value::Null, Value::Null]); + delete(&mut t, rk(&[Value::Int(1)]), vec![Value::Int(1), text("a")]); + let _ = t.apply(TrackerEdit::DraftCell { + draft_id, + col: 1, + input: CellInput::Value(text("a")), + }); + let edited = vec!["delete [Int(1)]".to_owned(), format!("insert {draft_id}")]; + assert_eq!(script(&t, &columns), edited); + + let _ = t.undo(); + assert_eq!( + script(&t, &columns), + vec![format!("insert {draft_id}"), "delete [Int(1)]".to_owned()] + ); + + let _ = t.redo(); + assert_eq!(script(&t, &columns), edited); + } + + #[test] + fn undo_and_redo_of_a_cell_edit_put_its_touch_back() { + let mut t = TabChangeTracker::default(); + let columns = vec![pk_col("id"), data_col("name")]; + edit(&mut t, 1, "a", "b"); + delete(&mut t, rk(&[Value::Int(2)]), vec![Value::Int(2), text("a")]); + edit(&mut t, 1, "a", "c"); + let edited = vec!["delete [Int(2)]".to_owned(), "update [Int(1)]".to_owned()]; + assert_eq!(script(&t, &columns), edited); + + let _ = t.undo(); + assert_eq!( + script(&t, &columns), + vec!["update [Int(1)]".to_owned(), "delete [Int(2)]".to_owned()] + ); + + let _ = t.redo(); + assert_eq!(script(&t, &columns), edited); + assert_eq!(pending(&t, 1), text("c")); + } + + #[test] + fn change_set_identical_across_trackers() { + let columns = vec![pk_col("id"), data_col("name")]; + // Every tracker's maps get their own hash seed, so with this + // many rows two trackers iterate them in different orders. + let edit_script = |t: &mut TabChangeTracker| { + for id in (0..40).rev() { + match id % 3 { + 0 => delete(t, rk(&[Value::Int(id)]), vec![Value::Int(id)]), + _ => t.track_cell_edit(rk(&[Value::Int(id)]), 1, text("a"), text("b")), + }; + } + let _ = t.track_insert(vec![vec![ + CellInput::Value(Value::Null), + CellInput::Value(text("draft")), + ]]); + }; + let mut first = TabChangeTracker::default(); + let mut second = TabChangeTracker::default(); + edit_script(&mut first); + edit_script(&mut second); + + let mut edit_order: Vec = (0..40) + .rev() + .map(|id| match id % 3 { + 0 => format!("delete [Int({id})]"), + _ => format!("update [Int({id})]"), + }) + .collect(); + edit_order.push("insert 0".to_owned()); + + assert_eq!(script(&first, &columns), edit_order); + assert_eq!(script(&second, &columns), edit_order); + } + + #[test] + fn deleted_row_emits_only_delete() { + let mut t = TabChangeTracker::default(); + let columns = vec![pk_col("id"), data_col("name")]; + edit(&mut t, 1, "a", "b"); + delete(&mut t, rk(&[Value::Int(1)]), vec![Value::Int(1), text("a")]); + + assert_eq!(script(&t, &columns), vec!["delete [Int(1)]".to_owned()]); + + // Undoing the delete brings the edit back into the save. + let _ = t.undo(); + assert_eq!(script(&t, &columns), vec!["update [Int(1)]".to_owned()]); + } + + #[test] + fn a_failing_step_maps_back_to_the_row_that_made_it() { + let mut t = TabChangeTracker::default(); + let columns = vec![pk_col("id"), data_col("name")]; + let doomed = rk(&[Value::Int(9)]); + t.track_insert(vec![vec![ + CellInput::Value(Value::Null), + CellInput::Value(Value::Text("draft".into())), + ]]); + delete( + &mut t, + doomed.clone(), + vec![Value::Int(9), Value::Text("doomed".into())], + ); + + let (tracked, plan) = plan(&t, &columns); + + let failing = plan.origin(1).and_then(|origin| tracked.source(origin)); + assert!(matches!(failing, Some(StatementSource::Delete { row_key }) if row_key == doomed)); + assert!(plan.origin(2).is_none()); + } + + #[test] + fn an_update_sets_only_the_moved_cell_and_keys_on_the_whole_primary_key() { + let mut t = TabChangeTracker::default(); + let columns = vec![pk_col("a"), pk_col("b"), data_col("name")]; + let key = RowKey::from_pk_values(&[Value::Int(1), Value::Int(2)]).unwrap(); + t.track_cell_edit(key, 2, Value::Text("old".into()), Value::Text("new".into())); + + let tracked = t.change_set(&columns).unwrap(); + + assert_eq!( + tracked.changes.changes, + vec![RowChange::Update(RowUpdate { + key: vec![Value::Int(1), Value::Int(2)], + assignments: vec![(2, Value::Text("new".into()))], + })] + ); + assert_eq!( + pk_identity(&columns), + RowIdentity::UniqueKey { + columns: vec!["a".to_owned(), "b".to_owned()] + } + ); + } + + #[test] + #[expect( + clippy::disallowed_methods, + reason = "a test fixture stands in for the catalogue, which is where a default expression comes from" + )] + fn a_draft_leaves_server_filled_columns_to_the_server() { + let mut t = TabChangeTracker::default(); + let mut stamped = data_col("created_at"); + stamped.default = tablepro_core::column::ColumnDefault::Expression( + tablepro_core::column::SqlExpression::from_catalog_text("now()"), + ); + let columns = vec![pk_col("id"), data_col("name"), stamped]; + let mut cells: Vec = columns.iter().map(new_draft_cell).collect(); + cells[1] = CellInput::Value(Value::Text("draft".into())); + let draft_id = match t.track_insert(vec![cells]).inserted_drafts.as_slice() { + [draft_id] => *draft_id, + other => panic!("an insert adds one draft row: {other:?}"), + }; + + let tracked = t.change_set(&columns).unwrap(); + + assert_eq!( + tracked.changes.changes, + vec![RowChange::Insert { + cells: vec![ + CellInput::Default, + CellInput::Value(Value::Text("draft".into())), + CellInput::Default + ] + }] + ); + + // NULL set on purpose is not the default. + let _ = t.apply(TrackerEdit::DraftCell { + draft_id, + col: 2, + input: CellInput::Value(Value::Null), + }); + assert_eq!( + t.draft_cells(draft_id, &columns).map(|cells| cells[2].clone()), + Some(CellInput::Value(Value::Null)) + ); + } + + #[test] + fn a_draft_leaves_an_auto_increment_column_to_the_server() { + let mut t = TabChangeTracker::default(); + let columns = vec![pk_col("id"), data_col("name")]; + let draft_id = insert(&mut t, vec![Value::Int(42), Value::Text("draft".into())]); + + let tracked = t.change_set(&columns).unwrap(); + + let expected = vec![CellInput::Default, CellInput::Value(Value::Text("draft".into()))]; + assert_eq!( + tracked.changes.changes, + vec![RowChange::Insert { + cells: expected.clone() + }] + ); + assert_eq!(t.draft_cells(draft_id, &columns), Some(expected)); + } + + #[test] + fn a_table_without_a_primary_key_cannot_save_an_update() { + let mut t = TabChangeTracker::default(); + let columns = vec![data_col("name")]; + t.track_cell_edit( + rk(&[Value::Int(1)]), + 0, + Value::Text("a".into()), + Value::Text("b".into()), + ); + + assert!(matches!(t.change_set(&columns), Err(BuildSqlError::NoRowIdentity))); + } + + #[test] + fn discard_draft_removes_insert_and_clears_undo() { + let mut t = TabChangeTracker::default(); + let draft_id = insert(&mut t, vec![Value::Null, Value::Text("hi".into())]); + // Edit the draft so undo stack has a CellEdit on top of Insert. + t.track_draft_cell_edit(draft_id, 1, CellInput::Value(Value::Text("edited".into()))); + assert_eq!(t.drafts().len(), 1); + assert!(t.can_undo()); + + let removed = drop_drafts(&mut t, vec![draft_id]); + + assert_eq!(removed.removed_drafts, vec![draft_id]); + assert!(t.drafts().is_empty(), "draft must be gone"); + assert!(!t.can_undo(), "undo entries scoped to the draft must be cleared"); + assert_eq!(t.pending_count(), 0, "no pending state after discard"); + } + + #[test] + fn discard_draft_no_op_for_unknown_id() { + let mut t = TabChangeTracker::default(); + let delta = drop_drafts(&mut t, vec![99_999]); + + assert!(delta.removed_drafts.is_empty()); + assert_eq!(delta.pending_count, 0); + assert!(!t.can_undo()); + } + + #[test] + fn discard_draft_leaves_other_drafts_intact() { + let mut t = TabChangeTracker::default(); + let a = insert(&mut t, vec![Value::Int(1)]); + let b = insert(&mut t, vec![Value::Int(2)]); + assert_eq!(t.drafts().len(), 2); + assert_eq!(drop_drafts(&mut t, vec![a]).pending_count, 1); + assert_eq!(t.drafts().len(), 1); + assert_eq!(t.drafts()[0].draft_id, b); + // The remaining draft is still undoable on its own. + assert!(t.can_undo()); + } + + #[test] + fn one_delete_over_many_rows_is_one_undo_step() { + let mut t = TabChangeTracker::default(); + let earlier = rk(&[Value::Int(9)]); + delete(&mut t, earlier.clone(), vec![Value::Int(9)]); + let keys: Vec = (10..70).map(|id| rk(&[Value::Int(id)])).collect(); + let draft_id = insert(&mut t, vec![Value::Null]); + + let deleted = t.apply(TrackerEdit::DeleteRows { + persisted: keys + .iter() + .map(|key| (key.clone(), vec![Value::Int(0)].into())) + .collect(), + drafts: vec![draft_id], + }); + assert_eq!(deleted.removed_drafts, vec![draft_id]); + assert_eq!(deleted.changed.len(), 61); + assert_eq!(t.pending_count(), 61); + + let undone = t.undo().expect("the delete to undo"); + assert_eq!(undone.changed, keys, "the undo left some of the rows marked"); + assert_eq!(t.pending_count(), 1); + assert_eq!(t.row_state(&earlier), RowState::PendingDelete); + let _ = t.redo(); + assert!(keys.iter().all(|key| t.row_state(key) == RowState::PendingDelete)); + } + + #[test] + fn a_delete_during_a_save_holds_back_only_the_rows_in_it() { + let columns = vec![pk_col("id"), data_col("name")]; + let mut t = saving_on(columns); + let saved_draft = insert(&mut t, vec![Value::Null, text("a")]); + let _batch = t.begin_save().unwrap().unwrap(); + let new_draft = insert(&mut t, vec![Value::Null, text("b")]); + let edit = TrackerEdit::DeleteRows { + persisted: vec![(rk(&[Value::Int(1)]), vec![Value::Int(1)].into())], + drafts: vec![saved_draft, new_draft], + }; + assert!(t.edit_is_in_flight(&edit)); + + let delta = t.apply(edit); + + assert_eq!(delta.removed_drafts, vec![new_draft]); + assert!( + delta.changed.contains(&RowKey::Draft(saved_draft)), + "the held draft is not drawn again" + ); + assert_eq!(t.drafts().len(), 1); + assert_eq!(t.row_state(&rk(&[Value::Int(1)])), RowState::PendingDelete); + } + + #[test] + fn apply_routes_each_edit_and_reports_the_rows_it_touched() { + let mut t = TabChangeTracker::default(); + let key = rk(&[Value::Int(1)]); + + let edited = t.apply(TrackerEdit::Cell { + row_key: key.clone(), + col: 1, + original: Value::Text("a".into()), + value: Value::Text("b".into()), + }); + let deleted = delete(&mut t, rk(&[Value::Int(2)]), vec![Value::Int(2)]); + + assert_eq!(edited.changed, vec![key]); + assert!(edited.inserted_drafts.is_empty() && edited.removed_drafts.is_empty()); + assert_eq!(edited.pending_count, 1); + assert_eq!(deleted.changed, vec![rk(&[Value::Int(2)])]); + assert_eq!(deleted.pending_count, 2); + } + + #[test] + fn a_draft_cell_edit_updates_the_draft_row_and_undo_restores_it() { + let mut t = TabChangeTracker::default(); + let draft_id = insert(&mut t, vec![Value::Null, Value::Text("a".into())]); + + let edited = t.apply(TrackerEdit::DraftCell { + draft_id, + col: 1, + input: CellInput::Value(Value::Text("b".into())), + }); + let undone = t.undo(); + + assert_eq!(edited.changed, vec![RowKey::Draft(draft_id)]); + assert!(edited.inserted_drafts.is_empty() && edited.removed_drafts.is_empty()); + assert_eq!(undone.map(|delta| delta.changed), Some(vec![RowKey::Draft(draft_id)])); + assert_eq!(t.drafts()[0].cells[1], CellInput::Value(Value::Text("a".into()))); + } + + #[test] + fn a_draft_cell_reads_from_the_tracker() { + let mut t = TabChangeTracker::default(); + let draft_id = insert(&mut t, vec![Value::Null, Value::Text("a".into())]); + let _ = t.apply(TrackerEdit::DraftCell { + draft_id, + col: 1, + input: CellInput::Value(Value::Text("b".into())), + }); + + let overlay = t.cell_overlay(&RowKey::Draft(draft_id), 1); + + assert_eq!(overlay.pending, Some(CellInput::Value(Value::Text("b".into())))); + assert_eq!(overlay.state, CellState::InsertDraft); + assert_eq!(t.cell_overlay(&RowKey::Draft(draft_id), 9).pending, None); + } + + #[test] + fn a_draft_cell_outside_the_row_changes_nothing() { + let mut t = TabChangeTracker::default(); + let draft_id = insert(&mut t, vec![Value::Null]); + + let delta = t.apply(TrackerEdit::DraftCell { + draft_id, + col: 4, + input: CellInput::Value(Value::Int(1)), + }); + + assert_eq!( + delta, + TrackerDelta { + pending_count: 1, + ..TrackerDelta::default() + } + ); + assert_eq!(delta.pending_count, 1); + } + + #[test] + fn undo_and_redo_of_an_insert_remove_and_restore_the_draft_row() { + let mut t = TabChangeTracker::default(); + let draft_id = insert(&mut t, vec![Value::Int(7)]); + + let undone = t.undo().expect("the insert to undo"); + let redone = t.redo().expect("the insert to redo"); + + assert_eq!( + (undone.inserted_drafts, undone.removed_drafts), + (Vec::new(), vec![draft_id]) + ); + assert_eq!( + (redone.inserted_drafts, redone.removed_drafts), + (vec![draft_id], Vec::new()) + ); + } + + #[test] + fn a_duplicate_of_more_rows_than_the_history_holds_is_one_undo_step() { + let mut t = TabChangeTracker::default(); + let rows: Vec> = (0..UNDO_LIMIT + 10) + .map(|id| vec![CellInput::Value(Value::Int(i64::try_from(id).unwrap()))]) + .collect(); + + let inserted = t.apply(TrackerEdit::InsertDrafts { rows }); + let undone = t.undo().expect("the duplicate to undo"); + + assert_eq!(inserted.inserted_drafts.len(), UNDO_LIMIT + 10); + assert_eq!(undone.removed_drafts, inserted.inserted_drafts); + assert!(t.drafts().is_empty(), "the undo left some copies behind"); + assert!(!t.can_undo()); + let redone = t.redo().expect("the duplicate to redo"); + assert_eq!(redone.inserted_drafts, inserted.inserted_drafts); + } + + #[test] + fn deleting_one_duplicated_draft_leaves_the_others_to_undo() { + let mut t = TabChangeTracker::default(); + let inserted = t.apply(TrackerEdit::InsertDrafts { + rows: (1..=3).map(|id| vec![CellInput::Value(Value::Int(id))]).collect(), + }); + let [first, middle, last] = inserted.inserted_drafts[..] else { + panic!("three drafts: {:?}", inserted.inserted_drafts); + }; + + let _ = drop_drafts(&mut t, vec![middle]); + let undone = t.undo().expect("the rest of the duplicate to undo"); + + assert_eq!(undone.removed_drafts, vec![first, last]); + assert!(t.drafts().is_empty()); + } + + #[test] + fn redo_of_a_persisted_cell_edit_leaves_the_fetched_row_alone() { + let mut t = TabChangeTracker::default(); + let key = rk(&[Value::Int(1)]); + t.track_cell_edit(key.clone(), 1, Value::Text("a".into()), Value::Text("b".into())); + let _ = t.undo(); + + let redone = t.redo(); + + assert_eq!( + redone.map(|delta| (delta.changed, delta.inserted_drafts, delta.removed_drafts)), + Some((vec![key], Vec::new(), Vec::new())) + ); + } + + #[test] + fn discard_drops_every_draft_row_and_reports_each_touched_row_once() { + let mut t = TabChangeTracker::default(); + let key = rk(&[Value::Int(1)]); + t.track_cell_edit(key.clone(), 1, Value::Int(1), Value::Int(2)); + t.track_cell_edit(key.clone(), 2, Value::Int(3), Value::Int(4)); + let draft_id = insert(&mut t, vec![Value::Null]); + + let delta = t.discard(); + + assert_eq!(delta.pending_count, 0); + assert_eq!(delta.removed_drafts, vec![draft_id]); + assert_eq!(delta.changed.len(), 2, "{:?}", delta.changed); + assert!(delta.changed.contains(&key) && delta.changed.contains(&RowKey::Draft(draft_id))); + } + + #[test] + fn cell_overlay_shows_the_pending_value_over_the_fetched_one() { + let mut t = TabChangeTracker::default(); + let key = rk(&[Value::Int(1)]); + let fetched = Value::Text("a".into()); + t.track_cell_edit(key.clone(), 1, fetched.clone(), Value::Text("b".into())); + + let edited = t.cell_overlay(&key, 1); + let untouched = t.cell_overlay(&key, 0); + + assert_eq!(edited.pending, Some(CellInput::Value(Value::Text("b".into())))); + assert_eq!(edited.state, CellState::Modified); + assert_eq!(untouched.pending, None, "a clean cell is drawn from its row"); + assert_eq!(untouched.state, CellState::Clean); + } + + fn saving_on(columns: Vec) -> TabChangeTracker { + let mut t = TabChangeTracker::default(); + let _ = t.set_target(SaveTarget { + table: tablepro_core::meta::TableRef::unqualified("t"), + columns, + }); + t + } + + fn committed() -> SaveOutcome { + SaveOutcome::Committed { rows: Some(1) } + } + + fn rolled_back() -> SaveOutcome { + SaveOutcome::RolledBack(crate::workspace::RollbackCause::Driver( + tablepro_core::DriverError::server("no"), + )) + } + + /// Each UPDATE's assignments, by column name. + fn assignments(t: &TabChangeTracker, columns: &[ColumnInfo]) -> Vec<(String, Value)> { + t.change_set(columns) + .unwrap() + .changes + .changes + .into_iter() + .flat_map(|change| match change { + RowChange::Update(update) => update.assignments, + other => panic!("only updates were made: {other:?}"), + }) + .map(|(col, value)| (columns[col].name.clone(), value)) + .collect() + } + + #[test] + fn a_return_to_the_fetched_value_during_a_save_is_written_after_it() { + let columns = vec![pk_col("id"), data_col("name")]; + let mut t = saving_on(columns.clone()); + edit(&mut t, 1, "a", "b"); + let batch = t.begin_save().unwrap().unwrap(); + edit(&mut t, 1, "a", "c"); + edit(&mut t, 1, "a", "a"); + + let _ = t.settle_save(&committed(), batch.snapshot); + + // The save left b in the database, so the a typed while it ran + // is a change. + assert_eq!(assignments(&t, &columns), vec![("name".to_owned(), text("a"))]); + assert_eq!(t.cell_state(&rk(&[Value::Int(1)]), 1), CellState::Modified); + } + + #[test] + fn a_return_to_the_fetched_value_during_a_save_that_rolls_back_is_no_change() { + let columns = vec![pk_col("id"), data_col("name")]; + let mut t = saving_on(columns.clone()); + edit(&mut t, 1, "a", "b"); + let batch = t.begin_save().unwrap().unwrap(); + edit(&mut t, 1, "a", "c"); + edit(&mut t, 1, "a", "a"); + + let delta = t.settle_save(&rolled_back(), batch.snapshot); + + assert_eq!(delta.pending_count, 0); + assert_eq!(t.pending_count(), 0); + assert_eq!(delta.changed, vec![rk(&[Value::Int(1)])]); + } + + #[test] + fn an_edit_that_outlives_a_save_is_measured_against_what_it_wrote() { + let columns = vec![pk_col("id"), data_col("name")]; + let mut t = saving_on(columns.clone()); + edit(&mut t, 1, "a", "b"); + let batch = t.begin_save().unwrap().unwrap(); + edit(&mut t, 1, "a", "c"); + let _ = t.settle_save(&committed(), batch.snapshot); + + // The page read after the commit holds b. + edit(&mut t, 1, "b", "a"); + assert_eq!(assignments(&t, &columns), vec![("name".to_owned(), text("a"))]); + + edit(&mut t, 1, "b", "b"); + assert_eq!(t.pending_count(), 0, "a return to what the save wrote stayed pending"); + } + + fn retarget(t: &mut TabChangeTracker, columns: &[ColumnInfo]) { + let _ = t.set_target(SaveTarget { + table: tablepro_core::meta::TableRef::unqualified("t"), + columns: columns.to_vec(), + }); + } + + fn rejection(t: &mut TabChangeTracker) -> String { + match t.begin_save() { + Err(rejection) => rejection.message(), + Ok(batch) => panic!("the save went ahead: {batch:?}"), + } + } + + #[test] + fn an_edit_follows_its_column_when_another_is_dropped() { + let before = vec![pk_col("id"), data_col("nickname"), data_col("name"), data_col("email")]; + let after = vec![pk_col("id"), data_col("name"), data_col("email")]; + let mut t = saving_on(before); + t.track_cell_edit(rk(&[Value::Int(1)]), 2, text("Ada"), text("Grace")); + + retarget(&mut t, &after); + + assert_eq!(assignments(&t, &after), vec![("name".to_owned(), text("Grace"))]); + assert_eq!(pending_at(&t, 1, 1), text("Grace")); + assert_eq!(t.cell_state(&rk(&[Value::Int(1)]), 2), CellState::Clean); + } + + #[test] + fn a_draft_follows_its_columns_when_one_is_added_and_one_dropped() { + let before = vec![pk_col("id"), data_col("nickname"), data_col("name")]; + let after = vec![pk_col("id"), data_col("name"), data_col("email")]; + let mut t = saving_on(before); + let draft_id = insert(&mut t, vec![Value::Null, Value::Null, text("Ada")]); + + retarget(&mut t, &after); + + assert_eq!( + t.draft_values(draft_id), + Some(vec![Value::Null, text("Ada"), Value::Null]) + ); + } + + #[test] + fn an_edit_to_a_dropped_column_refuses_the_save() { + let before = vec![pk_col("id"), data_col("nickname"), data_col("name")]; + let after = vec![pk_col("id"), data_col("name")]; + let mut t = saving_on(before); + t.track_cell_edit(rk(&[Value::Int(1)]), 1, text("ada"), text("countess")); + edit(&mut t, 2, "Grace", "Hopper"); + + retarget(&mut t, &after); + + assert!(rejection(&mut t).contains("nickname")); + assert_eq!(t.pending_count(), 2, "an edit nothing can save was dropped"); + assert_eq!(t.pending_state(), PendingState::Unsaved); + } + + #[test] + fn a_draft_with_a_value_in_a_dropped_column_refuses_the_save() { + let before = vec![pk_col("id"), data_col("nickname"), data_col("name")]; + let after = vec![pk_col("id"), data_col("name")]; + let mut t = saving_on(before); + let _ = insert(&mut t, vec![Value::Null, text("ada"), text("Ada")]); + + retarget(&mut t, &after); + + assert!(rejection(&mut t).contains("nickname")); + assert_eq!(t.pending_count(), 1); + } + + #[test] + fn an_edit_that_no_longer_fits_a_retyped_column_refuses_the_save() { + let before = vec![pk_col("id"), test_column("score", "text")]; + let after = vec![pk_col("id"), test_column("score", "integer")]; + let mut t = saving_on(before); + t.track_cell_edit(rk(&[Value::Int(1)]), 1, text("1"), text("high")); + + retarget(&mut t, &after); + + assert!(rejection(&mut t).contains("score")); + } + + #[test] + fn an_edit_that_still_fits_a_retyped_column_saves() { + let before = vec![pk_col("id"), test_column("score", "integer")]; + let after = vec![pk_col("id"), test_column("score", "bigint")]; + let mut t = saving_on(before); + t.track_cell_edit(rk(&[Value::Int(1)]), 1, Value::Int(1), Value::Int(2)); + + retarget(&mut t, &after); + + assert_eq!(assignments(&t, &after), vec![("score".to_owned(), Value::Int(2))]); + assert!(t.begin_save().is_ok_and(|batch| batch.is_some())); + } + + #[test] + fn a_new_primary_key_refuses_edits_to_rows_named_by_the_old_one() { + let before = vec![pk_col("id"), test_column("legacy_id", "integer"), data_col("name")]; + let mut legacy_key = test_column("legacy_id", "integer"); + legacy_key.primary_key = true; + let mut plain_id = test_column("id", "integer"); + plain_id.nullable = false; + let after = vec![plain_id, legacy_key, data_col("name")]; + let mut t = saving_on(before); + t.track_cell_edit(rk(&[Value::Int(1)]), 2, text("Ada"), text("Grace")); + delete( + &mut t, + rk(&[Value::Int(2)]), + vec![Value::Int(2), Value::Int(9), text("Bob")], + ); + + retarget(&mut t, &after); + + assert_eq!(t.begin_save().err(), Some(SaveRejection::KeyChanged)); + assert_eq!(t.pending_count(), 2); + } + + #[test] + fn undo_after_a_column_is_dropped_steps_back_on_the_moved_column() { + let before = vec![pk_col("id"), data_col("nickname"), data_col("name")]; + let after = vec![pk_col("id"), data_col("name")]; + let mut t = saving_on(before); + t.track_cell_edit(rk(&[Value::Int(1)]), 2, text("Ada"), text("Grace")); + t.track_cell_edit(rk(&[Value::Int(1)]), 2, text("Ada"), text("Hopper")); + retarget(&mut t, &after); + + let _ = t.undo(); + + assert_eq!(assignments(&t, &after), vec![("name".to_owned(), text("Grace"))]); + let _ = t.redo(); + assert_eq!(assignments(&t, &after), vec![("name".to_owned(), text("Hopper"))]); + } + + #[test] + fn discard_drops_an_edit_the_columns_left_behind() { + let before = vec![pk_col("id"), data_col("nickname")]; + let after = vec![pk_col("id")]; + let mut t = saving_on(before); + t.track_cell_edit(rk(&[Value::Int(1)]), 1, text("ada"), text("countess")); + retarget(&mut t, &after); + + let _ = t.discard(); + + assert_eq!(t.pending_count(), 0); + assert!(matches!(t.begin_save(), Ok(None))); + } + + /// The cell as the grid draws it at `col`, over a fetched "Ada". + fn pending_at(t: &TabChangeTracker, id: i64, col: usize) -> Value { + t.cell_overlay(&rk(&[Value::Int(id)]), col).shown(text("Ada")) + } + + #[test] + fn an_older_flash_timer_does_not_clear_a_newer_flash() { + let mut t = TabChangeTracker::default(); + let first = rk(&[Value::Int(1)]); + let second = RowKey::Draft(3); + let old = t.flash_error_row(first.clone()).generation; + let new = t.flash_error_row(second.clone()).generation; + + assert!(!t.clear_error_row_if(old)); + assert!(t.cell_overlay(&second, 0).error_flash); + assert!(!t.cell_overlay(&first, 0).error_flash); + assert!(t.clear_error_row_if(new)); + assert!(!t.cell_overlay(&second, 0).error_flash); + } + + #[test] + fn a_flash_names_the_row_whose_flash_it_cut_short() { + let mut t = TabChangeTracker::default(); + let first = rk(&[Value::Int(1)]); + let second = RowKey::Draft(3); + + assert_eq!(t.flash_error_row(first.clone()).replaced, None); + assert_eq!(t.flash_error_row(first.clone()).replaced, None, "a row replaced itself"); + assert_eq!(t.flash_error_row(second.clone()).replaced, Some(first)); + let ended = t.flash_error_row(second).generation; + assert!(t.clear_error_row_if(ended)); + assert_eq!( + t.flash_error_row(RowKey::Draft(4)).replaced, + None, + "an ended flash was replaced" + ); + } +} diff --git a/linux/crates/app/src/services/change_tracker_registry.rs b/linux/crates/app/src/services/change_tracker_registry.rs new file mode 100644 index 0000000000..60c59ea93d --- /dev/null +++ b/linux/crates/app/src/services/change_tracker_registry.rs @@ -0,0 +1,61 @@ +use super::tab_registry::TabRegistry; +use super::tab_tracker_handle::TabTrackerHandle; + +pub type ChangeTrackerRegistry = TabRegistry; + +#[cfg(test)] +mod tests { + use tablepro_core::Value; + use uuid::Uuid; + + use super::*; + use crate::services::change_tracker::RowKey; + use crate::services::tracker_edit::TrackerEdit; + + fn edit(tracker: &TabTrackerHandle) { + let _ = tracker.apply(TrackerEdit::Cell { + row_key: RowKey::from_pk_values(&[Value::Int(1)]).expect("a key"), + col: 1, + original: Value::Int(1), + value: Value::Int(2), + }); + } + + #[test] + fn registries_isolated_per_workspace() { + let first = ChangeTrackerRegistry::default(); + let second = ChangeTrackerRegistry::default(); + let tab = Uuid::new_v4(); + + edit(&first.register(tab)); + let other = second.register(tab); + + assert_eq!(first.get(tab).map(|tracker| tracker.pending_count()), Some(1)); + assert_eq!(other.pending_count(), 0); + } + + #[test] + fn a_discard_empties_the_tabs_tracker() { + let registry = ChangeTrackerRegistry::default(); + let edited = Uuid::new_v4(); + edit(®istry.register(edited)); + registry.register(Uuid::new_v4()); + + let _ = registry.get(edited).expect("registered").discard(); + + assert_eq!(registry.register(edited).pending_count(), 0); + } + + #[test] + fn register_returns_the_same_tracker_until_unregistered() { + let registry = ChangeTrackerRegistry::default(); + let tab = Uuid::new_v4(); + edit(®istry.register(tab)); + + assert_eq!(registry.register(tab).pending_count(), 1); + + registry.unregister(tab); + assert!(registry.get(tab).is_none()); + assert_eq!(registry.register(tab).pending_count(), 0); + } +} diff --git a/linux/crates/app/src/services/column_relayout.rs b/linux/crates/app/src/services/column_relayout.rs new file mode 100644 index 0000000000..daa4c34870 --- /dev/null +++ b/linux/crates/app/src/services/column_relayout.rs @@ -0,0 +1,215 @@ +use tablepro_core::column::ColumnType; +use tablepro_core::edit::{CellInput, parse_literal_text}; +use tablepro_core::export::value_text; +use tablepro_core::value::same_value; +use tablepro_core::{ColumnInfo, Value}; + +use super::change_tracker::{RowKey, new_draft_cell}; + +/// How the columns a tab's pending changes were made against line up +/// with the columns it read since. Columns are matched by name: once a +/// column is dropped or added, a position names another column. +#[derive(Debug)] +pub(crate) struct ColumnRelayout<'a> { + old: &'a [ColumnInfo], + new: &'a [ColumnInfo], + /// Each old column's position among the new ones. + positions: Vec>, + /// Each new column's position among the old ones. + sources: Vec>, + key_moved: bool, +} + +/// Why a pending change has no place under the new columns. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum Misfit { + /// These columns are gone, or no longer take the value. + Columns(Vec), + /// The primary key changed, so a row key read under the old one + /// may name another row, or none. + Key, +} + +impl<'a> ColumnRelayout<'a> { + pub(crate) fn new(old: &'a [ColumnInfo], new: &'a [ColumnInfo]) -> Self { + let position = |name: &str, columns: &[ColumnInfo]| columns.iter().position(|column| column.name == name); + Self { + old, + new, + positions: old.iter().map(|column| position(&column.name, new)).collect(), + sources: new.iter().map(|column| position(&column.name, old)).collect(), + key_moved: key_names(old) != key_names(new), + } + } + + pub(crate) fn position(&self, col: usize) -> Option { + self.positions.get(col).copied().flatten() + } + + /// Where a value put in old column `col` belongs now. + pub(crate) fn cell(&self, col: usize, value: &Value) -> Result { + let misfit = || { + Misfit::Columns( + self.old + .get(col) + .map(|column| column.name.clone()) + .into_iter() + .collect(), + ) + }; + let (Some(old), Some(moved)) = (self.old.get(col), self.position(col)) else { + return Err(misfit()); + }; + match fits(value, &old.column_type, &self.new[moved].column_type) { + true => Ok(moved), + false => Err(misfit()), + } + } + + /// Whether a row key read under the old primary key names the same + /// row under the new one. + pub(crate) fn key(&self, key: &RowKey) -> Result<(), Misfit> { + let RowKey::Persisted(parts) = key else { + return Ok(()); + }; + if self.key_moved { + return Err(Misfit::Key); + } + let old = self.old.iter().filter(|column| column.primary_key); + let new = self.new.iter().filter(|column| column.primary_key); + match parts + .iter() + .zip(old.zip(new)) + .all(|(part, (old, new))| fits(part.value(), &old.column_type, &new.column_type)) + { + true => Ok(()), + false => Err(Misfit::Key), + } + } + + /// Where a draft cell's input in old column `col` belongs now. A + /// cell left to the server fits anywhere, as NULL does. + pub(crate) fn input_cell(&self, col: usize, input: &CellInput) -> Result { + self.cell(col, input.value().unwrap_or(&Value::Null)) + } + + /// A new row's cells under the new columns. A value the user left + /// in a column that is gone, or that no longer takes it, has no + /// place; an empty cell there is dropped. A new column starts as a + /// new row's would. + pub(crate) fn draft(&self, cells: &[CellInput]) -> Result, Misfit> { + let mut lost = Vec::new(); + for (col, input) in cells.iter().enumerate() { + let Some(value) = input.value().filter(|value| !matches!(value, Value::Null)) else { + continue; + }; + if let Err(Misfit::Columns(columns)) = self.cell(col, value) { + lost.extend(columns); + } + } + if !lost.is_empty() { + return Err(Misfit::Columns(lost)); + } + Ok(self + .sources + .iter() + .zip(self.new) + .map(|(source, column)| match source.and_then(|col| cells.get(col)) { + Some(input) => input.clone(), + None => new_draft_cell(column), + }) + .collect()) + } + + /// A row as read under the old columns, under the new ones. A new + /// column reads as NULL. + pub(crate) fn fetched(&self, values: &[Value]) -> Vec { + self.sources + .iter() + .map(|source| source.and_then(|col| values.get(col)).cloned().unwrap_or(Value::Null)) + .collect() + } +} + +fn key_names(columns: &[ColumnInfo]) -> Vec<&str> { + columns + .iter() + .filter(|column| column.primary_key) + .map(|column| column.name.as_str()) + .collect() +} + +/// Whether a value made for a column of type `old` is still the value +/// a column of type `new` would take from the same text. NULL is left +/// to the server, which knows whether the column takes it. +fn fits(value: &Value, old: &ColumnType, new: &ColumnType) -> bool { + if old == new || matches!(value, Value::Null) { + return true; + } + value_text(value, old) + .and_then(|text| parse_literal_text(&text, new).ok()) + .is_some_and(|parsed| same_value(&parsed, value)) +} + +#[cfg(test)] +mod tests { + use tablepro_core::column::{ColumnDefault, ColumnKind, IntegerKind, IntegerWidth, TextLength}; + + use super::*; + + fn column(name: &str, kind: ColumnKind, primary_key: bool) -> ColumnInfo { + let mut column_type = ColumnType::untyped_text(name); + column_type.kind = kind; + ColumnInfo { + name: name.to_owned(), + column_type, + nullable: !primary_key, + primary_key, + is_auto_increment: false, + is_generated: false, + default: ColumnDefault::None, + comment: None, + } + } + + fn integer(width: IntegerWidth) -> ColumnKind { + ColumnKind::Integer(IntegerKind { width, signed: true }) + } + + fn text(length: TextLength) -> ColumnKind { + ColumnKind::Text { length } + } + + #[test] + fn a_wider_type_takes_the_value_and_a_narrower_or_other_one_does_not() { + let int = column("n", integer(IntegerWidth::W32), false); + let wide = ColumnType { + kind: integer(IntegerWidth::W64), + ..int.column_type.clone() + }; + let narrow = ColumnType { + kind: integer(IntegerWidth::W8), + ..int.column_type.clone() + }; + let words = ColumnType { + kind: text(TextLength::Unbounded), + ..int.column_type.clone() + }; + + assert!(fits(&Value::Int(300), &int.column_type, &wide)); + assert!(!fits(&Value::Int(300), &int.column_type, &narrow)); + assert!(!fits(&Value::Int(3), &int.column_type, &words), "a number became text"); + assert!(fits(&Value::Null, &int.column_type, &words)); + } + + #[test] + fn a_key_column_left_out_of_the_key_moves_every_row_key() { + let old = [column("id", integer(IntegerWidth::W32), true)]; + let new = [column("id", integer(IntegerWidth::W32), false)]; + let key = RowKey::from_pk_values(&[Value::Int(1)]).expect("a key"); + + assert_eq!(ColumnRelayout::new(&old, &new).key(&key), Err(Misfit::Key)); + assert_eq!(ColumnRelayout::new(&old, &old).key(&key), Ok(())); + assert_eq!(ColumnRelayout::new(&old, &new).key(&RowKey::Draft(1)), Ok(())); + } +} diff --git a/linux/crates/app/src/services/connection_deletion.rs b/linux/crates/app/src/services/connection_deletion.rs new file mode 100644 index 0000000000..08107783d8 --- /dev/null +++ b/linux/crates/app/src/services/connection_deletion.rs @@ -0,0 +1,534 @@ +use std::sync::Arc; + +use tablepro_core::credentials::SecretVault; +use tablepro_session::SessionKey; +use tablepro_session::connect::ConnectionWrites; +use tablepro_session::runtime::Tasks; +use tablepro_storage::QueryHistory; +use uuid::Uuid; + +use super::deletion_error::DeletionError; +use super::deletion_report::{DeletionReport, PurgeFailure}; +use crate::persistence::{ColumnWidthStore, FilterSettingsStore}; +use crate::workspace::WorkspaceStore; + +/// What the user chose in the delete dialog. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct DeletionOptions { + pub delete_history: bool, +} + +/// Deletes a saved connection, then everything stored for it. +/// +/// The order is what keeps a failure recoverable: the secrets go first, +/// because a secret with no entry is orphaned in the keyring while an +/// entry with no secrets only asks for them again. What is kept for the +/// connection goes last, once nothing can reach it. +/// +/// GTK-thread only, like the width, filter and workspace stores. +pub struct ConnectionDeletion { + pub secrets: Arc, + pub connections: Arc, + pub column_widths: ColumnWidthStore, + pub filters: FilterSettingsStore, + pub workspaces: WorkspaceStore, + /// `None` when the history database did not open. + pub history: Option, + pub spawner: Tasks, +} + +impl ConnectionDeletion { + pub async fn run(&self, id: Uuid, options: DeletionOptions) -> Result { + // The Secret Service client runs on the runtime. + let secrets = self.secrets.clone(); + self.spawner + .spawn_task(async move { secrets.delete_connection(id).await }) + .await + .map_err(DeletionError::Interrupted)? + .map_err(DeletionError::Secrets)?; + self.connections.remove(id).await.map_err(DeletionError::Entry)?; + Ok(self.purge(id, options).await) + } + + async fn purge(&self, id: Uuid, options: DeletionOptions) -> DeletionReport { + let mut report = DeletionReport::default(); + self.column_widths.forget_connection(id); + self.filters.forget_connection(id); + // Awaited inside the delete's hold, so a quit right after it + // cannot leave them on disk. + self.column_widths.flush().await; + self.filters.flush().await; + if let Err(error) = self.workspaces.purge(&SessionKey::Saved(id)).await { + report.purge_failures.push(PurgeFailure::Workspace(error)); + } + let Some(history) = self.history.clone() else { + let unavailable = || "the query history did not open".to_owned(); + report.purge_failures.push(PurgeFailure::SavedQueries(unavailable())); + if options.delete_history { + report.purge_failures.push(PurgeFailure::History(unavailable())); + } + return report; + }; + // The queries saved under the connection name tables only its + // database has, so they go with it whatever the history switch + // says. + let saved_queries = history.saved_queries(); + let removed = self + .spawner + .spawn_task(async move { saved_queries.delete_for_connection(id).await }) + .await; + if let Some(error) = failure_text(removed) { + report.purge_failures.push(PurgeFailure::SavedQueries(error)); + } + if options.delete_history { + let removed = self + .spawner + .spawn_task(async move { history.delete_for_connection(id).await }) + .await; + if let Some(error) = failure_text(removed) { + report.purge_failures.push(PurgeFailure::History(error)); + } + } + report + } +} + +fn failure_text( + outcome: Result, tablepro_session::runtime::TaskFailure>, +) -> Option { + match outcome { + Ok(Ok(_)) => None, + Ok(Err(error)) => Some(error.to_string()), + Err(failure) => Some(failure.to_string()), + } +} + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + use std::sync::Mutex; + + use futures::future::BoxFuture; + use secrecy::SecretString; + use tablepro_core::credentials::{SecretError, SecretKind}; + use tablepro_core::{AuthMode, Combinator, FilterOp, FilterRule, FilterSet, FilterValue, TlsConfig}; + use tablepro_session::connect::StoreConnectionWrites; + use tablepro_storage::query_history::{HistoryPageRequest, HistoryQuery, NewEntry, Outcome}; + use tablepro_storage::saved_queries::NewSavedQuery; + use tablepro_storage::{ConnectionStore, DraftId, DraftStore, SavedConnection, StorageError, StoragePaths}; + use tablepro_test_support::{Call, FakeSecretVault}; + + use super::*; + use crate::test_support::paused_tasks; + use crate::workspace::{TabRecord, WorkspaceFileName, WorkspaceRecord}; + + type Log = Arc>>; + + /// The list on disk, noting at each removal whether the secrets were + /// already deleted and the saved tabs were still there. + struct LoggedWrites { + writes: StoreConnectionWrites, + vault: Arc, + log: Log, + tabs: PathBuf, + } + + impl ConnectionWrites for LoggedWrites { + fn upsert(&self, connection: SavedConnection) -> BoxFuture<'_, Result<(), StorageError>> { + self.writes.upsert(connection) + } + + fn remove(&self, id: Uuid) -> BoxFuture<'_, Result<(), StorageError>> { + let secrets_gone = self.vault.calls().contains(&Call::DeleteConnection(id)); + self.log.lock().unwrap().push(format!( + "list, secrets deleted: {secrets_gone}, tabs still saved: {}", + self.tabs.exists() + )); + self.writes.remove(id) + } + } + + struct Fixture { + _root: tempfile::TempDir, + paths: StoragePaths, + store: ConnectionStore, + vault: Arc, + widths: ColumnWidthStore, + filters: FilterSettingsStore, + workspaces: WorkspaceStore, + drafts: DraftStore, + history: QueryHistory, + id: Uuid, + other: Uuid, + log: Log, + } + + fn connection(id: Uuid) -> SavedConnection { + SavedConnection { + id, + name: "Prod".to_owned(), + driver_id: "postgres".to_owned(), + host: "db.example.com".to_owned(), + port: 5432, + database: "shop".to_owned(), + username: "app".to_owned(), + tls: TlsConfig::verify_full(), + read_only: false, + auth_mode: AuthMode::Password, + ssh: None, + last_opened_at: None, + color: None, + group: None, + } + } + + fn filter() -> FilterSet { + FilterSet { + combinator: Combinator::And, + rules: vec![FilterRule { + column: "name".into(), + op: FilterOp::Eq, + value: Some(FilterValue::Single("alice".into())), + }], + extra_sql: None, + } + } + + fn ran(query: &str, connection: Uuid) -> NewEntry { + NewEntry { + query: query.to_owned(), + driver_id: "postgres".to_owned(), + connection_id: connection, + connection_name: "Prod".to_owned(), + executed_at: std::time::SystemTime::now(), + duration_ms: Some(1), + rows_affected: Some(0), + outcome: Outcome::Success, + } + } + + /// Two connections with something of every kind stored for each. + async fn fixture() -> Fixture { + let root = tempfile::tempdir().expect("tempdir"); + let paths = StoragePaths::under(root.path(), "tablepro-test", "app.tablepro.Test"); + let tasks = paused_tasks(); + let store = ConnectionStore::new(&paths); + let vault = Arc::new(FakeSecretVault::new()); + let widths = ColumnWidthStore::load(paths.column_widths_file(), &tasks); + let filters = FilterSettingsStore::load(paths.filter_settings_file(), &tasks); + let drafts = DraftStore::new(paths.drafts_dir()); + let workspaces = WorkspaceStore::start(paths.workspaces_dir(), drafts.clone(), &tasks); + let history = QueryHistory::open(&paths).await.expect("the history"); + let (id, other) = (Uuid::new_v4(), Uuid::new_v4()); + for connection_id in [id, other] { + store.upsert_blocking(connection(connection_id)).expect("the entry"); + vault + .store( + connection_id, + SecretKind::DatabasePassword, + &SecretString::from("s3cret"), + "label", + ) + .await + .expect("the password"); + widths.record(connection_id, Some("public"), "users", "name", 120); + filters.set_filters(connection_id, Some("public"), "users", filter()); + let key = SessionKey::Saved(connection_id); + let draft = DraftId::new(); + drafts + .write_blocking(&WorkspaceFileName::for_key(&key).draft_scope(), draft, "SELECT 1") + .expect("a draft"); + workspaces + .schedule( + &key, + WorkspaceRecord { + active_tab: Some(0), + tabs: vec![TabRecord::Editor { draft, file: None }], + }, + ) + .expect("scheduled"); + workspaces.flush(&key).await.expect("the saved tabs"); + history + .record(ran("SELECT * FROM users", connection_id)) + .await + .expect("a history entry"); + history + .saved_queries() + .save(NewSavedQuery { + name: "users".to_owned(), + query: "SELECT * FROM users".to_owned(), + connection_id, + connection_name: "Prod".to_owned(), + }) + .await + .expect("a saved query"); + } + Fixture { + _root: root, + paths, + store, + vault, + widths, + filters, + workspaces, + drafts, + history, + id, + other, + log: Log::default(), + } + } + + impl Fixture { + fn deletion(&self) -> ConnectionDeletion { + ConnectionDeletion { + secrets: self.vault.clone(), + connections: Arc::new(LoggedWrites { + writes: StoreConnectionWrites::new(self.store.clone(), paused_tasks()), + vault: self.vault.clone(), + log: self.log.clone(), + tabs: self.tabs(self.id), + }), + column_widths: self.widths.clone(), + filters: self.filters.clone(), + workspaces: self.workspaces.clone(), + history: Some(self.history.clone()), + spawner: paused_tasks(), + } + } + + fn tabs(&self, id: Uuid) -> PathBuf { + self.paths + .workspaces_dir() + .join(WorkspaceFileName::for_key(&SessionKey::Saved(id)).file_name()) + } + + fn drafts_of(&self, id: Uuid) -> PathBuf { + self.drafts.root().join( + WorkspaceFileName::for_key(&SessionKey::Saved(id)) + .draft_scope() + .as_str(), + ) + } + + fn listed(&self, id: Uuid) -> bool { + self.store + .load_blocking() + .expect("the list") + .iter() + .any(|saved| saved.id == id) + } + + fn has_password(&self, id: Uuid) -> bool { + self.vault.stored_secret(id, SecretKind::DatabasePassword).is_some() + } + + async fn history_count(&self, id: Uuid) -> u64 { + self.history.count_for_connection(id).await.expect("the count") + } + + async fn saved_query_count(&self, id: Uuid) -> usize { + self.history + .saved_queries() + .list(Some(id)) + .await + .expect("the saved queries") + .len() + } + + /// Everything stored for `id` other than its entry and secrets + /// is still there. + async fn kept_everything_for(&self, id: Uuid) { + assert!(self.widths.width(id, Some("public"), "users", "name").is_some()); + assert_eq!(self.filters.filters(id, Some("public"), "users"), filter()); + assert!(self.tabs(id).exists(), "the saved tabs went"); + assert!(self.drafts_of(id).exists(), "the drafts went"); + assert_eq!(self.history_count(id).await, 1); + assert_eq!(self.saved_query_count(id).await, 1); + } + } + + #[tokio::test] + async fn secret_failure_keeps_entry_and_skips_purge() { + let fixture = fixture().await; + fixture.vault.fail_delete_connection(SecretError::Locked); + + let refused = fixture + .deletion() + .run(fixture.id, DeletionOptions { delete_history: true }) + .await; + + assert!( + matches!(refused, Err(DeletionError::Secrets(SecretError::Locked))), + "{refused:?}" + ); + assert!(fixture.listed(fixture.id), "the entry went while its secrets stayed"); + assert!(fixture.log.lock().unwrap().is_empty(), "the list was written"); + assert!(fixture.has_password(fixture.id)); + fixture.kept_everything_for(fixture.id).await; + } + + #[tokio::test] + async fn order_is_secrets_then_list_then_purge() { + let fixture = fixture().await; + + fixture + .deletion() + .run(fixture.id, DeletionOptions::default()) + .await + .expect("the delete"); + + assert_eq!( + *fixture.log.lock().unwrap(), + ["list, secrets deleted: true, tabs still saved: true"], + "the steps ran out of order" + ); + assert!(!fixture.tabs(fixture.id).exists(), "the purge did not follow the list"); + } + + #[tokio::test] + async fn success_forgets_widths_filters_workspace_and_drafts() { + let fixture = fixture().await; + + let report = fixture + .deletion() + .run(fixture.id, DeletionOptions::default()) + .await + .expect("the delete"); + + assert!(report.is_complete(), "{report:?}"); + assert!(!fixture.listed(fixture.id)); + assert!(!fixture.has_password(fixture.id)); + assert!( + fixture + .widths + .width(fixture.id, Some("public"), "users", "name") + .is_none() + ); + assert!(fixture.filters.filters(fixture.id, Some("public"), "users").is_empty()); + for file in [fixture.paths.column_widths_file(), fixture.paths.filter_settings_file()] { + let on_disk = std::fs::read_to_string(&file).expect("the file"); + assert!( + !on_disk.contains(&fixture.id.to_string()), + "{} still names the deleted connection once the delete ended", + file.display() + ); + assert!(on_disk.contains(&fixture.other.to_string())); + } + assert!( + !fixture.tabs(fixture.id).exists(), + "the saved tabs outlived the connection" + ); + assert!( + !fixture.drafts_of(fixture.id).exists(), + "the drafts outlived the connection" + ); + assert_eq!(fixture.saved_query_count(fixture.id).await, 0); + assert!(fixture.listed(fixture.other)); + assert!(fixture.has_password(fixture.other)); + fixture.kept_everything_for(fixture.other).await; + } + + #[tokio::test] + async fn history_kept_unless_requested() { + let fixture = fixture().await; + + fixture + .deletion() + .run(fixture.id, DeletionOptions::default()) + .await + .expect("the delete"); + + assert_eq!(fixture.history_count(fixture.id).await, 1); + } + + #[tokio::test] + async fn history_deleted_when_requested() { + let fixture = fixture().await; + + let report = fixture + .deletion() + .run(fixture.id, DeletionOptions { delete_history: true }) + .await + .expect("the delete"); + + assert!(report.is_complete(), "{report:?}"); + assert_eq!(fixture.history_count(fixture.id).await, 0); + assert_eq!(fixture.history_count(fixture.other).await, 1); + let left = fixture + .history + .search_page(&HistoryQuery::from_input("users"), HistoryPageRequest::first(10)) + .await + .expect("search") + .entries; + assert!(left.iter().all(|entry| entry.connection_id == fixture.other)); + } + + #[tokio::test] + async fn without_the_history_database_only_the_saved_queries_are_left_unless_history_was_asked_for() { + let fixture = fixture().await; + let mut deletion = fixture.deletion(); + deletion.history = None; + + let report = deletion + .run(fixture.id, DeletionOptions::default()) + .await + .expect("the delete"); + + assert!( + matches!(report.purge_failures.as_slice(), [PurgeFailure::SavedQueries(_)]), + "{report:?}" + ); + } + + #[tokio::test] + async fn purge_failure_reported_but_entry_removed() { + let fixture = fixture().await; + let drafts = fixture.drafts_of(fixture.id); + std::fs::remove_dir_all(&drafts).expect("clear the drafts"); + // A file where the drafts directory goes cannot be removed as + // one. + std::fs::write(&drafts, b"not a directory").expect("seed"); + let mut deletion = fixture.deletion(); + deletion.history = None; + + let report = deletion + .run(fixture.id, DeletionOptions { delete_history: true }) + .await + .expect("the delete"); + + assert!(!fixture.listed(fixture.id), "a failed purge kept the entry"); + assert!(!fixture.has_password(fixture.id)); + assert!( + report + .purge_failures + .iter() + .any(|failure| matches!(failure, PurgeFailure::Workspace(_))), + "{report:?}" + ); + assert!( + report + .purge_failures + .iter() + .any(|failure| matches!(failure, PurgeFailure::SavedQueries(_))), + "{report:?}" + ); + assert!( + report + .purge_failures + .iter() + .any(|failure| matches!(failure, PurgeFailure::History(_))), + "{report:?}" + ); + assert!( + fixture + .widths + .width(fixture.id, Some("public"), "users", "name") + .is_none() + ); + assert!( + !fixture.tabs(fixture.id).exists(), + "the saved tabs stayed with the drafts" + ); + } +} diff --git a/linux/crates/app/src/services/deletion_error.rs b/linux/crates/app/src/services/deletion_error.rs new file mode 100644 index 0000000000..424f7253e3 --- /dev/null +++ b/linux/crates/app/src/services/deletion_error.rs @@ -0,0 +1,57 @@ +use tablepro_core::credentials::SecretError; +use tablepro_session::runtime::TaskFailure; +use tablepro_storage::StorageError; +use thiserror::Error; + +/// Why a saved connection is still there after a delete. +#[derive(Debug, Error)] +pub enum DeletionError { + /// The keyring kept the connection's secrets, so the entry was kept + /// too: a secret with no entry would be orphaned in the keyring. + #[error("the stored passwords could not be deleted: {0}")] + Secrets(SecretError), + /// The secrets are gone but the entry is not. It still connects + /// once the user types its passwords. + #[error("the connection could not be removed from the list: {0}")] + Entry(StorageError), + #[error("the delete did not finish: {0}")] + Interrupted(TaskFailure), +} + +impl DeletionError { + /// What the user is told about the delete of `name`. + pub fn summary(&self, name: &str) -> String { + match self { + Self::Secrets(_) => { + crate::i18n::gettext("The stored passwords could not be deleted, so the connection was kept.") + } + Self::Entry(_) => crate::i18n::gettext_f( + "“{name}” is still listed, but its stored passwords were deleted. Connecting will ask for them.", + &[("name", name)], + ), + Self::Interrupted(_) => crate::i18n::gettext_f("“{name}” could not be deleted", &[("name", name)]), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_kept_entry_says_its_passwords_are_gone() { + let kept = DeletionError::Entry(StorageError::Schema("read-only".to_owned())); + + let said = kept.summary("Prod"); + + assert!(said.contains("“Prod” is still listed"), "{said}"); + assert!(said.contains("passwords were deleted"), "{said}"); + } + + #[test] + fn an_interrupted_delete_says_nothing_was_deleted() { + let said = DeletionError::Interrupted(TaskFailure::Aborted).summary("Prod"); + + assert_eq!(said, "“Prod” could not be deleted"); + } +} diff --git a/linux/crates/app/src/services/deletion_report.rs b/linux/crates/app/src/services/deletion_report.rs new file mode 100644 index 0000000000..ba666cb0c0 --- /dev/null +++ b/linux/crates/app/src/services/deletion_report.rs @@ -0,0 +1,105 @@ +use thiserror::Error; + +use crate::workspace::WorkspaceSaveError; + +/// What was stored for a deleted connection and could not be removed. +/// None of it undoes the delete: nothing can reach it any more. +#[derive(Debug, Default)] +pub struct DeletionReport { + pub purge_failures: Vec, +} + +impl DeletionReport { + pub fn is_complete(&self) -> bool { + self.purge_failures.is_empty() + } + + /// The toast that tells the user what the delete of `name` left + /// behind, or `None` when it left nothing. + pub fn summary(&self, name: &str) -> Option { + let left = |wanted: fn(&PurgeFailure) -> bool| self.purge_failures.iter().any(wanted); + let tabs = left(|failure| matches!(failure, PurgeFailure::Workspace(_))); + let queries = left(|failure| matches!(failure, PurgeFailure::SavedQueries(_))); + let history = left(|failure| matches!(failure, PurgeFailure::History(_))); + let named = &[("name", name)]; + let text = match (tabs, queries, history) { + (false, false, false) => return None, + (true, false, false) => crate::i18n::gettext_f( + "“{name}” was deleted, but some saved tabs or settings could not be removed", + named, + ), + (true, ..) => crate::i18n::gettext_f( + "“{name}” was deleted, but some of what was stored for it could not be removed", + named, + ), + (false, true, true) => crate::i18n::gettext_f( + "“{name}” was deleted, but its saved queries and query history could not be removed", + named, + ), + (false, true, false) => crate::i18n::gettext_f( + "“{name}” was deleted, but its saved queries could not be removed", + named, + ), + (false, false, true) => crate::i18n::gettext_f( + "“{name}” was deleted, but its query history could not be removed", + named, + ), + }; + Some(text) + } +} + +#[derive(Debug, Error)] +pub enum PurgeFailure { + #[error(transparent)] + Workspace(WorkspaceSaveError), + #[error("could not delete the saved queries: {0}")] + SavedQueries(String), + #[error("could not delete the query history: {0}")] + History(String), +} + +#[cfg(test)] +mod tests { + use super::*; + + fn report(failures: Vec) -> DeletionReport { + DeletionReport { + purge_failures: failures, + } + } + + fn queries() -> PurgeFailure { + PurgeFailure::SavedQueries("the query history did not open".to_owned()) + } + + #[test] + fn a_complete_delete_has_nothing_to_say() { + assert_eq!(report(Vec::new()).summary("Prod"), None); + } + + #[test] + fn the_summary_names_what_was_left_behind() { + let tabs = report(vec![PurgeFailure::Workspace(WorkspaceSaveError::WriterStopped)]); + let saved = report(vec![queries()]); + let history = report(vec![PurgeFailure::History("locked".to_owned())]); + let both = report(vec![queries(), PurgeFailure::History("locked".to_owned())]); + + assert_eq!( + tabs.summary("Prod").as_deref(), + Some("“Prod” was deleted, but some saved tabs or settings could not be removed") + ); + assert_eq!( + saved.summary("Prod").as_deref(), + Some("“Prod” was deleted, but its saved queries could not be removed") + ); + assert_eq!( + history.summary("Prod").as_deref(), + Some("“Prod” was deleted, but its query history could not be removed") + ); + assert_eq!( + both.summary("Prod").as_deref(), + Some("“Prod” was deleted, but its saved queries and query history could not be removed") + ); + } +} diff --git a/linux/crates/app/src/services/duplicate_name.rs b/linux/crates/app/src/services/duplicate_name.rs new file mode 100644 index 0000000000..77202cdcda --- /dev/null +++ b/linux/crates/app/src/services/duplicate_name.rs @@ -0,0 +1,100 @@ +/// The name a copy of `base` gets: "Prod (Copy)", then "Prod (Copy 2)" +/// with the smallest number nobody holds, as GNOME Files names copies. +/// A copy of a copy counts on from the original rather than stacking a +/// second suffix. +pub(crate) fn duplicate_name(base: &str, existing: &[String]) -> String { + let original = original_name(base); + let taken = |candidate: &String| existing.iter().any(|name| name == candidate); + let first = copy_name(original); + if !taken(&first) { + return first; + } + // The list is a person's own connections, so the count ends within + // as many steps as they have copies. + (2_u64..) + .map(|n| numbered_copy_name(original, &n.to_string())) + .find(|candidate| !taken(candidate)) + .unwrap_or(first) +} + +fn copy_name(name: &str) -> String { + crate::i18n::gettext_f("{name} (Copy)", &[("name", name)]) +} + +fn numbered_copy_name(name: &str, n: &str) -> String { + crate::i18n::gettext_f("{name} (Copy {n})", &[("name", name), ("n", n)]) +} + +/// Stands in for the name and the number while the translated patterns +/// are taken apart. Neither can be typed into a name. +const NAME: &str = "\u{0}"; +const NUMBER: &str = "\u{1}"; + +/// `name` without the copy suffix it ends in, in the user's language. +fn original_name(name: &str) -> &str { + let plain = copy_name(NAME); + if let Some((before, after)) = plain.split_once(NAME) + && let Some(original) = name.strip_prefix(before).and_then(|rest| rest.strip_suffix(after)) + && !original.is_empty() + { + return original; + } + let numbered = numbered_copy_name(NAME, NUMBER); + let Some((before, rest)) = numbered.split_once(NAME) else { + return name; + }; + // A translation that puts the number ahead of the name is left as + // it is: the copy then reads "(Copy) (Copy)", which is still clear. + let Some((between, after)) = rest.split_once(NUMBER) else { + return name; + }; + let Some(inner) = name.strip_prefix(before).and_then(|rest| rest.strip_suffix(after)) else { + return name; + }; + match inner.rsplit_once(between) { + Some((original, n)) if !original.is_empty() && n.parse::().is_ok_and(|n| n >= 2) => original, + _ => name, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn names(list: &[&str]) -> Vec { + list.iter().map(|name| (*name).to_owned()).collect() + } + + #[test] + fn duplicate_name_appends_copy_then_counter() { + let mut existing = names(&["Prod"]); + for expected in ["Prod (Copy)", "Prod (Copy 2)", "Prod (Copy 3)"] { + let name = duplicate_name("Prod", &existing); + assert_eq!(name, expected); + existing.push(name); + } + } + + #[test] + fn the_smallest_free_number_is_taken() { + let existing = names(&["db", "db (Copy)", "db (Copy 3)"]); + + assert_eq!(duplicate_name("db", &existing), "db (Copy 2)"); + } + + #[test] + fn a_copy_of_a_copy_counts_on_from_the_original() { + let existing = names(&["Prod", "Prod (Copy)", "Prod (Copy 2)"]); + + assert_eq!(duplicate_name("Prod (Copy)", &existing), "Prod (Copy 3)"); + assert_eq!(duplicate_name("Prod (Copy 2)", &existing), "Prod (Copy 3)"); + } + + #[test] + fn a_name_that_only_looks_numbered_keeps_its_text() { + let existing = names(&["Release (Copy 1)", "(Copy)"]); + + assert_eq!(duplicate_name("Release (Copy 1)", &existing), "Release (Copy 1) (Copy)"); + assert_eq!(duplicate_name("(Copy)", &existing), "(Copy) (Copy)"); + } +} diff --git a/linux/crates/app/src/services/file_location.rs b/linux/crates/app/src/services/file_location.rs new file mode 100644 index 0000000000..8f8c7a7930 --- /dev/null +++ b/linux/crates/app/src/services/file_location.rs @@ -0,0 +1,340 @@ +//! Which filesystem a database file is on, and the file a document +//! portal path stands for. Each `_blocking` call reads the disk through +//! synchronous GIO, with no main context, and runs on the spawner. + +use std::ffi::OsString; +use std::os::unix::ffi::OsStringExt; +use std::path::{Path, PathBuf}; + +use gio::prelude::*; + +pub use super::file_location_kind::FileLocationKind; + +pub type LocationClassifier = fn(&Path) -> FileLocationKind; + +/// GIO's name for the portal's `user.document-portal.host-path`. +const HOST_PATH_ATTRIBUTE: &str = "xattr::document-portal.host-path"; + +/// Filesystems whose locks SQLite cannot rely on across machines. +const REMOTE_FILESYSTEMS: &[&str] = &[ + "nfs", + "nfs4", + "cifs", + "smb", + "smb2", + "smb3", + "ceph", + "9p", + "afs", + "glusterfs", + "lustre", +]; + +/// `fuseblk` is a block device behind FUSE, such as NTFS through +/// ntfs-3g, so its locks are this machine's own. +pub fn kind_for_fs_type(fs_type: &str) -> FileLocationKind { + match fs_type { + "fuseblk" => FileLocationKind::Local, + "fuse.portal" => FileLocationKind::Portal { + host_path: None, + host_is_local: false, + }, + remote if REMOTE_FILESYSTEMS.contains(&remote) => FileLocationKind::Remote { + fs_type: remote.to_owned(), + }, + fuse if fuse == "fuse" || fuse.starts_with("fuse.") => FileLocationKind::Unknown { + fs_type: fuse.to_owned(), + }, + _ => FileLocationKind::Local, + } +} + +/// The filesystem `path` is on. A file that does not exist yet is +/// classified by its folder's mount. +pub fn classify_blocking(path: &Path) -> FileLocationKind { + classify_with(path, mount_kind_blocking) +} + +fn classify_with(path: &Path, mount_kind: impl Fn(&Path) -> FileLocationKind) -> FileLocationKind { + let path = follow_links(path); + match mount_kind(&path) { + FileLocationKind::Portal { .. } => FileLocationKind::portal( + portal_host_path_blocking(&path), + |host| std::fs::symlink_metadata(host).is_ok(), + |host| mount_kind(&follow_links(host)), + ), + kind => kind, + } +} + +/// SQLite opens the file a link points to, while GIO finds the mount +/// of the link itself. A path that does not resolve, such as a file +/// not made yet, stays as given, and GIO resolves its folder. +fn follow_links(path: &Path) -> PathBuf { + std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf()) +} + +/// The filesystem's kind, without asking a portal for the file behind +/// it, so a portal path inside a portal is never followed. +fn mount_kind_blocking(path: &Path) -> FileLocationKind { + if let (Some(entry), _) = gio_unix::MountEntry::for_file_path(path) { + return kind_for_fs_type(&entry.fs_type()); + } + let attributes = format!( + "{},{}", + gio::FILE_ATTRIBUTE_FILESYSTEM_REMOTE, + gio::FILE_ATTRIBUTE_FILESYSTEM_TYPE + ); + match gio::File::for_path(path).query_filesystem_info(&attributes, gio::Cancellable::NONE) { + Ok(info) => { + let fs_type = info + .attribute_string(gio::FILE_ATTRIBUTE_FILESYSTEM_TYPE) + .map(String::from) + .unwrap_or_default(); + if info.boolean(gio::FILE_ATTRIBUTE_FILESYSTEM_REMOTE) { + FileLocationKind::Remote { fs_type } + } else { + kind_for_fs_type(&fs_type) + } + } + // The open that follows reports a file it cannot reach in its + // own words. + Err(error) => { + tracing::debug!(%error, path = %path.display(), "could not read the file's filesystem"); + FileLocationKind::Local + } + } +} + +/// The file a document portal path stands for, when the portal says. +pub fn portal_host_path_blocking(path: &Path) -> Option { + let info = gio::File::for_path(path) + .query_info( + HOST_PATH_ATTRIBUTE, + gio::FileQueryInfoFlags::NONE, + gio::Cancellable::NONE, + ) + .ok()?; + host_path_from_xattr(&info.attribute_string(HOST_PATH_ATTRIBUTE)?) +} + +/// GIO escapes every xattr byte outside printable ASCII, and the +/// backslash, as `\xNN`. +fn host_path_from_xattr(escaped: &str) -> Option { + let mut bytes = Vec::with_capacity(escaped.len()); + let mut rest = escaped.as_bytes(); + while let Some((&byte, tail)) = rest.split_first() { + match (byte, tail) { + (b'\\', [b'x', high, low, after @ ..]) => { + let hex = [*high, *low]; + let hex = std::str::from_utf8(&hex).ok()?; + bytes.push(u8::from_str_radix(hex, 16).ok()?); + rest = after; + } + _ => { + bytes.push(byte); + rest = tail; + } + } + } + while bytes.last() == Some(&0) { + bytes.pop(); + } + let path = PathBuf::from(OsString::from_vec(bytes)); + path.is_absolute().then_some(path) +} + +/// A path under the document portal's mount, whose folder is an id +/// that means nothing to the user. +pub fn is_portal_path(path: &Path) -> bool { + if let Ok(rest) = path.strip_prefix("/run/user") { + let mut parts = rest.iter(); + let uid = parts.next().and_then(|uid| uid.to_str()); + return uid.is_some_and(|uid| !uid.is_empty() && uid.bytes().all(|byte| byte.is_ascii_digit())) + && parts.next().is_some_and(|doc| doc == "doc") + && parts.next().is_some(); + } + path.strip_prefix("/run/flatpak/doc") + .is_ok_and(|rest| rest.iter().next().is_some()) +} + +/// How a database path reads to the user: the file a portal stands for +/// when it is known, the name alone for any other portal path, and the +/// path itself otherwise. +pub fn display_path(path: &Path, host_path: Option<&Path>) -> String { + if let Some(host) = host_path { + return host.display().to_string(); + } + match path.file_name() { + Some(name) if is_portal_path(path) => name.to_string_lossy().into_owned(), + _ => path.display().to_string(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn remote(fs_type: &str) -> FileLocationKind { + FileLocationKind::Remote { + fs_type: fs_type.to_owned(), + } + } + + fn unknown(fs_type: &str) -> FileLocationKind { + FileLocationKind::Unknown { + fs_type: fs_type.to_owned(), + } + } + + #[test] + fn fs_type_table() { + let portal = FileLocationKind::Portal { + host_path: None, + host_is_local: false, + }; + let cases = [ + ("nfs4", remote("nfs4")), + ("smb3", remote("smb3")), + ("cifs", remote("cifs")), + ("9p", remote("9p")), + ("fuse.sshfs", unknown("fuse.sshfs")), + ("fuse.gvfsd-fuse", unknown("fuse.gvfsd-fuse")), + ("fuse", unknown("fuse")), + ("fuseblk", FileLocationKind::Local), + ("fuse.portal", portal), + ("ext4", FileLocationKind::Local), + ("btrfs", FileLocationKind::Local), + ]; + + for (fs_type, expected) in cases { + assert_eq!(kind_for_fs_type(fs_type), expected, "{fs_type}"); + } + } + + #[test] + fn portal_without_host_path_requires_prompt() { + let kind = FileLocationKind::portal(None, |_| true, |_| FileLocationKind::Local); + + assert!(kind.requires_read_only_prompt()); + } + + #[test] + fn portal_with_invisible_host_path_requires_prompt() { + let kind = FileLocationKind::portal( + Some(PathBuf::from("/srv/data/app.db")), + |_| false, + |_| FileLocationKind::Local, + ); + + assert!(kind.requires_read_only_prompt()); + assert!(matches!( + kind, + FileLocationKind::Portal { host_path: Some(host), .. } if host == Path::new("/srv/data/app.db") + )); + } + + #[test] + fn portal_with_local_host_path_skips_prompt() { + let host = PathBuf::from("/home/me/app.db"); + let seen = host.clone(); + + let kind = FileLocationKind::portal(Some(host), |path| path == seen, |_| FileLocationKind::Local); + + assert!(!kind.requires_read_only_prompt()); + } + + #[test] + fn portal_with_host_on_a_share_or_another_portal_requires_prompt() { + let on_share = FileLocationKind::portal(Some(PathBuf::from("/mnt/share/app.db")), |_| true, |_| remote("nfs")); + let nested = FileLocationKind::portal( + Some(PathBuf::from("/run/user/1000/doc/1a2b/app.db")), + |_| true, + |_| kind_for_fs_type("fuse.portal"), + ); + + assert!(on_share.requires_read_only_prompt()); + assert!(nested.requires_read_only_prompt()); + } + + #[test] + fn only_a_local_file_opens_without_asking() { + assert!(!FileLocationKind::Local.requires_read_only_prompt()); + assert!(remote("nfs").requires_read_only_prompt()); + assert!(unknown("fuse.sshfs").requires_read_only_prompt()); + } + + #[test] + fn tempdir_is_local() { + let root = tempfile::tempdir().expect("tempdir"); + let database = root.path().join("app.db"); + std::fs::write(&database, b"").expect("seed"); + + assert_eq!(classify_blocking(&database), FileLocationKind::Local); + assert_eq!( + classify_blocking(&root.path().join("not-created-yet.db")), + FileLocationKind::Local + ); + assert_eq!(portal_host_path_blocking(&database), None); + } + + #[test] + fn a_link_is_classified_by_the_mount_of_the_file_it_points_to() { + let share = tempfile::tempdir().expect("share"); + let home = tempfile::tempdir().expect("home"); + let share_root = std::fs::canonicalize(share.path()).expect("canonical share"); + let target = share_root.join("app.db"); + std::fs::write(&target, b"").expect("seed"); + let link = home.path().join("app.db"); + std::os::unix::fs::symlink(&target, &link).expect("symlink"); + let on_share = |path: &Path| { + if path.starts_with(&share_root) { + remote("nfs4") + } else { + FileLocationKind::Local + } + }; + + assert_eq!(classify_with(&link, on_share), remote("nfs4")); + assert_eq!(classify_with(&share_root.join("new.db"), on_share), remote("nfs4")); + assert_eq!( + classify_with(&home.path().join("new.db"), on_share), + FileLocationKind::Local + ); + } + + #[test] + fn the_host_path_comes_back_unescaped() { + assert_eq!( + host_path_from_xattr("/home/me/T\\xc3\\xa0i li\\xe1\\xbb\\x87u/app.db\\x00"), + Some(PathBuf::from("/home/me/Tài liệu/app.db")) + ); + assert_eq!( + host_path_from_xattr("/srv/a\\x5cb.db"), + Some(PathBuf::from("/srv/a\\b.db")) + ); + assert_eq!(host_path_from_xattr("relative.db"), None); + assert_eq!(host_path_from_xattr("/srv/\\xzz.db"), None); + } + + #[test] + fn portal_paths_are_told_apart() { + assert!(is_portal_path(Path::new("/run/user/1000/doc/1a2b3c/app.db"))); + assert!(is_portal_path(Path::new("/run/flatpak/doc/1a2b3c/app.db"))); + assert!(!is_portal_path(Path::new("/run/user/1000/doc"))); + assert!(!is_portal_path(Path::new("/run/user/me/doc/1a2b3c/app.db"))); + assert!(!is_portal_path(Path::new("/home/me/doc/app.db"))); + } + + #[test] + fn a_portal_path_reads_as_its_host_path_or_its_name() { + let portal = Path::new("/run/user/1000/doc/1a2b3c/app.db"); + + assert_eq!( + display_path(portal, Some(Path::new("/srv/data/app.db"))), + "/srv/data/app.db" + ); + assert_eq!(display_path(portal, None), "app.db"); + assert_eq!(display_path(Path::new("/home/me/app.db"), None), "/home/me/app.db"); + } +} diff --git a/linux/crates/app/src/services/file_location_kind.rs b/linux/crates/app/src/services/file_location_kind.rs new file mode 100644 index 0000000000..e4127f7c0d --- /dev/null +++ b/linux/crates/app/src/services/file_location_kind.rs @@ -0,0 +1,52 @@ +use std::path::{Path, PathBuf}; + +/// The filesystem a database file is on, as far as SQLite's locking +/// can trust it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum FileLocationKind { + Local, + /// A network filesystem, whose locks SQLite cannot rely on. + Remote { + fs_type: String, + }, + /// A file the document portal hands over. `host_is_local` is true + /// only when the file behind it is visible here and on a local + /// filesystem. + Portal { + host_path: Option, + host_is_local: bool, + }, + /// A FUSE filesystem whose locking is not known. + Unknown { + fs_type: String, + }, +} + +impl FileLocationKind { + /// A portal file, checked through `visible` and `classify` for the + /// file it stands for. `classify` looks one level deep: a host path + /// that is itself behind a portal never counts as local. + pub fn portal( + host_path: Option, + visible: impl FnOnce(&Path) -> bool, + classify: impl FnOnce(&Path) -> FileLocationKind, + ) -> Self { + let host_is_local = host_path + .as_deref() + .is_some_and(|host| visible(host) && classify(host) == Self::Local); + Self::Portal { + host_path, + host_is_local, + } + } + + /// SQLite locking through the portal's FUSE layer is not + /// established, and the host file may be on a network share. + pub fn requires_read_only_prompt(&self) -> bool { + match self { + Self::Local => false, + Self::Remote { .. } | Self::Unknown { .. } => true, + Self::Portal { host_is_local, .. } => !host_is_local, + } + } +} diff --git a/linux/crates/app/src/services/file_resolution.rs b/linux/crates/app/src/services/file_resolution.rs new file mode 100644 index 0000000000..2a170600be --- /dev/null +++ b/linux/crates/app/src/services/file_resolution.rs @@ -0,0 +1,173 @@ +//! A database file handed to TablePro, resolved to the session it +//! opens. Resolving reads the disk, so it runs on the blocking pool. + +use std::path::{Path, PathBuf}; + +use tablepro_core::DriverRegistry; +use tablepro_session::SessionKey; +use tablepro_session::registry::FileIdentity; +use tablepro_storage::{ConnectionStore, SavedConnection}; +use thiserror::Error; + +/// Why a file could not be opened before any connect was tried. +#[derive(Debug, Error)] +pub enum FileResolveError { + /// A file with no local path, such as one on a remote share GIO + /// reaches without mounting it. + #[error("the file has no local path")] + NotLocal, + #[error("the file could not be found: {0}")] + NotFound(#[source] std::io::Error), + /// The background work that resolves it did not finish. + #[error("the file could not be resolved: {0}")] + Interrupted(#[source] tablepro_session::runtime::TaskFailure), +} + +/// What a file turned out to open. +#[derive(Debug)] +pub enum FileTarget { + /// The user saved this file as a connection, so that connection + /// opens, with its name, colour and saved tabs. + Saved(Box), + File(FileIdentity), +} + +/// A file the user opened and what it resolved to. +#[derive(Debug)] +pub struct ResolvedFile { + /// The URI it arrived as, for a Retry to resolve again. + pub uri: String, + pub target: Result, +} + +/// The file's canonical path, so a symlink and its target, or two +/// spellings of one path, open one session. The URI is the canonical +/// path's, which is what the session's key and saved tabs go by. +pub fn resolve_file_identity_blocking(path: Option<&Path>, uri: &str) -> Result { + let path = path.ok_or(FileResolveError::NotLocal)?; + let canonical = std::fs::canonicalize(path).map_err(FileResolveError::NotFound)?; + let uri = glib::filename_to_uri(&canonical, None).map_or_else(|_| uri.to_owned(), |uri| uri.to_string()); + Ok(FileIdentity::new(Some(canonical), uri)) +} + +/// Resolve the file, then find whether a saved connection opens the +/// same one. +pub fn resolve_file_blocking( + path: Option, + uri: String, + connections: &ConnectionStore, + drivers: &DriverRegistry, +) -> ResolvedFile { + let target = resolve_file_identity_blocking(path.as_deref(), &uri).map(|identity| { + let saved = match connections.load_blocking() { + Ok(saved) => saved.to_vec(), + Err(error) => { + tracing::warn!(%error, "the saved connections could not be read to match a file against"); + Vec::new() + } + }; + let is_file_driver = |id: &str| { + drivers + .get(id) + .is_some_and(|driver| driver.capabilities().is_file_based()) + }; + match SessionKey::for_file(&identity, &saved, is_file_driver) { + SessionKey::Saved(id) => match saved.into_iter().find(|connection| connection.id == id) { + Some(connection) => FileTarget::Saved(Box::new(connection)), + None => FileTarget::File(identity), + }, + SessionKey::File(_) => FileTarget::File(identity), + } + }); + ResolvedFile { uri, target } +} + +#[cfg(test)] +mod tests { + use tablepro_test_support::{ConnectionScript, FakeDriver}; + + use super::*; + + #[test] + fn a_missing_file_is_not_found() { + let root = tempfile::tempdir().expect("tempdir"); + let missing = root.path().join("gone.db"); + + let resolved = resolve_file_identity_blocking(Some(&missing), "file:///gone.db"); + + assert!(matches!(resolved, Err(FileResolveError::NotFound(_))), "{resolved:?}"); + assert!(matches!( + resolve_file_identity_blocking(None, "sftp://host/app.db"), + Err(FileResolveError::NotLocal) + )); + } + + fn drivers(capabilities: &'static tablepro_core::DriverCapabilities) -> DriverRegistry { + let mut drivers = DriverRegistry::new(); + drivers.register(std::sync::Arc::new(FakeDriver::connecting( + capabilities, + ConnectionScript::default(), + ))); + drivers + } + + /// `database` saved as a connection of the fake driver, and what + /// opening the file then resolves to. + fn resolve_saved( + database: &str, + capabilities: &'static tablepro_core::DriverCapabilities, + ) -> (uuid::Uuid, ResolvedFile) { + let root = tempfile::tempdir().expect("tempdir"); + let path = std::fs::canonicalize(root.path()).expect("canonical").join(database); + std::fs::write(&path, b"").expect("seed"); + let store = ConnectionStore::new(&tablepro_storage::StoragePaths::under( + root.path(), + "tablepro-test", + "app.tablepro.Test", + )); + let saved = SavedConnection { + id: uuid::Uuid::new_v4(), + name: "local".to_owned(), + driver_id: "fake".to_owned(), + host: String::new(), + port: 0, + database: path.display().to_string(), + username: String::new(), + tls: tablepro_core::TlsConfig::disabled(), + read_only: false, + auth_mode: tablepro_core::AuthMode::Password, + ssh: None, + last_opened_at: None, + color: None, + group: None, + }; + store.upsert_blocking(saved.clone()).expect("saved"); + let uri = format!("file://{}", path.display()); + + ( + saved.id, + resolve_file_blocking(Some(path), uri, &store, &drivers(capabilities)), + ) + } + + #[test] + fn a_saved_file_opens_its_saved_connection() { + let (id, resolved) = resolve_saved("app.db", &FakeDriver::FILE_CAPABILITIES); + + match resolved.target { + Ok(FileTarget::Saved(connection)) => assert_eq!(connection.id, id), + other => panic!("the saved connection was not found: {other:?}"), + } + } + + #[test] + fn a_server_whose_database_names_the_file_is_not_opened() { + let (_, resolved) = resolve_saved("shop", &FakeDriver::NETWORK_CAPABILITIES); + + assert!( + matches!(resolved.target, Ok(FileTarget::File(_))), + "{:?}", + resolved.target + ); + } +} diff --git a/linux/crates/app/src/services/history_availability.rs b/linux/crates/app/src/services/history_availability.rs new file mode 100644 index 0000000000..3d9b83254e --- /dev/null +++ b/linux/crates/app/src/services/history_availability.rs @@ -0,0 +1,98 @@ +use std::io::ErrorKind; + +use tablepro_storage::StorageError; + +/// Whether the query history is usable yet. +/// +/// The database opens in the background, so the menu item that needs it +/// stays disabled until it is `Ready` instead of opening a dialog that +/// cannot search. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum HistoryAvailability { + Starting, + Ready, + /// The database could not be opened, and why, as the user is told. + Failed(String), +} + +impl HistoryAvailability { + pub fn failed(error: &StorageError) -> Self { + Self::Failed(open_failure_text(error)) + } + + pub fn is_ready(&self) -> bool { + matches!(self, Self::Ready) + } + + /// Done opening, one way or the other. + pub fn is_settled(&self) -> bool { + !matches!(self, Self::Starting) + } + + pub fn failure(&self) -> Option<&str> { + match self { + Self::Failed(detail) => Some(detail), + _ => None, + } + } +} + +/// Query history and saved queries share the one database, so this +/// names both. +fn open_failure_text(error: &StorageError) -> String { + match error { + StorageError::HistoryNewerThanApp { .. } => crate::i18n::gettext( + "The query history was written by a newer version of TablePro. Update TablePro to read it.", + ), + StorageError::HistoryMigration(_) => { + crate::i18n::gettext("The query history could not be updated for this version of TablePro.") + } + StorageError::Io { path, source } | StorageError::Permissions { path, source } => { + let path = path.display().to_string(); + match source.kind() { + ErrorKind::PermissionDenied | ErrorKind::ReadOnlyFilesystem => { + crate::i18n::gettext_f("TablePro is not allowed to write {path}.", &[("path", &path)]) + } + ErrorKind::StorageFull | ErrorKind::QuotaExceeded => { + crate::i18n::gettext_f("The disk that holds {path} is full.", &[("path", &path)]) + } + _ => crate::i18n::gettext_f("{path} could not be created.", &[("path", &path)]), + } + } + _ => crate::i18n::gettext("The query history file could not be read. It may be damaged."), + } +} + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use super::*; + + fn io(kind: ErrorKind) -> StorageError { + StorageError::Io { + path: PathBuf::from("/home/ada/.local/state/tablepro/history.db"), + source: std::io::Error::from(kind), + } + } + + #[test] + fn the_reason_says_what_to_fix_without_the_raw_error() { + assert_eq!( + HistoryAvailability::failed(&io(ErrorKind::PermissionDenied)).failure(), + Some("TablePro is not allowed to write /home/ada/.local/state/tablepro/history.db.") + ); + assert_eq!( + HistoryAvailability::failed(&io(ErrorKind::StorageFull)).failure(), + Some("The disk that holds /home/ada/.local/state/tablepro/history.db is full.") + ); + assert_eq!( + HistoryAvailability::failed(&StorageError::HistoryNewerThanApp { version: 9 }).failure(), + Some("The query history was written by a newer version of TablePro. Update TablePro to read it.") + ); + assert_eq!( + HistoryAvailability::failed(&StorageError::NotInitialised).failure(), + Some("The query history file could not be read. It may be damaged.") + ); + } +} diff --git a/linux/crates/app/src/services/history_preferences.rs b/linux/crates/app/src/services/history_preferences.rs new file mode 100644 index 0000000000..77fd4cd8df --- /dev/null +++ b/linux/crates/app/src/services/history_preferences.rs @@ -0,0 +1,65 @@ +use std::rc::Rc; + +use gio::prelude::SettingsExt; +use tablepro_storage::AppSettings; +use tablepro_storage::settings::keys; +use tokio::sync::watch; + +/// The history preferences, mirrored where the history's worker and the +/// tasks that record can read them. +/// +/// `gio::Settings` lives on the GTK thread, so neither can hold it. Each +/// channel keeps the newest value, which is the one a prune or a record +/// must go by. +#[derive(Debug, Clone)] +pub struct HistoryPreferences { + pub retention_days: watch::Receiver, + pub save: watch::Receiver, +} + +impl HistoryPreferences { + pub fn follow(settings: &Rc) -> Self { + Self { + retention_days: mirror( + settings, + keys::HISTORY_RETENTION_DAYS, + AppSettings::history_retention_days, + ), + save: mirror(settings, keys::SAVE_QUERY_HISTORY, AppSettings::save_query_history), + } + } +} + +fn mirror( + settings: &Rc, + key: &str, + read: fn(&AppSettings) -> T, +) -> watch::Receiver { + let (sender, receiver) = watch::channel(read(settings)); + let settings_for_change = settings.clone(); + settings.gio().connect_changed(Some(key), move |_, _| { + sender.send_replace(read(&settings_for_change)); + }); + receiver +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_support::MemorySettings; + + /// On the GTK thread, which owns the main context GSettings sends + /// its change signals through. + #[gtk4::test] + fn a_changed_preference_reaches_the_channel() { + let settings = MemorySettings::new(); + let preferences = HistoryPreferences::follow(settings.get()); + assert!(*preferences.save.borrow()); + + settings.get().set_save_query_history(false).expect("store the flag"); + settings.get().set_history_retention_days(7).expect("store the days"); + + assert!(!*preferences.save.borrow()); + assert_eq!(*preferences.retention_days.borrow(), 7); + } +} diff --git a/linux/crates/app/src/services/history_service.rs b/linux/crates/app/src/services/history_service.rs new file mode 100644 index 0000000000..191d356e40 --- /dev/null +++ b/linux/crates/app/src/services/history_service.rs @@ -0,0 +1,403 @@ +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use tablepro_core::dialect::SqlDialect; +use tablepro_core::sql_syntax::HistoryRecording; +use tablepro_session::runtime::Tasks; +use tablepro_storage::query_history::NewEntry; +use tablepro_storage::{QueryHistory, StoragePaths}; +use tokio::sync::watch; + +use super::history_availability::HistoryAvailability; +use super::history_preferences::HistoryPreferences; + +/// How often unpinned history is pruned while the app runs. Startup does +/// one pass, and this keeps a long session from growing without bound. +const PRUNE_INTERVAL: Duration = Duration::from_secs(3600); + +/// Opens the query history off the GTK thread, prunes it on a timer and +/// decides what goes into it. +/// +/// Opening runs a migration and a prune, which is too slow to block +/// startup on, so the app starts and this reports when the database is +/// usable. +#[derive(Clone)] +pub struct HistoryService { + availability: watch::Receiver, + store: Arc>>, + save: watch::Receiver, + _worker: Arc>, +} + +impl HistoryService { + /// `retention` is read at every tick rather than captured, so a + /// value the user typed through on the way to another one never + /// decides what is deleted an hour later. + pub fn start(paths: StoragePaths, preferences: HistoryPreferences, tasks: &Tasks) -> Self { + let HistoryPreferences { + retention_days: retention, + save, + } = preferences; + let (sender, availability) = watch::channel(HistoryAvailability::Starting); + let store: Arc>> = Arc::new(Mutex::new(None)); + let store_for_worker = store.clone(); + + let worker = tasks.spawn_owned(async move { + let history = match QueryHistory::open(&paths).await { + Ok(history) => history, + Err(error) => { + tracing::warn!(%error, "query history unavailable"); + sender.send_replace(HistoryAvailability::failed(&error)); + return; + } + }; + prune_now(&history, &retention).await; + if let Ok(mut guard) = store_for_worker.lock() { + *guard = Some(history.clone()); + } + sender.send_replace(HistoryAvailability::Ready); + + let mut ticker = tokio::time::interval(PRUNE_INTERVAL); + // The first tick fires immediately, and the startup prune + // just ran. + ticker.tick().await; + loop { + ticker.tick().await; + prune_now(&history, &retention).await; + } + }); + + Self { + availability, + store, + save, + _worker: Arc::new(worker), + } + } + + /// Record one run once the database is open, unless the user turned + /// the history off or the SQL can carry a credential. + /// + /// `dialect` is the connection's: without it nobody can say which + /// statements hold a secret, so nothing is recorded. The setting is + /// read now, so a run that ends after the history was turned off is + /// not recorded either. + pub fn record( + &self, + entry: NewEntry, + dialect: Option>, + ) -> impl Future + Send + use<> { + let saving = *self.save.borrow(); + let store = self.ready_store(); + async move { + if !saving { + return; + } + let Some(dialect) = dialect else { + tracing::debug!(connection = %entry.connection_id, "no dialect, so the run is not recorded"); + return; + }; + if let HistoryRecording::Withhold(reason) = dialect.history_recording(&entry.query) { + tracing::debug!(connection = %entry.connection_id, ?reason, "a run was kept out of the history"); + return; + } + let Some(history) = store.await else { + return; + }; + if let Err(error) = history.record(entry).await { + tracing::warn!(%error, "history record failed"); + } + } + } + + pub fn availability(&self) -> watch::Receiver { + self.availability.clone() + } + + /// The database, once it opened. `None` while starting or after a + /// failure, so callers cannot use a half-open handle. + pub fn store(&self) -> Option { + self.store.lock().ok().and_then(|guard| guard.clone()) + } + + /// The database once the open at startup has finished, or `None` + /// when it failed, for work that must not skip the history just + /// because it ran early. + pub fn ready_store(&self) -> impl Future> + use<> { + let mut availability = self.availability.clone(); + let store = self.store.clone(); + async move { + // A sender gone means the worker ended, which it does only + // after it has said how the open went. + let _ = availability.wait_for(HistoryAvailability::is_settled).await; + store.lock().ok().and_then(|guard| guard.clone()) + } + } +} + +/// One prune at the retention the user has set right now. +/// +/// Reading the channel here rather than capturing a value is what keeps +/// a number typed through on the way to another one from deciding, an +/// hour later, what gets deleted. +async fn prune_now(history: &QueryHistory, retention: &watch::Receiver) { + // The guard cannot be held across the await below. + let days = *retention.borrow(); + prune(history, days).await; +} + +async fn prune(history: &QueryHistory, retention_days: u32) { + match history.prune(retention_days).await { + Ok(report) if report.removed_anything() => { + tracing::info!( + expired = report.expired, + over_cap = report.over_cap, + "pruned the query history" + ); + } + Ok(_) => {} + Err(error) => tracing::warn!(%error, "history prune failed"), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_support::paused_tasks; + + /// Preferences that stay as given: a dropped sender leaves the + /// receiver on its last value. + fn preferences(days: u32, save: bool) -> HistoryPreferences { + HistoryPreferences { + retention_days: watch::channel(days).1, + save: watch::channel(save).1, + } + } + + fn paths(root: &tempfile::TempDir) -> StoragePaths { + StoragePaths::under(root.path(), "tablepro-test", "app.tablepro.TablePro.Devel") + } + + async fn wait_for_ready(service: &HistoryService) -> HistoryAvailability { + let mut availability = service.availability(); + loop { + if !matches!(*availability.borrow_and_update(), HistoryAvailability::Starting) { + return availability.borrow().clone(); + } + availability.changed().await.expect("the sender outlives the service"); + } + } + + #[tokio::test] + async fn publishes_ready_after_open() { + let root = tempfile::tempdir().expect("tempdir"); + let tasks = paused_tasks(); + + let service = HistoryService::start(paths(&root), preferences(30, true), &tasks); + let state = wait_for_ready(&service).await; + + assert_eq!(state, HistoryAvailability::Ready); + assert!(service.store().is_some()); + } + + #[tokio::test] + async fn ready_store_waits_for_the_open() { + let root = tempfile::tempdir().expect("tempdir"); + + let service = HistoryService::start(paths(&root), preferences(30, true), &paused_tasks()); + assert!(service.store().is_none()); + + assert!(service.ready_store().await.is_some()); + } + + #[tokio::test] + async fn store_is_none_until_ready() { + let root = tempfile::tempdir().expect("tempdir"); + let tasks = paused_tasks(); + + let service = HistoryService::start(paths(&root), preferences(30, true), &tasks); + + // Nothing has been polled yet, so the worker cannot have run. + assert!(service.store().is_none()); + wait_for_ready(&service).await; + assert!(service.store().is_some()); + } + + /// One entry `age_days` old, unpinned unless asked. + async fn seed(history: &QueryHistory, query: &str, age_days: u64, pinned: bool) { + let id = history + .record(tablepro_storage::query_history::NewEntry { + query: query.to_owned(), + driver_id: "postgres".to_owned(), + connection_id: uuid::Uuid::new_v4(), + connection_name: "local".to_owned(), + executed_at: std::time::SystemTime::now() - Duration::from_secs(age_days * 86_400), + duration_ms: Some(1), + rows_affected: Some(0), + outcome: tablepro_storage::query_history::Outcome::Success, + }) + .await + .expect("record"); + if pinned { + history.set_pinned(id, true).await.expect("pin"); + } + } + + async fn queries(history: &QueryHistory) -> Vec { + history + .search_page( + &tablepro_storage::query_history::HistoryQuery::default(), + tablepro_storage::query_history::HistoryPageRequest::first(100), + ) + .await + .expect("search") + .entries + .into_iter() + .map(|entry| entry.query) + .collect() + } + + /// A service whose store is open, with its retention channel. + async fn ready_service(root: &tempfile::TempDir, days: u32) -> (QueryHistory, watch::Sender) { + let (retention, receiver) = watch::channel(days); + let preferences = HistoryPreferences { + retention_days: receiver, + save: watch::channel(true).1, + }; + let service = HistoryService::start(paths(root), preferences, &paused_tasks()); + let state = wait_for_ready(&service).await; + assert_eq!(state, HistoryAvailability::Ready, "the history did not open"); + (service.store().expect("the store"), retention) + } + + #[tokio::test] + async fn scheduled_prune_uses_current_setting() { + let root = tempfile::tempdir().expect("tempdir"); + let (history, retention) = ready_service(&root, 3_650).await; + seed(&history, "SELECT old", 40, false).await; + seed(&history, "SELECT pinned", 40, true).await; + seed(&history, "SELECT recent", 1, false).await; + // Set after the service started, so a captured value would + // still be the 3650 it was built with. + retention.send_replace(30); + + prune_now(&history, &retention.subscribe()).await; + + let left = queries(&history).await; + assert!(!left.contains(&"SELECT old".to_owned()), "{left:?}"); + assert!( + left.contains(&"SELECT pinned".to_owned()), + "a pinned entry was pruned: {left:?}" + ); + assert!(left.contains(&"SELECT recent".to_owned()), "{left:?}"); + } + + #[tokio::test] + async fn transient_retention_value_does_not_prune() { + let root = tempfile::tempdir().expect("tempdir"); + let (history, retention) = ready_service(&root, 30).await; + seed(&history, "SELECT kept", 20, false).await; + + // The spin row writes every value it passes through, so a user + // on their way from 30 to 1000 types a 1 first. Only the value + // at the prune may decide anything. + retention.send_replace(1); + retention.send_replace(30); + prune_now(&history, &retention.subscribe()).await; + + assert_eq!( + queries(&history).await, + vec!["SELECT kept".to_owned()], + "a value typed through pruned" + ); + } + + #[tokio::test] + async fn publishes_failed_for_an_unusable_state_dir() { + let root = tempfile::tempdir().expect("tempdir"); + let paths = paths(&root); + // A file where the state directory should be: the database + // cannot be created under it. + std::fs::create_dir_all(paths.state.parent().expect("parent")).expect("create"); + std::fs::write(&paths.state, b"not a directory").expect("seed"); + let tasks = paused_tasks(); + + let service = HistoryService::start(paths, preferences(30, true), &tasks); + let state = wait_for_ready(&service).await; + + assert!(state.failure().is_some(), "{state:?}"); + assert!(service.store().is_none()); + } + + fn run_of(query: &str) -> NewEntry { + NewEntry { + query: query.to_owned(), + driver_id: "postgres".to_owned(), + connection_id: uuid::Uuid::new_v4(), + connection_name: "local".to_owned(), + executed_at: std::time::SystemTime::now(), + duration_ms: Some(1), + rows_affected: None, + outcome: tablepro_storage::query_history::Outcome::Success, + } + } + + fn fake_dialect() -> Option> { + Some(Arc::new(tablepro_test_support::FakeDialect)) + } + + /// Record `query` through a service built with `save`, and read back + /// what the store holds. + async fn recorded(save: bool, query: &str, dialect: Option>) -> Vec { + let root = tempfile::tempdir().expect("tempdir"); + let service = HistoryService::start(paths(&root), preferences(30, save), &paused_tasks()); + + service.record(run_of(query), dialect).await; + + let history = service.ready_store().await.expect("the store"); + queries(&history).await + } + + #[tokio::test] + async fn history_service_skips_when_setting_off() { + assert_eq!(recorded(false, "SELECT 1", fake_dialect()).await, Vec::::new()); + } + + #[tokio::test] + async fn history_service_skips_without_dialect() { + assert_eq!(recorded(true, "SELECT 1", None).await, Vec::::new()); + } + + #[tokio::test] + async fn history_service_skips_credential_statement() { + assert_eq!( + recorded(true, "SELECT 1; CREATE USER u PASSWORD 'p'", fake_dialect()).await, + Vec::::new() + ); + } + + #[tokio::test] + async fn history_service_records_plain_select_unchanged() { + let query = "SELECT id FROM users WHERE password IS NULL AND email = 'a@b' -- kept as typed\n"; + + assert_eq!(recorded(true, query, fake_dialect()).await, vec![query.to_owned()]); + } + + #[tokio::test] + async fn turning_the_history_off_stops_the_next_record() { + let root = tempfile::tempdir().expect("tempdir"); + let (save, receiver) = watch::channel(true); + let preferences = HistoryPreferences { + retention_days: watch::channel(30).1, + save: receiver, + }; + let service = HistoryService::start(paths(&root), preferences, &paused_tasks()); + service.record(run_of("SELECT 'kept'"), fake_dialect()).await; + + save.send_replace(false); + service.record(run_of("SELECT 'dropped'"), fake_dialect()).await; + + let history = service.ready_store().await.expect("the store"); + assert_eq!(queries(&history).await, vec!["SELECT 'kept'".to_owned()]); + } +} diff --git a/linux/crates/app/src/services/mod.rs b/linux/crates/app/src/services/mod.rs new file mode 100644 index 0000000000..c34dc3f171 --- /dev/null +++ b/linux/crates/app/src/services/mod.rs @@ -0,0 +1,39 @@ +pub mod active_session; +pub mod cell_overlay; +pub mod change_origins; +pub mod change_tracker; +pub mod change_tracker_registry; +pub mod column_relayout; +pub mod connection_deletion; +pub mod deletion_error; +pub mod deletion_report; +pub mod duplicate_name; +pub mod file_location; +mod file_location_kind; +pub mod file_resolution; +pub mod history_availability; +pub mod history_preferences; +pub mod history_service; +pub mod pending_structure; +pub mod save_batch; +pub mod saved_connection_editor; +pub mod saved_connection_error; +pub mod saved_connection_list; +pub mod saved_connection_object; +mod saved_connection_object_imp; +pub mod saved_connection_order; +pub mod secret_change; +pub mod secret_changes; +pub mod secret_labels; +pub mod secret_save_report; +pub mod session_context; +pub mod ssh_environment; +pub mod structure_tracker; +pub mod structure_tracker_handle; +pub mod structure_tracker_registry; +pub mod tab_registry; +pub mod tab_tracker_handle; +pub mod touch; +pub mod tracker_delta; +pub mod tracker_edit; +pub mod undo_op; diff --git a/linux/crates/app/src/services/pending_structure.rs b/linux/crates/app/src/services/pending_structure.rs new file mode 100644 index 0000000000..1cb9e74db0 --- /dev/null +++ b/linux/crates/app/src/services/pending_structure.rs @@ -0,0 +1,508 @@ +use tablepro_core::ddl::{ + BuildDdlError, ChangeReview, DdlDialect, DdlOperation, DdlPlan, DraftProblem, TableChanges, TableDefinition, + TableDraft, +}; +use tablepro_core::meta::TableRef; + +/// What a Structure tab would save: the table as it was read, the +/// user's draft of it and the changes between the two. A new table has +/// no original. +#[derive(Debug, Clone, PartialEq)] +pub struct PendingStructure { + pub original: Option, + pub draft: TableDraft, + pub changes: TableChanges, + /// Columns whose typed default the engine's parser refused. The + /// draft still holds the default from before, so saving it would + /// drop what the user typed without a word. + pub unparsed_defaults: Vec, +} + +/// Why a structure cannot be saved as it stands. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SaveBlocker { + Draft(DraftProblem), + UnparsedDefault(String), +} + +impl PendingStructure { + /// Every save goes through this, the close prompts' included, which + /// never pass the tab's own Save button. + pub fn check(&self) -> Result<(), SaveBlocker> { + self.draft.validate().map_err(SaveBlocker::Draft)?; + match self.unparsed_defaults.first() { + Some(column) => Err(SaveBlocker::UnparsedDefault(column.clone())), + None => Ok(()), + } + } + + /// A default the parser refused is still the user's edit: the draft + /// cannot hold it, so without counting it here a close would drop it + /// without asking. + pub fn has_changes(&self) -> bool { + let drafted = match self.original { + Some(_) => !self.changes.is_empty(), + None => !self.draft.columns.is_empty(), + }; + drafted || !self.unparsed_defaults.is_empty() + } + + /// The edits the action bar counts. A new table is one. + pub fn change_count(&self) -> usize { + match self.original { + Some(_) => self.changes.change_count() + self.unparsed_defaults.len(), + None => usize::from(!self.draft.columns.is_empty() || !self.unparsed_defaults.is_empty()), + } + } + + /// Planned with the dialect of the connection that will run it, so + /// the SQL is spelled for that server. + pub fn plan(&self, ddl: &dyn DdlDialect) -> Result { + match &self.original { + Some(original) => { + self.draft.validate()?; + ddl.plan_alter_table(original, &self.changes) + } + None => ddl.plan_create_table(&self.draft), + } + } + + /// What the save would run and lose, planned as `plan` plans it. + pub fn review(&self, ddl: &dyn DdlDialect) -> Result { + let plan = self.plan(ddl)?; + Ok(match self.original { + Some(_) => ChangeReview::review(&self.changes, &plan), + None => ChangeReview::create(&plan), + }) + } + + /// What a connection without structure editing refuses. + pub fn operation(&self) -> DdlOperation { + match self.original { + Some(_) => DdlOperation::AlterTable, + None => DdlOperation::CreateTable, + } + } + + /// The table the write runs against, named as it is before the save. + pub fn table(&self) -> TableRef { + match &self.original { + Some(original) => original.table.clone(), + None => TableRef::new(self.draft.table.schema.clone(), self.draft.table.name.trim()), + } + } + + /// The name the tab follows once the save lands, when that is not + /// the one it already shows: the table a draft created, or the new + /// name of a renamed one. + pub fn saved_table_name(&self) -> Option { + match &self.original { + Some(_) => self.changes.renamed_table.clone(), + None => Some(self.draft.table.name.trim().to_owned()), + } + } +} + +#[cfg(test)] +mod tests { + use proptest::prelude::*; + use tablepro_core::ServerInfo; + use tablepro_core::column::{ColumnDefault, ColumnInfo}; + use tablepro_core::ddl::{ + ColumnAttributes, ColumnDefinition, DraftColumn, DraftType, PrimaryKeyConstraint, diff_table, + }; + use tablepro_core::dialect::SqlDialect; + + use super::*; + + fn draft() -> TableDraft { + let mut draft = TableDraft::new(TableRef::new(Some("app".to_owned()), " orders ")); + let mut id = DraftColumn::new("id", DraftType::Typed("integer".to_owned())); + id.primary_key = true; + id.nullable = false; + draft.columns.push(id); + draft + } + + fn created() -> PendingStructure { + PendingStructure { + original: None, + draft: draft(), + changes: TableChanges::default(), + unparsed_defaults: Vec::new(), + } + } + + #[test] + fn pending_structure_plans_with_supplied_dialect() { + let pending = created(); + let postgres = tablepro_driver_postgres::PgDialect::new(&ServerInfo::new("PostgreSQL", "18.0", 18, 0, 0)); + let mysql = tablepro_driver_mysql::MySqlDialect::new( + tablepro_driver_mysql::MySqlFlavour::MySql, + &ServerInfo::new("MySQL", "8.4.3", 8, 4, 3), + ); + + let in_postgres = pending.plan(&postgres).expect("the draft plans"); + let in_mysql = pending.plan(&mysql).expect("the draft plans"); + + assert!( + in_postgres.sql()[0].starts_with("CREATE TABLE \"app\".\"orders\""), + "{:?}", + in_postgres.sql() + ); + assert!( + in_mysql.sql()[0].starts_with("CREATE TABLE `app`.`orders`"), + "{:?}", + in_mysql.sql() + ); + assert_eq!(pending.table(), TableRef::new(Some("app".to_owned()), "orders")); + assert_eq!(pending.saved_table_name().as_deref(), Some("orders")); + } + + fn orders() -> TableDefinition { + let dialect = tablepro_driver_postgres::PgDialect::new(&ServerInfo::new("PostgreSQL", "18.0", 18, 0, 0)); + let column = |name: &str, primary_key: bool| { + ColumnDefinition::new( + ColumnInfo { + name: name.to_owned(), + column_type: dialect.parse_type("integer").expect("a PostgreSQL type"), + nullable: !primary_key, + primary_key, + is_auto_increment: false, + is_generated: false, + default: ColumnDefault::None, + comment: None, + }, + ColumnAttributes::default(), + ) + }; + TableDefinition { + table: TableRef::new(Some("app".to_owned()), "orders"), + columns: vec![column("id", true), column("total", false)], + primary_key: None, + indexes: Vec::new(), + foreign_keys: Vec::new(), + inbound_foreign_keys: Vec::new(), + source: None, + } + } + + fn edited(edit: impl FnOnce(&mut TableDraft)) -> PendingStructure { + let original = orders(); + let mut draft = TableDraft::from_definition(&original); + edit(&mut draft); + PendingStructure { + changes: diff_table(&original, &draft), + original: Some(original), + draft, + unparsed_defaults: Vec::new(), + } + } + + #[test] + fn a_save_waits_for_a_valid_draft_and_every_typed_default() { + let postgres = tablepro_driver_postgres::PgDialect::new(&ServerInfo::new("PostgreSQL", "18.0", 18, 0, 0)); + let mut typed_default = edited(|draft| draft.columns[1].nullable = false); + typed_default.unparsed_defaults = vec!["total".to_owned()]; + let unnamed = edited(|draft| draft.columns[1].name = String::new()); + + assert_eq!( + typed_default.check(), + Err(SaveBlocker::UnparsedDefault("total".to_owned())) + ); + assert_eq!(unnamed.check(), Err(SaveBlocker::Draft(DraftProblem::EmptyColumnName))); + assert_eq!( + unnamed.plan(&postgres), + Err(BuildDdlError::Draft(DraftProblem::EmptyColumnName)) + ); + assert_eq!(edited(|draft| draft.columns[1].nullable = false).check(), Ok(())); + assert_eq!(unnamed.operation(), DdlOperation::AlterTable); + assert_eq!(created().operation(), DdlOperation::CreateTable); + } + + #[test] + fn a_default_the_parser_refused_is_a_pending_change() { + let mut typed_default = edited(|_| {}); + typed_default.unparsed_defaults = vec!["total".to_owned()]; + + assert!(!edited(|_| {}).has_changes()); + assert!(typed_default.has_changes()); + assert_eq!(typed_default.change_count(), 1); + } + + #[test] + fn an_edit_counts_its_changes_and_follows_a_rename() { + let original = TableDefinition { + table: TableRef::new(Some("app".to_owned()), "orders"), + columns: Vec::new(), + primary_key: None, + indexes: Vec::new(), + foreign_keys: Vec::new(), + inbound_foreign_keys: Vec::new(), + source: None, + }; + let mut renamed = TableDraft::from_definition(&original); + renamed.table.name = "purchases".to_owned(); + let pending = PendingStructure { + changes: diff_table(&original, &renamed), + original: Some(original.clone()), + draft: renamed, + unparsed_defaults: Vec::new(), + }; + let unchanged = PendingStructure { + changes: TableChanges::default(), + draft: TableDraft::from_definition(&original), + original: Some(original), + unparsed_defaults: Vec::new(), + }; + + assert!(pending.has_changes()); + assert_eq!(pending.change_count(), 1); + assert_eq!(pending.saved_table_name().as_deref(), Some("purchases")); + assert_eq!(pending.table().name, "orders"); + assert!(!unchanged.has_changes()); + assert!( + !PendingStructure { + draft: TableDraft::new(TableRef::unqualified("t")), + ..created() + } + .has_changes() + ); + } + + /// One engine's dialect with the integer and text types its catalogue + /// would report, and the name it gives a primary key. + struct Engine { + name: &'static str, + dialect: Box, + integer: &'static str, + text: &'static str, + key_name: Option<&'static str>, + } + + fn engines() -> Vec { + let server = |name, major| ServerInfo::new(name, format!("{major}.0"), major, 0, 0); + let engine = |name, dialect: Box, integer, text, key_name| Engine { + name, + dialect, + integer, + text, + key_name, + }; + vec![ + engine( + "PostgreSQL", + Box::new(tablepro_driver_postgres::PgDialect::new(&server("PostgreSQL", 18))), + "integer", + "text", + Some("t_pkey"), + ), + engine( + "MySQL", + Box::new(tablepro_driver_mysql::MySqlDialect::new( + tablepro_driver_mysql::MySqlFlavour::MySql, + &ServerInfo::new("MySQL", "8.4.3", 8, 4, 3), + )), + "int", + "varchar(40)", + Some("PRIMARY"), + ), + engine( + "MariaDB", + Box::new(tablepro_driver_mysql::MySqlDialect::new( + tablepro_driver_mysql::MySqlFlavour::MariaDb, + &ServerInfo::new("MariaDB", "11.4.5", 11, 4, 5), + )), + "int", + "varchar(40)", + Some("PRIMARY"), + ), + engine( + "SQL Server", + Box::new(tablepro_driver_mssql::MssqlDialect::new(16)), + "int", + "nvarchar(40)", + Some("PK_t"), + ), + engine( + "SQLite", + Box::new(tablepro_driver_sqlite::SqliteDialect::new( + tablepro_driver_sqlite::SqliteVersion::new(3, 46, 0), + )), + "INTEGER", + "TEXT", + None, + ), + engine( + "ClickHouse", + Box::new(tablepro_driver_clickhouse::ClickHouseDialect::new(&server( + "ClickHouse", + 25, + ))), + "Int32", + "String", + None, + ), + ] + } + + /// `id` keyed, `name`, `score` with a default and `note` with a + /// comment, as the engine's catalogue reads them. + fn table(engine: &Engine) -> TableDefinition { + let column = |name: &str, declaration: &str| { + let column_type = engine.dialect.parse_type(declaration).expect("the engine's own type"); + ColumnDefinition::new( + ColumnInfo { + name: name.to_owned(), + column_type, + nullable: name != "id", + primary_key: name == "id", + is_auto_increment: false, + is_generated: false, + default: ColumnDefault::None, + comment: None, + }, + ColumnAttributes::default(), + ) + }; + let mut score = column("score", engine.integer); + score.info.default = ColumnDefault::Literal(tablepro_core::Value::Int(0)); + score.attributes.default_constraint_name = Some("DF_t_score".to_owned()); + let mut note = column("note", engine.text); + note.info.comment = Some("kept".to_owned()); + TableDefinition { + table: TableRef::new(Some("app".to_owned()), "t"), + columns: vec![column("id", engine.integer), column("name", engine.text), score, note], + primary_key: engine.key_name.map(|name| PrimaryKeyConstraint { + name: Some(name.to_owned()), + columns: vec!["id".to_owned()], + }), + indexes: Vec::new(), + foreign_keys: Vec::new(), + inbound_foreign_keys: Vec::new(), + source: None, + } + } + + /// One edit the Structure tab can make, aimed at a column by + /// position so every edit lands on some column. + #[derive(Debug, Clone)] + enum Edit { + RenameTable, + Rename(usize, usize), + Nullable(usize), + PrimaryKey(usize), + AutoIncrement(usize), + Default(usize, Option), + Retype(usize, bool), + Comment(usize), + Drop(usize), + Add(usize), + } + + const NAMES: [&str; 6] = ["id", "name", "score", "note", "fresh", "Score"]; + + fn edit() -> impl Strategy { + let column = 0usize..8; + let name = 0..NAMES.len(); + prop_oneof![ + Just(Edit::RenameTable), + (column.clone(), name.clone()).prop_map(|(column, name)| Edit::Rename(column, name)), + column.clone().prop_map(Edit::Nullable), + column.clone().prop_map(Edit::PrimaryKey), + column.clone().prop_map(Edit::AutoIncrement), + (column.clone(), proptest::option::of(-2i64..3)).prop_map(|(column, value)| Edit::Default(column, value)), + (column.clone(), any::()).prop_map(|(column, integer)| Edit::Retype(column, integer)), + column.clone().prop_map(Edit::Comment), + column.prop_map(Edit::Drop), + name.prop_map(Edit::Add), + ] + } + + fn column(draft: &mut TableDraft, index: usize) -> Option<&mut DraftColumn> { + let count = draft.columns.len(); + draft.columns.get_mut(index.checked_rem(count)?) + } + + fn apply(draft: &mut TableDraft, edit: &Edit, engine: &Engine) { + match *edit { + Edit::RenameTable => draft.table.name = "renamed".to_owned(), + Edit::Add(name) => draft + .columns + .push(DraftColumn::new(NAMES[name], DraftType::Typed(engine.text.to_owned()))), + Edit::Drop(index) => { + if let Some(index) = index.checked_rem(draft.columns.len()) { + draft.columns.remove(index); + } + } + Edit::Rename(index, name) => { + if let Some(column) = column(draft, index) { + column.name = NAMES[name].to_owned(); + } + } + Edit::Nullable(index) => { + if let Some(column) = column(draft, index) { + column.nullable = !column.nullable; + } + } + Edit::PrimaryKey(index) => { + if let Some(column) = column(draft, index) { + column.primary_key = !column.primary_key; + column.nullable &= !column.primary_key; + } + } + Edit::AutoIncrement(index) => { + if let Some(column) = column(draft, index) { + column.auto_increment = !column.auto_increment; + } + } + Edit::Default(index, value) => { + if let Some(column) = column(draft, index) { + column.default = value.map_or(ColumnDefault::None, |value| { + ColumnDefault::Literal(tablepro_core::Value::Int(value)) + }); + } + } + Edit::Retype(index, integer) => { + if let Some(column) = column(draft, index) { + let declaration = if integer { engine.integer } else { engine.text }; + column.column_type = DraftType::Typed(declaration.to_owned()); + } + } + Edit::Comment(index) => { + if let Some(column) = column(draft, index) { + column.comment = match column.comment { + Some(_) => None, + None => Some("new".to_owned()), + }; + } + } + } + } + + proptest! { + /// A save that changes something either writes a statement or is + /// refused: it never succeeds with the table left as it was. + #[test] + fn non_empty_changes_never_plan_empty(edits in proptest::collection::vec(edit(), 1..6)) { + for engine in engines() { + let original = table(&engine); + let mut draft = TableDraft::from_definition(&original); + for edit in &edits { + apply(&mut draft, edit, &engine); + } + let changes = diff_table(&original, &draft); + if changes.is_empty() { + continue; + } + let ddl = engine.dialect.ddl().expect("every engine plans its DDL"); + let plan = ddl.plan_alter_table(&original, &changes); + prop_assert!( + !matches!(&plan, Ok(plan) if plan.is_empty()), + "{} planned nothing for {changes:?}", + engine.name + ); + } + } + } +} diff --git a/linux/crates/app/src/services/save_batch.rs b/linux/crates/app/src/services/save_batch.rs new file mode 100644 index 0000000000..d33dad42a6 --- /dev/null +++ b/linux/crates/app/src/services/save_batch.rs @@ -0,0 +1,23 @@ +use tablepro_core::ColumnInfo; +use tablepro_core::meta::TableRef; + +use super::change_tracker::TrackedChanges; +use crate::workspace::SaveSnapshot; + +/// The table a tab's rows come from, as the tab last read it. +#[derive(Debug, Clone, PartialEq)] +pub struct SaveTarget { + pub table: TableRef, + pub columns: Vec, +} + +/// What one save of a tab's rows writes. The columns are the ones the +/// changes were built against, so the plan binds the same shape, and +/// `changes` maps a failing step back to its grid row. +#[derive(Debug, Clone)] +pub struct SaveBatch { + pub table: TableRef, + pub columns: Vec, + pub changes: TrackedChanges, + pub snapshot: SaveSnapshot, +} diff --git a/linux/crates/app/src/services/saved_connection_editor.rs b/linux/crates/app/src/services/saved_connection_editor.rs new file mode 100644 index 0000000000..e34d2e9059 --- /dev/null +++ b/linux/crates/app/src/services/saved_connection_editor.rs @@ -0,0 +1,735 @@ +use std::sync::Arc; + +use tablepro_core::credentials::{SecretError, SecretKind, SecretLookup, SecretVault}; +use tablepro_session::runtime::Tasks; +use tablepro_storage::{ConnectionColor, ConnectionGroupName, ConnectionStore, SavedConnection}; +use uuid::Uuid; + +use super::duplicate_name::duplicate_name; +use super::saved_connection_error::SavedConnectionError; +use super::secret_change::SecretChange; +use super::secret_changes::SecretChanges; +use super::secret_labels::secret_label; + +/// Changes a saved connection together with its keyring secrets: an +/// edit from the connect dialog, a copy, a move to another group. +/// +/// The keyring goes first, as in a delete: a refusal there stops before +/// the list changes, so the user can try again. When the entry does not +/// land, each secret the edit changed is put back as it was, and a copy's +/// secrets are deleted. Every write publishes the list, which every +/// picker follows. +/// +/// An edit and a copy run one at a time, since each writes the keyring +/// and the list in turn: two edits of one connection from two windows +/// would otherwise leave the endpoint of one with the password of the +/// other. +/// +/// Run it on the app runtime, which the Secret Service client needs. +#[derive(Clone)] +pub struct SavedConnectionEditor { + store: ConnectionStore, + secrets: Arc, + tasks: Tasks, + turn: Arc>, +} + +impl SavedConnectionEditor { + pub fn new(store: ConnectionStore, secrets: Arc, tasks: Tasks) -> Self { + Self { + store, + secrets, + tasks, + turn: Arc::default(), + } + } + + /// Save `connection` over the entry with its id, applying `changes` + /// to its secrets. The id stays, so a rename or a new host keeps + /// every secret the edit does not change. Colour, group and the + /// last-opened time are taken from the list as it is at the write, + /// since the connect dialog shows none of them. + pub async fn update( + &self, + connection: SavedConnection, + changes: SecretChanges, + ) -> Result { + let _turn = self.turn.lock().await; + let id = connection.id; + let original = self.find(id).await?; + let mut connection = connection; + changes.settle_passphrase(&mut connection, &original); + let mut changed = Vec::new(); + for kind in SecretKind::ALL { + if let Err(error) = self + .change_secret(&original, &connection, kind, changes.get(kind), &mut changed) + .await + { + self.put_back(&original, changed).await; + return Err(error.into()); + } + } + let written = self + .write(id, move |entry| { + *entry = SavedConnection { + last_opened_at: entry.last_opened_at, + color: entry.color, + group: entry.group.take(), + ..connection + }; + }) + .await; + match written { + Ok(Some(saved)) => Ok(saved), + // Deleted while the dialog was open: what the edit just + // stored has no entry left to belong to. + Ok(None) => { + self.forget_secrets(id).await; + Err(SavedConnectionError::NotFound(id)) + } + Err(error) => { + self.put_back(&original, changed).await; + Err(error) + } + } + } + + /// Apply `change` to the secret of `kind`, recording in `changed` + /// what the keyring held before, to put back if the entry does not + /// land. + async fn change_secret( + &self, + original: &SavedConnection, + connection: &SavedConnection, + kind: SecretKind, + change: &SecretChange, + changed: &mut Vec<(SecretKind, SecretLookup)>, + ) -> Result<(), SecretError> { + let id = connection.id; + let label = secret_label(kind, connection); + match change { + SecretChange::Replace(secret) => { + let before = self.secrets.load(id, kind).await?; + self.secrets.store(id, kind, secret, &label).await?; + changed.push((kind, before)); + } + SecretChange::Remove => { + let before = self.secrets.load(id, kind).await?; + self.secrets.delete_kind(id, kind).await?; + changed.push((kind, before)); + } + // A label only names the secret in Seahorse, so one the + // keyring will not rename is no reason to refuse the edit. + SecretChange::Keep if label != secret_label(kind, original) => { + if let Err(error) = self.secrets.relabel(id, kind, &label).await { + tracing::warn!(%error, ?kind, "a kept secret still has its old label"); + } + } + SecretChange::Keep => {} + } + Ok(()) + } + + /// Give back the secrets an edit changed before its entry failed to + /// land, so the entry still logs in the way it did. + async fn put_back(&self, original: &SavedConnection, changed: Vec<(SecretKind, SecretLookup)>) { + for (kind, before) in changed.into_iter().rev() { + let restored = match before { + SecretLookup::Found(secret) => { + self.secrets + .store(original.id, kind, &secret, &secret_label(kind, original)) + .await + } + SecretLookup::NotStored => self.secrets.delete_kind(original.id, kind).await, + }; + if let Err(error) = restored { + tracing::warn!(%error, ?kind, "a secret of an edit that was not saved was not put back"); + } + } + } + + /// Copy the connection `id` under a name of its own, with copies of + /// its secrets. The copy is a connection of its own: a new id, so + /// deleting either leaves the other able to connect. It has never + /// been opened, so it does not claim the original's place at the top. + pub async fn duplicate(&self, id: Uuid) -> Result { + let _turn = self.turn.lock().await; + let list = self.list().await?; + let source = list + .iter() + .find(|saved| saved.id == id) + .cloned() + .ok_or(SavedConnectionError::NotFound(id))?; + let names: Vec = list.iter().map(|saved| saved.name.clone()).collect(); + let copy = SavedConnection { + id: Uuid::new_v4(), + name: duplicate_name(&source.name, &names), + last_opened_at: None, + ..source.clone() + }; + if let Err(error) = self.copy_secrets(&source, ©).await { + self.forget_secrets(copy.id).await; + return Err(error.into()); + } + let store = self.store.clone(); + let entry = copy.clone(); + let written = self + .tasks + .spawn_blocking_task(move || store.upsert_blocking(entry)) + .await + .map_err(SavedConnectionError::from) + .and_then(|written| written.map_err(SavedConnectionError::from)); + if let Err(error) = written { + self.forget_secrets(copy.id).await; + return Err(error); + } + Ok(copy) + } + + /// File the connection `id` under `group`, or under none. + pub async fn move_to_group( + &self, + id: Uuid, + group: Option, + ) -> Result<(), SavedConnectionError> { + self.write(id, move |entry| entry.group = group) + .await? + .map(drop) + .ok_or(SavedConnectionError::NotFound(id)) + } + + /// Put `color` on the connection `id`, or take its colour off. + pub async fn set_color(&self, id: Uuid, color: Option) -> Result<(), SavedConnectionError> { + self.write(id, move |entry| entry.color = color) + .await? + .map(drop) + .ok_or(SavedConnectionError::NotFound(id)) + } + + async fn list(&self) -> Result, SavedConnectionError> { + let store = self.store.clone(); + Ok(self.tasks.spawn_blocking_task(move || store.load_blocking()).await??) + } + + async fn find(&self, id: Uuid) -> Result { + self.list() + .await? + .iter() + .find(|saved| saved.id == id) + .cloned() + .ok_or(SavedConnectionError::NotFound(id)) + } + + async fn write( + &self, + id: Uuid, + change: impl FnOnce(&mut SavedConnection) + Send + 'static, + ) -> Result, SavedConnectionError> { + let store = self.store.clone(); + Ok(self + .tasks + .spawn_blocking_task(move || store.update_blocking(id, change)) + .await??) + } + + async fn copy_secrets(&self, source: &SavedConnection, copy: &SavedConnection) -> Result<(), SecretError> { + for kind in SecretKind::ALL { + if let SecretLookup::Found(secret) = self.secrets.load(source.id, kind).await? { + self.secrets + .store(copy.id, kind, &secret, &secret_label(kind, copy)) + .await?; + } + } + Ok(()) + } + + async fn forget_secrets(&self, id: Uuid) { + if let Err(error) = self.secrets.delete_connection(id).await { + tracing::warn!(%error, "secrets of a connection that was not saved are still in the keyring"); + } + } +} + +impl std::fmt::Debug for SavedConnectionEditor { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("SavedConnectionEditor") + .field("store", &self.store) + .finish_non_exhaustive() + } +} + +#[cfg(test)] +mod tests { + use secrecy::SecretString; + use tablepro_core::{AuthMode, TlsConfig}; + use tablepro_storage::{ConnectionListState, StoragePaths}; + use tablepro_test_support::{Call, FakeSecretVault}; + + use super::*; + use crate::test_support::paused_tasks; + + struct Fixture { + editor: SavedConnectionEditor, + store: ConnectionStore, + vault: Arc, + saved: SavedConnection, + _root: tempfile::TempDir, + } + + fn connection() -> SavedConnection { + SavedConnection { + id: Uuid::new_v4(), + name: "Prod".to_owned(), + driver_id: "postgres".to_owned(), + host: "db.example.com".to_owned(), + port: 5432, + database: "shop".to_owned(), + username: "app".to_owned(), + tls: TlsConfig::verify_full(), + read_only: false, + auth_mode: AuthMode::Password, + ssh: None, + last_opened_at: Some(chrono::Utc::now()), + color: Some(ConnectionColor::Red), + group: Some(ConnectionGroupName::new("Work").expect("a name")), + } + } + + /// One saved connection with its database password in the keyring. + async fn fixture() -> Fixture { + let root = tempfile::tempdir().expect("tempdir"); + let store = ConnectionStore::new(&StoragePaths::under(root.path(), "tablepro-test", "app.tablepro.Test")); + let vault = Arc::new(FakeSecretVault::new()); + let saved = connection(); + store.upsert_blocking(saved.clone()).expect("the entry"); + vault + .store( + saved.id, + SecretKind::DatabasePassword, + &SecretString::from("s3cret"), + &secret_label(SecretKind::DatabasePassword, &saved), + ) + .await + .expect("the password"); + Fixture { + editor: SavedConnectionEditor::new(store.clone(), vault.clone(), paused_tasks()), + store, + vault, + saved, + _root: root, + } + } + + fn stored(store: &ConnectionStore) -> Vec { + store.load_blocking().expect("the list").to_vec() + } + + fn writes(vault: &FakeSecretVault) -> Vec { + vault + .calls() + .into_iter() + .filter(|call| !matches!(call, Call::Load(..))) + .collect() + } + + /// A vault whose first SSH password store waits until the test lets + /// it go, so an edit can be held between two of its secrets. + struct HeldVault { + inner: Arc, + held: futures::lock::Mutex>>, + reached: tokio::sync::Notify, + } + + #[async_trait::async_trait] + impl SecretVault for HeldVault { + async fn store( + &self, + id: Uuid, + kind: SecretKind, + secret: &SecretString, + label: &str, + ) -> Result<(), SecretError> { + if kind == SecretKind::SshPassword { + let gate = self.held.lock().await.take(); + if let Some(gate) = gate { + self.reached.notify_one(); + gate.await.expect("the test lets the store go"); + } + } + self.inner.store(id, kind, secret, label).await + } + + async fn load(&self, id: Uuid, kind: SecretKind) -> Result { + self.inner.load(id, kind).await + } + + async fn relabel(&self, id: Uuid, kind: SecretKind, label: &str) -> Result<(), SecretError> { + self.inner.relabel(id, kind, label).await + } + + async fn delete_kind(&self, id: Uuid, kind: SecretKind) -> Result<(), SecretError> { + self.inner.delete_kind(id, kind).await + } + + async fn delete_connection(&self, id: Uuid) -> Result<(), SecretError> { + self.inner.delete_connection(id).await + } + } + + #[tokio::test] + async fn two_edits_of_one_connection_do_not_mix() { + let fixture = fixture().await; + let (release, gate) = tokio::sync::oneshot::channel(); + let vault = Arc::new(HeldVault { + inner: fixture.vault.clone(), + held: futures::lock::Mutex::new(Some(gate)), + reached: tokio::sync::Notify::new(), + }); + let editor = SavedConnectionEditor::new(fixture.store.clone(), vault.clone(), paused_tasks()); + let edit = |host: &str, password: &str| { + let connection = SavedConnection { + host: host.to_owned(), + ..fixture.saved.clone() + }; + let changes = SecretChanges { + database_password: SecretChange::Replace(SecretString::from(password.to_owned())), + ssh_password: SecretChange::Replace(SecretString::from(password.to_owned())), + ssh_passphrase: SecretChange::Keep, + }; + (connection, changes) + }; + let (first, first_changes) = edit("first.example.com", "first"); + let (second, second_changes) = edit("second.example.com", "second"); + + let held = editor.update(first, first_changes); + let racing = async { + vault.reached.notified().await; + let second = editor.update(second, second_changes); + let letting_go = async { + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + release.send(()).expect("the held store is waiting"); + }; + futures::future::join(second, letting_go).await.0 + }; + let (first, second) = futures::future::join(held, racing).await; + first.expect("the first edit"); + second.expect("the second edit"); + + let saved = stored(&fixture.store); + let password = fixture + .vault + .stored_secret(fixture.saved.id, SecretKind::DatabasePassword); + assert_eq!(saved[0].host, "second.example.com"); + assert_eq!( + password.as_deref(), + Some("second"), + "the endpoint and password come from different edits" + ); + } + + #[tokio::test] + async fn duplicate_copies_secrets_to_new_id() { + let fixture = fixture().await; + + let copy = fixture.editor.duplicate(fixture.saved.id).await.expect("the copy"); + + assert_ne!(copy.id, fixture.saved.id); + assert_eq!(copy.name, "Prod (Copy)"); + assert_eq!(copy.last_opened_at, None, "the copy claimed the original's recency"); + assert_eq!(copy.group, fixture.saved.group); + assert_eq!(copy.color, fixture.saved.color); + assert_eq!(copy.host, fixture.saved.host); + assert_eq!( + fixture + .vault + .stored_secret(copy.id, SecretKind::DatabasePassword) + .as_deref(), + Some("s3cret") + ); + assert_eq!( + fixture.vault.stored_label(copy.id, SecretKind::DatabasePassword), + Some(secret_label(SecretKind::DatabasePassword, ©)) + ); + assert!(stored(&fixture.store).contains(©)); + } + + #[tokio::test] + async fn duplicate_rolls_back_entry_when_secret_copy_fails() { + let fixture = fixture().await; + fixture + .vault + .fail_store(SecretKind::DatabasePassword, SecretError::Locked); + + let refused = fixture.editor.duplicate(fixture.saved.id).await; + + assert!( + matches!(refused, Err(SavedConnectionError::Secret(SecretError::Locked))), + "{refused:?}" + ); + assert_eq!(stored(&fixture.store), vec![fixture.saved.clone()], "a copy was left"); + let copied: Vec = writes(&fixture.vault) + .into_iter() + .filter(|call| !matches!(call, Call::Store(id, _) if *id == fixture.saved.id)) + .collect(); + assert!( + matches!(copied.last(), Some(Call::DeleteConnection(id)) if *id != fixture.saved.id), + "the copy's secrets were not taken back: {copied:?}" + ); + } + + #[tokio::test] + async fn duplicate_of_a_deleted_connection_writes_nothing() { + let fixture = fixture().await; + let gone = Uuid::new_v4(); + + let refused = fixture.editor.duplicate(gone).await; + + assert!(matches!(refused, Err(SavedConnectionError::NotFound(id)) if id == gone)); + assert_eq!(stored(&fixture.store).len(), 1); + } + + #[tokio::test] + async fn update_with_keep_does_not_store_secrets() { + let fixture = fixture().await; + let before = fixture.vault.calls().len(); + let edited = SavedConnection { + read_only: true, + ..fixture.saved.clone() + }; + + fixture + .editor + .update(edited, SecretChanges::default()) + .await + .expect("the edit"); + + let after = fixture.vault.calls().split_off(before); + assert!( + after.iter().all(|call| matches!(call, Call::Load(..))), + "the keyring was written: {after:?}" + ); + assert!(stored(&fixture.store)[0].read_only); + } + + #[tokio::test] + async fn a_rename_keeps_every_secret_and_the_entry_id() { + let fixture = fixture().await; + let renamed = SavedConnection { + name: "Production".to_owned(), + ..fixture.saved.clone() + }; + + let saved = fixture + .editor + .update(renamed, SecretChanges::default()) + .await + .expect("the rename"); + + assert_eq!(saved.id, fixture.saved.id); + assert_eq!(saved.name, "Production"); + assert_eq!( + fixture + .vault + .stored_secret(fixture.saved.id, SecretKind::DatabasePassword) + .as_deref(), + Some("s3cret") + ); + assert_eq!(stored(&fixture.store), vec![saved]); + } + + #[tokio::test] + async fn update_keeps_colour_group_and_recency_from_the_list() { + let fixture = fixture().await; + let edited = SavedConnection { + host: "db2.example.com".to_owned(), + last_opened_at: None, + color: None, + group: None, + ..fixture.saved.clone() + }; + + let saved = fixture + .editor + .update(edited, SecretChanges::default()) + .await + .expect("the edit"); + + assert_eq!(saved.host, "db2.example.com"); + assert_eq!(saved.last_opened_at, fixture.saved.last_opened_at); + assert_eq!(saved.color, fixture.saved.color); + assert_eq!(saved.group, fixture.saved.group); + } + + #[tokio::test] + async fn a_new_target_relabels_the_kept_secrets() { + let fixture = fixture().await; + let moved = SavedConnection { + host: "db2.example.com".to_owned(), + ..fixture.saved.clone() + }; + + fixture + .editor + .update(moved.clone(), SecretChanges::default()) + .await + .expect("the edit"); + + assert_eq!( + fixture + .vault + .stored_label(fixture.saved.id, SecretKind::DatabasePassword), + Some(secret_label(SecretKind::DatabasePassword, &moved)) + ); + } + + #[tokio::test] + async fn replaced_and_removed_secrets_reach_the_keyring() { + let fixture = fixture().await; + let id = fixture.saved.id; + let changes = SecretChanges { + database_password: SecretChange::Replace(SecretString::from("n3w")), + ssh_password: SecretChange::Remove, + ssh_passphrase: SecretChange::Keep, + }; + + fixture + .editor + .update(fixture.saved.clone(), changes) + .await + .expect("the edit"); + + assert_eq!( + fixture.vault.stored_secret(id, SecretKind::DatabasePassword).as_deref(), + Some("n3w") + ); + assert!( + fixture + .vault + .calls() + .contains(&Call::DeleteKind(id, SecretKind::SshPassword)) + ); + } + + #[tokio::test] + async fn a_refused_secret_leaves_the_entry_as_it_was() { + let fixture = fixture().await; + fixture + .vault + .fail_store(SecretKind::DatabasePassword, SecretError::UnlockDismissed); + let revision = fixture.store.snapshot().revision; + let edited = SavedConnection { + host: "db2.example.com".to_owned(), + ..fixture.saved.clone() + }; + let changes = SecretChanges { + database_password: SecretChange::Replace(SecretString::from("n3w")), + ..SecretChanges::default() + }; + + let refused = fixture.editor.update(edited, changes).await; + + assert!(matches!(refused, Err(SavedConnectionError::Secret(_))), "{refused:?}"); + assert_eq!(stored(&fixture.store), vec![fixture.saved.clone()]); + assert_eq!(fixture.store.snapshot().revision, revision); + } + + #[tokio::test] + async fn a_refused_second_secret_puts_the_first_back() { + let fixture = fixture().await; + let id = fixture.saved.id; + fixture.vault.fail_store(SecretKind::SshPassword, SecretError::Locked); + let changes = SecretChanges { + database_password: SecretChange::Replace(SecretString::from("n3w")), + ssh_password: SecretChange::Replace(SecretString::from("tunnel")), + ssh_passphrase: SecretChange::Keep, + }; + + let refused = fixture.editor.update(fixture.saved.clone(), changes).await; + + assert!( + matches!(refused, Err(SavedConnectionError::Secret(SecretError::Locked))), + "{refused:?}" + ); + assert_eq!( + fixture.vault.stored_secret(id, SecretKind::DatabasePassword).as_deref(), + Some("s3cret"), + "the refused edit kept its new password" + ); + assert_eq!(fixture.vault.stored_secret(id, SecretKind::SshPassword), None); + } + + #[tokio::test] + async fn a_list_that_cannot_be_written_puts_the_secrets_back() { + let fixture = fixture().await; + let id = fixture.saved.id; + let list_file = + StoragePaths::under(fixture._root.path(), "tablepro-test", "app.tablepro.Test").connections_file(); + std::fs::remove_file(&list_file).expect("the list file"); + std::fs::create_dir(&list_file).expect("a directory where the list goes"); + let edited = SavedConnection { + host: "db2.example.com".to_owned(), + ..fixture.saved.clone() + }; + let changes = SecretChanges { + database_password: SecretChange::Replace(SecretString::from("n3w")), + ..SecretChanges::default() + }; + + let refused = fixture.editor.update(edited, changes).await; + + assert!(matches!(refused, Err(SavedConnectionError::Storage(_))), "{refused:?}"); + assert_eq!( + fixture.vault.stored_secret(id, SecretKind::DatabasePassword).as_deref(), + Some("s3cret"), + "the unsaved edit kept its new password" + ); + assert_eq!( + fixture.vault.stored_label(id, SecretKind::DatabasePassword), + Some(secret_label(SecretKind::DatabasePassword, &fixture.saved)) + ); + } + + #[tokio::test] + async fn an_edit_of_a_deleted_connection_stores_no_secret() { + let fixture = fixture().await; + let id = fixture.saved.id; + fixture.store.remove_blocking(id).expect("the delete"); + let before = writes(&fixture.vault).len(); + let changes = SecretChanges { + database_password: SecretChange::Replace(SecretString::from("n3w")), + ..SecretChanges::default() + }; + + let refused = fixture.editor.update(fixture.saved.clone(), changes).await; + + assert!(matches!(refused, Err(SavedConnectionError::NotFound(gone)) if gone == id)); + assert_eq!(writes(&fixture.vault).len(), before, "the keyring was written"); + assert!(stored(&fixture.store).is_empty(), "the edit brought the entry back"); + } + + #[tokio::test] + async fn move_to_group_publishes_a_revision() { + let fixture = fixture().await; + let mut receiver = fixture.store.subscribe(); + receiver.borrow_and_update(); + let archive = ConnectionGroupName::new("Archive").expect("a name"); + + fixture + .editor + .move_to_group(fixture.saved.id, Some(archive.clone())) + .await + .expect("the move"); + + assert!(receiver.has_changed().expect("the store is alive")); + let ConnectionListState::Ready(list) = receiver.borrow_and_update().state.clone() else { + panic!("expected a ready list"); + }; + assert_eq!(list[0].group, Some(archive)); + fixture + .editor + .move_to_group(fixture.saved.id, None) + .await + .expect("out of the group"); + assert_eq!(stored(&fixture.store)[0].group, None); + } +} diff --git a/linux/crates/app/src/services/saved_connection_error.rs b/linux/crates/app/src/services/saved_connection_error.rs new file mode 100644 index 0000000000..716c3cfea0 --- /dev/null +++ b/linux/crates/app/src/services/saved_connection_error.rs @@ -0,0 +1,18 @@ +use tablepro_core::credentials::SecretError; +use tablepro_session::runtime::TaskFailure; +use tablepro_storage::StorageError; +use uuid::Uuid; + +/// Why an edit, a copy or a move of a saved connection did not happen. +#[derive(Debug, thiserror::Error)] +pub enum SavedConnectionError { + #[error(transparent)] + Storage(#[from] StorageError), + #[error(transparent)] + Secret(#[from] SecretError), + /// Deleted, here or in another window, since it was shown. + #[error("no saved connection has the id {0}")] + NotFound(Uuid), + #[error(transparent)] + Task(#[from] TaskFailure), +} diff --git a/linux/crates/app/src/services/saved_connection_list.rs b/linux/crates/app/src/services/saved_connection_list.rs new file mode 100644 index 0000000000..b70c01675d --- /dev/null +++ b/linux/crates/app/src/services/saved_connection_list.rs @@ -0,0 +1,323 @@ +//! The saved connections every picker shows, as one list model that +//! follows the connection store. + +use std::cell::{Cell, RefCell}; +use std::rc::{Rc, Weak}; +use std::sync::Arc; + +use gtk4::prelude::*; +use gtk4::{gio, glib}; +use tablepro_storage::{ConnectionGroupName, ConnectionListState, ConnectionStore, SavedConnection}; +use uuid::Uuid; + +use super::saved_connection_object::SavedConnectionObject; +use super::saved_connection_order; + +type StateObserver = Rc; + +/// One model for every window. Each store write publishes a new list, +/// so a save in the connect dialog, a connect's last-opened stamp, a +/// delete or an edit reaches every picker without anyone asking. +pub struct SavedConnectionList { + store: gio::ListStore, + /// The last list the store had ready, in display order. A list that + /// cannot be read keeps it, as the rows do. + connections: RefCell>, + state: RefCell, + watch: RefCell>>, + observers: RefCell>, + next_observer: Cell, +} + +/// What `connect_state_changed` returns, to disconnect with. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct StateObserverId(u64); + +impl SavedConnectionList { + /// Follow `connections` from its list now. The watch runs on the GTK + /// thread and polls nothing but the store's channel, and it ends + /// with the store or with this list. + pub fn follow(connections: &ConnectionStore) -> Rc { + let mut receiver = connections.subscribe(); + let current = receiver.borrow_and_update().state.clone(); + let list = Rc::new(Self { + store: gio::ListStore::new::(), + connections: RefCell::new(Arc::from([])), + state: RefCell::new(ConnectionListState::Loading), + watch: RefCell::new(None), + observers: RefCell::new(Vec::new()), + next_observer: Cell::new(0), + }); + list.apply(current); + let following: Weak = Rc::downgrade(&list); + let watch = glib::spawn_future_local(async move { + while receiver.changed().await.is_ok() { + let state = receiver.borrow_and_update().state.clone(); + let Some(list) = following.upgrade() else { + return; + }; + list.apply(state); + } + }); + list.watch.replace(Some(watch)); + list + } + + pub fn model(&self) -> gio::ListModel { + self.store.clone().upcast() + } + + pub fn state(&self) -> ConnectionListState { + self.state.borrow().clone() + } + + /// The saved connection `id` as the list holds it now. + pub fn get(&self, id: Uuid) -> Option { + self.connections.borrow().iter().find(|saved| saved.id == id).cloned() + } + + /// Every saved connection, in display order. + pub fn connections(&self) -> Arc<[SavedConnection]> { + self.connections.borrow().clone() + } + + /// The groups the list files connections under, in display order. + pub fn groups(&self) -> Vec { + saved_connection_order::groups(&self.connections.borrow()) + } + + /// Run `observer` on every state the store publishes, as the banner + /// that says the list cannot be read needs. + pub fn connect_state_changed(&self, observer: impl Fn(&ConnectionListState) + 'static) -> StateObserverId { + let id = self.next_observer.get(); + self.next_observer.set(id + 1); + self.observers.borrow_mut().push((id, Rc::new(observer))); + StateObserverId(id) + } + + pub fn disconnect_state_changed(&self, id: StateObserverId) { + self.observers.borrow_mut().retain(|(observer, _)| *observer != id.0); + } + + /// A ready list replaces the objects of the connections that changed + /// and keeps the rest, so a write from another window leaves a row's + /// open menu and the keyboard focus alone. A new set of groups + /// replaces every object, because every row's menu offers them. A + /// list that is loading or cannot be read keeps the rows it had, and + /// only its state changes, for the banner. + pub(crate) fn apply(&self, state: ConnectionListState) { + // The state first: the rows the splice builds read it. + self.state.replace(state.clone()); + if let ConnectionListState::Ready(list) = &state { + let mut sorted = list.to_vec(); + saved_connection_order::sort(&mut sorted); + let regrouped = saved_connection_order::groups(&sorted) != self.groups(); + let sorted: Arc<[SavedConnection]> = Arc::from(sorted); + self.connections.replace(sorted.clone()); + if regrouped || self.store.n_items() == 0 { + let objects: Vec = + sorted.iter().cloned().map(SavedConnectionObject::new).collect(); + self.store.splice(0, self.store.n_items(), &objects); + } else { + patch(&self.store, &sorted); + } + } + let observers: Vec = self + .observers + .borrow() + .iter() + .map(|(_, observer)| observer.clone()) + .collect(); + for observer in observers { + observer(&state); + } + } +} + +/// Bring `store` to `wanted`, keeping each object whose connection is +/// unchanged where it can. +fn patch(store: &gio::ListStore, wanted: &[SavedConnection]) { + let mut position = 0; + for saved in wanted { + let kept = (position..store.n_items()).find(|&at| { + store + .item(at) + .and_downcast::() + .is_some_and(|object| object.saved() == Some(saved)) + }); + match kept { + Some(at) if at > position => store.splice(position, at - position, &[] as &[SavedConnectionObject]), + Some(_) => {} + None => store.insert(position, &SavedConnectionObject::new(saved.clone())), + } + position += 1; + } + let extra = store.n_items().saturating_sub(position); + if extra > 0 { + store.splice(position, extra, &[] as &[SavedConnectionObject]); + } +} + +impl Drop for SavedConnectionList { + fn drop(&mut self) { + if let Some(watch) = self.watch.take() { + watch.abort(); + } + } +} + +impl std::fmt::Debug for SavedConnectionList { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("SavedConnectionList") + .field("rows", &self.store.n_items()) + .finish_non_exhaustive() + } +} + +#[cfg(test)] +mod tests { + use std::time::Duration; + + use tablepro_storage::{DocumentProblem, DocumentProblemKind, StoragePaths}; + + use super::*; + use crate::test_support::wait_until; + + const WAIT: Duration = Duration::from_secs(10); + + fn connection(name: &str) -> SavedConnection { + SavedConnection { + id: Uuid::new_v4(), + name: name.to_owned(), + driver_id: "postgres".to_owned(), + host: "db".to_owned(), + port: 5432, + database: "app".to_owned(), + username: "postgres".to_owned(), + tls: tablepro_core::TlsConfig::verify_full(), + read_only: false, + auth_mode: tablepro_core::AuthMode::Password, + ssh: None, + last_opened_at: None, + color: None, + group: None, + } + } + + fn objects(list: &SavedConnectionList) -> Vec { + let model = list.model(); + (0..model.n_items()) + .filter_map(|position| model.item(position).and_downcast::()) + .collect() + } + + fn names(list: &SavedConnectionList) -> Vec { + objects(list).iter().map(|object| object.name()).collect() + } + + fn ready(connections: &[SavedConnection]) -> ConnectionListState { + ConnectionListState::Ready(Arc::from(connections.to_vec())) + } + + #[gtk4::test] + fn a_write_to_the_store_reaches_the_list() { + let dir = tempfile::tempdir().expect("a temporary directory"); + let store = ConnectionStore::new(&StoragePaths::under(dir.path(), "tablepro", crate::config::APP_ID)); + let list = SavedConnectionList::follow(&store); + assert!(matches!(list.state(), ConnectionListState::Loading)); + let corp = connection("Corp"); + + store.upsert_blocking(corp.clone()).expect("the write"); + + wait_until(WAIT, || names(&list) == ["Corp"]).expect("the new row"); + assert_eq!(list.get(corp.id).map(|saved| saved.name), Some("Corp".to_owned())); + store.remove_blocking(corp.id).expect("the delete"); + wait_until(WAIT, || names(&list).is_empty()).expect("the row to go"); + assert_eq!(list.get(corp.id), None); + } + + #[gtk4::test] + fn unreadable_list_keeps_rows_and_reports_its_state() { + let dir = tempfile::tempdir().expect("a temporary directory"); + let store = ConnectionStore::new(&StoragePaths::under(dir.path(), "tablepro", crate::config::APP_ID)); + let list = SavedConnectionList::follow(&store); + let seen = Rc::new(RefCell::new(Vec::new())); + let sink = seen.clone(); + let observer = list.connect_state_changed(move |state| { + sink.borrow_mut() + .push(matches!(state, ConnectionListState::Unavailable(_))); + }); + let corp = connection("Corp"); + list.apply(ConnectionListState::Ready(Arc::from([corp.clone()]))); + + list.apply(ConnectionListState::Unavailable(Arc::new(DocumentProblem::new( + "/tmp/connections.json", + DocumentProblemKind::Unreadable { + detail: "denied".to_owned(), + }, + )))); + + assert_eq!(names(&list), ["Corp"], "an unreadable list emptied the picker"); + assert_eq!(list.get(corp.id).map(|saved| saved.id), Some(corp.id)); + assert!(matches!(list.state(), ConnectionListState::Unavailable(_))); + assert_eq!(*seen.borrow(), [false, true]); + list.disconnect_state_changed(observer); + list.apply(ConnectionListState::Loading); + assert_eq!(seen.borrow().len(), 2, "a disconnected observer still ran"); + } + + #[gtk4::test] + fn a_write_keeps_the_objects_of_unchanged_connections() { + let dir = tempfile::tempdir().expect("a temporary directory"); + let store = ConnectionStore::new(&StoragePaths::under(dir.path(), "tablepro", crate::config::APP_ID)); + let list = SavedConnectionList::follow(&store); + let (alpha, beta, gamma) = (connection("Alpha"), connection("Beta"), connection("Gamma")); + list.apply(ready(&[alpha.clone(), beta.clone(), gamma.clone()])); + let before = objects(&list); + + let mut stamped = gamma.clone(); + stamped.last_opened_at = Some(chrono::Utc::now()); + list.apply(ready(&[alpha.clone(), beta.clone(), stamped])); + let after = objects(&list); + + assert_eq!(names(&list), ["Gamma", "Alpha", "Beta"]); + assert_eq!(after[1], before[0], "the stamp rebuilt Alpha's row"); + assert_eq!(after[2], before[1], "the stamp rebuilt Beta's row"); + assert_ne!(after[0], before[2], "the stamped row shows the old connection"); + + list.apply(ready(&[alpha, beta])); + assert_eq!(objects(&list), [after[1].clone(), after[2].clone()]); + } + + #[gtk4::test] + fn a_new_group_replaces_every_object() { + let dir = tempfile::tempdir().expect("a temporary directory"); + let store = ConnectionStore::new(&StoragePaths::under(dir.path(), "tablepro", crate::config::APP_ID)); + let list = SavedConnectionList::follow(&store); + let (alpha, beta) = (connection("Alpha"), connection("Beta")); + list.apply(ready(&[alpha.clone(), beta.clone()])); + let before = objects(&list); + + let mut filed = beta; + filed.group = Some(ConnectionGroupName::new("Work").expect("a group name")); + list.apply(ready(&[alpha, filed])); + + let after = objects(&list); + assert_eq!(list.groups(), [ConnectionGroupName::new("Work").expect("a group name")]); + assert_ne!(after[0], before[0], "Alpha's menu does not offer the new group"); + } + + #[gtk4::test] + fn a_dropped_list_stops_following() { + let dir = tempfile::tempdir().expect("a temporary directory"); + let store = ConnectionStore::new(&StoragePaths::under(dir.path(), "tablepro", crate::config::APP_ID)); + let list = SavedConnectionList::follow(&store); + let model = list.model(); + + drop(list); + store.upsert_blocking(connection("Corp")).expect("the write"); + crate::test_support::drain_main_context(); + + assert_eq!(model.n_items(), 0, "a dropped list still followed the store"); + } +} diff --git a/linux/crates/app/src/services/saved_connection_object.rs b/linux/crates/app/src/services/saved_connection_object.rs new file mode 100644 index 0000000000..5577285d17 --- /dev/null +++ b/linux/crates/app/src/services/saved_connection_object.rs @@ -0,0 +1,115 @@ +use std::path::Path; + +use gtk4::glib; +use gtk4::subclass::prelude::*; +use tablepro_core::AuthMode; +use tablepro_storage::SavedConnection; + +use super::file_location; +use super::saved_connection_object_imp as imp; + +glib::wrapper! { + /// One saved connection as the pickers' shared model holds it. It + /// never changes: a changed connection gets a new object. + pub struct SavedConnectionObject(ObjectSubclass); +} + +impl SavedConnectionObject { + pub(crate) fn new(saved: SavedConnection) -> Self { + let object: Self = glib::Object::new(); + let _ = object.imp().saved.set(saved); + object + } + + /// Always set: `new` is the only way to build one. + pub(crate) fn saved(&self) -> Option<&SavedConnection> { + self.imp().saved.get() + } +} + +pub(crate) fn is_sqlite(saved: &SavedConnection) -> bool { + saved.driver_id == "sqlite" +} + +/// The driver and where it connects. `host_path` names the file a +/// portal path stands for, once it is read. +pub(crate) fn subtitle_for(saved: &SavedConnection, host_path: Option<&Path>) -> String { + if is_sqlite(saved) { + let shown = file_location::display_path(Path::new(&saved.database), host_path); + return format!("sqlite · {shown}"); + } + match saved.auth_mode { + AuthMode::Kerberos => format!("{} · {}:{}", saved.driver_id, saved.host, saved.port), + AuthMode::Password => format!("{} · {}@{}:{}", saved.driver_id, saved.username, saved.host, saved.port), + } +} + +#[cfg(test)] +mod tests { + use chrono::{TimeZone, Utc}; + use uuid::Uuid; + + use super::*; + + fn saved(username: &str, auth_mode: AuthMode) -> SavedConnection { + SavedConnection { + id: Uuid::new_v4(), + name: "Corp".into(), + driver_id: "mssql".into(), + host: "sql.corp.example".into(), + port: 1433, + database: "sales".into(), + username: username.into(), + tls: tablepro_core::TlsConfig::verify_full(), + read_only: false, + auth_mode, + ssh: None, + last_opened_at: None, + color: None, + group: None, + } + } + + #[test] + fn a_kerberos_row_has_no_username_separator_to_dangle() { + assert_eq!( + subtitle_for(&saved("", AuthMode::Kerberos), None), + "mssql · sql.corp.example:1433" + ); + assert_eq!( + subtitle_for(&saved("sa", AuthMode::Password), None), + "mssql · sa@sql.corp.example:1433" + ); + } + + #[test] + fn portal_path_shows_host_path() { + let mut file = saved("", AuthMode::Password); + file.driver_id = "sqlite".into(); + file.database = "/run/user/1000/doc/1a2b3c/app.db".into(); + + assert_eq!( + subtitle_for(&file, Some(Path::new("/srv/data/app.db"))), + "sqlite · /srv/data/app.db" + ); + assert_eq!(subtitle_for(&file, None), "sqlite · app.db"); + file.database = "/home/me/app.db".into(); + assert_eq!(subtitle_for(&file, None), "sqlite · /home/me/app.db"); + } + + #[gtk4::test] + fn the_object_carries_the_connection_as_properties() { + let mut connection = saved("sa", AuthMode::Password); + connection.group = Some(tablepro_storage::ConnectionGroupName::new("Work").expect("a group name")); + connection.last_opened_at = Utc.timestamp_opt(1_700_000_000, 0).single(); + + let object = SavedConnectionObject::new(connection.clone()); + + assert_eq!(object.id(), connection.id.to_string()); + assert_eq!(object.name(), "Corp"); + assert_eq!(object.subtitle(), "mssql · sa@sql.corp.example:1433"); + assert_eq!(object.group().as_deref(), Some("Work")); + assert_eq!(object.last_opened(), 1_700_000_000_000); + assert_eq!(object.saved().map(|saved| saved.id), Some(connection.id)); + } +} diff --git a/linux/crates/app/src/services/saved_connection_object_imp.rs b/linux/crates/app/src/services/saved_connection_object_imp.rs new file mode 100644 index 0000000000..97bf17f6bc --- /dev/null +++ b/linux/crates/app/src/services/saved_connection_object_imp.rs @@ -0,0 +1,65 @@ +use std::cell::OnceCell; +use std::marker::PhantomData; + +use gtk4::glib; +use gtk4::prelude::*; +use gtk4::subclass::prelude::*; +use tablepro_storage::SavedConnection; + +#[derive(Default, glib::Properties)] +#[properties(wrapper_type = super::saved_connection_object::SavedConnectionObject)] +pub struct SavedConnectionObject { + pub(super) saved: OnceCell, + #[property(get = Self::id)] + id: PhantomData, + #[property(get = Self::name)] + name: PhantomData, + /// The driver and where it connects, as the row shows it. + #[property(get = Self::subtitle)] + subtitle: PhantomData, + #[property(get = Self::group)] + group: PhantomData>, + /// Milliseconds since the epoch, or 0 for never. + #[property(get = Self::last_opened)] + last_opened: PhantomData, +} + +#[glib::object_subclass] +impl ObjectSubclass for SavedConnectionObject { + const NAME: &'static str = "TableProSavedConnectionObject"; + type Type = super::saved_connection_object::SavedConnectionObject; +} + +#[glib::derived_properties] +impl ObjectImpl for SavedConnectionObject {} + +impl SavedConnectionObject { + fn id(&self) -> String { + self.saved.get().map(|saved| saved.id.to_string()).unwrap_or_default() + } + + fn name(&self) -> String { + self.saved.get().map(|saved| saved.name.clone()).unwrap_or_default() + } + + fn subtitle(&self) -> String { + self.saved + .get() + .map(|saved| super::saved_connection_object::subtitle_for(saved, None)) + .unwrap_or_default() + } + + fn group(&self) -> Option { + self.saved + .get() + .and_then(|saved| saved.group.as_ref()) + .map(|group| group.as_str().to_owned()) + } + + fn last_opened(&self) -> i64 { + self.saved + .get() + .and_then(|saved| saved.last_opened_at) + .map_or(0, |at| at.timestamp_millis()) + } +} diff --git a/linux/crates/app/src/services/saved_connection_order.rs b/linux/crates/app/src/services/saved_connection_order.rs new file mode 100644 index 0000000000..7eacacb295 --- /dev/null +++ b/linux/crates/app/src/services/saved_connection_order.rs @@ -0,0 +1,163 @@ +//! The order every connection picker shows the saved connections in. + +use tablepro_storage::{ConnectionGroupName, SavedConnection}; + +/// Sort the list the way every picker shows it. +/// +/// Ungrouped connections lead, because that is where every connection +/// starts and a user with no groups sees the list they always saw. +/// Groups follow in alphabetical order. Inside each block the order is +/// recency-first with an alphabetical tiebreaker, so the connection +/// opened last is the one nearest the top. Never-opened connections +/// fall to the end of their block and sort by name among themselves. +pub(crate) fn sort(connections: &mut [SavedConnection]) { + connections.sort_by(|a, b| { + group_key(a) + .cmp(&group_key(b)) + .then_with(|| recency_key(a).cmp(&recency_key(b))) + .then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase())) + }); +} + +/// Ungrouped sorts before every group, and groups sort by name +/// regardless of how they were capitalised. Two names that differ only +/// in case stay two blocks, in a fixed order. +fn group_key(connection: &SavedConnection) -> Option<(String, &str)> { + connection + .group + .as_ref() + .map(|group| (group.as_str().to_lowercase(), group.as_str())) +} + +/// Newest first, with never-opened last. The timestamp is negated +/// rather than compared backwards so it composes with the keys around +/// it. +fn recency_key(connection: &SavedConnection) -> (bool, i64) { + match connection.last_opened_at { + Some(at) => (false, -at.timestamp_millis()), + None => (true, 0), + } +} + +/// Every group in `sorted`, once each, in the order the list shows them: +/// the order of the picker's sections and of every Move to Group menu. +pub(crate) fn groups(sorted: &[SavedConnection]) -> Vec { + let mut found: Vec = Vec::new(); + for group in sorted.iter().filter_map(|connection| connection.group.as_ref()) { + if !found.contains(group) { + found.push(group.clone()); + } + } + found +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::{TimeZone, Utc}; + use uuid::Uuid; + + fn connection(name: &str, group: Option<&str>, opened_days_ago: Option) -> SavedConnection { + SavedConnection { + id: Uuid::new_v4(), + name: name.to_owned(), + driver_id: "postgres".to_owned(), + host: "db".to_owned(), + port: 5432, + database: "app".to_owned(), + username: "postgres".to_owned(), + tls: tablepro_core::TlsConfig::verify_full(), + read_only: false, + auth_mode: tablepro_core::AuthMode::Password, + ssh: None, + last_opened_at: opened_days_ago.map(|days| { + Utc.timestamp_opt(1_700_000_000 - days * 86_400, 0) + .single() + .expect("a time") + }), + color: None, + group: group.map(|group| ConnectionGroupName::new(group).expect("a group name")), + } + } + + fn names(connections: &[SavedConnection]) -> Vec { + connections.iter().map(|saved| saved.name.clone()).collect() + } + + #[test] + fn ungrouped_connections_lead_the_list() { + let mut list = vec![ + connection("in a group", Some("Work"), Some(1)), + connection("on its own", None, Some(1)), + ]; + + sort(&mut list); + + assert_eq!(names(&list), vec!["on its own", "in a group"]); + } + + #[test] + fn groups_follow_in_alphabetical_order_whatever_the_case() { + let mut list = vec![ + connection("c", Some("work"), Some(1)), + connection("a", Some("Archive"), Some(1)), + connection("b", Some("Personal"), Some(1)), + ]; + + sort(&mut list); + + assert_eq!(names(&list), vec!["a", "b", "c"]); + } + + #[test] + fn the_connection_opened_last_leads_its_own_group() { + let mut list = vec![ + connection("older", Some("Work"), Some(9)), + connection("newer", Some("Work"), Some(1)), + connection("never", Some("Work"), None), + ]; + + sort(&mut list); + + assert_eq!(names(&list), vec!["newer", "older", "never"]); + } + + #[test] + fn never_opened_connections_sort_by_name_among_themselves() { + let mut list = vec![connection("zeta", None, None), connection("Alpha", None, None)]; + + sort(&mut list); + + assert_eq!(names(&list), vec!["Alpha", "zeta"]); + } + + #[test] + fn the_group_list_holds_each_name_once_in_list_order() { + let mut list = vec![ + connection("a", Some("Work"), Some(1)), + connection("b", Some("Archive"), Some(1)), + connection("c", Some("Work"), Some(2)), + connection("d", None, Some(1)), + ]; + sort(&mut list); + + let names: Vec = groups(&list).into_iter().map(String::from).collect(); + assert_eq!(names, ["Archive", "Work"]); + } + + #[test] + fn groups_that_differ_only_in_case_keep_blocks_of_their_own() { + let mut list = vec![ + connection("a", Some("work"), Some(1)), + connection("b", Some("Work"), Some(3)), + connection("c", Some("work"), Some(5)), + connection("d", Some("Work"), Some(2)), + ]; + + sort(&mut list); + + assert_eq!(names(&list), vec!["d", "b", "a", "c"]); + let groups: Vec = groups(&list).into_iter().map(String::from).collect(); + assert_eq!(groups, ["Work", "work"]); + } +} diff --git a/linux/crates/app/src/services/secret_change.rs b/linux/crates/app/src/services/secret_change.rs new file mode 100644 index 0000000000..9620cbb8c0 --- /dev/null +++ b/linux/crates/app/src/services/secret_change.rs @@ -0,0 +1,13 @@ +use secrecy::SecretString; + +/// What an edit does to one of a saved connection's stored secrets. +#[derive(Debug, Clone, Default)] +pub enum SecretChange { + /// Leave whatever the keyring holds, which may be nothing. + #[default] + Keep, + Replace(SecretString), + /// The edited connection no longer logs in with it, or the user + /// asked not to keep it. + Remove, +} diff --git a/linux/crates/app/src/services/secret_changes.rs b/linux/crates/app/src/services/secret_changes.rs new file mode 100644 index 0000000000..80a3133818 --- /dev/null +++ b/linux/crates/app/src/services/secret_changes.rs @@ -0,0 +1,105 @@ +use tablepro_core::credentials::SecretKind; +use tablepro_storage::{SavedConnection, SavedSshAuth}; + +use super::secret_change::SecretChange; + +/// What an edit does to each secret a saved connection can keep. +#[derive(Debug, Clone, Default)] +pub struct SecretChanges { + pub database_password: SecretChange, + pub ssh_password: SecretChange, + pub ssh_passphrase: SecretChange, +} + +impl SecretChanges { + pub fn get(&self, kind: SecretKind) -> &SecretChange { + match kind { + SecretKind::DatabasePassword => &self.database_password, + SecretKind::SshPassword => &self.ssh_password, + SecretKind::SshPassphrase => &self.ssh_passphrase, + } + } + + /// Record on `connection` whether its key's passphrase is in the + /// keyring once the edit is saved. The form only knows what was + /// typed, and an empty passphrase row keeps the one `original` had. + pub fn settle_passphrase(&self, connection: &mut SavedConnection, original: &SavedConnection) { + let Some(SavedSshAuth::PrivateKey { has_passphrase, .. }) = connection.ssh.as_mut().map(|ssh| &mut ssh.auth) + else { + return; + }; + *has_passphrase = match &self.ssh_passphrase { + SecretChange::Replace(_) => true, + SecretChange::Remove => false, + SecretChange::Keep => original.has_stored_passphrase(), + }; + } +} + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use secrecy::SecretString; + use tablepro_core::{AuthMode, TlsConfig}; + use tablepro_storage::SavedSshConfig; + use uuid::Uuid; + + use super::*; + + fn keyed(has_passphrase: bool) -> SavedConnection { + SavedConnection { + id: Uuid::new_v4(), + name: "Prod".to_owned(), + driver_id: "postgres".to_owned(), + host: "db.example.com".to_owned(), + port: 5432, + database: "shop".to_owned(), + username: "app".to_owned(), + tls: TlsConfig::verify_full(), + read_only: false, + auth_mode: AuthMode::Password, + ssh: Some(SavedSshConfig { + host: "bastion".to_owned(), + port: None, + username: None, + jump_hosts: Vec::new(), + auth: SavedSshAuth::PrivateKey { + path: Some(PathBuf::from("/home/u/.ssh/id_ed25519")), + has_passphrase, + }, + }), + last_opened_at: None, + color: None, + group: None, + } + } + + #[test] + fn an_empty_passphrase_row_keeps_the_stored_passphrase() { + let original = keyed(true); + let mut edited = keyed(false); + + SecretChanges::default().settle_passphrase(&mut edited, &original); + + assert!(edited.has_stored_passphrase()); + } + + #[test] + fn a_typed_or_removed_passphrase_says_so() { + let mut edited = keyed(false); + let typed = SecretChanges { + ssh_passphrase: SecretChange::Replace(SecretString::from("phrase")), + ..SecretChanges::default() + }; + typed.settle_passphrase(&mut edited, &keyed(false)); + assert!(edited.has_stored_passphrase()); + + let removed = SecretChanges { + ssh_passphrase: SecretChange::Remove, + ..SecretChanges::default() + }; + removed.settle_passphrase(&mut edited, &keyed(true)); + assert!(!edited.has_stored_passphrase()); + } +} diff --git a/linux/crates/app/src/services/secret_labels.rs b/linux/crates/app/src/services/secret_labels.rs new file mode 100644 index 0000000000..3f5b93a1c4 --- /dev/null +++ b/linux/crates/app/src/services/secret_labels.rs @@ -0,0 +1,144 @@ +use tablepro_core::credentials::SecretKind; +use tablepro_storage::SavedConnection; + +/// The keyring label for a connection's secret. Seahorse shows it, so +/// it names what the secret opens in the user's own terms. +pub(crate) fn secret_label(kind: SecretKind, connection: &SavedConnection) -> String { + label_for_target(kind, &connection.secret_target(kind)) +} + +/// The label for a secret whose target is already worked out, as the +/// keyring credentials hand it over when the user asks to remember a +/// secret they typed. +pub(crate) fn label_for_target(kind: SecretKind, target: &str) -> String { + match kind { + SecretKind::DatabasePassword => { + crate::i18n::gettext_f("TablePro database password for {target}", &[("target", target)]) + } + SecretKind::SshPassword => crate::i18n::gettext_f("TablePro SSH password for {target}", &[("target", target)]), + SecretKind::SshPassphrase => { + crate::i18n::gettext_f("TablePro SSH key passphrase for {target}", &[("target", target)]) + } + } +} + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use tablepro_core::AuthMode; + use tablepro_storage::{SavedSshAuth, SavedSshConfig}; + use uuid::Uuid; + + use super::*; + + fn connection() -> SavedConnection { + SavedConnection { + id: Uuid::nil(), + name: "Prod".into(), + driver_id: "postgres".into(), + host: "db.example.com".into(), + port: 5432, + database: "shop".into(), + username: "app".into(), + tls: tablepro_core::TlsConfig::verify_full(), + read_only: false, + auth_mode: AuthMode::Password, + ssh: None, + last_opened_at: None, + color: None, + group: None, + } + } + + fn with_ssh(auth: SavedSshAuth, username: Option<&str>) -> SavedConnection { + SavedConnection { + ssh: Some(SavedSshConfig { + host: "bastion.example.com".into(), + port: None, + username: username.map(str::to_owned), + jump_hosts: Vec::new(), + auth, + }), + ..connection() + } + } + + #[test] + fn secret_label_and_target_per_kind() { + let plain = connection(); + assert_eq!( + plain.secret_target(SecretKind::DatabasePassword), + "app@db.example.com:5432/shop" + ); + assert_eq!( + secret_label(SecretKind::DatabasePassword, &plain), + "TablePro database password for app@db.example.com:5432/shop" + ); + + let with_user = with_ssh(SavedSshAuth::Password, Some("deploy")); + assert_eq!( + with_user.secret_target(SecretKind::SshPassword), + "deploy@bastion.example.com" + ); + + let keyed = with_ssh( + SavedSshAuth::PrivateKey { + path: Some(PathBuf::from("/home/u/.ssh/id_ed25519")), + has_passphrase: true, + }, + Some("deploy"), + ); + assert_eq!( + keyed.secret_target(SecretKind::SshPassphrase), + "/home/u/.ssh/id_ed25519" + ); + + let agent = with_ssh(SavedSshAuth::Agent, Some("deploy")); + assert_eq!( + agent.secret_target(SecretKind::SshPassphrase), + "deploy@bastion.example.com" + ); + } + + #[test] + fn an_empty_user_is_left_out_of_the_target() { + let anonymous = SavedConnection { + username: String::new(), + ..connection() + }; + assert_eq!( + anonymous.secret_target(SecretKind::DatabasePassword), + "db.example.com:5432/shop" + ); + + let no_ssh_user = with_ssh(SavedSshAuth::Password, None); + assert_eq!( + no_ssh_user.secret_target(SecretKind::SshPassword), + "bastion.example.com" + ); + + let empty_ssh_user = with_ssh(SavedSshAuth::Password, Some("")); + assert_eq!( + empty_ssh_user.secret_target(SecretKind::SshPassword), + "bastion.example.com" + ); + } + + #[test] + fn every_kind_has_a_distinct_label() { + let keyed = with_ssh( + SavedSshAuth::PrivateKey { + path: Some(PathBuf::from("/home/u/.ssh/id_ed25519")), + has_passphrase: true, + }, + Some("deploy"), + ); + let labels: std::collections::HashSet = SecretKind::ALL + .into_iter() + .map(|kind| secret_label(kind, &keyed)) + .collect(); + + assert_eq!(labels.len(), 3, "two kinds share a label: {labels:?}"); + } +} diff --git a/linux/crates/app/src/services/secret_save_report.rs b/linux/crates/app/src/services/secret_save_report.rs new file mode 100644 index 0000000000..683fc9aeb2 --- /dev/null +++ b/linux/crates/app/src/services/secret_save_report.rs @@ -0,0 +1,39 @@ +use tablepro_core::credentials::SecretError; + +/// The toast for a secret the keyring would not keep. The connection +/// still works; the user is asked for the secret next time. +pub(crate) fn not_saved_message(error: &SecretError) -> String { + match error { + SecretError::ServiceUnavailable { .. } => crate::i18n::gettext( + "No keyring is running, so the password was not saved. It will be asked for next time.", + ), + SecretError::PortalUnavailable { .. } => crate::i18n::gettext( + "The secret portal is unavailable, so the password was not saved. It will be asked for next time.", + ), + SecretError::Locked | SecretError::UnlockDismissed => crate::i18n::gettext( + "The keyring stayed locked, so the password was not saved. It will be asked for next time.", + ), + SecretError::Backend { detail } => { + crate::i18n::gettext_f("The keyring did not save the password: {reason}", &[("reason", detail)]) + } + SecretError::InvalidEncoding => crate::i18n::gettext("The keyring did not save the password."), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_message_says_what_happened_in_words_not_the_error_text() { + let locked = not_saved_message(&SecretError::UnlockDismissed); + let backend = not_saved_message(&SecretError::Backend { + detail: "org.freedesktop.Secret.Error.NoSuchObject".to_owned(), + }); + + assert!(locked.contains("password was not saved"), "{locked}"); + assert!(!locked.contains(&SecretError::UnlockDismissed.to_string()), "{locked}"); + assert!(backend.contains("NoSuchObject"), "{backend}"); + assert!(!backend.contains("the secret service reported"), "{backend}"); + } +} diff --git a/linux/crates/app/src/services/session_context.rs b/linux/crates/app/src/services/session_context.rs new file mode 100644 index 0000000000..6bc192e36a --- /dev/null +++ b/linux/crates/app/src/services/session_context.rs @@ -0,0 +1,123 @@ +use std::rc::Rc; +use std::sync::Arc; +use std::time::Duration; + +use tablepro_core::credentials::{CredentialInteraction, CredentialPrompter, SecretKind, SecretVault}; +use tablepro_core::{DriverRegistry, LivenessPolicy}; +use tablepro_session::operation::{DeadlinePolicy, OperationKind}; +use tablepro_session::runtime::{ProcessHold, Tasks}; +use tablepro_session::{Connector, CredentialSource, NoStoredCredentials, SessionDeps, SessionRegistry}; +use tablepro_storage::{KeyringCredentials, SavedConnection}; + +use super::active_session::ActiveSession; + +/// Everything a window needs to connect, run a session and close it, +/// built once in `run()` and shared on the GTK thread. +#[derive(Clone)] +pub struct SessionContext { + pub drivers: Arc, + pub connector: Arc, + pub deadlines: Arc, + pub prompter: Arc, + pub secrets: Arc, + pub sessions: Rc, + pub hold: Rc, + pub tasks: Tasks, + pub liveness: LivenessPolicy, + pub credentials: Rc, + pub active: ActiveSession, +} + +/// Where a session's secrets come from at each connect and reconnect. +pub trait CredentialSourceFactory { + /// A saved connection's keyring entries, or for a connection with no + /// saved entry, only what its request carries. `session_only` names + /// the secrets typed for the session that the keyring does not hold. + fn for_connection(&self, saved: Option<&SavedConnection>, session_only: &[SecretKind]) + -> Arc; +} + +/// The keyring, labelling what it remembers in the user's language. +pub struct KeyringSources { + secrets: Arc, +} + +impl KeyringSources { + pub fn new(secrets: Arc) -> Self { + Self { secrets } + } +} + +impl CredentialSourceFactory for KeyringSources { + fn for_connection( + &self, + saved: Option<&SavedConnection>, + session_only: &[SecretKind], + ) -> Arc { + match saved { + Some(saved) => Arc::new( + KeyringCredentials::for_saved(self.secrets.clone(), saved) + .with_labeler(translated_secret_label) + .keeping_session_secrets(session_only), + ), + None => Arc::new(NoStoredCredentials), + } + } +} + +impl SessionContext { + /// A connect the user is waiting on may ask them for what is missing. + pub fn attended(&self) -> CredentialInteraction { + CredentialInteraction::Attended(self.prompter.clone()) + } + + /// What a connect to `saved` reads its secrets from, or one with no + /// saved entry. + pub fn credentials_for(&self, saved: Option<&SavedConnection>) -> Arc { + self.credentials.for_connection(saved, &[]) + } + + /// What a session opened from `saved` reconnects with, keeping the + /// `session_only` secrets it logged in with over the keyring's. + pub fn session_credentials( + &self, + saved: Option<&SavedConnection>, + session_only: &[SecretKind], + ) -> Arc { + self.credentials.for_connection(saved, session_only) + } + + /// What a started session reconnects with. A Retry the user presses + /// may prompt again; the monitor's own retries never do. + pub fn session_deps(&self, credentials: Arc) -> SessionDeps { + SessionDeps { + connector: self.connector.clone(), + credentials, + deadlines: self.deadlines.clone(), + tasks: self.tasks.clone(), + prompter: Some(self.prompter.clone()), + } + } + + pub fn close_timeout(&self) -> Duration { + self.deadlines + .deadline(OperationKind::Close) + .unwrap_or(self.liveness.close_timeout) + } +} + +impl std::fmt::Debug for SessionContext { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("SessionContext") + .field("drivers", &self.drivers.len()) + .field("sessions", &self.sessions) + .field("active", &self.active) + .finish_non_exhaustive() + } +} + +/// The keyring label for a secret the user chose to remember, in the +/// wording the connect dialog uses for the same secret. +fn translated_secret_label(kind: SecretKind, target: &str) -> String { + super::secret_labels::label_for_target(kind, target) +} diff --git a/linux/crates/app/src/services/ssh_environment.rs b/linux/crates/app/src/services/ssh_environment.rs new file mode 100644 index 0000000000..ca8b1fafae --- /dev/null +++ b/linux/crates/app/src/services/ssh_environment.rs @@ -0,0 +1,25 @@ +//! What the machine offers for SSH logins. + +use std::path::PathBuf; + +/// What the machine offers for SSH logins, read each time the connect +/// dialog opens so a test can say what it has instead. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct SshEnvironment { + pub agent: bool, + pub default_key: Option, +} + +impl SshEnvironment { + /// Looking for a key file reads the disk, so this runs on the + /// blocking pool, never on the GTK thread. + pub fn detect_blocking() -> Self { + let agent = std::env::var_os("SSH_AUTH_SOCK").is_some_and(|socket| !socket.is_empty()); + let ssh_dir = glib::home_dir().join(".ssh"); + let default_key = ["id_ed25519", "id_ecdsa", "id_rsa"] + .into_iter() + .map(|candidate| ssh_dir.join(candidate)) + .find(|path| path.exists()); + Self { agent, default_key } + } +} diff --git a/linux/crates/app/src/services/structure_tracker.rs b/linux/crates/app/src/services/structure_tracker.rs new file mode 100644 index 0000000000..5e75d0d394 --- /dev/null +++ b/linux/crates/app/src/services/structure_tracker.rs @@ -0,0 +1,175 @@ +//! What a Structure tab would save, kept so the close prompt and the +//! save dispatcher can read it without reaching into the tab. The tab +//! replaces it on every edit; DDL has no per-change undo, only Discard +//! back to the table as it was read. + +use super::pending_structure::PendingStructure; +use crate::workspace::{PendingState, SaveOutcome, SaveRejection, UnknownCause}; + +#[derive(Debug, Default)] +pub struct StructureChangeTracker { + pending: Option, + saving: bool, + /// A save that may have landed. Nothing saves until the tab reads + /// the table again or its changes are discarded. + outcome_unknown: Option, +} + +impl StructureChangeTracker { + pub fn has_pending(&self) -> bool { + self.pending.as_ref().is_some_and(PendingStructure::has_changes) + } + + pub fn set_pending(&mut self, pending: Option) { + self.pending = pending; + } + + pub fn is_saving(&self) -> bool { + self.saving + } + + pub fn pending_state(&self) -> PendingState { + if let Some(cause) = &self.outcome_unknown { + return PendingState::OutcomeUnknown(cause.clone()); + } + if self.saving { + return PendingState::Saving; + } + match self.has_pending() { + true => PendingState::Unsaved, + false => PendingState::Clean, + } + } + + /// What Save would write, or `None` when there is nothing to. The + /// tab's own checks run here too, so a save from a close prompt + /// cannot skip them. + pub fn pending_save(&self) -> Result, SaveRejection> { + if self.outcome_unknown.is_some() { + return Err(SaveRejection::OutcomeUnknown); + } + if self.saving { + return Err(SaveRejection::AlreadySaving); + } + let Some(pending) = self.pending.clone().filter(PendingStructure::has_changes) else { + return Ok(None); + }; + pending.check().map_err(SaveRejection::Blocked)?; + Ok(Some(pending)) + } + + /// What Save writes, held as in flight until it settles. + pub fn begin_save(&mut self) -> Result, SaveRejection> { + let pending = self.pending_save()?; + self.saving = pending.is_some(); + Ok(pending) + } + + /// A commit leaves nothing pending: the tab reads the table again and + /// diffs against what is there now. + pub fn settle_save(&mut self, outcome: &SaveOutcome) { + self.saving = false; + match outcome { + SaveOutcome::Committed { .. } => self.pending = None, + SaveOutcome::RolledBack(_) => {} + SaveOutcome::Unknown(cause) => self.outcome_unknown = Some(cause.clone()), + } + } + + /// Drop what is pending, and with it a save whose outcome was + /// unknown: the tab reads the table again. + pub fn clear(&mut self) { + self.pending = None; + self.outcome_unknown = None; + } +} + +#[cfg(test)] +mod tests { + use tablepro_core::ddl::{DraftColumn, DraftProblem, DraftType, TableChanges, TableDraft}; + use tablepro_core::meta::TableRef; + + use super::*; + use crate::services::pending_structure::SaveBlocker; + + fn new_table(columns: usize) -> PendingStructure { + let mut draft = TableDraft::new(TableRef::unqualified("t")); + for index in 0..columns { + draft.columns.push(DraftColumn::new( + format!("c{index}"), + DraftType::Typed("int".to_owned()), + )); + } + PendingStructure { + original: None, + draft, + changes: TableChanges::default(), + unparsed_defaults: Vec::new(), + } + } + + #[test] + fn empty_tracker_has_no_pending() { + let mut tracker = StructureChangeTracker::default(); + + assert!(!tracker.has_pending()); + assert_eq!(tracker.begin_save(), Ok(None)); + } + + #[test] + fn only_a_structure_with_changes_is_saved() { + let mut tracker = StructureChangeTracker::default(); + tracker.set_pending(Some(new_table(0))); + assert!(!tracker.has_pending()); + assert_eq!(tracker.begin_save(), Ok(None)); + + tracker.set_pending(Some(new_table(1))); + assert!(tracker.has_pending()); + assert_eq!(tracker.begin_save(), Ok(Some(new_table(1)))); + tracker.settle_save(&SaveOutcome::Committed { rows: None }); + + assert!(!tracker.has_pending()); + } + + #[test] + fn a_save_runs_once_and_an_unknown_outcome_holds_the_next() { + let mut tracker = StructureChangeTracker::default(); + tracker.set_pending(Some(new_table(1))); + + assert!(matches!(tracker.begin_save(), Ok(Some(_)))); + assert_eq!(tracker.begin_save(), Err(SaveRejection::AlreadySaving)); + tracker.settle_save(&SaveOutcome::Unknown(UnknownCause::Closed)); + + assert_eq!(tracker.begin_save(), Err(SaveRejection::OutcomeUnknown)); + assert!(tracker.has_pending()); + tracker.clear(); + assert_eq!(tracker.pending_state(), PendingState::Clean); + } + + #[test] + fn reading_what_a_save_would_write_takes_nothing() { + let mut tracker = StructureChangeTracker::default(); + tracker.set_pending(Some(new_table(1))); + + assert_eq!(tracker.pending_save(), Ok(Some(new_table(1)))); + assert_eq!(tracker.pending_state(), PendingState::Unsaved); + assert!(matches!(tracker.begin_save(), Ok(Some(_)))); + assert_eq!(tracker.pending_save(), Err(SaveRejection::AlreadySaving)); + } + + #[test] + fn a_draft_the_tab_would_refuse_is_refused_here_too() { + let mut tracker = StructureChangeTracker::default(); + let mut unnamed = new_table(1); + unnamed.draft.columns[0].name = String::new(); + tracker.set_pending(Some(unnamed)); + + assert_eq!( + tracker.begin_save(), + Err(SaveRejection::Blocked(SaveBlocker::Draft( + DraftProblem::EmptyColumnName + ))) + ); + assert_eq!(tracker.pending_state(), PendingState::Unsaved); + } +} diff --git a/linux/crates/app/src/services/structure_tracker_handle.rs b/linux/crates/app/src/services/structure_tracker_handle.rs new file mode 100644 index 0000000000..b48c94e3c9 --- /dev/null +++ b/linux/crates/app/src/services/structure_tracker_handle.rs @@ -0,0 +1,45 @@ +use std::cell::RefCell; +use std::rc::Rc; + +use super::pending_structure::PendingStructure; +use super::structure_tracker::StructureChangeTracker; +use crate::workspace::{PendingState, SaveOutcome, SaveRejection}; + +/// A Structure tab's pending save. Every method borrows only for its own +/// body and returns owned data. +#[derive(Debug, Clone, Default)] +pub struct StructureTrackerHandle(Rc>); + +impl StructureTrackerHandle { + pub fn set_pending(&self, pending: Option) { + self.0.borrow_mut().set_pending(pending); + } + + pub fn pending_save(&self) -> Result, SaveRejection> { + self.0.borrow().pending_save() + } + + pub fn begin_save(&self) -> Result, SaveRejection> { + self.0.borrow_mut().begin_save() + } + + pub fn settle_save(&self, outcome: &SaveOutcome) { + self.0.borrow_mut().settle_save(outcome); + } + + pub fn pending_state(&self) -> PendingState { + self.0.borrow().pending_state() + } + + pub fn is_saving(&self) -> bool { + self.0.borrow().is_saving() + } + + pub fn has_pending(&self) -> bool { + self.0.borrow().has_pending() + } + + pub fn clear(&self) { + self.0.borrow_mut().clear(); + } +} diff --git a/linux/crates/app/src/services/structure_tracker_registry.rs b/linux/crates/app/src/services/structure_tracker_registry.rs new file mode 100644 index 0000000000..dffec7c9ad --- /dev/null +++ b/linux/crates/app/src/services/structure_tracker_registry.rs @@ -0,0 +1,53 @@ +use super::structure_tracker_handle::StructureTrackerHandle; +use super::tab_registry::TabRegistry; + +pub type StructureTrackerRegistry = TabRegistry; + +#[cfg(test)] +mod tests { + use tablepro_core::ddl::{DraftColumn, DraftType, TableChanges, TableDraft}; + use tablepro_core::meta::TableRef; + use uuid::Uuid; + + use super::*; + use crate::services::pending_structure::PendingStructure; + + fn new_table() -> Option { + let mut draft = TableDraft::new(TableRef::unqualified("t")); + draft + .columns + .push(DraftColumn::new("a", DraftType::Typed("int".to_owned()))); + Some(PendingStructure { + original: None, + draft, + changes: TableChanges::default(), + unparsed_defaults: Vec::new(), + }) + } + + #[test] + fn registries_isolated_per_workspace() { + let first = StructureTrackerRegistry::default(); + let second = StructureTrackerRegistry::default(); + let tab = Uuid::new_v4(); + + first.register(tab).set_pending(new_table()); + second.register(tab); + + assert!(first.get(tab).is_some_and(|tracker| tracker.has_pending())); + assert!(second.get(tab).is_some_and(|tracker| !tracker.has_pending())); + } + + #[test] + fn register_returns_the_tabs_own_tracker() { + let registry = StructureTrackerRegistry::default(); + let edited = Uuid::new_v4(); + registry.register(edited).set_pending(new_table()); + registry.register(Uuid::new_v4()); + + assert!(registry.register(edited).has_pending()); + + registry.get(edited).expect("registered").clear(); + assert!(!registry.register(edited).has_pending()); + } +} diff --git a/linux/crates/app/src/services/tab_registry.rs b/linux/crates/app/src/services/tab_registry.rs new file mode 100644 index 0000000000..390614d872 --- /dev/null +++ b/linux/crates/app/src/services/tab_registry.rs @@ -0,0 +1,34 @@ +use std::cell::RefCell; +use std::collections::HashMap; + +use uuid::Uuid; + +/// One workspace's trackers, keyed by tab. Each window owns its own, so +/// edits in one window never count as unsaved in another. +#[derive(Debug)] +pub struct TabRegistry { + trackers: RefCell>, +} + +impl Default for TabRegistry { + fn default() -> Self { + Self { + trackers: RefCell::new(HashMap::new()), + } + } +} + +impl TabRegistry { + /// The tab's tracker, created on first use. + pub fn register(&self, tab_id: Uuid) -> H { + self.trackers.borrow_mut().entry(tab_id).or_default().clone() + } + + pub fn unregister(&self, tab_id: Uuid) { + self.trackers.borrow_mut().remove(&tab_id); + } + + pub fn get(&self, tab_id: Uuid) -> Option { + self.trackers.borrow().get(&tab_id).cloned() + } +} diff --git a/linux/crates/app/src/services/tab_tracker_handle.rs b/linux/crates/app/src/services/tab_tracker_handle.rs new file mode 100644 index 0000000000..5e8d923278 --- /dev/null +++ b/linux/crates/app/src/services/tab_tracker_handle.rs @@ -0,0 +1,372 @@ +use std::cell::RefCell; +use std::rc::Rc; + +use tablepro_core::dml::BuildSqlError; +use tablepro_core::edit::CellInput; +use tablepro_core::{ColumnInfo, Value}; + +use super::cell_overlay::{CellMarks, CellOverlay}; +use super::change_tracker::{DraftRow, ErrorFlash, RowKey, RowState, TabChangeTracker, TrackedChanges}; +use super::save_batch::{SaveBatch, SaveTarget}; +use super::tracker_delta::TrackerDelta; +use super::tracker_edit::TrackerEdit; +use crate::workspace::{PendingState, SaveOutcome, SaveRejection, SaveSnapshot}; + +/// A browse tab's tracker. Each method borrows only for its own body +/// and hands back owned data, so a grid bind that runs while the +/// caller is still applying a delta never meets a held borrow. +#[derive(Debug, Clone, Default)] +pub struct TabTrackerHandle(Rc>); + +impl TabTrackerHandle { + pub fn cell_overlay(&self, row_key: &RowKey, col: usize) -> CellOverlay { + self.0.borrow().cell_overlay(row_key, col) + } + + pub fn cell_marks(&self, row_key: &RowKey, col: usize) -> CellMarks { + self.0.borrow().cell_marks(row_key, col) + } + + pub fn row_state(&self, row_key: &RowKey) -> RowState { + self.0.borrow().row_state(row_key) + } + + pub fn pending_values(&self, row_key: &RowKey, width: usize) -> Vec<(usize, Value)> { + self.0.borrow().pending_values(row_key, width) + } + + #[must_use = "the grid shows stale rows until the delta is applied"] + pub fn apply(&self, edit: TrackerEdit) -> TrackerDelta { + self.0.borrow_mut().apply(edit) + } + + /// Whether `apply` would hold the edit back for the running save. + pub fn refuses(&self, edit: &TrackerEdit) -> bool { + self.0.borrow().edit_is_in_flight(edit) + } + + #[must_use = "the grid shows stale rows until the delta is applied"] + pub fn undo(&self) -> Option { + self.0.borrow_mut().undo() + } + + #[must_use = "the grid shows stale rows until the delta is applied"] + pub fn redo(&self) -> Option { + self.0.borrow_mut().redo() + } + + pub fn pending_count(&self) -> usize { + self.0.borrow().pending_count() + } + + pub fn can_undo(&self) -> bool { + self.0.borrow().can_undo() + } + + pub fn can_redo(&self) -> bool { + self.0.borrow().can_redo() + } + + pub fn drafts(&self) -> Vec { + self.0.borrow().drafts().to_vec() + } + + pub fn draft_values(&self, draft_id: u64) -> Option> { + self.0.borrow().draft_values(draft_id) + } + + pub fn draft_inputs(&self, draft_id: u64) -> Option> { + self.0.borrow().draft_inputs(draft_id) + } + + pub fn draft_input(&self, draft_id: u64, col: usize) -> Option { + self.0.borrow().draft_input(draft_id, col) + } + + pub fn draft_cells(&self, draft_id: u64, columns: &[ColumnInfo]) -> Option> { + self.0.borrow().draft_cells(draft_id, columns) + } + + pub fn change_set(&self, columns: &[ColumnInfo]) -> Result { + self.0.borrow().change_set(columns) + } + + #[must_use = "the grid shows stale rows until the delta is applied"] + pub fn discard(&self) -> TrackerDelta { + self.0.borrow_mut().discard() + } + + pub fn flash_error_row(&self, row_key: RowKey) -> ErrorFlash { + self.0.borrow_mut().flash_error_row(row_key) + } + + pub fn clear_error_row_if(&self, generation: u64) -> bool { + self.0.borrow_mut().clear_error_row_if(generation) + } + + pub fn is_error_row(&self, row_key: &RowKey) -> bool { + self.0.borrow().is_error_row(row_key) + } + + /// The table and columns the tab read, which every save is planned + /// against. + #[must_use = "the grid shows stale rows until the delta is applied"] + pub(crate) fn set_target(&self, target: SaveTarget) -> TrackerDelta { + self.0.borrow_mut().set_target(target) + } + + pub(crate) fn pending_state(&self) -> PendingState { + self.0.borrow().pending_state() + } + + pub(crate) fn begin_save(&self) -> Result, SaveRejection> { + self.0.borrow_mut().begin_save() + } + + #[must_use = "the grid shows stale rows until the delta is applied"] + pub(crate) fn settle_save(&self, outcome: &SaveOutcome, snapshot: SaveSnapshot) -> TrackerDelta { + self.0.borrow_mut().settle_save(outcome, snapshot) + } +} + +#[cfg(test)] +mod tests { + use tablepro_core::DriverError; + use tablepro_core::column::{ColumnDefault, ColumnKind, ColumnType, IntegerKind, IntegerWidth}; + use tablepro_core::meta::TableRef; + + use super::*; + use crate::workspace::{RollbackCause, UnknownCause}; + + fn row(id: i64) -> RowKey { + RowKey::from_pk_values(&[Value::Int(id)]).expect("a key") + } + + fn integer(name: &str, primary_key: bool) -> ColumnInfo { + let mut column_type = ColumnType::untyped_text("integer"); + column_type.kind = ColumnKind::Integer(IntegerKind { + width: IntegerWidth::W32, + signed: true, + }); + ColumnInfo { + name: name.to_owned(), + column_type, + nullable: false, + primary_key, + is_auto_increment: false, + is_generated: false, + default: ColumnDefault::None, + comment: None, + } + } + + /// A tracker on a two-column table, with one edit pending. + fn edited() -> TabTrackerHandle { + let tracker = TabTrackerHandle::default(); + let _ = tracker.set_target(SaveTarget { + table: TableRef::unqualified("t"), + columns: vec![integer("id", true), integer("n", false)], + }); + edit_cell(&tracker, 1, 2); + tracker + } + + fn edit_cell(tracker: &TabTrackerHandle, id: i64, value: i64) { + let _ = tracker.apply(TrackerEdit::Cell { + row_key: row(id), + col: 1, + original: Value::Int(0), + value: Value::Int(value), + }); + } + + fn rolled_back() -> SaveOutcome { + SaveOutcome::RolledBack(RollbackCause::Driver(DriverError::server("no"))) + } + + #[test] + fn frozen_tracker_rejects_begin_save() { + let tracker = edited(); + let batch = tracker.begin_save().expect("a save").expect("changes to save"); + + assert_eq!(tracker.begin_save().err(), Some(SaveRejection::AlreadySaving)); + let _ = tracker.settle_save(&SaveOutcome::Unknown(UnknownCause::Interrupted), batch.snapshot); + + assert_eq!(tracker.begin_save().err(), Some(SaveRejection::OutcomeUnknown)); + assert_eq!( + tracker.pending_state(), + PendingState::OutcomeUnknown(UnknownCause::Interrupted) + ); + let _ = tracker.discard(); + assert_eq!(tracker.pending_state(), PendingState::Clean); + assert!(matches!(tracker.begin_save(), Ok(None))); + } + + #[test] + fn rolled_back_keeps_dirty() { + let tracker = edited(); + let batch = tracker.begin_save().expect("a save").expect("changes to save"); + assert_eq!(tracker.pending_state(), PendingState::Saving); + + let delta = tracker.settle_save(&rolled_back(), batch.snapshot); + + assert_eq!(delta.pending_count, 1); + assert_eq!(tracker.pending_state(), PendingState::Unsaved); + let again = tracker.begin_save().expect("a save").expect("changes to save"); + assert_eq!(again.changes.changes.changes, batch.changes.changes.changes); + } + + #[test] + fn committed_clears_only_snapshot_edits() { + let tracker = edited(); + let _ = tracker.apply(TrackerEdit::InsertDrafts { + rows: vec![ + vec![Value::Int(9), Value::Int(9)] + .into_iter() + .map(CellInput::Value) + .collect(), + ], + }); + let batch = tracker.begin_save().expect("a save").expect("changes to save"); + assert_eq!(batch.changes.changes.changes.len(), 2); + + // Made while the save ran: a new value for a cell in the save, + // and an edit to another row. + edit_cell(&tracker, 1, 3); + edit_cell(&tracker, 4, 5); + let delta = tracker.settle_save(&SaveOutcome::Committed { rows: Some(2) }, batch.snapshot); + + assert_eq!(delta.pending_count, 2); + assert_eq!(delta.removed_drafts.len(), 1); + assert!(tracker.drafts().is_empty(), "the inserted draft stayed pending"); + assert_eq!(tracker.pending_state(), PendingState::Unsaved); + assert_eq!(tracker.cell_overlay(&row(1), 1).shown(Value::Int(0)), Value::Int(3)); + assert_eq!(tracker.cell_overlay(&row(4), 1).shown(Value::Int(0)), Value::Int(5)); + } + + #[test] + fn an_insert_or_delete_in_flight_is_not_changed_until_it_settles() { + let tracker = edited(); + let inserted = tracker.apply(TrackerEdit::InsertDrafts { + rows: vec![ + vec![Value::Int(9), Value::Int(9)] + .into_iter() + .map(CellInput::Value) + .collect(), + ], + }); + let draft_id = match inserted.inserted_drafts.as_slice() { + [draft_id] => *draft_id, + other => panic!("an insert adds one draft row: {other:?}"), + }; + let batch = tracker.begin_save().expect("a save").expect("changes to save"); + + let typed = TrackerEdit::DraftCell { + draft_id, + col: 1, + input: CellInput::Value(Value::Int(10)), + }; + assert!(tracker.refuses(&typed)); + let held = tracker.apply(typed); + let _ = tracker.apply(TrackerEdit::DeleteRows { + persisted: Vec::new(), + drafts: vec![draft_id], + }); + assert!(tracker.undo().is_none(), "undo ran while a save was in flight"); + + assert_eq!( + held.changed, + [RowKey::Draft(draft_id)], + "the typed value stayed on screen" + ); + assert!(held.inserted_drafts.is_empty() && held.removed_drafts.is_empty()); + + assert_eq!(tracker.drafts()[0].cells[1], CellInput::Value(Value::Int(9))); + let _ = tracker.settle_save(&rolled_back(), batch.snapshot); + assert!(tracker.undo().is_some()); + } + + #[test] + fn apply_returns_delta_with_pending_count() { + let tracker = TabTrackerHandle::default(); + + let edited = tracker.apply(TrackerEdit::Cell { + row_key: row(1), + col: 1, + original: Value::Text("a".into()), + value: Value::Text("b".into()), + }); + let inserted = tracker.apply(TrackerEdit::InsertDrafts { + rows: vec![ + vec![Value::Null, Value::Text("c".into())] + .into_iter() + .map(CellInput::Value) + .collect(), + ], + }); + + assert_eq!(edited.changed, vec![row(1)]); + assert_eq!(edited.pending_count, 1); + assert_eq!(inserted.pending_count, 2); + let [draft_id] = inserted.inserted_drafts.as_slice() else { + panic!("an insert adds one draft row: {:?}", inserted.inserted_drafts); + }; + assert_eq!( + tracker.draft_values(*draft_id), + Some(vec![Value::Null, Value::Text("c".into())]) + ); + assert_eq!(tracker.pending_count(), 2); + } + + #[test] + fn undo_rebinds_the_row_with_the_previous_pending_value() { + let tracker = TabTrackerHandle::default(); + let fetched = Value::Text("a".into()); + for value in ["b", "c"] { + let _ = tracker.apply(TrackerEdit::Cell { + row_key: row(1), + col: 1, + original: fetched.clone(), + value: Value::Text(value.into()), + }); + } + + let undone = tracker.undo().expect("an edit to undo"); + + assert_eq!(undone.changed, vec![row(1)]); + assert_eq!(undone.pending_count, 1); + assert_eq!(tracker.cell_overlay(&row(1), 1).shown(fetched), Value::Text("b".into())); + } + + #[test] + fn clones_share_one_tracker() { + let tracker = TabTrackerHandle::default(); + let grid_copy = tracker.clone(); + + let _ = tracker.apply(TrackerEdit::DeleteRows { + persisted: vec![(row(4), vec![Value::Int(4)].into())], + drafts: Vec::new(), + }); + + assert_eq!(grid_copy.row_state(&row(4)), RowState::PendingDelete); + } + + #[test] + fn a_read_inside_a_delta_loop_finds_no_held_borrow() { + let tracker = TabTrackerHandle::default(); + let delta = tracker.apply(TrackerEdit::Cell { + row_key: row(1), + col: 0, + original: Value::Int(1), + value: Value::Int(2), + }); + + for key in &delta.changed { + let _ = tracker.apply(TrackerEdit::DeleteRows { + persisted: vec![(key.clone(), vec![Value::Int(1)].into())], + drafts: Vec::new(), + }); + assert_eq!(tracker.cell_overlay(key, 0).shown(Value::Int(1)), Value::Int(2)); + } + } +} diff --git a/linux/crates/app/src/services/touch.rs b/linux/crates/app/src/services/touch.rs new file mode 100644 index 0000000000..bc783a472e --- /dev/null +++ b/linux/crates/app/src/services/touch.rs @@ -0,0 +1,12 @@ +/// When a pending change was made, from a counter its tracker owns. A +/// save runs its rows in touch order, so the statements follow the +/// edits rather than the order a hash map happens to iterate in. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct Touch(u64); + +impl Touch { + #[must_use] + pub fn next(self) -> Self { + Touch(self.0 + 1) + } +} diff --git a/linux/crates/app/src/services/tracker_delta.rs b/linux/crates/app/src/services/tracker_delta.rs new file mode 100644 index 0000000000..5db29b8fff --- /dev/null +++ b/linux/crates/app/src/services/tracker_delta.rs @@ -0,0 +1,14 @@ +use super::change_tracker::RowKey; + +/// What a tracker change means for the grid, handed back once the +/// tracker is no longer borrowed so the caller can touch GTK freely. +#[derive(Debug, Clone, Default, PartialEq)] +pub struct TrackerDelta { + /// Rows whose pending state changed and need a redraw. + pub changed: Vec, + /// Draft rows the list store gains, oldest first. + pub inserted_drafts: Vec, + /// Draft rows the list store loses. + pub removed_drafts: Vec, + pub pending_count: usize, +} diff --git a/linux/crates/app/src/services/tracker_edit.rs b/linux/crates/app/src/services/tracker_edit.rs new file mode 100644 index 0000000000..14bf499224 --- /dev/null +++ b/linux/crates/app/src/services/tracker_edit.rs @@ -0,0 +1,46 @@ +use std::sync::Arc; + +use tablepro_core::Value; +use tablepro_core::edit::CellInput; + +use super::change_tracker::RowKey; + +/// One change a browse tab records against its pending edits. +#[derive(Debug, Clone, PartialEq)] +pub enum TrackerEdit { + Cell { + row_key: RowKey, + col: usize, + original: Value, + value: Value, + }, + DraftCell { + draft_id: u64, + col: usize, + input: CellInput, + }, + /// One undo step however many rows it holds. + InsertDrafts { rows: Vec> }, + /// One Delete: the saved rows are marked, each with the row as it + /// was read, and the drafts are dropped. + DeleteRows { + persisted: Vec<(RowKey, Arc<[Value]>)>, + drafts: Vec, + }, +} + +impl TrackerEdit { + /// The rows the edit changes. An insert makes a new one. + pub fn row_keys(&self) -> Vec { + match self { + Self::Cell { row_key, .. } => vec![row_key.clone()], + Self::DraftCell { draft_id, .. } => vec![RowKey::Draft(*draft_id)], + Self::DeleteRows { persisted, drafts } => persisted + .iter() + .map(|(row_key, _)| row_key.clone()) + .chain(drafts.iter().map(|draft_id| RowKey::Draft(*draft_id))) + .collect(), + Self::InsertDrafts { .. } => Vec::new(), + } + } +} diff --git a/linux/crates/app/src/services/undo_op.rs b/linux/crates/app/src/services/undo_op.rs new file mode 100644 index 0000000000..757ca26051 --- /dev/null +++ b/linux/crates/app/src/services/undo_op.rs @@ -0,0 +1,134 @@ +use tablepro_core::edit::CellInput; + +use super::change_tracker::{CellEdit, DeleteMark, DraftRow, RowKey}; +use super::column_relayout::ColumnRelayout; +use super::touch::Touch; + +/// One step of a tab's undo history, holding the pending state on both +/// sides of the step. Undo puts `before` back exactly and redo puts +/// `after` back, touches included, so a save after an undo runs in the +/// same order it would have before the step. +#[derive(Debug, Clone, PartialEq)] +pub enum UndoOp { + /// `None` on either side means the cell had no pending edit, which + /// is also what an edit back to the fetched value leaves. + CellEdit { + row_key: RowKey, + col: usize, + before: Option, + after: Option, + }, + DraftCell { + draft_id: u64, + col: usize, + before: CellInput, + after: CellInput, + before_touch: Touch, + after_touch: Touch, + }, + /// One undo removes every draft the insert made. + Insert { + drafts: Vec, + }, + Delete { + steps: Vec, + }, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct DeleteStep { + pub row_key: RowKey, + pub before: Option, + pub after: DeleteMark, +} + +impl UndoOp { + /// The step under the columns `layout` moves to, or `None` when a + /// value in it has no place there. + pub(crate) fn relayout(&self, layout: &ColumnRelayout<'_>) -> Option { + let delete_mark = |mark: &DeleteMark| DeleteMark { + original_values: layout.fetched(&mark.original_values), + touched: mark.touched, + }; + match self { + UndoOp::CellEdit { + row_key, + col, + before, + after, + } => { + layout.key(row_key).ok()?; + let mut moved = layout.position(*col)?; + for edit in [before, after].into_iter().flatten() { + moved = layout.cell(*col, &edit.pending).ok()?; + } + Some(UndoOp::CellEdit { + row_key: row_key.clone(), + col: moved, + before: before.clone(), + after: after.clone(), + }) + } + UndoOp::DraftCell { + draft_id, + col, + before, + after, + before_touch, + after_touch, + } => { + layout.input_cell(*col, before).ok()?; + Some(UndoOp::DraftCell { + draft_id: *draft_id, + col: layout.input_cell(*col, after).ok()?, + before: before.clone(), + after: after.clone(), + before_touch: *before_touch, + after_touch: *after_touch, + }) + } + UndoOp::Insert { drafts } => Some(UndoOp::Insert { + drafts: drafts + .iter() + .map(|draft| { + Some(DraftRow { + cells: layout.draft(&draft.cells).ok()?, + ..draft.clone() + }) + }) + .collect::>()?, + }), + UndoOp::Delete { steps } => Some(UndoOp::Delete { + steps: steps + .iter() + .map(|step| { + layout.key(&step.row_key).ok()?; + Some(DeleteStep { + row_key: step.row_key.clone(), + before: step.before.as_ref().map(delete_mark), + after: delete_mark(&step.after), + }) + }) + .collect::>()?, + }), + } + } + + /// Drops what the step says about a draft that is gone, and returns + /// whether anything is left of the step. + pub fn forget_draft(&mut self, draft_id: u64) -> bool { + let gone = RowKey::Draft(draft_id); + match self { + UndoOp::Insert { drafts } => { + drafts.retain(|draft| draft.draft_id != draft_id); + !drafts.is_empty() + } + UndoOp::DraftCell { draft_id: id, .. } => *id != draft_id, + UndoOp::CellEdit { row_key, .. } => *row_key != gone, + UndoOp::Delete { steps } => { + steps.retain(|step| step.row_key != gone); + !steps.is_empty() + } + } + } +} diff --git a/linux/crates/app/src/storage.rs b/linux/crates/app/src/storage.rs new file mode 100644 index 0000000000..2c69dd7254 --- /dev/null +++ b/linux/crates/app/src/storage.rs @@ -0,0 +1,101 @@ +use std::rc::Rc; + +use tablepro_core::credentials::SecretVault; +use tablepro_session::runtime::Tasks; +use tablepro_storage::{ConnectionStore, StoragePaths}; + +use crate::persistence::{ColumnWidthStore, FilterSettingsStore}; +use crate::workspace::{WorkspaceStore, remove_legacy_tab_file}; + +/// Everything the app persists, resolved once at startup and handed to +/// the root component. Nothing reaches for a global, so a test can point +/// a whole app at a temporary root. +#[derive(Clone)] +pub struct AppStorage { + paths: StoragePaths, + connections: ConnectionStore, + secrets: std::sync::Arc, + column_widths: ColumnWidthStore, + filter_settings: FilterSettingsStore, + workspaces: WorkspaceStore, +} + +impl AppStorage { + /// The vault is injected so a test can substitute one that never + /// touches the user's keyring. + pub fn new(paths: StoragePaths, secrets: std::sync::Arc, tasks: &Tasks) -> Self { + let connections = ConnectionStore::new(&paths); + let column_widths = ColumnWidthStore::load(paths.column_widths_file(), tasks); + let filter_settings = FilterSettingsStore::load(paths.filter_settings_file(), tasks); + let workspaces = WorkspaceStore::start( + paths.workspaces_dir(), + tablepro_storage::DraftStore::new(paths.drafts_dir()), + tasks, + ); + let state = paths.state.clone(); + drop(tasks.spawn_blocking_task(move || match remove_legacy_tab_file(&state) { + Ok(true) => tracing::info!("removed the tab file an older build left"), + Ok(false) => {} + Err(error) => tracing::warn!(%error, "could not remove the tab file an older build left"), + })); + Self { + paths, + connections, + secrets, + column_widths, + filter_settings, + workspaces, + } + } + + pub fn paths(&self) -> &StoragePaths { + &self.paths + } + + pub fn connections(&self) -> &ConnectionStore { + &self.connections + } + + pub fn secrets(&self) -> &std::sync::Arc { + &self.secrets + } + + pub fn column_widths(&self) -> &ColumnWidthStore { + &self.column_widths + } + + pub fn filter_settings(&self) -> &FilterSettingsStore { + &self.filter_settings + } + + pub fn workspaces(&self) -> &WorkspaceStore { + &self.workspaces + } +} + +pub type SharedStorage = Rc; + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_support::{test_runtime, wait_until}; + + #[gtk4::test] + fn opening_the_storage_removes_the_tab_file_an_older_build_left() { + let root = tempfile::tempdir().expect("a root"); + let paths = StoragePaths::under(root.path(), "tablepro-test", "app.tablepro.TablePro.Devel"); + std::fs::create_dir_all(&paths.state).expect("the state directory"); + let legacy = paths.state.join("workspace_state.json"); + std::fs::write(&legacy, b"{}").expect("the legacy file"); + let runtime = test_runtime(); + + let _storage = AppStorage::new( + paths, + std::sync::Arc::new(tablepro_storage::SecretStore::new(crate::config::secret_schema())), + &runtime.tasks(), + ); + + wait_until(std::time::Duration::from_secs(10), || !legacy.exists()).expect("the legacy file is removed"); + runtime.shutdown(); + } +} diff --git a/linux/crates/app/src/test_support/accessible_label.rs b/linux/crates/app/src/test_support/accessible_label.rs new file mode 100644 index 0000000000..f64d98c5c6 --- /dev/null +++ b/linux/crates/app/src/test_support/accessible_label.rs @@ -0,0 +1,51 @@ +use gtk4::prelude::*; +use gtk4::{AccessibleProperty, AccessibleRelation}; +use libadwaita as adw; +use libadwaita::prelude::*; + +use crate::test_support::{UnlabelledWidget, first_descendant_of_type}; + +pub(crate) fn assert_labelled(widget: &impl IsA) -> Result<(), UnlabelledWidget> { + if !test_backend_records_properties() { + return Err(UnlabelledWidget::TestBackendMissing); + } + let widget = widget.as_ref(); + let type_name = widget.type_().name().to_owned(); + + let Some(row) = widget.downcast_ref::() else { + let labelled = gtk4::test_accessible_has_property(widget, AccessibleProperty::Label) + || gtk4::test_accessible_has_relation(widget, AccessibleRelation::LabelledBy); + return if labelled { + Ok(()) + } else { + Err(UnlabelledWidget::MissingLabel { type_name }) + }; + }; + + // A row whose controls are its content names itself instead. + if row.title().is_empty() && !gtk4::test_accessible_has_property(widget, AccessibleProperty::Label) { + return Err(UnlabelledWidget::EmptyRowTitle { type_name }); + } + if widget.is::() { + let labelled = first_descendant_of_type::(widget) + .is_some_and(|text| gtk4::test_accessible_has_relation(&text, AccessibleRelation::LabelledBy)); + return if labelled { + Ok(()) + } else { + Err(UnlabelledWidget::EntryTextNotLabelled { type_name }) + }; + } + if let Some(action_row) = widget.downcast_ref::() + && let Some(activatable) = action_row.activatable_widget() + && !gtk4::test_accessible_has_relation(&activatable, AccessibleRelation::LabelledBy) + { + return Err(UnlabelledWidget::ActivatableWidgetNotLabelled { type_name }); + } + Ok(()) +} + +pub(crate) fn test_backend_records_properties() -> bool { + let probe = gtk4::Button::new(); + probe.update_property(&[gtk4::accessible::Property::Label("probe")]); + gtk4::test_accessible_has_property(&probe, AccessibleProperty::Label) +} diff --git a/linux/crates/app/src/test_support/alert_response.rs b/linux/crates/app/src/test_support/alert_response.rs new file mode 100644 index 0000000000..791c491bf7 --- /dev/null +++ b/linux/crates/app/src/test_support/alert_response.rs @@ -0,0 +1,30 @@ +use gtk4::prelude::*; +use libadwaita as adw; +use libadwaita::prelude::*; + +/// Answer the presented `dialog` with `response` by clicking its button. +/// Emitting `response` by hand would skip the close a click makes first, +/// and with it the `closed` that reaches handlers before the response. +pub(crate) fn respond(dialog: &adw::AlertDialog, response: &str) { + assert!( + click_response(dialog, response), + "the dialog has no “{response}” button" + ); +} + +/// Click the button for `response` in `dialog`. False when it has none. +pub(crate) fn click_response(dialog: &adw::AlertDialog, response: &str) -> bool { + if !dialog.has_response(response) { + return false; + } + let label = dialog.response_label(response); + let Some(button) = super::descendants(dialog) + .into_iter() + .filter_map(|widget| widget.downcast::().ok()) + .find(|button| button.label().as_deref() == Some(label.as_str())) + else { + return false; + }; + button.emit_clicked(); + true +} diff --git a/linux/crates/app/src/test_support/application_end.rs b/linux/crates/app/src/test_support/application_end.rs new file mode 100644 index 0000000000..20426b1b45 --- /dev/null +++ b/linux/crates/app/src/test_support/application_end.rs @@ -0,0 +1,25 @@ +use gtk4::glib; +use gtk4::prelude::*; + +/// End a registered application the way `g_application_run` ends one: +/// `shutdown`, then leaving the session bus, which GLib does only there. +/// +/// GtkApplication holds itself from startup until shutdown, so a test +/// that registers one and drops it leaks it with every service it owns. +/// Destroy the windows first, as a quit closes them. The run would then +/// activate the application, which restores a session or opens a window +/// after the test is over, so `activate` stops before the application's +/// own handler, and the run quits at its first main loop iteration. +pub(crate) fn end_application(app: &impl IsA) { + let inert = app.connect_activate(|app| app.stop_signal_emission_by_name("activate")); + let quitting = app.clone(); + glib::idle_add_local_once(move || { + #[expect( + clippy::disallowed_methods, + reason = "ends the run this helper starts; the app quits through request_quit, which asks each window" + )] + quitting.quit(); + }); + app.run_with_args::<&str>(&[]); + app.disconnect(inert); +} diff --git a/linux/crates/app/src/test_support/fake_clocks.rs b/linux/crates/app/src/test_support/fake_clocks.rs new file mode 100644 index 0000000000..bcafafdc4c --- /dev/null +++ b/linux/crates/app/src/test_support/fake_clocks.rs @@ -0,0 +1,25 @@ +use std::cell::Cell; +use std::rc::Rc; +use std::time::Duration; + +use crate::runtime::{ClockSample, ClockSource}; + +/// Clocks the test moves by hand. +#[derive(Clone, Default)] +pub(crate) struct FakeClocks(Rc>); + +impl FakeClocks { + pub(crate) fn advance(&self, boottime: u64, monotonic: u64) { + let now = self.0.get(); + self.0.set(ClockSample { + boottime: now.boottime + Duration::from_secs(boottime), + monotonic: now.monotonic + Duration::from_secs(monotonic), + }); + } +} + +impl ClockSource for FakeClocks { + fn sample(&self) -> ClockSample { + self.0.get() + } +} diff --git a/linux/crates/app/src/test_support/fake_reachability.rs b/linux/crates/app/src/test_support/fake_reachability.rs new file mode 100644 index 0000000000..5f6bb3226e --- /dev/null +++ b/linux/crates/app/src/test_support/fake_reachability.rs @@ -0,0 +1,86 @@ +use std::cell::RefCell; +use std::collections::VecDeque; +use std::rc::Rc; + +use futures::FutureExt; +use futures::channel::oneshot; +use futures::future::LocalBoxFuture; +use tablepro_core::NetworkEndpoint; +use tablepro_session::fakes::{FakeConnector, FakeEstablish}; +use tablepro_session::health::Reachability; +use tablepro_session::runtime::Tasks; +use tablepro_session::{SessionClaim, SessionKey, SessionRegistry}; +use tablepro_test_support::{ConnectionScript, FakeReply}; + +use crate::runtime::ReachabilityProbe; +use crate::test_support::FakeSession; + +/// Answers each check with the next scripted reply, or holds it until +/// the test sends one, and records what it was asked. +#[derive(Default)] +pub(crate) struct FakeProbe { + asked: RefCell>, + replies: RefCell>>, +} + +impl FakeProbe { + pub(crate) fn answering(reachability: Reachability) -> Rc { + let probe = Rc::new(Self::default()); + probe.then(reachability); + probe + } + + pub(crate) fn then(&self, reachability: Reachability) { + self.replies + .borrow_mut() + .push_back(futures::future::ready(reachability).boxed_local()); + } + + /// The next check waits for the sender. + pub(crate) fn then_held(&self) -> oneshot::Sender { + let (sender, receiver) = oneshot::channel(); + self.replies + .borrow_mut() + .push_back(receiver.map(|reply| reply.unwrap_or_default()).boxed_local()); + sender + } + + pub(crate) fn asked(&self) -> Vec { + self.asked.borrow().clone() + } +} + +impl ReachabilityProbe for FakeProbe { + fn can_reach(&self, endpoint: &NetworkEndpoint) -> LocalBoxFuture<'static, Reachability> { + self.asked.borrow_mut().push(endpoint.clone()); + let next = self.replies.borrow_mut().pop_front(); + next.unwrap_or_else(|| futures::future::ready(Reachability::Reachable).boxed_local()) + } +} + +/// A session on `host` port 5432, attached under a key of its own. +pub(crate) struct Attached { + pub fake: FakeSession, + pub key: SessionKey, + _claim: SessionClaim, +} + +pub(crate) fn attach_remote( + registry: &Rc, + host: &str, + reconnects: Vec, + tasks: Tasks, +) -> Attached { + let mut script = ConnectionScript::default(); + script.probe.always(FakeReply::ok(())); + let request = FakeConnector::network_request(host, 5432).expect("a request"); + let fake = FakeSession::start_for(request, script, reconnects, tasks); + let key = SessionKey::Saved(uuid::Uuid::new_v4()); + let claim = registry.claim(key.clone(), 1).expect("a claim"); + claim.attach(&fake.session); + Attached { + fake, + key, + _claim: claim, + } +} diff --git a/linux/crates/app/src/test_support/fake_session.rs b/linux/crates/app/src/test_support/fake_session.rs new file mode 100644 index 0000000000..84d19437d5 --- /dev/null +++ b/linux/crates/app/src/test_support/fake_session.rs @@ -0,0 +1,59 @@ +use std::sync::Arc; + +use tablepro_core::LivenessPolicy; +use tablepro_session::fakes::{FakeConnector, FakeCredentialSource, FakeEstablish}; +use tablepro_session::operation::DefaultDeadlines; +use tablepro_session::runtime::Tasks; +use tablepro_session::{ConnectRequest, Session, SessionDeps}; +use tablepro_test_support::{ConnectionScript, FakeConnection}; + +/// A session over a fake connection, and what each reconnect gets. +pub(crate) struct FakeSession { + pub session: Arc, + pub connection: Arc, + /// Every connection a reconnect built, in order. + pub connector: FakeConnector, +} + +impl FakeSession { + pub(crate) fn start( + script: ConnectionScript, + reconnects: impl IntoIterator, + tasks: Tasks, + ) -> Self { + let request = FakeConnector::request().expect("a request"); + Self::start_for(request, script, reconnects, tasks) + } + + /// A session opened by `request`, which decides what the network has + /// to reach for it. + pub(crate) fn start_for( + request: ConnectRequest, + script: ConnectionScript, + reconnects: impl IntoIterator, + tasks: Tasks, + ) -> Self { + let (pending, connection) = FakeConnector::pending_session(request, script); + let connector = FakeConnector::new(reconnects); + let session = Session::start( + pending, + SessionDeps { + connector: Arc::new(connector.clone()), + credentials: Arc::new(FakeCredentialSource::new()), + deadlines: Arc::new(DefaultDeadlines::new(LivenessPolicy::DESKTOP)), + tasks, + prompter: None, + }, + ); + Self { + session, + connection, + connector, + } + } + + /// The connection the last reconnect put in place. + pub(crate) fn replacement(&self) -> Arc { + self.connector.connections().pop().expect("a reconnect's connection") + } +} diff --git a/linux/crates/app/src/test_support/focus_thief.rs b/linux/crates/app/src/test_support/focus_thief.rs new file mode 100644 index 0000000000..f0e4bc09eb --- /dev/null +++ b/linux/crates/app/src/test_support/focus_thief.rs @@ -0,0 +1,51 @@ +use std::time::Duration; + +use gtk4 as gtk; +use gtk4::prelude::*; + +use super::{WaitTimedOut, wait_until}; + +const WAIT: Duration = Duration::from_secs(5); + +/// A window of its own that takes the focus from a test's window, as +/// another application does, so handing it back is a real focus-in. +/// GDK focuses a presented window itself when no window manager runs, +/// as under Xvfb. +pub(crate) struct FocusThief { + window: gtk::Window, +} + +impl FocusThief { + /// The window has the focus first, so a focus-out it has not seen + /// yet cannot land after the theft. Other tests' windows take the + /// focus too, so it is asked for until it comes. + pub(crate) fn take_from(window: &impl IsA) -> Result { + let window = window.as_ref(); + wait_until(WAIT, || { + if !window.is_active() { + window.present(); + } + window.is_active() + })?; + let thief = gtk::Window::new(); + thief.present(); + wait_until(WAIT, || !window.is_active())?; + Ok(Self { window: thief }) + } + + /// Give the focus back to `window` without running the main loop. + /// The display round trip queues the focus-in, and GDK dispatches + /// events ahead of painting, so the window sees it before the next + /// frame. + pub(crate) fn give_back(&self, window: &impl IsA) { + let window = window.as_ref(); + window.present(); + WidgetExt::display(window).sync(); + } +} + +impl Drop for FocusThief { + fn drop(&mut self) { + self.window.destroy(); + } +} diff --git a/linux/crates/app/src/test_support/icon_buttons.rs b/linux/crates/app/src/test_support/icon_buttons.rs new file mode 100644 index 0000000000..aa491c08f5 --- /dev/null +++ b/linux/crates/app/src/test_support/icon_buttons.rs @@ -0,0 +1,110 @@ +use gtk4::prelude::*; +use gtk4::{AccessibleProperty, AccessibleRelation, AccessibleRole, glib}; +use libadwaita as adw; + +use crate::test_support::descendants; + +/// What a button shows, which decides whether only an accessible label +/// can name it. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ButtonFace { + NotAButton, + /// A label, or content of its own, which is taken to carry text. + Text, + Icon, + /// Neither yet: the code gives it one. + Empty, +} + +pub(crate) fn button_face(widget: >k4::Widget) -> ButtonFace { + let face = |label: Option, child: Option, icon: Option| match ( + label.is_some_and(|label| !label.is_empty()), + child, + icon, + ) { + (true, ..) => ButtonFace::Text, + (false, Some(child), _) if child.is::() => ButtonFace::Icon, + (false, Some(_), _) => ButtonFace::Text, + (false, None, Some(_)) => ButtonFace::Icon, + (false, None, None) => ButtonFace::Empty, + }; + if let Some(button) = widget.downcast_ref::() { + return face(button.label(), button.child(), button.icon_name()); + } + if let Some(button) = widget.downcast_ref::() { + return face(button.label(), button.child(), button.icon_name()); + } + if let Some(button) = widget.downcast_ref::() { + return face(button.label(), button.child(), button.icon_name()); + } + if widget.is::() { + return ButtonFace::Icon; + } + ButtonFace::NotAButton +} + +pub(crate) fn is_icon_only(widget: >k4::Widget) -> bool { + button_face(widget) == ButtonFace::Icon +} + +pub(crate) fn has_accessible_label(widget: >k4::Widget) -> bool { + gtk4::test_accessible_has_property(widget, AccessibleProperty::Label) + || gtk4::test_accessible_has_relation(widget, AccessibleRelation::LabelledBy) +} + +/// The icon-only buttons under `root` a screen reader reaches and cannot +/// name, each as its type and tooltip. The button inside a menu, split +/// or tab button is left out, since the outer one is the control. +pub(crate) fn unlabelled_icon_buttons(root: &impl IsA) -> Vec { + let root = root.as_ref(); + descendants(root) + .into_iter() + .filter(|widget| is_icon_only(widget) && !has_accessible_label(widget) && reachable(widget, root)) + .filter(|widget| { + !matches!( + widget.accessible_role(), + AccessibleRole::None | AccessibleRole::Presentation + ) + }) + .filter(|widget| { + !widget.parent().is_some_and(|parent| { + parent.is::() || parent.is::() || parent.is::() + }) + }) + .map(|widget| format!("{} {:?}", widget.type_().name(), widget.tooltip_text())) + .collect() +} + +/// Whether a screen reader reaches `widget`: it and every widget above +/// it up to `root` is shown, and every revealer on the way reveals. +fn reachable(widget: >k4::Widget, root: >k4::Widget) -> bool { + let mut current = widget.clone(); + while ¤t != root { + if !current.is_visible() || !current.is_child_visible() { + return false; + } + let Some(parent) = current.parent() else { + return true; + }; + if &parent != root + && parent + .downcast_ref::() + .is_some_and(|revealer| !revealer.reveals_child()) + { + return false; + } + current = parent; + } + true +} + +/// The tooltips under `root` that spell out a key, which belongs on the +/// accessible node, where a screen reader says it in its own words. +pub(crate) fn tooltips_with_keys(root: &impl IsA) -> Vec { + descendants(root) + .into_iter() + .filter_map(|widget| widget.tooltip_text()) + .map(|tooltip| tooltip.to_string()) + .filter(|tooltip| tooltip.contains("Ctrl+")) + .collect() +} diff --git a/linux/crates/app/src/test_support/key_injection_error.rs b/linux/crates/app/src/test_support/key_injection_error.rs new file mode 100644 index 0000000000..005f7238a7 --- /dev/null +++ b/linux/crates/app/src/test_support/key_injection_error.rs @@ -0,0 +1,14 @@ +#[derive(Debug, thiserror::Error)] +pub(crate) enum KeyInjectionError { + #[error("could not connect to the X server: {0}")] + Connect(#[from] x11rb::errors::ConnectError), + #[error("the X connection failed: {0}")] + Connection(#[from] x11rb::errors::ConnectionError), + #[error("the X server refused a request: {0}")] + Reply(#[from] x11rb::errors::ReplyError), + #[error("no keycode carries keysym {0:#x}")] + KeysymNotMapped(u32), + /// XTEST reaches GDK only through an X11 display; set GDK_BACKEND=x11. + #[error("the display is {0}, not X11")] + NotX11(String), +} diff --git a/linux/crates/app/src/test_support/key_injector.rs b/linux/crates/app/src/test_support/key_injector.rs new file mode 100644 index 0000000000..b5e74cfb65 --- /dev/null +++ b/linux/crates/app/src/test_support/key_injector.rs @@ -0,0 +1,119 @@ +use gtk4::gdk; +use gtk4::glib::translate::IntoGlib; +use gtk4::prelude::*; +use x11rb::connection::Connection; +use x11rb::protocol::xproto::{ConnectionExt as _, KEY_PRESS_EVENT, KEY_RELEASE_EVENT}; +use x11rb::protocol::xtest::ConnectionExt as _; +use x11rb::rust_connection::RustConnection; + +use super::KeyInjectionError; + +const X11_DISPLAY: &str = "GdkX11Display"; + +/// Presses keys through XTEST, which the X server delivers as it does a +/// keyboard's, so the press meets every capture, class and bubble +/// handler in the order GTK runs them. GDK focuses a presented window +/// itself when no window manager runs, as under Xvfb. +pub(crate) struct KeyInjector { + connection: RustConnection, +} + +impl KeyInjector { + pub(crate) fn connect() -> Result { + let display = gdk::Display::default().map(|display| display.type_().name().to_owned()); + require_x11(display.as_deref().unwrap_or("no display"))?; + let (connection, _screen) = x11rb::connect(None)?; + Ok(Self { connection }) + } + + /// Take the keyboard for `window`. A window an earlier test left open + /// in this process can hold a popup's keyboard grab, which takes + /// every key, so every other window goes first. + pub(crate) fn focus_only(window: &impl IsA) { + let window = window.as_ref(); + for other in gtk4::Window::list_toplevels() { + if let Ok(other) = other.downcast::() + && &other != window + { + other.destroy(); + } + } + window.present(); + } + + /// The modifiers go down first and come up last, as a hand does it. + pub(crate) fn press(&self, keyval: gdk::Key, modifiers: gdk::ModifierType) -> Result<(), KeyInjectionError> { + let mut held = Vec::new(); + for (mask, keysym) in [ + (gdk::ModifierType::CONTROL_MASK, gdk::Key::Control_L), + (gdk::ModifierType::SHIFT_MASK, gdk::Key::Shift_L), + (gdk::ModifierType::ALT_MASK, gdk::Key::Alt_L), + ] { + if modifiers.contains(mask) { + held.push(self.keycode(keysym)?); + } + } + let key = self.keycode(keyval)?; + for code in &held { + self.fake(KEY_PRESS_EVENT, *code)?; + } + self.fake(KEY_PRESS_EVENT, key)?; + self.fake(KEY_RELEASE_EVENT, key)?; + for code in held.iter().rev() { + self.fake(KEY_RELEASE_EVENT, *code)?; + } + self.connection.flush()?; + // A round trip, so the server has taken every event before the + // test waits for what they do. + self.connection.get_input_focus()?.reply()?; + Ok(()) + } + + /// The keycode whose unshifted keysym is `keyval`, or failing that + /// one that carries it at another level. + fn keycode(&self, keyval: gdk::Key) -> Result { + let keysym = keyval.into_glib(); + let setup = self.connection.setup(); + let (first, last) = (setup.min_keycode, setup.max_keycode); + let mapping = self + .connection + .get_keyboard_mapping(first, last.saturating_sub(first).saturating_add(1))? + .reply()?; + let per_keycode = usize::from(mapping.keysyms_per_keycode).max(1); + let keycodes: Vec<&[u32]> = mapping.keysyms.chunks(per_keycode).collect(); + keycodes + .iter() + .position(|syms| syms.first() == Some(&keysym)) + .or_else(|| keycodes.iter().position(|syms| syms.contains(&keysym))) + .and_then(|index| u8::try_from(usize::from(first) + index).ok()) + .ok_or(KeyInjectionError::KeysymNotMapped(keysym)) + } + + fn fake(&self, kind: u8, keycode: u8) -> Result<(), KeyInjectionError> { + self.connection + .xtest_fake_input(kind, keycode, x11rb::CURRENT_TIME, x11rb::NONE, 0, 0, 0)?; + Ok(()) + } +} + +fn require_x11(display_type: &str) -> Result<(), KeyInjectionError> { + if display_type == X11_DISPLAY { + Ok(()) + } else { + Err(KeyInjectionError::NotX11(display_type.to_owned())) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn key_injector_reports_not_x11() { + assert!(matches!( + require_x11("GdkWaylandDisplay"), + Err(KeyInjectionError::NotX11(name)) if name == "GdkWaylandDisplay" + )); + assert!(require_x11(X11_DISPLAY).is_ok()); + } +} diff --git a/linux/crates/app/src/test_support/main_loop.rs b/linux/crates/app/src/test_support/main_loop.rs new file mode 100644 index 0000000000..b26c5e1a60 --- /dev/null +++ b/linux/crates/app/src/test_support/main_loop.rs @@ -0,0 +1,62 @@ +use std::cell::{Cell, RefCell}; +use std::future::Future; +use std::rc::Rc; +use std::time::{Duration, Instant}; + +use gtk4::prelude::*; +use gtk4::{gdk, glib}; + +use crate::test_support::WaitTimedOut; + +pub(crate) fn drain_main_context() { + let context = glib::MainContext::default(); + while context.iteration(false) {} +} + +/// Wait for the window `widget` is in to paint a frame. GTK leaves some +/// work for after the paint: it moves a focus out of a removed widget +/// then, and holds that widget until it has. +pub(crate) fn after_next_frame(widget: &impl IsA) { + let clock = widget.frame_clock().expect("the window's frame clock"); + let painted = Rc::new(Cell::new(false)); + let flag = painted.clone(); + let handler = clock.connect_after_paint(move |_| flag.set(true)); + clock.request_phase(gdk::FrameClockPhase::AFTER_PAINT); + wait_until(Duration::from_secs(10), || painted.get()).expect("the window paints a frame"); + clock.disconnect(handler); +} + +pub(crate) fn wait_until(timeout: Duration, mut condition: impl FnMut() -> bool) -> Result<(), WaitTimedOut> { + let context = glib::MainContext::default(); + let wake = glib::timeout_add_local(Duration::from_millis(10), || glib::ControlFlow::Continue); + let deadline = Instant::now() + timeout; + let outcome = loop { + if condition() { + break Ok(()); + } + if Instant::now() >= deadline { + break Err(WaitTimedOut { timeout }); + } + context.iteration(true); + }; + wake.remove(); + outcome +} + +/// Run `future` on the main context until it ends, or give it up once +/// `timeout` passes. +pub(crate) fn finish_on_main( + timeout: Duration, + future: impl Future + 'static, +) -> Result { + let outcome = Rc::new(RefCell::new(None)); + let slot = outcome.clone(); + let running = glib::spawn_future_local(async move { + slot.replace(Some(future.await)); + }); + if let Err(timed_out) = wait_until(timeout, || outcome.borrow().is_some()) { + running.abort(); + return Err(timed_out); + } + outcome.take().ok_or(WaitTimedOut { timeout }) +} diff --git a/linux/crates/app/src/test_support/memory_settings.rs b/linux/crates/app/src/test_support/memory_settings.rs new file mode 100644 index 0000000000..d477f23c3c --- /dev/null +++ b/linux/crates/app/src/test_support/memory_settings.rs @@ -0,0 +1,30 @@ +use std::rc::Rc; + +use tablepro_storage::AppSettings; + +/// The schema compiled into a `TempDir` behind a memory backend, so a +/// test never reads or writes the developer's own dconf database. +pub(crate) struct MemorySettings { + _dir: tempfile::TempDir, + settings: Rc, +} + +impl MemorySettings { + pub(crate) fn new() -> Self { + let dir = tempfile::tempdir().expect("a temporary directory"); + if let Err(error) = super::settings_schema::compile(dir.path(), &[crate::config::APP_ID]) { + panic!("{error}"); + } + let settings = + AppSettings::with_backend(crate::config::APP_ID, dir.path(), &gio::memory_settings_backend_new()) + .expect("open the compiled schema"); + Self { + _dir: dir, + settings: Rc::new(settings), + } + } + + pub(crate) fn get(&self) -> &Rc { + &self.settings + } +} diff --git a/linux/crates/app/src/test_support/mod.rs b/linux/crates/app/src/test_support/mod.rs new file mode 100644 index 0000000000..c780e8ac2d --- /dev/null +++ b/linux/crates/app/src/test_support/mod.rs @@ -0,0 +1,71 @@ +mod accessible_label; +mod alert_response; +mod application_end; +mod fake_clocks; +mod fake_reachability; +mod fake_session; +mod focus_thief; +mod icon_buttons; +mod key_injection_error; +mod key_injector; +mod main_loop; +mod memory_settings; +mod owned_window; +#[path = "../../tests/support/settings_schema.rs"] +pub(crate) mod settings_schema; +pub(crate) mod shortcut_keys; +mod signal_log; +mod tests; +mod unlabelled_widget; +mod wait_timed_out; +mod widget_lookup; + +pub(crate) use accessible_label::{assert_labelled, test_backend_records_properties}; +pub(crate) use alert_response::{click_response, respond}; +pub(crate) use application_end::end_application; +pub(crate) use fake_clocks::FakeClocks; +pub(crate) use fake_reachability::{Attached, FakeProbe, attach_remote}; +pub(crate) use fake_session::FakeSession; +pub(crate) use focus_thief::FocusThief; +pub(crate) use icon_buttons::{ + ButtonFace, button_face, has_accessible_label, tooltips_with_keys, unlabelled_icon_buttons, +}; +pub(crate) use key_injection_error::KeyInjectionError; +pub(crate) use key_injector::KeyInjector; +pub(crate) use main_loop::{after_next_frame, drain_main_context, finish_on_main, wait_until}; +pub(crate) use memory_settings::MemorySettings; +pub(crate) use owned_window::{OwnedWindow, drop_focus}; +pub(crate) use signal_log::SignalLog; +pub(crate) use unlabelled_widget::UnlabelledWidget; +pub(crate) use wait_timed_out::WaitTimedOut; +pub(crate) use widget_lookup::{descendants, find_by_action_name, first_descendant_of_type, status_page_description}; + +/// `Tasks` bound to the current test runtime. +/// +/// A test already runs inside `#[tokio::test]`, so taking the ambient +/// handle here is the one place it is correct. +#[expect( + clippy::disallowed_methods, + reason = "a test is already inside its own runtime; production code takes the handle from AppRuntime" +)] +pub(crate) fn paused_tasks() -> tablepro_session::runtime::Tasks { + tablepro_session::runtime::Tasks::new(tokio::runtime::Handle::current()) +} + +/// Write a file a test opens. Kept here because CI's check for blocking +/// file I/O reads every UI file not named `tests.rs`. +pub(crate) fn write_text_file(path: &std::path::Path, text: &str) { + std::fs::write(path, text).expect("write the test's file"); +} + +pub(crate) fn read_text_file(path: &std::path::Path) -> String { + std::fs::read_to_string(path).expect("read the test's file") +} + +/// A runtime a GTK test can borrow `Tasks` from. +/// +/// A `#[gtk4::test]` has no ambient tokio runtime, so it owns one for +/// the length of the test and drops it at the end. +pub(crate) fn test_runtime() -> crate::runtime::AppRuntime { + crate::runtime::AppRuntime::build().expect("a runtime") +} diff --git a/linux/crates/app/src/test_support/owned_window.rs b/linux/crates/app/src/test_support/owned_window.rs new file mode 100644 index 0000000000..bcea397059 --- /dev/null +++ b/linux/crates/app/src/test_support/owned_window.rs @@ -0,0 +1,48 @@ +use std::ops::Deref; + +use gtk4 as gtk; +use gtk4::prelude::*; + +use super::drain_main_context; + +// GTK keeps every window, and what its widgets hold, until it is destroyed. +pub(crate) struct OwnedWindow>(W); + +impl> OwnedWindow { + pub(crate) fn new(window: W) -> Self { + Self(window) + } + + pub(crate) fn presented(window: W) -> Self { + window.present(); + Self(window) + } +} + +impl> Deref for OwnedWindow { + type Target = W; + + fn deref(&self) -> &W { + &self.0 + } +} + +impl> AsRef for OwnedWindow { + fn as_ref(&self) -> >k::Widget { + self.0.upcast_ref::().upcast_ref() + } +} + +impl> Drop for OwnedWindow { + fn drop(&mut self) { + drop_focus(&self.0); + self.0.destroy(); + drain_main_context(); + } +} + +// GTK 4.22 leaks a focused widget that is hidden and then unparented, +// as tearing a window down does (fixed in GTK 4.24). +pub(crate) fn drop_focus(window: &impl IsA) { + window.set_focus(None::<>k::Widget>); +} diff --git a/linux/crates/app/src/test_support/shortcut_keys.rs b/linux/crates/app/src/test_support/shortcut_keys.rs new file mode 100644 index 0000000000..a37570e2d1 --- /dev/null +++ b/linux/crates/app/src/test_support/shortcut_keys.rs @@ -0,0 +1,124 @@ +use std::collections::HashSet; + +use gtk4 as gtk; +use gtk4::gdk; +use gtk4::prelude::*; + +use super::descendants; +use crate::application::{ACCELERATORS, AcceleratorScope, ShortcutOwner, keypad_twins}; + +/// A key with its modifiers, keyval lowered as GtkKeyvalTrigger stores it. +pub(crate) type Keystroke = (gdk::Key, gdk::ModifierType); + +/// The name GTK gives the controller that runs a widget class's bindings. +const CLASS_SHORTCUTS: &str = "gtk-widget-class-shortcuts"; + +pub(crate) fn keystroke(accel: &str) -> Keystroke { + let (key, modifiers) = gtk::accelerator_parse(accel).unwrap_or_else(|| panic!("{accel} does not parse")); + (key.to_lower(), modifiers) +} + +/// `accel` and the keypad twins the table binds beside it. +pub(crate) fn keystrokes_with_twins(accel: &str) -> Vec { + let (key, modifiers) = keystroke(accel); + std::iter::once(key) + .chain(keypad_twins(key).iter().copied()) + .map(|key| (key.to_lower(), modifiers)) + .collect() +} + +pub(crate) fn trigger_keys(trigger: >k::ShortcutTrigger) -> Vec { + let mut keys = Vec::new(); + collect_trigger_keys(trigger, &mut keys); + keys +} + +fn collect_trigger_keys(trigger: >k::ShortcutTrigger, into: &mut Vec) { + if let Some(keyval) = trigger.downcast_ref::() { + into.push((keyval.keyval().to_lower(), keyval.modifiers())); + } else if let Some(pair) = trigger.downcast_ref::() { + collect_trigger_keys(&pair.first(), into); + collect_trigger_keys(&pair.second(), into); + } +} + +pub(crate) fn shortcuts(controller: >k::ShortcutController) -> Vec { + (0..controller.n_items()) + .filter_map(|position| controller.item(position).and_downcast::()) + .collect() +} + +pub(crate) fn shortcut_controllers(widget: &impl IsA) -> Vec { + let controllers = widget.as_ref().observe_controllers(); + (0..controllers.n_items()) + .filter_map(|position| controllers.item(position).and_downcast::()) + .collect() +} + +pub(crate) fn controller_keys(controller: >k::ShortcutController) -> Vec { + shortcuts(controller) + .iter() + .filter_map(gtk::Shortcut::trigger) + .flat_map(|trigger| trigger_keys(&trigger)) + .collect() +} + +/// The controller `local_shortcut_controller` put on `widget` for +/// `owner`, if any. +pub(crate) fn owner_controller( + widget: &impl IsA, + owner: ShortcutOwner, +) -> Option { + let name = crate::application::controller_name(owner); + shortcut_controllers(widget) + .into_iter() + .find(|controller| controller.name().is_some_and(|found| found == name)) +} + +/// The keys the classes of `widget` bind. +pub(crate) fn class_binding_keys(widget: &impl IsA) -> HashSet { + shortcut_controllers(widget) + .iter() + .filter(|controller| controller.name().is_some_and(|name| name == CLASS_SHORTCUTS)) + .flat_map(controller_keys) + .collect() +} + +/// The class bindings a key meets on its way up to `owner` from any +/// focusable widget inside it, the owner's own included. +pub(crate) fn class_keys_on_focus_paths( + owner: &impl IsA, + skip: impl Fn(>k::Widget) -> bool, +) -> HashSet { + let owner = owner.as_ref(); + let mut on_path: Vec = vec![owner.clone()]; + for focusable in descendants(owner).into_iter().filter(|widget| widget.is_focusable()) { + let mut widget = Some(focusable); + while let Some(current) = widget { + if ¤t == owner || on_path.contains(¤t) { + break; + } + widget = current.parent(); + on_path.push(current); + } + } + on_path + .iter() + .filter(|widget| !skip(widget)) + .flat_map(class_binding_keys) + .collect() +} + +/// Every key the table gives `owner`, with keypad twins for the ones it +/// installs. +pub(crate) fn table_keys(owner: ShortcutOwner) -> HashSet { + ACCELERATORS + .iter() + .filter(|entry| match entry.scope { + AcceleratorScope::Local { owner: of, .. } | AcceleratorScope::Component(of) => of == owner, + AcceleratorScope::Application | AcceleratorScope::Stock => false, + }) + .flat_map(|entry| entry.triggers.iter()) + .flat_map(|accel| keystrokes_with_twins(accel)) + .collect() +} diff --git a/linux/crates/app/src/test_support/signal_log.rs b/linux/crates/app/src/test_support/signal_log.rs new file mode 100644 index 0000000000..e337147547 --- /dev/null +++ b/linux/crates/app/src/test_support/signal_log.rs @@ -0,0 +1,47 @@ +use std::cell::RefCell; +use std::rc::Rc; + +use gtk4::glib; +use gtk4::glib::prelude::*; + +pub(crate) struct SignalLog { + entries: Rc>>, + object: glib::WeakRef, + handler: Option, +} + +impl SignalLog { + pub fn connect( + object: &impl IsA, + name: &str, + extract: impl Fn(&[glib::Value]) -> T + 'static, + ) -> Self { + let entries = Rc::new(RefCell::new(Vec::new())); + let sink = Rc::clone(&entries); + let handler = object.connect_local(name, false, move |values| { + sink.borrow_mut().push(extract(values)); + None + }); + Self { + entries, + object: object.upcast_ref::().downgrade(), + handler: Some(handler), + } + } + + pub fn count(&self) -> usize { + self.entries.borrow().len() + } + + pub fn take(&self) -> Vec { + std::mem::take(&mut *self.entries.borrow_mut()) + } +} + +impl Drop for SignalLog { + fn drop(&mut self) { + if let (Some(object), Some(handler)) = (self.object.upgrade(), self.handler.take()) { + object.disconnect(handler); + } + } +} diff --git a/linux/crates/app/src/test_support/tests.rs b/linux/crates/app/src/test_support/tests.rs new file mode 100644 index 0000000000..dea17c6f86 --- /dev/null +++ b/linux/crates/app/src/test_support/tests.rs @@ -0,0 +1,131 @@ +use std::cell::Cell; +use std::rc::Rc; +use std::time::Duration; + +use gtk4::glib; +use gtk4::prelude::*; +use libadwaita as adw; +use libadwaita::prelude::*; + +use crate::test_support::{ + SignalLog, UnlabelledWidget, WaitTimedOut, assert_labelled, descendants, drain_main_context, find_by_action_name, + first_descendant_of_type, wait_until, +}; + +fn init_adwaita() { + adw::init().unwrap(); +} + +#[gtk4::test] +fn wait_until_returns_once_an_idle_source_sets_the_flag() { + let flag = Rc::new(Cell::new(false)); + let setter = Rc::clone(&flag); + glib::idle_add_local_once(move || setter.set(true)); + assert_eq!(wait_until(Duration::from_secs(2), || flag.get()), Ok(())); + drain_main_context(); +} + +#[gtk4::test] +fn wait_until_times_out_instead_of_blocking() { + let timeout = Duration::from_millis(50); + assert_eq!(wait_until(timeout, || false), Err(WaitTimedOut { timeout })); +} + +#[gtk4::test] +fn signal_log_records_clicked_emissions() { + let button = gtk4::Button::new(); + let log = SignalLog::connect(&button, "clicked", |_| ()); + button.emit_clicked(); + button.emit_clicked(); + assert_eq!(log.count(), 2); + assert_eq!(log.take().len(), 2); + assert_eq!(log.count(), 0); +} + +#[gtk4::test] +fn signal_log_disconnects_on_drop() { + let button = gtk4::Button::new(); + let clicked = glib::subclass::SignalId::lookup("clicked", gtk4::Button::static_type()).unwrap(); + let log = SignalLog::connect(&button, "clicked", |_| ()); + assert!(glib::signal::signal_has_handler_pending(&button, clicked, None, false)); + drop(log); + assert!(!glib::signal::signal_has_handler_pending(&button, clicked, None, false)); +} + +#[gtk4::test] +fn find_by_action_name_finds_a_nested_button() { + let outer = gtk4::Box::new(gtk4::Orientation::Vertical, 0); + let inner = gtk4::Box::new(gtk4::Orientation::Horizontal, 0); + let button = gtk4::Button::with_label("Save"); + button.set_action_name(Some("win.save")); + inner.append(>k4::Label::new(Some("Unsaved changes"))); + inner.append(&button); + outer.append(&inner); + + assert_eq!(find_by_action_name(&outer, "win.save"), Some(button.clone().upcast())); + assert_eq!(find_by_action_name(&outer, "win.missing"), None); + assert_eq!(first_descendant_of_type::(&outer), Some(button)); + assert!(descendants(&outer).len() >= 3); +} + +#[gtk4::test] +fn icon_button_without_label_is_reported() { + let button = gtk4::Button::from_icon_name(crate::ui::icons::LIST_ADD); + assert_eq!( + assert_labelled(&button), + Err(UnlabelledWidget::MissingLabel { + type_name: "GtkButton".to_owned() + }) + ); +} + +#[gtk4::test] +fn icon_button_with_accessible_label_passes() { + let button = gtk4::Button::from_icon_name(crate::ui::icons::LIST_ADD); + button.update_property(&[gtk4::accessible::Property::Label("Add connection")]); + assert_eq!(assert_labelled(&button), Ok(())); +} + +#[gtk4::test] +fn entry_labelled_by_a_label_passes() { + let label = gtk4::Label::new(Some("Host")); + let entry = gtk4::Entry::new(); + entry.update_relation(&[gtk4::accessible::Relation::LabelledBy(&[label.upcast_ref()])]); + assert_eq!(assert_labelled(&entry), Ok(())); +} + +#[gtk4::test] +fn entry_row_with_title_passes() { + init_adwaita(); + let row = adw::EntryRow::builder().title("Host").build(); + assert_eq!(assert_labelled(&row), Ok(())); +} + +#[gtk4::test] +fn entry_row_without_title_is_reported() { + init_adwaita(); + let row = adw::EntryRow::new(); + assert_eq!( + assert_labelled(&row), + Err(UnlabelledWidget::EmptyRowTitle { + type_name: "AdwEntryRow".to_owned() + }) + ); +} + +#[gtk4::test] +fn password_entry_row_with_title_passes() { + init_adwaita(); + let row = adw::PasswordEntryRow::builder().title("Password").build(); + assert_eq!(assert_labelled(&row), Ok(())); +} + +#[gtk4::test] +fn action_row_with_activatable_check_button_passes() { + init_adwaita(); + let check = gtk4::CheckButton::new(); + let row = adw::ActionRow::builder().title("Read only").build(); + row.add_suffix(&check); + row.set_activatable_widget(Some(&check)); + assert_eq!(assert_labelled(&row), Ok(())); +} diff --git a/linux/crates/app/src/test_support/unlabelled_widget.rs b/linux/crates/app/src/test_support/unlabelled_widget.rs new file mode 100644 index 0000000000..22fd905491 --- /dev/null +++ b/linux/crates/app/src/test_support/unlabelled_widget.rs @@ -0,0 +1,15 @@ +use thiserror::Error; + +#[derive(Debug, Clone, PartialEq, Eq, Error)] +pub(crate) enum UnlabelledWidget { + #[error("the GTK test accessibility backend is not active; run the tests with GTK_A11Y=test")] + TestBackendMissing, + #[error("{type_name} has an empty row title")] + EmptyRowTitle { type_name: String }, + #[error("the text field inside {type_name} is not labelled by the row title")] + EntryTextNotLabelled { type_name: String }, + #[error("the activatable widget of {type_name} is not labelled by the row")] + ActivatableWidgetNotLabelled { type_name: String }, + #[error("{type_name} has no accessible label")] + MissingLabel { type_name: String }, +} diff --git a/linux/crates/app/src/test_support/wait_timed_out.rs b/linux/crates/app/src/test_support/wait_timed_out.rs new file mode 100644 index 0000000000..d391b9af51 --- /dev/null +++ b/linux/crates/app/src/test_support/wait_timed_out.rs @@ -0,0 +1,9 @@ +use std::time::Duration; + +use thiserror::Error; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)] +#[error("the condition did not hold within {timeout:?}")] +pub(crate) struct WaitTimedOut { + pub timeout: Duration, +} diff --git a/linux/crates/app/src/test_support/widget_lookup.rs b/linux/crates/app/src/test_support/widget_lookup.rs new file mode 100644 index 0000000000..c6ea668a71 --- /dev/null +++ b/linux/crates/app/src/test_support/widget_lookup.rs @@ -0,0 +1,42 @@ +use gtk4::prelude::*; + +pub(crate) fn descendants(root: &impl IsA) -> Vec { + let mut found = Vec::new(); + let mut pending: Vec = root.as_ref().first_child().into_iter().collect(); + while let Some(widget) = pending.pop() { + if let Some(sibling) = widget.next_sibling() { + pending.push(sibling); + } + if let Some(child) = widget.first_child() { + pending.push(child); + } + found.push(widget); + } + found +} + +pub(crate) fn find_by_action_name(root: &impl IsA, name: &str) -> Option { + descendants(root).into_iter().find(|widget| { + widget + .dynamic_cast_ref::() + .and_then(ActionableExt::action_name) + .is_some_and(|action| action == name) + }) +} + +/// The description a status page shows, as text rather than the markup +/// it was given: markup that does not parse shows nothing. +pub(crate) fn status_page_description(page: &libadwaita::StatusPage) -> Option { + let markup = page.description()?; + descendants(page) + .into_iter() + .filter_map(|widget| widget.downcast::().ok()) + .find(|label| label.label() == markup) + .map(|label| label.text().to_string()) +} + +pub(crate) fn first_descendant_of_type>(root: &impl IsA) -> Option { + descendants(root) + .into_iter() + .find_map(|widget| widget.downcast::().ok()) +} diff --git a/linux/crates/app/src/ui/a11y/announce.rs b/linux/crates/app/src/ui/a11y/announce.rs new file mode 100644 index 0000000000..2aafa4565d --- /dev/null +++ b/linux/crates/app/src/ui/a11y/announce.rs @@ -0,0 +1,59 @@ +use gtk4 as gtk; +use gtk4::prelude::*; + +/// Say `message` from `widget`, for a change a screen reader would not +/// otherwise hear: one away from the focus, or text that changed under +/// it. +pub(crate) fn announce(widget: &impl IsA, message: &str, priority: gtk::AccessibleAnnouncementPriority) { + if message.is_empty() { + return; + } + let widget = widget.as_ref(); + #[cfg(test)] + said::record(widget, message, priority); + widget.announce(message, priority); +} + +/// Show `text` on a status line, and say it when it changed: a screen +/// reader does not read a label that changes away from the focus. +pub(crate) fn show_status(label: >k::Label, text: &str) { + if label.label() == text { + return; + } + label.set_label(text); + announce(label, text, gtk::AccessibleAnnouncementPriority::Medium); +} + +/// GTK's test backend keeps no announcement, so a test reads them here. +#[cfg(test)] +pub(crate) mod said { + use std::cell::RefCell; + + use gtk4 as gtk; + use gtk4::glib; + use gtk4::prelude::*; + + thread_local! { + static SAID: RefCell, String, gtk::AccessibleAnnouncementPriority)>> = + const { RefCell::new(Vec::new()) }; + } + + pub(super) fn record(widget: >k::Widget, message: &str, priority: gtk::AccessibleAnnouncementPriority) { + SAID.with_borrow_mut(|said| said.push((widget.downgrade(), message.to_owned(), priority))); + } + + /// What `root`, or a widget inside it, announced, oldest first. + pub(crate) fn announced_in(root: &impl IsA) -> Vec<(String, gtk::AccessibleAnnouncementPriority)> { + let root = root.as_ref(); + SAID.with_borrow(|said| { + said.iter() + .filter(|(widget, _, _)| { + widget + .upgrade() + .is_some_and(|widget| &widget == root || widget.is_ancestor(root)) + }) + .map(|(_, message, priority)| (message.clone(), *priority)) + .collect() + }) + } +} diff --git a/linux/crates/app/src/ui/a11y/focus.rs b/linux/crates/app/src/ui/a11y/focus.rs new file mode 100644 index 0000000000..8c33a373a0 --- /dev/null +++ b/linux/crates/app/src/ui/a11y/focus.rs @@ -0,0 +1,40 @@ +use gtk4 as gtk; +use gtk4::prelude::*; + +/// Whether the window's focus is `widget` or inside it. +pub(crate) fn holds_focus(widget: &impl IsA) -> bool { + let widget = widget.as_ref(); + widget + .root() + .and_then(|root| root.focus()) + .is_some_and(|focus| &focus == widget || focus.is_ancestor(widget)) +} + +/// Move the focus from inside `from` to `to`, before `from` is hidden or +/// turned off. GTK would move it to the first control of the window. +pub(crate) fn pass_focus(from: &impl IsA, to: &impl IsA) -> bool { + holds_focus(from) && to.as_ref().grab_focus() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_support::{OwnedWindow, drain_main_context}; + + #[gtk4::test] + fn the_focus_moves_only_from_inside() { + let first = gtk::Button::with_label("First"); + let second = gtk::Button::with_label("Second"); + let row = gtk::Box::new(gtk::Orientation::Horizontal, 0); + row.append(&first); + row.append(&second); + let window = OwnedWindow::presented(gtk::Window::builder().child(&row).build()); + first.grab_focus(); + drain_main_context(); + + assert!(!pass_focus(&second, &first), "the focus was not in Second"); + assert!(pass_focus(&first, &second)); + assert_eq!(GtkWindowExt::focus(&*window).as_ref(), Some(second.upcast_ref())); + assert!(holds_focus(&row)); + } +} diff --git a/linux/crates/app/src/ui/a11y/icon_button.rs b/linux/crates/app/src/ui/a11y/icon_button.rs new file mode 100644 index 0000000000..c8920f6116 --- /dev/null +++ b/linux/crates/app/src/ui/a11y/icon_button.rs @@ -0,0 +1,58 @@ +use gtk4 as gtk; +use gtk4::prelude::*; + +use super::aria_key_shortcuts; +use crate::application::triggers_for; + +/// The tooltip names the button and the accessible label says the same, +/// since an icon has no text of its own. The keys go on the accessible +/// node, never in the tooltip, so they are announced in the reader's own +/// words. +pub(crate) fn describe_icon_button(widget: &impl IsA, label: &str, action: Option<&str>) { + let widget = widget.as_ref(); + widget.set_tooltip_text(Some(label)); + widget.update_property(&[gtk::accessible::Property::Label(label)]); + if let Some(action) = action { + announce_key_shortcuts(widget, action); + } +} + +/// The keys of `action`, from the accelerator table, on `widget`. +pub(crate) fn announce_key_shortcuts(widget: &impl IsA, action: &str) { + if let Some(keys) = aria_key_shortcuts(triggers_for(action)) { + widget.update_property(&[gtk::accessible::Property::KeyShortcuts(&keys)]); + } +} + +#[cfg(test)] +mod tests { + use gtk4::AccessibleProperty; + + use super::*; + + #[gtk4::test] + fn icon_button_gets_label_tooltip_and_its_actions_keys() { + let button = gtk::Button::from_icon_name("list-add-symbolic"); + + describe_icon_button(&button, "Insert Row", Some("browse.insert-row")); + + assert_eq!(button.tooltip_text().as_deref(), Some("Insert Row")); + assert!(gtk::test_accessible_has_property(&button, AccessibleProperty::Label)); + assert!(gtk::test_accessible_has_property( + &button, + AccessibleProperty::KeyShortcuts + )); + } + + #[gtk4::test] + fn an_action_without_keys_announces_none() { + let button = gtk::Button::from_icon_name("go-first-symbolic"); + + describe_icon_button(&button, "First Page", Some("browse.first-page")); + + assert!(!gtk::test_accessible_has_property( + &button, + AccessibleProperty::KeyShortcuts + )); + } +} diff --git a/linux/crates/app/src/ui/a11y/invalid.rs b/linux/crates/app/src/ui/a11y/invalid.rs new file mode 100644 index 0000000000..7aac20f5e8 --- /dev/null +++ b/linux/crates/app/src/ui/a11y/invalid.rs @@ -0,0 +1,62 @@ +use gtk4 as gtk; +use gtk4::prelude::*; + +/// Flag `field` as holding a mistake, described by `problem`, or clear +/// both. An entry row or a search entry hands the focus to the text +/// field inside it, which is what a screen reader reads, so that field +/// carries them too. +pub(crate) fn show_invalid(field: &impl IsA, problem: Option<&str>) { + let field = field.as_ref(); + let delegate = field + .downcast_ref::() + .and_then(|editable| editable.delegate()) + .map(|delegate| delegate.upcast::()); + for widget in std::iter::once(field).chain(delegate.as_ref()) { + match problem { + Some(problem) => { + widget.update_state(&[gtk::accessible::State::Invalid(gtk::AccessibleInvalidState::True)]); + widget.update_property(&[gtk::accessible::Property::Description(problem)]); + } + None => { + widget.reset_state(gtk::AccessibleState::Invalid); + widget.reset_property(gtk::AccessibleProperty::Description); + } + } + } +} + +#[cfg(test)] +mod tests { + use gtk4::{AccessibleProperty, AccessibleState}; + use libadwaita as adw; + + use super::*; + + #[gtk4::test] + fn an_entry_row_flags_its_text_field() { + adw::init().expect("libadwaita initialises under the test backend"); + let row = adw::EntryRow::builder().title("Name").build(); + let text = row + .delegate() + .and_downcast::() + .expect("the row's text field"); + + show_invalid(&row, Some("A name is required.")); + + for widget in [row.upcast_ref::(), text.upcast_ref()] { + assert!(gtk::test_accessible_has_state(widget, AccessibleState::Invalid)); + assert!(gtk::test_accessible_has_property( + widget, + AccessibleProperty::Description + )); + } + + show_invalid(&row, None); + + assert!(!gtk::test_accessible_has_state(&text, AccessibleState::Invalid)); + assert!(!gtk::test_accessible_has_property( + &text, + AccessibleProperty::Description + )); + } +} diff --git a/linux/crates/app/src/ui/a11y/key_shortcuts.rs b/linux/crates/app/src/ui/a11y/key_shortcuts.rs new file mode 100644 index 0000000000..46219e9ab4 --- /dev/null +++ b/linux/crates/app/src/ui/a11y/key_shortcuts.rs @@ -0,0 +1,79 @@ +use gtk4 as gtk; +use gtk4::gdk; + +/// The `aria-keyshortcuts` value for `triggers`: each one as modifiers +/// and a key value joined by `+`, alternatives joined by spaces. +pub(crate) fn aria_key_shortcuts(triggers: &[&str]) -> Option { + let shortcuts: Vec = triggers + .iter() + .filter_map(|accel| gtk::accelerator_parse(*accel)) + .filter_map(|(key, modifiers)| { + let key = key_value(key)?; + let mut parts: Vec<&str> = [ + (gdk::ModifierType::CONTROL_MASK, "Control"), + (gdk::ModifierType::SHIFT_MASK, "Shift"), + (gdk::ModifierType::ALT_MASK, "Alt"), + (gdk::ModifierType::SUPER_MASK, "Super"), + ] + .into_iter() + .filter(|(mask, _)| modifiers.contains(*mask)) + .map(|(_, name)| name) + .collect(); + parts.push(&key); + Some(parts.join("+")) + }) + .collect(); + (!shortcuts.is_empty()).then(|| shortcuts.join(" ")) +} + +/// The UI Events `key` value ARIA uses, not the GDK keysym name. +fn key_value(key: gdk::Key) -> Option { + let named = match key { + gdk::Key::Return | gdk::Key::KP_Enter | gdk::Key::ISO_Enter => "Enter", + gdk::Key::Page_Up => "PageUp", + gdk::Key::Page_Down => "PageDown", + gdk::Key::BackSpace => "Backspace", + gdk::Key::Menu => "ContextMenu", + gdk::Key::space => "Space", + gdk::Key::Up => "ArrowUp", + gdk::Key::Down => "ArrowDown", + gdk::Key::Left => "ArrowLeft", + gdk::Key::Right => "ArrowRight", + gdk::Key::ISO_Left_Tab => "Tab", + _ => "", + }; + if !named.is_empty() { + return Some(named.to_owned()); + } + match key.to_unicode() { + Some(character) if !character.is_control() => Some(character.to_uppercase().collect()), + _ => key.name().map(|name| name.to_string()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[gtk4::test] + fn aria_key_shortcuts_formats_modifiers_and_keys() { + assert_eq!( + aria_key_shortcuts(&["t"]).as_deref(), + Some("Control+Shift+T") + ); + assert_eq!( + aria_key_shortcuts(&["z", "y"]).as_deref(), + Some("Control+Shift+Z Control+Y") + ); + assert_eq!(aria_key_shortcuts(&["Page_Up"]).as_deref(), Some("PageUp")); + assert_eq!(aria_key_shortcuts(&["Page_Down"]).as_deref(), Some("Alt+PageDown")); + assert_eq!( + aria_key_shortcuts(&["Return"]).as_deref(), + Some("Control+Enter") + ); + assert_eq!(aria_key_shortcuts(&["Delete", "F5"]).as_deref(), Some("Delete F5")); + assert_eq!(aria_key_shortcuts(&["slash"]).as_deref(), Some("Control+/")); + assert_eq!(aria_key_shortcuts(&[]), None); + assert_eq!(aria_key_shortcuts(&["not a key"]), None); + } +} diff --git a/linux/crates/app/src/ui/a11y/mod.rs b/linux/crates/app/src/ui/a11y/mod.rs new file mode 100644 index 0000000000..c7dbac9341 --- /dev/null +++ b/linux/crates/app/src/ui/a11y/mod.rs @@ -0,0 +1,17 @@ +//! Accessibility properties the toolkit cannot set by itself. + +mod announce; +mod focus; +mod icon_button; +mod invalid; +mod key_shortcuts; +#[cfg(test)] +mod tests; + +#[cfg(test)] +pub(crate) use announce::said::announced_in; +pub(crate) use announce::{announce, show_status}; +pub(crate) use focus::{holds_focus, pass_focus}; +pub(crate) use icon_button::{announce_key_shortcuts, describe_icon_button}; +pub(crate) use invalid::show_invalid; +pub(crate) use key_shortcuts::aria_key_shortcuts; diff --git a/linux/crates/app/src/ui/a11y/tests.rs b/linux/crates/app/src/ui/a11y/tests.rs new file mode 100644 index 0000000000..3860e1fb96 --- /dev/null +++ b/linux/crates/app/src/ui/a11y/tests.rs @@ -0,0 +1,282 @@ +use gtk4 as gtk; +use gtk4::prelude::*; +use gtk4::{gio, glib}; +use libadwaita as adw; + +use crate::test_support::{ + ButtonFace, button_face, descendants, has_accessible_label, test_backend_records_properties, +}; +use crate::ui::{ + PreferencesDialog, SessionWindow, browse_tab, connect_dialog, editor, export, filter_strip, history_dialog, + preferences_dialog, session_window, ssh_section, structure_tab, welcome_view, workspace, +}; + +/// Every class the app's templates build. A template whose class is +/// missing here fails the walk, so a new one cannot slip past it. The +/// templates nested in another are registered by its class. +fn template_classes() -> Vec { + vec![ + browse_tab::BrowseTab::static_type(), + browse_tab::PagerBar::static_type(), + browse_tab::PendingChangesBar::static_type(), + connect_dialog::ConnectDialog::static_type(), + editor::SqlEditor::static_type(), + export::ExportOperationsButton::static_type(), + filter_strip::FilterRuleRow::static_type(), + filter_strip::FilterStrip::static_type(), + history_dialog::HistoryDialog::static_type(), + history_dialog::HistoryRow::static_type(), + session_window::ConnectFailedPage::static_type(), + session_window::ConnectingPage::static_type(), + session_window::PickerPage::static_type(), + PreferencesDialog::static_type(), + preferences_dialog::DataBrowserGroup::static_type(), + preferences_dialog::EditorPage::static_type(), + preferences_dialog::GeneralPage::static_type(), + preferences_dialog::HistoryGroup::static_type(), + SessionWindow::static_type(), + ssh_section::SshSection::static_type(), + structure_tab::StructureTab::static_type(), + welcome_view::WelcomeView::static_type(), + workspace::Workspace::static_type(), + ] +} + +/// The markup of every template the app registers. +fn app_templates() -> Vec<(String, String)> { + let dir = format!("{}/ui", crate::config::RESOURCE_BASE_PATH); + gio::resources_enumerate_children(&dir, gio::ResourceLookupFlags::NONE) + .expect("the templates are registered") + .into_iter() + .map(|name| { + let path = format!("{dir}/{name}"); + let bytes = gio::resources_lookup_data(&path, gio::ResourceLookupFlags::NONE).expect("a template"); + let markup = String::from_utf8(bytes.to_vec()).expect("UTF-8 markup"); + (name.to_string(), markup) + }) + .collect() +} + +/// The template's class, and its markup with the root turned into a +/// plain object of the parent class, which a GtkBuilder builds without +/// the class, so what the template alone says can be read back. A +/// binding names the class's own properties and a signal the class's own +/// handler, which the parent lacks, and neither says anything about a +/// label, so both go. A property of the class itself set on the root +/// still fails the parse, with an error that names it. +fn plain_template(markup: &str) -> Option<(String, String)> { + let start = markup.find("", + "", + 1, + ); + for binding in ["bind-source", "bind-property", "bind-flags"] { + plain = without_attribute(&plain, binding); + } + Some((class.to_owned(), without_element(&plain, "signal"))) +} + +fn without_element(markup: &str, name: &str) -> String { + let open = format!("<{name} "); + let close = format!(""); + let mut kept = String::with_capacity(markup.len()); + let mut rest = markup; + while let Some(start) = rest.find(&open) { + kept.push_str(&rest[..start]); + let element = &rest[start..]; + rest = match element.find('>') { + Some(end) if element[..end].ends_with('/') => &element[end + 1..], + Some(_) => element.find(&close).map_or("", |end| &element[end + close.len()..]), + None => "", + }; + } + kept.push_str(rest); + kept +} + +fn without_attribute(markup: &str, name: &str) -> String { + let key = format!(" {name}=\""); + let mut kept = String::with_capacity(markup.len()); + let mut rest = markup; + while let Some(start) = rest.find(&key) { + kept.push_str(&rest[..start]); + let value = &rest[start + key.len()..]; + rest = value.find('"').map_or("", |end| &value[end + 1..]); + } + kept.push_str(rest); + kept +} + +fn attribute<'a>(tag: &'a str, name: &str) -> Option<&'a str> { + let key = format!(" {name}=\""); + let from = tag.find(&key)? + key.len(); + let len = tag[from..].find('"')?; + Some(&tag[from..from + len]) +} + +/// The id GtkBuilder gave the object, unless it made one up. +fn declared_id(widget: >k::Widget) -> Option { + widget + .buildable_id() + .map(|id| id.to_string()) + .filter(|id| !id.starts_with("___object_")) +} + +/// A button with no text, whose icon the template or the code gives it. +fn needs_label(widget: >k::Widget) -> bool { + matches!(button_face(widget), ButtonFace::Icon | ButtonFace::Empty) +} + +/// What is wrong with the icon-only buttons `markup` declares. A button +/// the template labels passes; one it does not must have an id, and be +/// labelled once `class` is built, which is where the code names it. +fn unlabelled_in_template(name: &str, markup: &str, class: Option) -> Vec { + let builder = gtk::Builder::new(); + if let Err(error) = builder.add_from_string(markup) { + return vec![format!("{name}: {error}")]; + } + let objects = builder.objects(); + let unlabelled: Vec = objects + .iter() + .filter_map(|object| object.downcast_ref::()) + .filter(|widget| needs_label(widget) && !has_accessible_label(widget)) + .cloned() + .collect(); + let instance = class + .filter(|_| !unlabelled.is_empty()) + .and_then(|class| glib::Object::with_type(class).downcast::().ok()); + let built = instance.as_ref().map(descendants).unwrap_or_default(); + + let mut problems = Vec::new(); + for button in unlabelled { + let kind = button.type_().name(); + let Some(id) = declared_id(&button) else { + problems.push(format!( + "{name}: an icon-only {kind} with no id has no accessible label in the template" + )); + continue; + }; + let same: Vec<>k::Widget> = built + .iter() + .filter(|widget| widget.type_() == button.type_() && declared_id(widget).as_deref() == Some(id.as_str())) + .collect(); + if same.is_empty() { + problems.push(format!( + "{name}: {id} has no accessible label and is not in the built widget" + )); + } + if same + .iter() + .any(|widget| needs_label(widget) && !has_accessible_label(widget)) + { + problems.push(format!("{name}: {id} has no accessible label")); + } + } + let windows = objects + .iter() + .filter_map(|object| object.downcast_ref::()) + .chain( + instance + .iter() + .filter_map(|widget| widget.downcast_ref::()), + ); + for window in windows { + window.destroy(); + } + problems.sort(); + problems +} + +#[gtk4::test] +fn every_icon_only_button_in_a_template_is_labelled() { + adw::init().expect("libadwaita initialises under the test backend"); + crate::register_test_resources(); + assert!( + test_backend_records_properties(), + "the GTK test accessibility backend is not active; run the tests with GTK_A11Y=test" + ); + // A class's own init registers the templates nested in it. + let _classes: Vec<_> = template_classes() + .into_iter() + .filter_map(glib::Class::::from_type) + .collect(); + + let templates = app_templates(); + let mut problems = Vec::new(); + for (name, markup) in &templates { + let Some((class, plain)) = plain_template(markup) else { + problems.push(format!("{name}: not a template")); + continue; + }; + let Some(class) = glib::Type::from_name(&class) else { + problems.push(format!("{name}: {class} is not in template_classes")); + continue; + }; + problems.extend(unlabelled_in_template(name, &plain, Some(class))); + } + + assert!(!templates.is_empty(), "the walk found no templates"); + assert!(problems.is_empty(), "{problems:#?}"); +} + +#[gtk4::test] +fn the_walk_reports_an_unlabelled_icon_button() { + adw::init().expect("libadwaita initialises under the test backend"); + let markup = r#" + + + + list-add-symbolic + Add + + + + + view-more-symbolic + + + + + user-trash-symbolic + + Delete + + + + + + Save + + + + "#; + + let problems = unlabelled_in_template("probe.ui", markup, None); + + assert_eq!( + problems, + [ + "probe.ui: an icon-only GtkButton with no id has no accessible label in the template", + "probe.ui: named_in_code has no accessible label and is not in the built widget", + ] + ); +} + +#[test] +fn a_template_is_read_as_an_object_of_its_parent_class() { + let markup = r#""#; + + assert_eq!( + plain_template(markup), + Some(( + "TableProThing".to_owned(), + r#""#.to_owned() + )) + ); + assert_eq!(plain_template(""), None); +} diff --git a/linux/crates/app/src/ui/browse_tab/browse_actions.rs b/linux/crates/app/src/ui/browse_tab/browse_actions.rs new file mode 100644 index 0000000000..7903a7f6cc --- /dev/null +++ b/linux/crates/app/src/ui/browse_tab/browse_actions.rs @@ -0,0 +1,140 @@ +use gtk4 as gtk; +use gtk4::prelude::*; +use gtk4::subclass::prelude::*; +use gtk4::{gio, glib}; + +use super::{ + BrowseModel, BrowseTab, BrowseTabInput, ClipboardInput, EditInput, PagingInput, SaveInput, SelectionInput, +}; +use crate::application::{ShortcutOwner, local_shortcut_controller}; +use crate::i18n::gettext; +use crate::services::tab_tracker_handle::TabTrackerHandle; +use crate::ui::a11y::{announce_key_shortcuts, describe_icon_button}; + +const ACTION_GROUP: &str = "browse"; + +/// The `browse` actions whose state follows the tab. A disabled one lets +/// its key go on, and greys out the button that names it. +#[derive(Clone)] +pub(super) struct BrowseActions { + pub(super) save: gio::SimpleAction, + pub(super) undo: gio::SimpleAction, + pub(super) redo: gio::SimpleAction, + pub(super) insert_row: gio::SimpleAction, + pub(super) delete_rows: gio::SimpleAction, + pub(super) copy_rows: gio::SimpleAction, + pub(super) clear_selection: gio::SimpleAction, + /// The More menu's Count Rows, on where the pager offers it. + pub(super) count_rows: gio::SimpleAction, + /// The More menu's page sizes, its state the size the tab reads. + pub(super) page_size: gio::SimpleAction, +} + +impl BrowseActions { + pub(super) fn follow_tracker(&self, tracker: &TabTrackerHandle, saving: bool) { + self.save.set_enabled(!saving && tracker.pending_count() > 0); + self.undo.set_enabled(tracker.can_undo()); + self.redo.set_enabled(tracker.can_redo()); + } + + pub(super) fn follow_selection(&self, selected: u32) { + self.copy_rows.set_enabled(selected > 0); + // Clearing a single focused row strands the focus indicator. + self.clear_selection.set_enabled(selected > 1); + } +} + +/// The tab's `browse` group, the tab's own keys for it and the keys its +/// buttons announce. The grid's keys go on each column view the tab +/// builds, through `install_grid_shortcuts`. +pub(super) fn install_browse_actions(tab: &BrowseTab, page_size: u64) -> BrowseActions { + let group = gio::SimpleActionGroup::new(); + let add = |name: &str, input: fn() -> BrowseTabInput| { + let action = gio::SimpleAction::new(name, None); + let weak = tab.downgrade(); + action.connect_activate(move |_, _| { + if let Some(tab) = weak.upgrade() { + tab.send(input()); + } + }); + group.add_action(&action); + action + }; + add("filter", || PagingInput::ShowFilterStrip.into()); + add("toggle-filter", || PagingInput::ToggleFilterStrip.into()); + add("export", || ClipboardInput::Export(None).into()); + let page_size = + gio::SimpleAction::new_stateful("page-size", Some(glib::VariantTy::UINT64), &page_size.to_variant()); + let weak = tab.downgrade(); + page_size.connect_change_state(move |_, size| { + if let (Some(tab), Some(size)) = (weak.upgrade(), size.and_then(u64::from_variant)) { + tab.send(PagingInput::PageSizeChanged(size)); + } + }); + group.add_action(&page_size); + add("refresh", || PagingInput::RequestRefresh.into()); + add("paste-rows", || ClipboardInput::PasteNotSupported.into()); + let previous = add("previous-page", || PagingInput::PrevPage.into()); + let next = add("next-page", || PagingInput::NextPage.into()); + tab.imp().pager_bar.follow_page_actions(ACTION_GROUP, &previous, &next); + let delete_rows = gio::SimpleAction::new("delete-rows", None); + let weak = tab.downgrade(); + delete_rows.connect_activate(move |_, _| { + let Some(tab) = weak.upgrade() else { + return; + }; + let rows = tab.read(BrowseModel::selected_rows).unwrap_or_default(); + tab.send(EditInput::DeleteRows(rows)); + }); + group.add_action(&delete_rows); + let actions = BrowseActions { + save: add("save", || SaveInput::CommitSave.into()), + undo: add("undo", || EditInput::Undo.into()), + redo: add("redo", || EditInput::Redo.into()), + insert_row: add("insert-row", || EditInput::InsertRow.into()), + delete_rows, + copy_rows: add("copy-rows", || ClipboardInput::CopySelectedRowsAsTsv.into()), + clear_selection: add("clear-selection", || SelectionInput::ClearSelection.into()), + count_rows: add("count-rows", || PagingInput::CountRows.into()), + page_size, + }; + for action in [ + &actions.save, + &actions.undo, + &actions.redo, + &actions.insert_row, + &actions.delete_rows, + &actions.copy_rows, + &actions.clear_selection, + &actions.count_rows, + ] { + action.set_enabled(false); + } + tab.imp().pending_bar.announce_save_keys(ACTION_GROUP, &actions.save); + tab.insert_action_group(ACTION_GROUP, Some(&group)); + tab.add_controller(local_shortcut_controller(ShortcutOwner::BrowseTab)); + actions +} + +/// Names the icon buttons and gives every button the keys of its action. +/// Run as the tab is built, so the names never wait on its actions. The +/// pager and the pending changes bar name their own. +pub(super) fn describe_buttons(tab: &BrowseTab) { + let imp = tab.imp(); + for (button, label, action) in [ + (&imp.insert_button, gettext("Insert Row"), Some("browse.insert-row")), + ( + &imp.delete_button, + gettext("Delete Selected Rows"), + Some("browse.delete-rows"), + ), + (&imp.export_button, gettext("Export Rows…"), None), + ] { + describe_icon_button(&button.get(), &label, action); + } + announce_key_shortcuts(&imp.filter_button.get(), "browse.filter"); +} + +pub(super) fn install_grid_shortcuts(view: >k::ColumnView) { + view.add_controller(local_shortcut_controller(ShortcutOwner::BrowseGrid)); +} diff --git a/linux/crates/app/src/ui/browse_tab/browse_command.rs b/linux/crates/app/src/ui/browse_tab/browse_command.rs new file mode 100644 index 0000000000..869b266c03 --- /dev/null +++ b/linux/crates/app/src/ui/browse_tab/browse_command.rs @@ -0,0 +1,27 @@ +use std::sync::Arc; + +use tablepro_core::ColumnInfo; +use tablepro_core::meta::{RowIdentity, TableEstimate}; +use tablepro_session::OperationError; + +use super::page_reply::PageReply; +use crate::runtime::Stamped; +use crate::ui::tab_load::LoadFailure; + +/// The table's columns from the session's catalog, and how its rows are +/// told apart, read together because a page needs both. +#[derive(Debug, Clone)] +pub(crate) struct ColumnsReply { + pub columns: Arc<[ColumnInfo]>, + pub identity: RowIdentity, +} + +/// A reply to one of the tab's reads, stamped with the request it +/// answers so a reply to a replaced request is dropped. +#[derive(Debug)] +pub(crate) enum BrowseCommand { + Columns(Stamped>), + Page(Stamped>), + Estimate(Stamped>), + Count(Stamped>), +} diff --git a/linux/crates/app/src/ui/browse_tab/browse_pager.rs b/linux/crates/app/src/ui/browse_tab/browse_pager.rs new file mode 100644 index 0000000000..e8f54cbb60 --- /dev/null +++ b/linux/crates/app/src/ui/browse_tab/browse_pager.rs @@ -0,0 +1,340 @@ +use tablepro_core::meta::PagePosition; + +use super::row_total::RowTotal; + +/// Where the tab's page starts, and how many rows a page holds. Each +/// move gives the position to read and the row the page then starts at. +/// +/// A table that pages by key or heap address is read past the edge +/// row of the page on screen, so a page far into it costs what the +/// first one does. Only a page with no such edge, or a sort of the +/// user's, is read by offset. +#[derive(Debug, Clone, PartialEq)] +pub(crate) struct BrowsePager { + page_size: u64, + start_ordinal: u64, + /// What a read of the page the pager is on asks for. + position: PagePosition, + /// How many rows that read asks for: a page, or for the last page + /// read from the end, what is left of the table. + limit: u64, + /// The way from the page on screen to the pages either side of it, + /// gone once the pager moves off that page. + edges: PageEdges, + /// The last page can be read backward from the table's end, which + /// the last page read said. + seeks: bool, +} + +/// How to reach the pages either side of a page by its edge rows. +#[derive(Debug, Clone, Default, PartialEq)] +pub(crate) struct PageEdges { + pub(crate) previous: Option, + pub(crate) next: Option, +} + +/// A page the pager can move to. +#[derive(Debug, Clone, PartialEq)] +pub(crate) struct PageTarget { + pub(crate) position: PagePosition, + /// How many rows of the table come before the page. + pub(crate) start: u64, + pub(crate) limit: u64, +} + +impl BrowsePager { + pub(crate) fn new(page_size: u64, start_ordinal: u64) -> Self { + let page_size = page_size.max(1); + Self { + page_size, + start_ordinal, + position: position_at(start_ordinal), + limit: page_size, + edges: PageEdges::default(), + seeks: false, + } + } + + pub(crate) fn page_size(&self) -> u64 { + self.page_size + } + + pub(crate) fn start_ordinal(&self) -> u64 { + self.start_ordinal + } + + /// The position a read of the page the pager is on asks for. + pub(crate) fn position(&self) -> PagePosition { + self.position.clone() + } + + pub(crate) fn limit(&self) -> u64 { + self.limit + } + + pub(crate) fn move_to(&mut self, target: PageTarget) { + self.position = target.position; + self.start_ordinal = target.start; + self.limit = target.limit; + self.edges = PageEdges::default(); + } + + /// Back to the first row, under a sort or filter that may page + /// another way, which the next page read says. + pub(crate) fn restart(&mut self) { + self.move_to(self.target(PagePosition::First, 0)); + self.seeks = false; + } + + /// The table's row identity changed, so a key the pager holds names + /// rows by columns the table may no longer key on. + pub(crate) fn rebase(&mut self) { + self.move_to(self.target(position_at(self.start_ordinal), self.start_ordinal)); + self.seeks = false; + } + + /// A page landed: where it starts, what it was read as, and the way + /// on from it. + pub(crate) fn land(&mut self, start: u64, position: PagePosition, edges: PageEdges, seeks: bool) { + if position == PagePosition::First { + self.limit = self.page_size; + } + self.position = position; + self.start_ordinal = start; + self.edges = edges; + self.seeks = seeks; + } + + /// A new page size starts again from the first row. + pub(crate) fn resize(&mut self, page_size: u64) { + self.page_size = page_size.max(1); + self.move_to(self.target(PagePosition::First, 0)); + } + + pub(crate) fn first(&self) -> Option { + (self.start_ordinal > 0).then(|| self.target(PagePosition::First, 0)) + } + + pub(crate) fn prev(&self) -> Option { + (self.start_ordinal > 0).then(|| { + let start = self.start_ordinal.saturating_sub(self.page_size); + let position = match start { + 0 => PagePosition::First, + start => self.edges.previous.clone().unwrap_or(PagePosition::Offset(start)), + }; + self.target(position, start) + }) + } + + /// A page with fewer rows than a page holds was the last one. + pub(crate) fn next(&self, rows_on_page: u64) -> Option { + (rows_on_page >= self.page_size).then(|| { + let start = self.start_ordinal.saturating_add(self.page_size); + let position = self.edges.next.clone().unwrap_or(PagePosition::Offset(start)); + self.target(position, start) + }) + } + + /// Only an exact total says where the last page starts. + pub(crate) fn last(&self, total: &RowTotal) -> Option { + let total = total.exact()?; + let start = self.last_start(total)?; + (start != self.start_ordinal).then(|| self.last_page(start, total)) + } + + /// The last page, for a pager left past the end of a table that + /// shrank since. + pub(crate) fn clamp(&self, total: u64) -> Option { + let start = self.last_start(total)?; + (self.start_ordinal >= total).then(|| self.last_page(start, total)) + } + + fn last_page(&self, start: u64, total: u64) -> PageTarget { + match (start, self.seeks) { + (0, _) => self.target(PagePosition::First, 0), + (start, true) => PageTarget { + position: PagePosition::Last, + start, + limit: total - start, + }, + (start, false) => self.target(PagePosition::Offset(start), start), + } + } + + fn last_start(&self, total: u64) -> Option { + let last_row = total.checked_sub(1)?; + Some(last_row / self.page_size * self.page_size) + } + + fn target(&self, position: PagePosition, start: u64) -> PageTarget { + PageTarget { + position, + start, + limit: self.page_size, + } + } +} + +fn position_at(start: u64) -> PagePosition { + match start { + 0 => PagePosition::First, + start => PagePosition::Offset(start), + } +} + +#[cfg(test)] +mod tests { + use tablepro_core::Value; + + use super::*; + + fn target(position: PagePosition, start: u64) -> Option { + Some(PageTarget { + position, + start, + limit: 100, + }) + } + + fn keyed_edges(first: i64, last: i64) -> PageEdges { + PageEdges { + previous: Some(PagePosition::BeforeKey(vec![Value::Int(first)])), + next: Some(PagePosition::AfterKey(vec![Value::Int(last)])), + } + } + + #[test] + fn browse_pager_last_requires_exact_total() { + let pager = BrowsePager::new(100, 0); + + assert_eq!(pager.last(&RowTotal::Unknown), None); + assert_eq!(pager.last(&RowTotal::Estimated(1_000)), None); + assert_eq!(pager.last(&RowTotal::Counting { estimate: Some(1_000) }), None); + assert_eq!( + pager.last(&RowTotal::Exact(1_000)), + target(PagePosition::Offset(900), 900) + ); + assert_eq!( + pager.last(&RowTotal::Exact(1_001)), + target(PagePosition::Offset(1_000), 1_000) + ); + assert_eq!(pager.last(&RowTotal::Exact(0)), None); + assert_eq!(BrowsePager::new(100, 900).last(&RowTotal::Exact(1_000)), None); + } + + #[test] + fn the_pager_moves_by_offset_and_stops_at_the_ends() { + let pager = BrowsePager::new(100, 200); + + assert_eq!(pager.position(), PagePosition::Offset(200)); + assert_eq!(pager.first(), target(PagePosition::First, 0)); + assert_eq!(pager.prev(), target(PagePosition::Offset(100), 100)); + assert_eq!(pager.next(100), target(PagePosition::Offset(300), 300)); + assert_eq!(pager.next(40), None, "a short page went on"); + assert_eq!(BrowsePager::new(100, 0).first(), None); + assert_eq!(BrowsePager::new(100, 0).prev(), None); + assert_eq!(BrowsePager::new(100, 50).prev(), target(PagePosition::First, 0)); + } + + #[test] + fn a_keyed_page_pages_past_its_edge_rows_and_keeps_the_row_numbers() { + let mut pager = BrowsePager::new(100, 0); + pager.land(200, PagePosition::Offset(200), keyed_edges(201, 300), true); + + assert_eq!( + pager.next(100), + target(PagePosition::AfterKey(vec![Value::Int(300)]), 300) + ); + assert_eq!( + pager.prev(), + target(PagePosition::BeforeKey(vec![Value::Int(201)]), 100) + ); + let mut near_start = BrowsePager::new(100, 0); + near_start.land(100, PagePosition::Offset(100), keyed_edges(101, 200), true); + assert_eq!( + near_start.prev(), + target(PagePosition::First, 0), + "a page read backward from the second page would start mid-table" + ); + } + + #[test] + fn a_move_forgets_the_edges_of_the_page_it_left() { + let mut pager = BrowsePager::new(100, 0); + pager.land(0, PagePosition::First, keyed_edges(1, 100), true); + let next = pager.next(100).expect("a next page"); + + pager.move_to(next); + + assert_eq!( + pager.next(100), + target(PagePosition::Offset(200), 200), + "a page not yet read has no edge to read past" + ); + } + + #[test] + fn the_last_page_of_a_seekable_table_reads_what_is_left_from_its_end() { + let mut pager = BrowsePager::new(100, 0); + pager.land(0, PagePosition::First, keyed_edges(1, 100), true); + + assert_eq!( + pager.last(&RowTotal::Exact(1_042)), + Some(PageTarget { + position: PagePosition::Last, + start: 1_000, + limit: 42, + }) + ); + let mut past_end = BrowsePager::new(100, 0); + past_end.land(1_500, PagePosition::Offset(1_500), PageEdges::default(), true); + assert_eq!( + past_end.clamp(1_100), + Some(PageTarget { + position: PagePosition::Last, + start: 1_000, + limit: 100, + }) + ); + } + + #[test] + fn a_new_sort_pages_by_offset_until_its_first_page_lands() { + let mut pager = BrowsePager::new(100, 0); + pager.land(300, PagePosition::Offset(300), keyed_edges(301, 400), true); + + pager.restart(); + + assert_eq!((pager.position(), pager.start_ordinal()), (PagePosition::First, 0)); + assert_eq!( + pager.last(&RowTotal::Exact(1_042)), + target(PagePosition::Offset(1_000), 1_000) + ); + } + + #[test] + fn a_pager_past_the_end_clamps_to_the_last_page() { + assert_eq!( + BrowsePager::new(100, 500).clamp(250), + target(PagePosition::Offset(200), 200) + ); + assert_eq!(BrowsePager::new(100, 200).clamp(250), None); + assert_eq!( + BrowsePager::new(100, 250).clamp(260), + None, + "a page with rows on it moved" + ); + assert_eq!(BrowsePager::new(100, 200).clamp(0), None); + } + + #[test] + fn a_new_page_size_starts_from_the_first_row() { + let mut pager = BrowsePager::new(100, 300); + + pager.resize(500); + + assert_eq!((pager.page_size(), pager.start_ordinal()), (500, 0)); + assert_eq!((pager.position(), pager.limit()), (PagePosition::First, 500)); + assert_eq!(BrowsePager::new(0, 0).page_size(), 1); + } +} diff --git a/linux/crates/app/src/ui/browse_tab/browse_request_kind.rs b/linux/crates/app/src/ui/browse_tab/browse_request_kind.rs new file mode 100644 index 0000000000..a7aeb39eb5 --- /dev/null +++ b/linux/crates/app/src/ui/browse_tab/browse_request_kind.rs @@ -0,0 +1,13 @@ +/// The reads a browse tab makes. Each kind has at most one request +/// running; a new one replaces the last. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub(crate) enum BrowseRequestKind { + /// The table's columns and how its rows are identified. A page and + /// a count are built from them, so both wait for this one. + Columns, + Page, + /// The server's guess at the table's size. + Estimate, + /// The exact total, which can scan the whole table. + Count, +} diff --git a/linux/crates/app/src/ui/browse_tab/browse_tab_input.rs b/linux/crates/app/src/ui/browse_tab/browse_tab_input.rs new file mode 100644 index 0000000000..23cd1c4b17 --- /dev/null +++ b/linux/crates/app/src/ui/browse_tab/browse_tab_input.rs @@ -0,0 +1,75 @@ +use super::clipboard_input::ClipboardInput; +use super::data_input::DataInput; +use super::edit_input::EditInput; +use super::paging_input::PagingInput; +use super::save_input::SaveInput; +use super::selection_input::SelectionInput; +use crate::ui::grid::GridMsg; + +/// An input to the tab, grouped by the state it changes, each group +/// handled on its own. +#[derive(Debug)] +pub(crate) enum BrowseTabInput { + Data(DataInput), + Paging(PagingInput), + Edit(EditInput), + Save(SaveInput), + Selection(SelectionInput), + Clipboard(ClipboardInput), +} + +impl From for BrowseTabInput { + fn from(input: DataInput) -> Self { + Self::Data(input) + } +} + +impl From for BrowseTabInput { + fn from(input: PagingInput) -> Self { + Self::Paging(input) + } +} + +impl From for BrowseTabInput { + fn from(input: EditInput) -> Self { + Self::Edit(input) + } +} + +impl From for BrowseTabInput { + fn from(input: SaveInput) -> Self { + Self::Save(input) + } +} + +impl From for BrowseTabInput { + fn from(input: SelectionInput) -> Self { + Self::Selection(input) + } +} + +impl From for BrowseTabInput { + fn from(input: ClipboardInput) -> Self { + Self::Clipboard(input) + } +} + +impl From for BrowseTabInput { + fn from(msg: GridMsg) -> Self { + match msg { + GridMsg::SortChanged(col_idx, ascending) => PagingInput::SortChanged { col_idx, ascending }.into(), + GridMsg::SortCleared => PagingInput::SortCleared.into(), + GridMsg::Edit { row, column, edit } => EditInput::GridEdit { row, column, edit }.into(), + GridMsg::Copy(request) => ClipboardInput::GridCopy(request).into(), + GridMsg::ShowRowJson(request) => ClipboardInput::GridShowRowJson(request).into(), + GridMsg::ShowToast(text) => ClipboardInput::GridShowToast(text).into(), + GridMsg::CopyRowsAsInsert { rows } => ClipboardInput::GridCopyRowsAsInsert(rows).into(), + GridMsg::CopyInClause(values) => ClipboardInput::GridCopyInClause(values).into(), + GridMsg::SetCellValue { row, column, preset } => EditInput::GridSetCellValue { row, column, preset }.into(), + GridMsg::ExportResults(page) => ClipboardInput::Export(Some(page)).into(), + GridMsg::DeleteRows { rows } => EditInput::DeleteRows(rows).into(), + GridMsg::InsertRow => EditInput::InsertRow.into(), + GridMsg::DuplicateRows { rows } => EditInput::DuplicateRows(rows).into(), + } + } +} diff --git a/linux/crates/app/src/ui/browse_tab/browse_tab_widgets.rs b/linux/crates/app/src/ui/browse_tab/browse_tab_widgets.rs new file mode 100644 index 0000000000..808d02d6c1 --- /dev/null +++ b/linux/crates/app/src/ui/browse_tab/browse_tab_widgets.rs @@ -0,0 +1,28 @@ +use gtk4 as gtk; +use libadwaita as adw; + +use super::pager_bar::PagerBar; +use super::pending_changes_bar::PendingChangesBar; +use crate::ui::tab_load::StaleBanner; + +pub(super) struct BrowseTabWidgets { + pub(super) inner_stack: gtk::Stack, + pub(super) grid_holder: gtk::Box, + pub(super) loading_page: adw::StatusPage, + pub(super) error_page: adw::StatusPage, + pub(super) stale_banner: StaleBanner, + /// Banners are for states the user cannot clear by saving; pending + /// edits show in the footer and the tab title instead. + pub(super) read_only_banner: adw::Banner, + pub(super) no_pk_banner: adw::Banner, + pub(super) selection_label: gtk::Label, + pub(super) filter_button: gtk::Button, + pub(super) filter_badge: gtk::Label, + pub(super) insert_button: gtk::Button, + pub(super) delete_button: gtk::Button, + pub(super) pager_bar: PagerBar, + pub(super) pending_bar: PendingChangesBar, + pub(super) page_size_dropdown: gtk::DropDown, + /// The size at each place in the dropdown and the More menu. + pub(super) page_sizes: Vec, +} diff --git a/linux/crates/app/src/ui/browse_tab/clipboard_handlers.rs b/linux/crates/app/src/ui/browse_tab/clipboard_handlers.rs new file mode 100644 index 0000000000..cbd22f6ede --- /dev/null +++ b/linux/crates/app/src/ui/browse_tab/clipboard_handlers.rs @@ -0,0 +1,155 @@ +use gtk4::prelude::*; + +use super::{BrowseModel, BrowseTabOutput, ClipboardInput, Effects, qualified}; +use crate::ui::clipboard::{CopyKind, CopyRequest, InsertCopy, InsertRow}; +use crate::ui::export::{ExportOffer, ExportOrigin, ExportQuery, ExportSource}; +use crate::ui::grid::{GridRowId, RowObject, RowSnapshot, export_snapshot}; + +impl BrowseModel { + pub(super) fn on_clipboard(&self, input: ClipboardInput, fx: &mut Effects) { + match input { + ClipboardInput::Export(page) => self.offer_export(page, fx), + ClipboardInput::GridCopy(request) => fx.output(BrowseTabOutput::Copy(request)), + ClipboardInput::GridCopyRowsAsInsert(rows) => { + if !self.grid_is_stale() { + self.copy_rows_as_insert(&rows, fx); + } + } + ClipboardInput::GridCopyInClause(values) => match self.session.current_dialect() { + Some(dialect) => fx.output(BrowseTabOutput::Copy(CopyRequest::InClause { dialect, values })), + None => fx.output(BrowseTabOutput::ShowToast(crate::i18n::gettext( + "Connect to a database first.", + ))), + }, + ClipboardInput::CopySelectedRowsAsTsv => self.copy_selected_rows(fx), + ClipboardInput::PasteNotSupported => { + fx.output(BrowseTabOutput::ShowToast(crate::i18n::gettext( + "Pasting rows isn't supported yet", + ))); + } + ClipboardInput::GridShowRowJson(request) => fx.output(BrowseTabOutput::ShowRowJson(request)), + ClipboardInput::GridShowToast(message) => fx.output(BrowseTabOutput::ShowToast(message)), + } + } + + /// The table a browse tab's rows came from, so an export can + /// write them back as `INSERT` statements. + fn sql_target(&self) -> crate::ui::export::dialog::SqlTarget { + crate::ui::export::dialog::SqlTarget { + table: self.table_ref(), + generated: self + .current_columns + .iter() + .filter(|column| column.is_generated) + .map(|column| column.name.clone()) + .collect(), + } + } + + fn export_name(&self) -> String { + qualified(self.schema.as_deref(), &self.table) + } + + /// The page the paginator's Export button writes: the grid's rows, + /// so it matches the context menu's Export row for row. + fn export_payload(&self) -> Option { + let page = self.shown_page()?; + let rows = self + .current_column_view + .as_ref() + .map(|column_view| export_snapshot(column_view, &self.grid_context())) + .unwrap_or_default(); + Some(ExportSource::Rows { + columns: page.result_columns.clone(), + rows, + }) + } + + /// Ask the window to export the page, or every row the filter + /// selects. Rows behind an error page are not what the table holds + /// under the current filter, so they are never offered. + fn offer_export(&self, page: Option, fx: &mut Effects) { + let page = match (self.shown_page(), page) { + (Some(_), Some(page)) => Some(page), + (Some(_), None) => self.export_payload(), + (None, _) => None, + }; + let (Some(page), Some(table)) = (page, self.table_export()) else { + fx.output(BrowseTabOutput::ShowToast(crate::i18n::gettext("Nothing to export"))); + return; + }; + fx.output(BrowseTabOutput::Export(Box::new(ExportOffer { + origin: ExportOrigin::Browse { + page, + query: ExportQuery::Table(table), + total: self.total.clone(), + has_unsaved_changes: self.tracker.pending_count() > 0, + }, + session: Some(self.session.clone()), + name: self.export_name(), + target: Some(self.sql_target()), + }))); + } + + /// Each row as an INSERT for the server the tab is connected to, + /// pending edits included. A draft's is the INSERT its save would + /// run, so the columns the server fills stay out of it. + fn copy_rows_as_insert(&self, rows: &[RowObject], fx: &mut Effects) { + let Some(dialect) = self.session.current_dialect() else { + fx.output(BrowseTabOutput::ShowToast(crate::i18n::gettext( + "Connect to a database first.", + ))); + return; + }; + fx.output(BrowseTabOutput::Copy(CopyRequest::Insert(InsertCopy { + dialect, + table: self.table_ref(), + columns: self.current_columns.iter().cloned().collect(), + rows: self.insert_rows(rows), + }))); + } + + /// The rows an INSERT copy renders off the GTK thread. A saved row + /// shares the page's cells; only a draft's are copied here. + pub(super) fn insert_rows(&self, rows: &[RowObject]) -> Vec { + let ctx = self.grid_context(); + rows.iter() + .map(|row| match row.id() { + GridRowId::Draft(draft_id) => InsertRow::Draft( + self.tracker + .draft_cells(*draft_id, &self.current_columns) + .unwrap_or_default(), + ), + _ => InsertRow::Saved(ctx.snapshot(row)), + }) + .collect() + } + + /// Same request as the context menu's Copy As > Rows: one selection + /// cannot produce two different clipboard payloads. + fn copy_selected_rows(&self, fx: &mut Effects) { + let (Some(selection), Some(_)) = (self.current_selection.as_ref(), self.shown_page()) else { + return; + }; + let ctx = self.grid_context(); + let rows: Vec = crate::ui::grid::selected_positions(selection) + .iter() + .filter_map(|position| selection.item(*position).and_downcast::()) + .map(|row| ctx.snapshot(&row)) + .collect(); + if rows.is_empty() { + return; + } + fx.output(BrowseTabOutput::Copy(CopyRequest::Rows { + columns: result_columns(&self.current_columns), + rows, + kind: CopyKind::Rows, + })); + } +} + +/// The result-column view of a catalogue column list, for a result the +/// grid renders from the schema it already has. +fn result_columns(columns: &[tablepro_core::ColumnInfo]) -> std::sync::Arc<[tablepro_core::column::ResultColumn]> { + columns.iter().map(|column| column.result_column()).collect() +} diff --git a/linux/crates/app/src/ui/browse_tab/clipboard_input.rs b/linux/crates/app/src/ui/browse_tab/clipboard_input.rs new file mode 100644 index 0000000000..209743dfdc --- /dev/null +++ b/linux/crates/app/src/ui/browse_tab/clipboard_input.rs @@ -0,0 +1,19 @@ +use crate::ui::clipboard::{CopyRequest, InClauseRows}; +use crate::ui::export::ExportSource; +use crate::ui::grid::{RowJsonRequest, RowObject}; + +/// What the tab hands on and keeps no state for: copies in each format, +/// exports and the grid's notices. +#[derive(Debug)] +pub(crate) enum ClipboardInput { + /// The paginator's Export button, or the grid's Export with the page + /// as the grid holds it. + Export(Option), + GridCopy(CopyRequest), + GridCopyRowsAsInsert(Vec), + GridCopyInClause(InClauseRows), + CopySelectedRowsAsTsv, + PasteNotSupported, + GridShowRowJson(RowJsonRequest), + GridShowToast(String), +} diff --git a/linux/crates/app/src/ui/browse_tab/column_recheck.rs b/linux/crates/app/src/ui/browse_tab/column_recheck.rs new file mode 100644 index 0000000000..a931a80de2 --- /dev/null +++ b/linux/crates/app/src/ui/browse_tab/column_recheck.rs @@ -0,0 +1,165 @@ +use tablepro_core::ColumnInfo; +use tablepro_core::browse::PageLayout; +use tablepro_core::column::ResultColumn; + +/// Checks that a page came back with the columns the tab built it from. +/// A table altered between the column read and the page shows up here, +/// and the columns are read again once before the tab gives up on it. +#[derive(Debug, Default)] +pub(crate) struct ColumnRecheck { + attempted: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum RecheckAction { + Accept, + /// Read the columns again, then the page. + Refetch, + /// The columns moved again after they were read again: + /// `LoadFailure::ColumnsChanged`. + Fail, +} + +impl ColumnRecheck { + pub(crate) fn check( + &mut self, + columns: &[ColumnInfo], + result_columns: &[ResultColumn], + layout: &PageLayout, + ) -> RecheckAction { + let width = layout.visible.len() + layout.identity.len(); + let visible_names = result_columns + .get(layout.visible.clone()) + .unwrap_or_default() + .iter() + .map(|column| column.name.as_str()); + let matches = result_columns.len() == width + && layout.visible.len() == columns.len() + && visible_names.eq(columns.iter().map(|column| column.name.as_str())); + if matches { + self.attempted = false; + return RecheckAction::Accept; + } + // A failure spends the refetch, so the next mismatch, after Try + // Again or a page turn, gets one of its own. + match std::mem::replace(&mut self.attempted, true) { + false => RecheckAction::Refetch, + true => { + self.attempted = false; + RecheckAction::Fail + } + } + } + + /// A read of the columns other than the recheck's own, or the end of + /// the recheck's own in a failure. + pub(crate) fn reset(&mut self) { + self.attempted = false; + } +} + +#[cfg(test)] +mod tests { + use tablepro_core::column::{ColumnDefault, ColumnType}; + + use super::*; + + fn column(name: &str) -> ColumnInfo { + ColumnInfo { + name: name.to_owned(), + column_type: ColumnType::untyped_text("text"), + nullable: true, + primary_key: false, + is_auto_increment: false, + is_generated: false, + default: ColumnDefault::None, + comment: None, + } + } + + fn result(names: &[&str]) -> Vec { + names.iter().map(|name| column(name).result_column()).collect() + } + + fn layout(visible: usize, identity: usize) -> PageLayout { + PageLayout { + visible: 0..visible, + identity: visible..visible + identity, + } + } + + #[test] + fn column_recheck_accepts_matching_visible_names_with_identity_tail() { + let mut recheck = ColumnRecheck::default(); + let columns = [column("id"), column("name")]; + + let action = recheck.check( + &columns, + &result(&["id", "name", "__tablepro_row_oid", "__tablepro_row_id"]), + &layout(2, 2), + ); + + assert_eq!(action, RecheckAction::Accept); + } + + #[test] + fn column_recheck_refetches_once_then_fails() { + let mut recheck = ColumnRecheck::default(); + let columns = [column("id"), column("name")]; + let renamed = result(&["id", "full_name"]); + + assert_eq!(recheck.check(&columns, &renamed, &layout(2, 0)), RecheckAction::Refetch); + assert_eq!(recheck.check(&columns, &renamed, &layout(2, 0)), RecheckAction::Fail); + assert_eq!( + recheck.check(&columns, &result(&["id", "name"]), &layout(2, 0)), + RecheckAction::Accept + ); + assert_eq!( + recheck.check(&columns, &renamed, &layout(2, 0)), + RecheckAction::Refetch, + "a page that matched did not reset the guard" + ); + } + + #[test] + fn a_failed_recheck_leaves_the_next_mismatch_its_refetch() { + let mut recheck = ColumnRecheck::default(); + let columns = [column("id"), column("name")]; + let renamed = result(&["id", "full_name"]); + + assert_eq!(recheck.check(&columns, &renamed, &layout(2, 0)), RecheckAction::Refetch); + assert_eq!(recheck.check(&columns, &renamed, &layout(2, 0)), RecheckAction::Fail); + assert_eq!(recheck.check(&columns, &renamed, &layout(2, 0)), RecheckAction::Refetch); + } + + #[test] + fn a_reset_leaves_the_next_mismatch_its_refetch() { + let mut recheck = ColumnRecheck::default(); + let columns = [column("id"), column("name")]; + let renamed = result(&["id", "full_name"]); + + assert_eq!(recheck.check(&columns, &renamed, &layout(2, 0)), RecheckAction::Refetch); + recheck.reset(); + + assert_eq!(recheck.check(&columns, &renamed, &layout(2, 0)), RecheckAction::Refetch); + } + + #[test] + fn column_recheck_rejects_wrong_identity_width() { + let mut recheck = ColumnRecheck::default(); + let columns = [column("id"), column("name")]; + + let action = recheck.check(&columns, &result(&["id", "name", "__tablepro_row_id"]), &layout(2, 2)); + + assert_eq!(action, RecheckAction::Refetch); + } + + #[test] + fn column_recheck_rejects_a_column_the_tab_does_not_have() { + let mut recheck = ColumnRecheck::default(); + + let action = recheck.check(&[column("id")], &result(&["id", "name"]), &layout(2, 0)); + + assert_eq!(action, RecheckAction::Refetch); + } +} diff --git a/linux/crates/app/src/ui/browse_tab/columns_fetch.rs b/linux/crates/app/src/ui/browse_tab/columns_fetch.rs new file mode 100644 index 0000000000..1ea2691fb1 --- /dev/null +++ b/linux/crates/app/src/ui/browse_tab/columns_fetch.rs @@ -0,0 +1,41 @@ +use std::sync::Arc; + +use tablepro_core::ColumnInfo; +use tablepro_core::meta::TableRef; +use tablepro_session::Session; + +use crate::ui::tab_load::LoadFailure; + +/// Whether a read of the table's columns may take the catalog's copy. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ColumnFreshness { + Cached, + /// Ask the server even when the catalog has them. A failure keeps + /// the catalog's copy for the next read. + Refetch, +} + +/// The columns a page is built from. A table with none the account can +/// read is a failure too, since no page can be read from it. +pub(crate) async fn load_columns( + session: Arc, + table: TableRef, + freshness: ColumnFreshness, +) -> Result, LoadFailure> { + let read = match freshness { + ColumnFreshness::Cached => session.columns(table.clone()).await, + ColumnFreshness::Refetch => session.refresh_columns(table.clone()).await, + }; + let failure = match read { + Ok(columns) if !columns.is_empty() => return Ok(columns), + Ok(_) => LoadFailure::NoColumns, + Err(error) => LoadFailure::Columns(error), + }; + tracing::warn!( + schema = ?table.schema, + table = %table.name, + category = ?failure.category(), + "browse columns failed" + ); + Err(failure) +} diff --git a/linux/crates/app/src/ui/browse_tab/command_handlers.rs b/linux/crates/app/src/ui/browse_tab/command_handlers.rs new file mode 100644 index 0000000000..6e2d0adfc3 --- /dev/null +++ b/linux/crates/app/src/ui/browse_tab/command_handlers.rs @@ -0,0 +1,152 @@ +use std::sync::Arc; + +use tablepro_session::OperationError; + +use super::column_recheck::RecheckAction; +use super::columns_fetch::ColumnFreshness; +use super::{BrowseCommand, BrowseModel, BrowseRequestKind, Effects, PageReply}; +use crate::runtime::Stamped; +use crate::ui::tab_load::LoadFailure; + +/// The replies to the tab's reads. +impl BrowseModel { + pub(super) fn on_command(&mut self, command: BrowseCommand, fx: &mut Effects) { + match command { + BrowseCommand::Columns(Stamped { stamp, value }) => { + if !self.sequencer.accept(BrowseRequestKind::Columns, stamp) { + return; + } + self.failed.record(BrowseRequestKind::Columns, &value); + match value { + Ok(reply) => { + if self.identity.as_ref() != Some(&reply.identity) { + self.pager.rebase(); + } + self.identity = Some(reply.identity); + self.on_columns_loaded(reply.columns.to_vec(), fx); + for kind in std::mem::take(&mut self.after_columns) { + self.fetch(kind, fx); + } + } + Err(failure) => { + self.recheck.reset(); + // What waited on the columns was never asked, so it + // is asked again with them rather than set off by a + // later read of the columns that wanted none of it. + for kind in std::mem::take(&mut self.after_columns) { + self.failed.insert(kind); + } + self.on_load_failed(failure, fx); + self.retry_if_link_moved(BrowseRequestKind::Columns, fx); + } + } + } + BrowseCommand::Page(Stamped { stamp, value }) => { + if !self.sequencer.accept(BrowseRequestKind::Page, stamp) { + return; + } + self.failed.record(BrowseRequestKind::Page, &value); + match value { + Ok(reply) => self.on_page_read(reply, fx), + Err(error) => { + self.on_load_failed(LoadFailure::Page(Arc::new(error)), fx); + self.retry_if_link_moved(BrowseRequestKind::Page, fx); + } + } + } + BrowseCommand::Estimate(Stamped { stamp, value }) => { + if !self.sequencer.accept(BrowseRequestKind::Estimate, stamp) { + return; + } + self.failed.record(BrowseRequestKind::Estimate, &value); + let failed = value.is_err(); + self.on_estimate(value, fx); + if failed { + self.retry_if_link_moved(BrowseRequestKind::Estimate, fx); + } + } + // A failed count is not asked again on a reconnect: Try Again + // runs it, so a count that ran out of time does not run for + // another deadline, or under a filter applied since. + BrowseCommand::Count(Stamped { stamp, value }) => { + if !self.sequencer.accept(BrowseRequestKind::Count, stamp) { + return; + } + if let Err(error) = &value { + tracing::debug!( + kind = failure_kind(error), + category = ?error.category(), + "the row count could not be read" + ); + } + self.on_count(value, fx); + } + } + } + + /// A page read under columns the table no longer has would put cells + /// under the wrong headers, so the columns are read again once, then + /// the page; a second mismatch fails the read. + fn on_page_read(&mut self, reply: PageReply, fx: &mut Effects) { + match self + .recheck + .check(&self.current_columns, &reply.result.columns, &reply.layout) + { + RecheckAction::Accept => { + let offset = reply.offset().unwrap_or(self.pager.start_ordinal()); + self.on_rows_loaded(offset, reply, fx); + } + RecheckAction::Refetch => { + tracing::debug!("a page came back with other columns; reading the columns again"); + self.after_columns.insert(BrowseRequestKind::Page); + self.read_columns(ColumnFreshness::Refetch); + } + RecheckAction::Fail => self.on_load_failed(LoadFailure::ColumnsChanged, fx), + } + } + + /// A read that failed after its connection was already replaced + /// would wait for a reconnect that has come and gone, so it is + /// asked again now. + fn retry_if_link_moved(&mut self, kind: BrowseRequestKind, fx: &mut Effects) { + let issued = self.issued_on.get(&kind).copied(); + if issued.is_some_and(|issued| self.session.link_generation() > issued) { + self.retry_failed(fx); + } + } + + pub(super) fn retry_failed(&mut self, fx: &mut Effects) { + if self.failed.is_empty() { + return; + } + let kinds = self.failed.take(); + if kinds.contains(&BrowseRequestKind::Page) || kinds.contains(&BrowseRequestKind::Columns) { + self.begin_read(fx); + } + match kinds.contains(&BrowseRequestKind::Columns) { + true => { + self.after_columns + .extend(kinds.into_iter().filter(|kind| *kind != BrowseRequestKind::Columns)); + self.fetch_columns(ColumnFreshness::Cached); + } + false => { + for kind in kinds { + self.fetch(kind, fx); + } + } + } + } +} + +/// What a failed read was, for the log. A server's message can quote +/// the statement it rejected, and with it a literal from the user's +/// filter, so the log names the failure and leaves its text to the UI. +fn failure_kind(error: &OperationError) -> &'static str { + match error { + OperationError::Driver(_) => "driver", + OperationError::Sql(_) | OperationError::Browse(_) | OperationError::Build(_) => "statement", + OperationError::Panicked(_) => "panic", + OperationError::Cancelled => "cancelled", + OperationError::Closed => "closed", + } +} diff --git a/linux/crates/app/src/ui/browse_tab/confirming_delete.rs b/linux/crates/app/src/ui/browse_tab/confirming_delete.rs new file mode 100644 index 0000000000..95011dc730 --- /dev/null +++ b/linux/crates/app/src/ui/browse_tab/confirming_delete.rs @@ -0,0 +1,16 @@ +use libadwaita as adw; +use tablepro_core::ColumnInfo; + +use super::row_deletion::RowDeletion; + +/// A bulk delete the user has not answered yet. +/// +/// Its rows are keyed by the primary key of the columns they were read +/// under, so a change to those columns moves them or cancels the delete +/// before it lands. +pub(super) struct ConfirmingDelete { + pub(super) ticket: u64, + pub(super) deletion: RowDeletion, + pub(super) columns: Vec, + pub(super) dialog: adw::AlertDialog, +} diff --git a/linux/crates/app/src/ui/browse_tab/count_failure_text.rs b/linux/crates/app/src/ui/browse_tab/count_failure_text.rs new file mode 100644 index 0000000000..11439e3a19 --- /dev/null +++ b/linux/crates/app/src/ui/browse_tab/count_failure_text.rs @@ -0,0 +1,86 @@ +use std::time::Duration; + +use tablepro_core::{DriverError, TimeoutPhase}; +use tablepro_session::OperationError; + +/// Why a count gave no total. A count stopped at its deadline names the +/// deadline it ran into, which the policy sets, not the driver. A lock +/// wait or a connect that ran out of time never reached that deadline. +pub(crate) fn count_failure_text(error: &OperationError, count_deadline: Option) -> String { + match (error, count_deadline) { + ( + OperationError::Driver(DriverError::Timeout { + phase: TimeoutPhase::Statement, + .. + }), + Some(deadline), + ) => { + let seconds = deadline.as_secs(); + crate::i18n::ngettext_f( + "Counting took longer than {seconds} second and was stopped.", + "Counting took longer than {seconds} seconds and was stopped.", + u32::try_from(seconds).unwrap_or(u32::MAX), + &[("seconds", &seconds.to_string())], + ) + } + _ => crate::ui::error_text::operation_error_message(error), + } +} + +#[cfg(test)] +mod tests { + use tablepro_core::LossPhase; + + use super::*; + + fn timed_out() -> OperationError { + OperationError::Driver(DriverError::Timeout { + phase: TimeoutPhase::Statement, + server_cancelled: true, + }) + } + + #[test] + fn count_failure_text_uses_policy_deadline() { + assert_eq!( + count_failure_text(&timed_out(), Some(Duration::from_secs(45))), + "Counting took longer than 45 seconds and was stopped." + ); + assert_eq!( + count_failure_text(&timed_out(), Some(Duration::from_secs(1))), + "Counting took longer than 1 second and was stopped." + ); + } + + #[test] + fn a_timeout_before_the_count_ran_is_not_its_deadline() { + for phase in [TimeoutPhase::LockWait, TimeoutPhase::Connect, TimeoutPhase::Login] { + let error = OperationError::Driver(DriverError::Timeout { + phase, + server_cancelled: false, + }); + + assert_eq!( + count_failure_text(&error, Some(Duration::from_secs(45))), + crate::ui::error_text::operation_error_message(&error), + "{phase}" + ); + } + } + + #[test] + fn other_count_failures_say_what_kind_of_problem_it_was() { + let lost = OperationError::Driver(DriverError::ConnectionLost { + during: LossPhase::Statement, + }); + + assert_eq!( + count_failure_text(&lost, Some(Duration::from_secs(45))), + "The connection was closed. Try reconnecting." + ); + assert_eq!( + count_failure_text(&timed_out(), None), + "The statement ran past its time limit, so the server stopped it." + ); + } +} diff --git a/linux/crates/app/src/ui/browse_tab/count_policy.rs b/linux/crates/app/src/ui/browse_tab/count_policy.rs new file mode 100644 index 0000000000..ba2ef4ef54 --- /dev/null +++ b/linux/crates/app/src/ui/browse_tab/count_policy.rs @@ -0,0 +1,66 @@ +use tablepro_core::meta::RowCountEstimate; + +/// Up to this many rows, a count costs little enough to run on its own. +pub(crate) const AUTO_COUNT_MAX_ROWS: u64 = 100_000; + +/// What the paginator does with the server's estimate of the table. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum CountAction { + UseExact(u64), + /// Show the estimate, and offer the count. + UseEstimate(u64), + /// Count now: the table is small enough that it bounds the count. + CountNow, + /// Offer the count and run nothing until the user asks. + OfferCount, +} + +/// `estimate` is the whole table's, so under a filter it says only how +/// large a count could get: no total is shown from it, and a small table +/// is still counted. +pub(crate) fn decide(estimate: Result, filtered: bool) -> CountAction { + match (estimate, filtered) { + (Ok(RowCountEstimate::Exact(rows)), false) => CountAction::UseExact(rows), + (Ok(RowCountEstimate::Exact(rows) | RowCountEstimate::Approximate(rows)), _) if rows <= AUTO_COUNT_MAX_ROWS => { + CountAction::CountNow + } + (Ok(RowCountEstimate::Approximate(rows)), false) => CountAction::UseEstimate(rows), + (Ok(_), _) | (Err(()), _) => CountAction::OfferCount, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn count_policy_table() { + let small = AUTO_COUNT_MAX_ROWS; + let large = AUTO_COUNT_MAX_ROWS + 1; + let cases = [ + (Ok(RowCountEstimate::Exact(7)), false, CountAction::UseExact(7)), + (Ok(RowCountEstimate::Exact(large)), false, CountAction::UseExact(large)), + (Ok(RowCountEstimate::Approximate(small)), false, CountAction::CountNow), + ( + Ok(RowCountEstimate::Approximate(large)), + false, + CountAction::UseEstimate(large), + ), + (Ok(RowCountEstimate::Unknown), false, CountAction::OfferCount), + (Err(()), false, CountAction::OfferCount), + (Ok(RowCountEstimate::Exact(7)), true, CountAction::CountNow), + (Ok(RowCountEstimate::Exact(large)), true, CountAction::OfferCount), + (Ok(RowCountEstimate::Approximate(small)), true, CountAction::CountNow), + (Ok(RowCountEstimate::Approximate(large)), true, CountAction::OfferCount), + (Ok(RowCountEstimate::Unknown), true, CountAction::OfferCount), + (Err(()), true, CountAction::OfferCount), + ]; + for (estimate, filtered, expected) in cases { + assert_eq!( + decide(estimate, filtered), + expected, + "{estimate:?}, filtered {filtered}" + ); + } + } +} diff --git a/linux/crates/app/src/ui/browse_tab/data_handlers.rs b/linux/crates/app/src/ui/browse_tab/data_handlers.rs new file mode 100644 index 0000000000..51f7738e29 --- /dev/null +++ b/linux/crates/app/src/ui/browse_tab/data_handlers.rs @@ -0,0 +1,331 @@ +use std::sync::Arc; + +use gtk4 as gtk; +use gtk4::glib; +use gtk4::prelude::*; +use tablepro_core::{ColumnInfo, QueryResult}; +use tablepro_session::LinkGeneration; + +use super::browse_actions::install_grid_shortcuts; +use super::columns_fetch::ColumnFreshness; +use super::selection_handlers::{selected_rows, show_delete_availability, update_selection_chrome}; +use super::{BrowseModel, BrowseTabOutput, DataInput, Effects, PageReply, PageState, PageView, loading_description}; +use crate::services::change_tracker::pk_identity; +use crate::services::save_batch::SaveTarget; +use crate::ui::grid::{ + GridContent, GridRowIndex, GridSorting, RowKeyBuilder, RowObject, build_column_view, data_columns, +}; +use crate::ui::tab_load::{ContentPage, LoadFailure, LoadPresentation}; + +impl BrowseModel { + pub(super) fn on_data(&mut self, input: DataInput, fx: &mut Effects) { + match input { + DataInput::Start => { + self.refresh_crud_buttons(); + self.refresh_pending_bar(0); + // A saved filter shows on the button before the first page + // returns. + self.refresh_filter_chrome(); + // DDL from the SQL editor or another client never reaches + // the catalog, so a tab that opens asks the server. + self.begin_read(fx); + self.reload_from_columns(ColumnFreshness::Refetch); + } + DataInput::Command(command) => self.on_command(command, fx), + DataInput::LinkChanged(generation) => self.on_link_changed(generation, fx), + DataInput::SchemaChanged => { + self.capture_selection(); + self.begin_read(fx); + self.stop_count(true); + self.reload_from_columns(ColumnFreshness::Cached); + } + } + } + + /// The session reconnected. Reads that failed are asked again on the + /// new connection; a tab whose reads all landed keeps its rows. + fn on_link_changed(&mut self, generation: LinkGeneration, fx: &mut Effects) { + if generation <= self.link_generation { + return; + } + self.link_generation = generation; + self.dialect = self.session.current_dialect(); + self.retry_failed(fx); + } + + /// A read of the page the tab asks for starts. Rows read under the + /// same filter, sort and offset stay through a failure; rows of + /// another page do not, since they are not what the tab shows. + pub(super) fn begin_read(&mut self, fx: &mut Effects) { + let presentation = match self.shown_view.as_ref() == Some(&self.page_view()) { + true => self.load.begin(), + false => self.load.begin_replacing(), + }; + self.present(presentation, fx); + } + + /// Put the read's state on screen: the spinner, the grid or the + /// error page, the banner over rows that could not be read again, + /// and the tab bar's spinner. + pub(super) fn present(&mut self, presentation: LoadPresentation, fx: &mut Effects) { + let content_had_focus = + std::mem::take(&mut self.content_had_focus) || crate::ui::a11y::holds_focus(&self.widgets.inner_stack); + match &presentation.page { + ContentPage::Spinner => { + self.content_had_focus = content_had_focus; + self.show_loading(); + } + ContentPage::Content => self.widgets.inner_stack.set_visible_child_name("grid"), + ContentPage::Error(failure) => { + let message = failure.message(); + // The status page reads its description as markup. + self.widgets + .error_page + .set_description(Some(&glib::markup_escape_text(&message))); + // The page takes the place of the rows the focus was in, so + // the focus goes to Try Again and the reason is said. + self.widgets.inner_stack.set_visible_child_name("error"); + if content_had_focus && let Some(retry) = self.widgets.error_page.child() { + retry.grab_focus(); + } + crate::ui::a11y::announce( + &self.widgets.error_page, + &format!("{}. {message}", self.widgets.error_page.title()), + gtk::AccessibleAnnouncementPriority::High, + ); + } + } + self.widgets.stale_banner.show(presentation.stale.as_ref()); + self.sync_pager_buttons(); + self.sync_total_chrome(); + self.sync_delete_action(); + if self.tab_loading != presentation.tab_loading { + self.tab_loading = presentation.tab_loading; + fx.output(BrowseTabOutput::LoadingChanged(presentation.tab_loading)); + } + } + + /// Reveal at most ONE banner at a time. Read-only takes priority + /// over no-PK because read-only blocks every kind of edit. + pub(super) fn refresh_banner_visibility(&self) { + let read_only = self.read_only; + let no_pk = !self.current_columns.is_empty() && !self.current_columns.iter().any(|c| c.primary_key); + self.widgets.read_only_banner.set_revealed(read_only); + self.widgets.no_pk_banner.set_revealed(!read_only && no_pk); + } + + fn show_loading(&self) { + self.widgets + .loading_page + .set_description(Some(&loading_description(&self.table_label()))); + self.widgets.inner_stack.set_visible_child_name("loading"); + } + + /// A read failed. Rows read again for the same page stay, under the + /// banner; with no rows, or rows of another page, the error page says + /// why. The paginator stays, so the user can go back. + pub(super) fn on_load_failed(&mut self, failure: LoadFailure, fx: &mut Effects) { + self.pending_restore = None; + let presentation = self.load.fail(failure); + self.present(presentation, fx); + } + + pub(super) fn on_columns_loaded(&mut self, columns: Vec, fx: &mut Effects) { + // A page read under other columns would put their names in the + // headers, so the grid waits for the page read under these. + if self.current_columns != columns { + self.page = None; + self.shown_view = None; + self.drop_grid(fx); + } + self.current_columns = columns.clone(); + self.rebase_confirming_delete(fx); + let delta = self.tracker.set_target(SaveTarget { + table: self.table_ref(), + columns: columns.clone(), + }); + self.apply_tracker_delta(&delta, fx); + self.refresh_crud_buttons(); + // Operator lists narrow per type, so the strip rebuilds against + // the new columns. + self.filter_strip.set_columns(columns); + self.render_grid_if_ready(fx); + } + + /// Take the page apart into rows the store owns. Each row's cells + /// move into it, and the identity cells after them name the row and + /// are dropped, so nothing past the visible columns reaches a copy + /// or an export. The tab's drafts stay on top. + pub(super) fn on_rows_loaded(&mut self, offset: u64, reply: PageReply, fx: &mut Effects) { + let PageReply { + position, + result, + layout, + edges, + seeks, + } = reply; + self.pager.land(offset, position, edges, seeks); + let QueryResult { columns, rows, .. } = result; + // The tracker saves a row by its primary key, so that is what + // names it here too. + let keys = RowKeyBuilder::for_page(&pk_identity(&self.current_columns), &self.current_columns, &layout); + let row_count = rows.len(); + let drafts = self.tracker.drafts(); + let mut objects = Vec::with_capacity(drafts.len() + row_count); + objects.extend(drafts.iter().rev().map(|draft| RowObject::draft(draft.draft_id))); + let visible_end = layout.visible.end; + objects.extend(rows.into_iter().map(|mut cells| { + let identity = cells.split_off(visible_end.min(cells.len())); + let key = keys.as_ref().and_then(|keys| keys.key_for(&cells, &identity)); + RowObject::persisted(key, cells.into()) + })); + let result_columns = match columns.len() == layout.visible.len() { + true => columns, + false => columns.get(layout.visible.clone()).map(Arc::from).unwrap_or_default(), + }; + self.page = Some(PageState { + result_columns, + row_count, + start_ordinal: offset, + filtered: !self.current_filter.is_empty(), + }); + self.shown_view = Some(self.page_view()); + self.show_page(objects, fx); + } + + fn page_view(&self) -> PageView { + PageView { + filter: self.current_filter.clone(), + sort: self.current_sort, + offset: self.pager.start_ordinal(), + limit: self.pager.page_size(), + } + } + + /// The columns came back the same, so the page on screen still + /// stands under them: only the chrome follows. + fn render_grid_if_ready(&mut self, fx: &mut Effects) { + if self.page.is_none() || !self.column_view_matches_current_columns() { + return; + } + self.refresh_grid_chrome(); + fx.output(BrowseTabOutput::StateChanged); + } + + /// Put a page's rows on screen. Under the same columns the store + /// takes them in one splice, which the column view sees as a single + /// change; otherwise the view is built for them. + fn show_page(&mut self, rows: Vec, fx: &mut Effects) { + let Some(page) = self.page.as_ref() else { + return; + }; + if self.column_view_matches_current_columns() + && let Some(store) = self.list_store() + { + store.splice(0, store.n_items(), &rows); + self.reindex_rows(); + self.sync_selection(); + self.refresh_grid_chrome(); + let presentation = self.load.succeed(); + self.present(presentation, fx); + self.restore_selection(); + fx.output(BrowseTabOutput::StateChanged); + return; + } + + clear_box(&self.widgets.grid_holder); + let content = GridContent { + columns: page.result_columns.clone(), + rows, + }; + let (column_view, selection) = build_column_view( + content, + &self.current_columns, + self.grid_sink.clone(), + GridSorting::Server { + initial: self.current_sort, + }, + self.grid_context(), + ); + // A screen reader names the grid as the tab does. + column_view.update_property(&[gtk::accessible::Property::Label(&self.table_label())]); + self.current_selection = Some(selection); + self.current_column_view = Some(column_view.clone()); + self.view_columns = data_columns(&column_view); + self.rendered_columns = self.current_columns.clone(); + + // The selection model is new per rebuild, so the previous + // handler goes with the old one. The tab holds the selection, so + // the handler holds the tab's widgets weakly. + if let Some(sel) = self.current_selection.as_ref() { + let label = self.widgets.selection_label.downgrade(); + let button = self.widgets.delete_button.downgrade(); + let actions = self.actions.clone(); + let read_only = self.read_only; + sel.connect_selection_changed(move |sel, _, _| { + let (Some(label), Some(button)) = (label.upgrade(), button.upgrade()) else { + return; + }; + let n = u32::try_from(sel.selection().size()).unwrap_or(u32::MAX); + update_selection_chrome(&label, n); + actions.follow_selection(n); + show_delete_availability(&actions.delete_rows, &button, &selected_rows(sel), read_only); + }); + } + install_grid_shortcuts(&column_view); + + self.reindex_rows(); + self.sync_selection(); + + let scrolled = gtk::ScrolledWindow::builder() + .child(&column_view) + .hexpand(true) + .vexpand(true) + .build(); + self.widgets.grid_holder.append(&scrolled); + self.refresh_grid_chrome(); + let presentation = self.load.succeed(); + self.present(presentation, fx); + self.restore_selection(); + fx.output(BrowseTabOutput::StateChanged); + } + + /// The grid was built for columns the table no longer has, so it + /// goes before a click or a key in it lands on a moved column. + fn drop_grid(&mut self, fx: &mut Effects) { + if self.current_column_view.take().is_none() { + return; + } + clear_box(&self.widgets.grid_holder); + self.current_selection = None; + self.view_columns.clear(); + self.rendered_columns.clear(); + self.row_index = GridRowIndex::default(); + self.pending_restore = None; + self.sync_selection(); + let presentation = self.load.drop_content(); + self.present(presentation, fx); + } + + fn reindex_rows(&mut self) { + match self.list_store() { + Some(store) => self.row_index.rebuild(&store), + None => self.row_index = GridRowIndex::default(), + } + } + + /// Paginator label, button sensitivity and stack child: chrome that + /// depends on the page but not the column structure. + fn refresh_grid_chrome(&self) { + self.refresh_crud_buttons(); + self.sync_pager_buttons(); + self.sync_total_chrome(); + self.suppress_combo_emit.set(false); + } +} + +fn clear_box(b: >k::Box) { + while let Some(child) = b.first_child() { + b.remove(&child); + } +} diff --git a/linux/crates/app/src/ui/browse_tab/data_input.rs b/linux/crates/app/src/ui/browse_tab/data_input.rs new file mode 100644 index 0000000000..1584704e01 --- /dev/null +++ b/linux/crates/app/src/ui/browse_tab/data_input.rs @@ -0,0 +1,18 @@ +use tablepro_session::LinkGeneration; + +use super::BrowseCommand; + +/// What changes the columns and rows the tab holds: its own reads, and +/// changes to the connection or the table under it. +#[derive(Debug)] +pub(crate) enum DataInput { + /// The tab is built: read the columns, then the page and the count. + Start, + /// A reply to one of the tab's reads. + Command(BrowseCommand), + /// A reconnect replaced the session's connection. + LinkChanged(LinkGeneration), + /// The table's definition changed. The catalog has already dropped + /// its columns, so they are read again before the page. + SchemaChanged, +} diff --git a/linux/crates/app/src/ui/browse_tab/edit_handlers.rs b/linux/crates/app/src/ui/browse_tab/edit_handlers.rs new file mode 100644 index 0000000000..bfa6e0aa81 --- /dev/null +++ b/linux/crates/app/src/ui/browse_tab/edit_handlers.rs @@ -0,0 +1,382 @@ +use std::sync::Arc; + +use gtk4::glib; +use gtk4::prelude::*; +use libadwaita as adw; +use libadwaita::prelude::*; +use tablepro_core::Value; +use tablepro_core::edit::{CellInput, EditParseError, parse_edit_text}; + +use super::confirming_delete::ConfirmingDelete; +use super::row_deletion::{RowDeletion, plan_row_deletion}; +use super::{BULK_DELETE_CONFIRM_THRESHOLD, BrowseModel, BrowseTabOutput, EditInput, Effects, apply_draft_changes}; +use crate::services::change_tracker::{RowKey, new_draft_cell, pk_identity}; +use crate::services::column_relayout::ColumnRelayout; +use crate::services::tracker_delta::TrackerDelta; +use crate::services::tracker_edit::TrackerEdit; +use crate::ui::grid::{CellPreset, GridRowId, RowObject, cell_editability, cell_presets, edit_context, edit_row}; + +impl BrowseModel { + pub(super) fn on_edit(&mut self, input: EditInput, fx: &mut Effects) { + match input { + EditInput::InsertRow => { + if self.read_only || self.current_columns.is_empty() { + return; + } + let cells = self.current_columns.iter().map(new_draft_cell).collect(); + self.apply_edit(TrackerEdit::InsertDrafts { rows: vec![cells] }, fx); + fx.input(EditInput::FocusInsertedDraft); + } + EditInput::FocusInsertedDraft => self.focus_inserted_draft(), + EditInput::DeleteRows(rows) => self.delete_rows(&rows, fx), + EditInput::GridEdit { row, column, edit } => { + // The cell shows what was typed until its row is drawn again. + if self.refuses_edit(&row, column) { + row.bump_revision(); + return; + } + self.apply_edit(edit, fx); + } + EditInput::GridSetCellValue { row, column, preset } => self.set_cell_value(&row, column, preset, fx), + EditInput::DuplicateRows(rows) => self.duplicate_rows(&rows, fx), + EditInput::Undo => { + if let Some(delta) = self.tracker.undo() { + self.apply_tracker_delta(&delta, fx); + } + } + EditInput::Redo => { + if let Some(delta) = self.tracker.redo() { + self.apply_tracker_delta(&delta, fx); + } + } + EditInput::MarkRowsDeleted(deletion) => { + self.apply_edit( + TrackerEdit::DeleteRows { + persisted: deletion.persisted, + drafts: deletion.drafts, + }, + fx, + ); + // A bulk action ends the selection it acted on. + if let Some(selection) = self.current_selection.as_ref() { + selection.unselect_all(); + } + } + EditInput::BulkDeleteAnswered { ticket, confirmed } => self.bulk_delete_answered(ticket, confirmed, fx), + } + } + + fn set_cell_value(&mut self, row: &RowObject, column: usize, preset: CellPreset, fx: &mut Effects) { + if self.refuses_edit(row, column) { + return; + } + let input = match self.resolve_cell_preset(preset, row, column) { + Some(Ok(input)) => input, + Some(Err(message)) => { + fx.output(BrowseTabOutput::ShowToast(message)); + return; + } + None => return, + }; + self.edit_cell(row, column, input, fx); + } + + fn duplicate_rows(&mut self, rows: &[RowObject], fx: &mut Effects) { + if self.read_only || self.current_columns.is_empty() || self.grid_is_stale() || rows.is_empty() { + return; + } + let ctx = self.grid_context(); + let copies = rows + .iter() + .map(|row| { + // With its pending edits, since the copy is of the + // row the user is looking at. + let source = ctx.effective_inputs(row); + // The duplicate is a new row, so values the database + // owns (key, identity, generated) are left to it. + self.current_columns + .iter() + .enumerate() + .map(|(i, col)| { + if col.primary_key || col.is_auto_increment || col.is_generated { + new_draft_cell(col) + } else { + source.get(i).cloned().unwrap_or(CellInput::Value(Value::Null)) + } + }) + .collect() + }) + .collect(); + self.apply_edit(TrackerEdit::InsertDrafts { rows: copies }, fx); + fx.input(EditInput::FocusInsertedDraft); + } + + fn bulk_delete_answered(&mut self, ticket: u64, confirmed: bool, fx: &mut Effects) { + // An answer to a confirmation the tab already cancelled + // or replaced. + if self + .confirming_delete + .as_ref() + .is_none_or(|pending| pending.ticket != ticket) + { + return; + } + if !confirmed { + self.confirming_delete = None; + return; + } + self.rebase_confirming_delete(fx); + if let Some(pending) = self.confirming_delete.take() { + fx.input(EditInput::MarkRowsDeleted(pending.deletion)); + } + } + + /// Checked again here because a keyboard shortcut or a late event + /// can reach a locked cell. + fn refuses_edit(&self, row: &RowObject, column: usize) -> bool { + if self.grid_is_stale() { + return true; + } + let Some(info) = self.current_columns.get(column) else { + return true; + }; + let locked = cell_editability(info, row.id(), &pk_identity(&self.current_columns), self.read_only); + if let Err(lock) = locked { + tracing::debug!(?lock, column, "refused an edit to a locked cell"); + return true; + } + false + } + + /// Resolve a preset against the column it lands in. The menu offers + /// only the ones the cell can take; the keyboard's Set NULL reaches + /// here for any cell, and a column that refuses the value says why, + /// as committing an empty cell would. + fn resolve_cell_preset( + &self, + preset: CellPreset, + row: &RowObject, + col: usize, + ) -> Option> { + let Some(column) = self.current_columns.get(col) else { + tracing::error!(col, "a preset reached a column the tab does not have"); + return None; + }; + let presets = cell_presets(column, row.id()); + Some(match preset { + CellPreset::Empty => parse_edit_text("", column, edit_context(row.id())) + .map_err(|error| crate::ui::error_text::edit_parse_message(&error)), + CellPreset::Null if presets.null => Ok(CellInput::Value(Value::Null)), + CellPreset::Null => Err(crate::ui::error_text::edit_parse_message(&EditParseError::Required)), + CellPreset::Default if presets.default => Ok(CellInput::Default), + CellPreset::Default => Err(crate::i18n::gettext("This cell can't take the column's default")), + }) + } + + /// Apply what a tracker call changed: the draft rows the store gains + /// or loses, then a redraw of each touched row, then the footer. A + /// row keeps its object, so the selection and the focus stay on it. + pub(super) fn apply_tracker_delta(&mut self, delta: &TrackerDelta, fx: &mut Effects) { + if let Some(store) = self.list_store() { + apply_draft_changes(&store, &mut self.row_index, delta); + } + for key in &delta.changed { + if let Some(row) = self.row_index.get(key) { + row.bump_revision(); + } + } + self.sync_pending_chrome(delta.pending_count, fx); + self.sync_delete_action(); + } + + fn apply_edit(&mut self, edit: TrackerEdit, fx: &mut Effects) { + if self.tracker.refuses(&edit) && !std::mem::replace(&mut fx.told_edit_held, true) { + fx.output(BrowseTabOutput::ShowToast(crate::i18n::gettext( + "This row is being saved. Change it again once the save finishes.", + ))); + } + let delta = self.tracker.apply(edit); + self.apply_tracker_delta(&delta, fx); + } + + /// A preset value for one cell, against the row the edit was made + /// on, wherever it sits now. + fn edit_cell(&mut self, row: &RowObject, col: usize, input: CellInput, fx: &mut Effects) { + match (row.id(), input) { + (GridRowId::Draft(draft_id), input) => { + self.apply_edit( + TrackerEdit::DraftCell { + draft_id: *draft_id, + col, + input, + }, + fx, + ); + } + (GridRowId::Persisted(row_key), CellInput::Value(value)) => { + let Some(original) = row.cell(col).cloned() else { + return; + }; + self.apply_edit( + TrackerEdit::Cell { + row_key: row_key.clone(), + col, + original, + value, + }, + fx, + ); + } + (GridRowId::Persisted(_), CellInput::Default) | (GridRowId::Result | GridRowId::Unkeyed, _) => {} + } + } + + pub(super) fn refresh_crud_buttons(&self) { + let has_columns = !self.current_columns.is_empty(); + let has_pk = self.has_primary_key(); + if self.read_only { + self.widgets.insert_button.set_visible(false); + self.actions.insert_row.set_enabled(false); + return; + } + self.widgets.insert_button.set_visible(true); + // No-PK tables don't get inline editing because RowKey can't be + // formed without a PK. Disable instead of hide so the affordance + // stays discoverable; the tooltip explains the gate. + self.actions.insert_row.set_enabled(has_columns && has_pk); + if has_columns && !has_pk { + self.widgets.insert_button.set_tooltip_text(Some(&crate::i18n::gettext( + "This table has no primary key, so editing is turned off.", + ))); + } else { + self.widgets + .insert_button + .set_tooltip_text(Some(&crate::i18n::gettext("Insert Row"))); + } + self.refresh_banner_visibility(); + } + + /// Edit the first editable cell of the new draft, which is always + /// the top row. Queued on idle so the inner-stack flip (empty to + /// grid) has allocated the ScrolledWindow's adjustments before the + /// grid scrolls: in the same tick GTK aborts with + /// `gtk_list_base_update_adjustments: bounds.y == 0`. + fn focus_inserted_draft(&self) { + let Some(cv) = self.current_column_view.clone() else { + return; + }; + glib::idle_add_local_once(move || { + // F5 between the insert and this idle detaches the column + // view, and a failed read hides it behind the error page. The + // row was inserted; only the edit is missed. + if cv.is_mapped() { + edit_row(&cv, 0); + } + }); + } + + /// Mark saved rows for deletion and drop drafts. Nothing leaves the + /// tab until Save. The action is off for rows this cannot delete, so + /// such rows reaching here is a bug, which changes nothing. + fn delete_rows(&mut self, rows: &[RowObject], fx: &mut Effects) { + if self.grid_is_stale() { + return; + } + if self.read_only { + tracing::error!("Delete reached a read-only tab"); + return; + } + let deletion = plan_row_deletion(rows); + if deletion.unkeyed > 0 { + tracing::error!(unkeyed = deletion.unkeyed, "Delete reached rows with no key"); + return; + } + if deletion.is_empty() { + return; + } + if deletion.len() < BULK_DELETE_CONFIRM_THRESHOLD || !self.settings.confirm_bulk_delete() { + fx.input(EditInput::MarkRowsDeleted(deletion)); + return; + } + self.confirm_delete(deletion); + } + + fn confirm_delete(&mut self, deletion: RowDeletion) { + let count = deletion.len(); + let title = crate::i18n::ngettext_f( + "Delete {n} Row?", + "Delete {n} Rows?", + u32::try_from(count).unwrap_or(u32::MAX), + &[("n", &count.to_string())], + ); + let body = deletion.confirmation_body(); + let dialog = adw::AlertDialog::new(Some(&title), Some(&body)); + dialog.add_response("cancel", &crate::i18n::gettext("Cancel")); + dialog.add_response("delete", &crate::i18n::gettext("Delete")); + dialog.set_response_appearance("delete", adw::ResponseAppearance::Destructive); + dialog.set_default_response(Some("cancel")); + dialog.set_close_response("cancel"); + // A second Delete while one waits replaces it. + self.cancel_confirming_delete(); + let ticket = self.next_confirmation; + self.next_confirmation += 1; + let tab = self.this.clone(); + dialog.connect_response(None, move |_, response| { + if let Some(tab) = tab.upgrade() { + tab.send(EditInput::BulkDeleteAnswered { + ticket, + confirmed: response == "delete", + }); + } + }); + dialog.present(Some(&self.widgets.inner_stack)); + self.confirming_delete = Some(ConfirmingDelete { + ticket, + deletion, + columns: self.current_columns.clone(), + dialog, + }); + } + + fn cancel_confirming_delete(&mut self) { + if let Some(pending) = self.confirming_delete.take() { + pending.dialog.force_close(); + } + } + + /// Moves a waiting delete's rows onto the columns the tab read + /// since. A key read under another primary key can name another + /// row, so then the delete is cancelled rather than sent with it. + pub(super) fn rebase_confirming_delete(&mut self, fx: &mut Effects) { + let Some(pending) = self.confirming_delete.as_mut() else { + return; + }; + if pending.columns == self.current_columns { + return; + } + let layout = ColumnRelayout::new(&pending.columns, &self.current_columns); + let moved: Option)>> = pending + .deletion + .persisted + .iter() + .map(|(key, values)| { + layout + .key(key) + .ok() + .map(|()| (key.clone(), layout.fetched(values).into())) + }) + .collect(); + match moved { + Some(rows) => { + pending.deletion.persisted = rows; + pending.columns = self.current_columns.clone(); + } + None => { + self.cancel_confirming_delete(); + fx.output(BrowseTabOutput::ShowToast(crate::i18n::gettext( + "The table's primary key changed, so no rows were marked for deletion. Select them again.", + ))); + } + } + } +} diff --git a/linux/crates/app/src/ui/browse_tab/edit_input.rs b/linux/crates/app/src/ui/browse_tab/edit_input.rs new file mode 100644 index 0000000000..8b1f5ae13d --- /dev/null +++ b/linux/crates/app/src/ui/browse_tab/edit_input.rs @@ -0,0 +1,38 @@ +use super::row_deletion::RowDeletion; +use crate::services::tracker_edit::TrackerEdit; +use crate::ui::grid::{CellPreset, RowObject}; + +/// What changes the tab's pending edits: a cell, a new or duplicated +/// row, a delete, undo and redo. +#[derive(Debug)] +pub(crate) enum EditInput { + InsertRow, + /// Queued after an insert, so the focus lands on the column view + /// as it is once the insert is drawn. + FocusInsertedDraft, + DeleteRows(Vec), + /// Checked against the cell's lock before the tracker takes it. + GridEdit { + row: RowObject, + column: usize, + edit: TrackerEdit, + }, + /// A preset from a cell's menu or Ctrl+Backspace. The tab resolves it + /// against the column, so both paths reach the same value. + GridSetCellValue { + row: RowObject, + column: usize, + preset: CellPreset, + }, + DuplicateRows(Vec), + Undo, + Redo, + /// A delete the user confirmed, or one small enough to need no + /// confirmation. + MarkRowsDeleted(RowDeletion), + /// The user answered the bulk delete confirmation `ticket` opened. + BulkDeleteAnswered { + ticket: u64, + confirmed: bool, + }, +} diff --git a/linux/crates/app/src/ui/browse_tab/fetch.rs b/linux/crates/app/src/ui/browse_tab/fetch.rs new file mode 100644 index 0000000000..3d471ca4f0 --- /dev/null +++ b/linux/crates/app/src/ui/browse_tab/fetch.rs @@ -0,0 +1,214 @@ +use std::future::Future; +use std::sync::Arc; + +use tablepro_core::browse::{PageRequest, SortKey, read_page}; +use tablepro_core::meta::{RowIdentity, TableRef}; +use tablepro_core::statement::StatementBuilder; +use tablepro_core::{BuildFilterError, ColumnInfo, FilterSet}; +use tablepro_session::{OperationError, OperationKind}; + +use super::columns_fetch::{ColumnFreshness, load_columns}; +use super::{BrowseCommand, BrowseModel, BrowseRequestKind, ColumnsReply, DataInput, Effects, PageReply}; +use crate::runtime::{RequestTicket, Stamped}; +use crate::ui::export::TableExport; +use crate::ui::tab_load::LoadFailure; + +/// What a page or a count reads, copied off the tab so the work owns +/// it. The SQL is built inside the operation from the lease it runs on, +/// so it is spelled for that server even when a reconnect lands first. +#[derive(Debug, Clone)] +pub(super) struct ReadPlan { + pub(super) table: TableRef, + pub(super) columns: Vec, + identity: RowIdentity, + pub(super) filter: FilterSet, + sort: Vec, + limit: u64, +} + +impl BrowseModel { + /// Ask for one kind of read. A page or an estimate waits for the + /// columns, since the tab reads nothing of a table whose columns it + /// could not read. A count needs no such wait: it runs only once the + /// user or an estimate asks, after the columns. + pub(super) fn fetch(&mut self, kind: BrowseRequestKind, fx: &mut Effects) { + match kind { + BrowseRequestKind::Columns => self.fetch_columns(ColumnFreshness::Cached), + BrowseRequestKind::Page | BrowseRequestKind::Estimate if self.identity.is_none() => { + self.after_columns.insert(kind); + if !self.sequencer.is_pending(BrowseRequestKind::Columns) { + self.fetch_columns(ColumnFreshness::Cached); + } + } + BrowseRequestKind::Page => self.fetch_page(fx), + BrowseRequestKind::Estimate => self.fetch_estimate(), + BrowseRequestKind::Count => self.start_count(self.total.estimate(), fx), + } + } + + /// Read the columns, then the page built from them and the table's + /// estimate. No page is asked for before the columns land, since a + /// page is built from them, and a failure to read them fails the read + /// at once rather than leave the tab waiting. + pub(super) fn reload_from_columns(&mut self, freshness: ColumnFreshness) { + self.after_columns.insert(BrowseRequestKind::Page); + self.after_columns.insert(BrowseRequestKind::Estimate); + self.fetch_columns(freshness); + } + + pub(super) fn issue(&mut self, kind: BrowseRequestKind) -> RequestTicket { + self.issued_on.insert(kind, self.session.link_generation()); + self.sequencer.issue(kind) + } + + /// A new read of the columns, so the page read under them gets its + /// own recheck. + pub(super) fn fetch_columns(&mut self, freshness: ColumnFreshness) { + self.recheck.reset(); + self.read_columns(freshness); + } + + pub(super) fn read_columns(&mut self, freshness: ColumnFreshness) { + let ticket = self.issue(BrowseRequestKind::Columns); + let session = self.session.clone(); + let table = self.table_ref(); + let cancel = ticket.cancel.clone(); + let work = async move { + // The catalog's read is shared with other views, so a + // replaced request stops waiting on it without stopping it. + let columns = tokio::select! { + columns = load_columns(session.clone(), table.clone(), freshness) => columns?, + () = cancel.cancelled() => return Err(LoadFailure::Columns(Arc::new(OperationError::Cancelled))), + }; + let identity = session + .run_cancellable(OperationKind::Metadata, &cancel, move |lease, options| { + let table = table.clone(); + async move { Ok(lease.row_identity(&table, options).await?) } + }) + .await + .map_err(|error| { + if error != OperationError::Cancelled { + tracing::warn!(category = ?error.category(), "browse row identity failed"); + } + LoadFailure::Columns(Arc::new(error)) + })?; + Ok(ColumnsReply { columns, identity }) + }; + self.deliver(ticket, work, BrowseCommand::Columns, |failure| { + LoadFailure::Columns(Arc::new(OperationError::from(failure))) + }); + } + + fn fetch_page(&mut self, fx: &mut Effects) { + let Some(plan) = self.read_plan() else { + return; + }; + if let Some(problem) = self.filter_problem() { + self.on_filter_problem(problem, fx); + return; + } + self.begin_read(fx); + let ticket = self.issue(BrowseRequestKind::Page); + let session = self.session.clone(); + let cancel = ticket.cancel.clone(); + let position = self.pager.position(); + let work = async move { + session + .run_cancellable(OperationKind::Page, &cancel, move |lease, options| { + let plan = plan.clone(); + let position = position.clone(); + async move { + let request = PageRequest { + table: &plan.table, + columns: &plan.columns, + filter: &plan.filter, + sort: &plan.sort, + identity: &plan.identity, + position: &position, + limit: plan.limit, + }; + read_page::(&*lease, &request, &options).await + } + }) + .await + .map(PageReply::from) + }; + self.deliver(ticket, work, BrowseCommand::Page, OperationError::from); + } + + /// Run `work` on the app runtime and hand its stamped reply back to + /// the tab on the GTK thread. The delivery holds the tab weakly, so a + /// tab closed meanwhile is not kept alive by its own reads. + pub(super) fn deliver( + &self, + ticket: RequestTicket, + work: W, + command: impl FnOnce(Stamped>) -> BrowseCommand + 'static, + failed: impl FnOnce(tablepro_session::runtime::TaskFailure) -> E + 'static, + ) where + T: Send + 'static, + E: Send + 'static, + W: Future> + Send + 'static, + { + let tab = self.this.clone(); + let stamp = ticket.stamp; + let _delivery = crate::runtime::spawn_and_deliver(&self.tasks, work, move |joined| { + let value = joined.unwrap_or_else(|failure| Err(failed(failure))); + if let Some(tab) = tab.upgrade() { + tab.send(DataInput::Command(command(Stamped { stamp, value }))); + } + }); + } + + pub(super) fn read_plan(&self) -> Option { + Some(ReadPlan { + table: self.table_ref(), + columns: self.current_columns.clone(), + identity: self.identity.clone()?, + filter: self.current_filter.clone(), + sort: self + .current_sort + .map(|(column, ascending)| SortKey { column, ascending }) + .into_iter() + .collect(), + limit: self.pager.limit(), + }) + } + + /// Every row the tab's filter and sort select, for an export. `None` + /// until the columns and the row identity are in. + pub(super) fn table_export(&self) -> Option { + let plan = self.read_plan()?; + Some(TableExport { + table: plan.table, + columns: plan.columns.into(), + filter: plan.filter, + sort: plan.sort, + identity: plan.identity, + }) + } + + /// Why the tab's filter cannot be written for the current server, + /// if it cannot. + pub(super) fn filter_problem(&self) -> Option { + let dialect = self.dialect.as_ref()?; + let mut builder = StatementBuilder::new(); + tablepro_core::build_filter(&mut builder, &**dialect, &self.current_columns, &self.current_filter).err() + } + + /// A filter the dialect cannot write is the user's to fix, and no + /// page is read under it. A page still on its way under the old + /// filter is stopped. The failure stays on screen until a read + /// lands: on the banner over rows already shown, or on the error + /// page when there are none. + fn on_filter_problem(&mut self, problem: BuildFilterError, fx: &mut Effects) { + self.sequencer.cancel(BrowseRequestKind::Page); + self.load.abandon(); + self.on_load_failed(filter_failure(problem), fx); + } +} + +/// A filter the current server cannot write, as the tab reports it. +pub(super) fn filter_failure(problem: BuildFilterError) -> LoadFailure { + LoadFailure::Page(Arc::new(OperationError::Browse(problem.into()))) +} diff --git a/linux/crates/app/src/ui/browse_tab/imp.rs b/linux/crates/app/src/ui/browse_tab/imp.rs new file mode 100644 index 0000000000..4346df5147 --- /dev/null +++ b/linux/crates/app/src/ui/browse_tab/imp.rs @@ -0,0 +1,128 @@ +use std::cell::{Cell, RefCell}; +use std::collections::VecDeque; +use std::sync::OnceLock; + +use glib::subclass::Signal; +use gtk4 as gtk; +use gtk4::glib; +use gtk4::prelude::*; +use gtk4::subclass::prelude::*; +use libadwaita as adw; +use libadwaita::subclass::prelude::*; +use tablepro_session::runtime::WatchForwarder; + +use super::pager_bar::PagerBar; +use super::pending_changes_bar::PendingChangesBar; +use super::{BrowseModel, BrowseTabInput}; + +#[derive(Default, gtk::CompositeTemplate)] +#[template(resource = "/app/tablepro/TablePro/ui/browse-tab.ui")] +pub struct BrowseTab { + #[template_child] + pub toolbar: TemplateChild, + #[template_child] + pub stale_banner: TemplateChild, + #[template_child] + pub read_only_banner: TemplateChild, + #[template_child] + pub no_pk_banner: TemplateChild, + #[template_child] + pub inner_stack: TemplateChild, + #[template_child] + pub loading_page: TemplateChild, + #[template_child] + pub grid_holder: TemplateChild, + #[template_child] + pub error_page: TemplateChild, + #[template_child] + pub retry_button: TemplateChild, + #[template_child] + pub insert_button: TemplateChild, + #[template_child] + pub delete_button: TemplateChild, + #[template_child] + pub pager_bar: TemplateChild, + #[template_child] + pub selection_label: TemplateChild, + #[template_child] + pub more_button: TemplateChild, + #[template_child] + pub export_button: TemplateChild, + #[template_child] + pub filter_button: TemplateChild, + #[template_child] + pub filter_badge: TemplateChild, + #[template_child] + pub page_size_dropdown: TemplateChild, + #[template_child] + pub pending_bar: TemplateChild, + + pub(super) model: RefCell>, + pub(super) queue: RefCell>, + pub(super) dispatching: Cell, + /// Delivers each reconnect, for as long as the tab is open. + pub(super) link_watch: RefCell>, +} + +#[glib::object_subclass] +impl ObjectSubclass for BrowseTab { + const NAME: &'static str = "TableProBrowseTab"; + type Type = super::BrowseTab; + type ParentType = adw::BreakpointBin; + + fn class_init(klass: &mut Self::Class) { + PagerBar::ensure_type(); + PendingChangesBar::ensure_type(); + klass.bind_template(); + } + + fn instance_init(object: &glib::subclass::InitializingObject) { + object.init_template(); + } +} + +impl ObjectImpl for BrowseTab { + fn constructed(&self) { + self.parent_constructed(); + super::browse_actions::describe_buttons(&self.obj()); + } + + fn signals() -> &'static [Signal] { + static SIGNALS: OnceLock> = OnceLock::new(); + SIGNALS.get_or_init(|| { + vec![ + Signal::builder("state-changed").build(), + Signal::builder("export-results") + .param_types([glib::BoxedAnyObject::static_type()]) + .build(), + Signal::builder("alert") + .param_types([String::static_type(), String::static_type()]) + .build(), + Signal::builder("toast").param_types([String::static_type()]).build(), + Signal::builder("dirty-changed") + .param_types([bool::static_type()]) + .build(), + Signal::builder("save-requested").build(), + Signal::builder("loading-changed") + .param_types([bool::static_type()]) + .build(), + Signal::builder("stop-save-requested").build(), + Signal::builder("refresh-requested").build(), + ] + }) + } + + /// A cancelled read lets the server drop its statement rather than + /// finish it for nobody. + fn dispose(&self) { + self.link_watch.take(); + if let Ok(mut model) = self.model.try_borrow_mut() + && let Some(mut model) = model.take() + { + model.sequencer.cancel_all(); + } + } +} + +impl WidgetImpl for BrowseTab {} +impl BreakpointBinImpl for BrowseTab {} diff --git a/linux/crates/app/src/ui/browse_tab/layout.rs b/linux/crates/app/src/ui/browse_tab/layout.rs new file mode 100644 index 0000000000..37397c6c0b --- /dev/null +++ b/linux/crates/app/src/ui/browse_tab/layout.rs @@ -0,0 +1,221 @@ +use std::rc::Rc; + +use gtk4 as gtk; +use gtk4::prelude::*; +use gtk4::subclass::prelude::*; +use gtk4::{gio, glib}; +use tablepro_core::FilterSet; + +use super::browse_tab_widgets::BrowseTabWidgets; +use super::pager_bar::PagerRequest; +use super::pending_changes_bar::PendingChangesRequest; +use super::{ + BrowseTab, BrowseTabInput, ClipboardInput, PAGE_SIZE_OPTIONS, PagingInput, SaveInput, loading_description, +}; +use crate::i18n::gettext; +use crate::ui::filter_strip::FilterStrip; +use crate::ui::format::format_thousands; +use crate::ui::grid::GridSink; +use crate::ui::tab_load::StaleBanner; + +pub(super) struct LayoutInit<'a> { + pub(super) table_label: &'a str, + pub(super) read_only: bool, + pub(super) page_size: u64, +} + +/// The tab as it opens, on the loading page, with each control sending +/// its input. Gives back the widgets the handlers drive. +pub(super) fn build(tab: &BrowseTab, init: &LayoutInit<'_>) -> BrowseTabWidgets { + let imp = tab.imp(); + imp.loading_page + .set_description(Some(&loading_description(init.table_label))); + imp.inner_stack.set_visible_child_name("loading"); + imp.read_only_banner.set_revealed(init.read_only); + let page_sizes = page_size_choices(&imp.page_size_dropdown, init.page_size); + imp.more_button.set_menu_model(Some(&more_menu(&page_sizes))); + connect_controls(tab, page_sizes.clone()); + BrowseTabWidgets { + inner_stack: imp.inner_stack.get(), + grid_holder: imp.grid_holder.get(), + loading_page: imp.loading_page.get(), + error_page: imp.error_page.get(), + stale_banner: StaleBanner::new(imp.stale_banner.get(), sender(tab, || PagingInput::Refresh)), + read_only_banner: imp.read_only_banner.get(), + no_pk_banner: imp.no_pk_banner.get(), + selection_label: imp.selection_label.get(), + filter_button: imp.filter_button.get(), + filter_badge: imp.filter_badge.get(), + insert_button: imp.insert_button.get(), + delete_button: imp.delete_button.get(), + pager_bar: imp.pager_bar.get(), + pending_bar: imp.pending_bar.get(), + page_size_dropdown: imp.page_size_dropdown.get(), + page_sizes, + } +} + +/// What the bottom bar holds past the tab's breakpoint, where it has no +/// room for it. +fn more_menu(page_sizes: &[u64]) -> gio::Menu { + let actions = gio::Menu::new(); + actions.append(Some(&gettext("Filter Rows")), Some("browse.filter")); + actions.append(Some(&gettext("Export Rows…")), Some("browse.export")); + actions.append(Some(&gettext("Count Rows")), Some("browse.count-rows")); + let sizes = gio::Menu::new(); + for size in page_sizes { + let item = gio::MenuItem::new(Some(&format_thousands(*size)), None); + item.set_action_and_target_value(Some("browse.page-size"), Some(&size.to_variant())); + sizes.append_item(&item); + } + let menu = gio::Menu::new(); + menu.append_section(None, &actions); + menu.append_section(Some(&gettext("Rows per Page")), &sizes); + menu +} + +/// An inline editor that slides down above the grid. It lives in the +/// tab so the user's in-progress rules survive a click into a cell or +/// the SQL editor. +pub(super) fn filter_strip(tab: &BrowseTab, initial: &FilterSet) -> FilterStrip { + let strip = FilterStrip::new(Vec::new(), initial); + strip.connect_applied({ + let tab = tab.downgrade(); + move |_, set| { + if let Some(tab) = tab.upgrade() { + tab.send(PagingInput::FilterApplied(set)); + } + } + }); + tab.imp().toolbar.add_top_bar(&strip); + collapse_top_bars_with(tab, &strip); + strip +} + +/// Handled at once: a tracked change redraws the cells bound to its +/// row in place, so the cell that sent it is still there for the Tab +/// that ended the edit to move focus from. +pub(super) fn grid_sink(tab: &BrowseTab) -> GridSink { + let tab = tab.downgrade(); + Rc::new(move |msg| { + if let Some(tab) = tab.upgrade() { + tab.send(BrowseTabInput::from(msg)); + } + }) +} + +/// The usual sizes, and the tab's own when Preferences or a saved tab +/// set another, so the dropdown always shows the size the tab reads. +/// Gives back the size at each position. +fn page_size_choices(dropdown: >k::DropDown, page_size: u64) -> Vec { + let sizes = page_sizes_with(page_size); + let labels: Vec = sizes.iter().map(|n| format_thousands(*n)).collect(); + let strs: Vec<&str> = labels.iter().map(String::as_str).collect(); + dropdown.set_model(Some(>k::StringList::new(&strs))); + let selected = sizes.iter().position(|n| *n == page_size).unwrap_or_default(); + dropdown.set_selected(u32::try_from(selected).unwrap_or_default()); + sizes +} + +fn page_sizes_with(page_size: u64) -> Vec { + let mut sizes = PAGE_SIZE_OPTIONS.to_vec(); + if !sizes.contains(&page_size) { + sizes.push(page_size); + sizes.sort_unstable(); + } + sizes +} + +/// Holds the tab weakly, since the tab owns the control that holds this. +fn sender + 'static>(tab: &BrowseTab, msg: fn() -> I) -> impl Fn() + 'static { + let tab = tab.downgrade(); + move || { + if let Some(tab) = tab.upgrade() { + tab.send(msg()); + } + } +} + +fn connect_controls(tab: &BrowseTab, page_sizes: Vec) { + let imp = tab.imp(); + for (request, send) in [ + (PagerRequest::First, sender(tab, || PagingInput::FirstPage)), + (PagerRequest::Previous, sender(tab, || PagingInput::PrevPage)), + (PagerRequest::Next, sender(tab, || PagingInput::NextPage)), + (PagerRequest::Last, sender(tab, || PagingInput::LastPage)), + (PagerRequest::CountRows, sender(tab, || PagingInput::CountRows)), + ] { + imp.pager_bar.connect_request(request, move |_| send()); + } + for (request, send) in [ + (PendingChangesRequest::Save, sender(tab, || SaveInput::CommitSave)), + ( + PendingChangesRequest::Discard, + sender(tab, || SaveInput::ConfirmDiscard), + ), + (PendingChangesRequest::StopSaving, sender(tab, || SaveInput::StopSaving)), + ] { + imp.pending_bar.connect_request(request, move |_| send()); + } + let export = sender(tab, || ClipboardInput::Export(None)); + imp.export_button.connect_clicked(move |_| export()); + let retry = sender(tab, || PagingInput::Refresh); + imp.retry_button.connect_clicked(move |_| retry()); + let tab = tab.downgrade(); + imp.page_size_dropdown.connect_selected_notify(move |dropdown| { + let index = usize::try_from(dropdown.selected()).unwrap_or(usize::MAX); + if let (Some(tab), Some(&size)) = (tab.upgrade(), page_sizes.get(index)) { + tab.send(PagingInput::PageSizeChanged(size)); + } + }); +} + +/// The top-bar slot draws a thin strip even when every banner in it is +/// collapsed, so the slot itself is hidden unless one shows. +fn collapse_top_bars_with(tab: &BrowseTab, filter: &FilterStrip) { + let imp = tab.imp(); + // The banners and the strip hold this through their notify handlers, + // and the tab holds them, so it holds them all weakly. + let sync: Rc = { + let toolbar = imp.toolbar.downgrade(); + let banners = [ + imp.stale_banner.downgrade(), + imp.read_only_banner.downgrade(), + imp.no_pk_banner.downgrade(), + ]; + let filter = filter.downgrade(); + Rc::new(move || { + let (Some(toolbar), Some(filter)) = (toolbar.upgrade(), filter.upgrade()) else { + return; + }; + let banner_shown = banners + .iter() + .filter_map(glib::WeakRef::upgrade) + .any(|banner| banner.is_revealed()); + toolbar.set_reveal_top_bars(banner_shown || filter.revealed()); + }) + }; + sync(); + for banner in [&imp.stale_banner, &imp.read_only_banner, &imp.no_pk_banner] { + let sync = sync.clone(); + banner.connect_revealed_notify(move |_| sync()); + } + filter.connect_revealed_notify(move |_| sync()); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[gtk4::test] + fn the_dropdown_shows_a_page_size_set_in_preferences() { + let dropdown = gtk::DropDown::default(); + + let sizes = page_size_choices(&dropdown, 2_500); + + assert_eq!(sizes, [100, 500, 1_000, 2_500, 5_000, 10_000]); + assert_eq!(sizes.get(dropdown.selected() as usize), Some(&2_500)); + assert_eq!(page_size_choices(&dropdown, 1_000), PAGE_SIZE_OPTIONS); + assert_eq!(sizes.len(), PAGE_SIZE_OPTIONS.len() + 1); + } +} diff --git a/linux/crates/app/src/ui/browse_tab/mod.rs b/linux/crates/app/src/ui/browse_tab/mod.rs new file mode 100644 index 0000000000..d9bd2b9688 --- /dev/null +++ b/linux/crates/app/src/ui/browse_tab/mod.rs @@ -0,0 +1,671 @@ +//! A table's rows in a workspace tab. +//! +//! The tab reads its own columns, pages and row count through the +//! session, one request of each kind at a time: a newer request stops +//! the one it replaces, and a reply to a replaced request is dropped. +//! A read that failed is asked again once the session reconnects. A +//! count is not: it runs only when the estimate or the user asks. +//! +//! An input belongs to the group of state it changes, and each group is +//! handled in its own `*_handlers` module. + +mod browse_actions; +mod browse_command; +mod browse_pager; +mod browse_request_kind; +mod browse_tab_input; +mod browse_tab_widgets; +mod clipboard_handlers; +mod clipboard_input; +mod column_recheck; +mod columns_fetch; +mod command_handlers; +mod confirming_delete; +mod count_failure_text; +mod count_policy; +mod data_handlers; +mod data_input; +mod edit_handlers; +mod edit_input; +mod fetch; +mod imp; +mod layout; +mod page_label; +mod page_reply; +mod page_state; +mod pager_bar; +mod paging_handlers; +mod paging_input; +mod pending_changes_bar; +mod row_count; +mod row_deletion; +mod row_total; +mod save_handlers; +mod save_input; +mod selection_handlers; +mod selection_input; +mod selection_snapshot; +#[cfg(test)] +mod tests; + +use std::cell::Cell; +use std::collections::{BTreeSet, VecDeque}; +use std::rc::Rc; +use std::sync::Arc; + +use gtk4 as gtk; +use gtk4::glib; +use gtk4::prelude::*; +use gtk4::subclass::prelude::*; +use libadwaita as adw; +use tablepro_core::ColumnInfo; +use tablepro_core::dialect::SqlDialect; +use tablepro_core::meta::{RowIdentity, TableRef}; +use tablepro_session::operation::DeadlinePolicy; +use tablepro_session::runtime::{Tasks, WatchForwarder}; +use tablepro_session::{LinkGeneration, Session}; +use tablepro_storage::AppSettings; + +use self::browse_actions::BrowseActions; +pub(crate) use self::browse_command::{BrowseCommand, ColumnsReply}; +use self::browse_pager::BrowsePager; +pub(crate) use self::browse_request_kind::BrowseRequestKind; +pub(crate) use self::browse_tab_input::BrowseTabInput; +use self::browse_tab_widgets::BrowseTabWidgets; +pub(crate) use self::clipboard_input::ClipboardInput; +use self::column_recheck::ColumnRecheck; +use self::confirming_delete::ConfirmingDelete; +pub(crate) use self::data_input::DataInput; +pub(crate) use self::edit_input::EditInput; +pub(crate) use self::page_reply::PageReply; +use self::page_state::PageState; +#[cfg(test)] +pub(super) use self::pager_bar::PagerBar; +pub(crate) use self::paging_input::PagingInput; +#[cfg(test)] +pub(super) use self::pending_changes_bar::PendingChangesBar; +pub(crate) use self::row_total::RowTotal; +pub(crate) use self::save_input::SaveInput; +pub(crate) use self::selection_input::SelectionInput; +use self::selection_snapshot::SelectionSnapshot; +use super::filter_strip::FilterStrip; +use super::grid::{ + ColumnWidthBinding, GridRowIndex, GridSink, RowJsonRequest, RowObject, TabGridContext, present_row_json, +}; +use super::tab_load::{ContentLoad, FirstLoad, start_first_load}; +use crate::runtime::{FailedRequests, RequestSequencer}; +use crate::services::change_tracker::{RowKey, pk_identity}; +use crate::services::tab_tracker_handle::TabTrackerHandle; +use crate::services::tracker_delta::TrackerDelta; +use crate::ui::clipboard::CopyRequest; +use crate::ui::export::ExportOffer; + +const PAGE_SIZE_OPTIONS: &[u64] = &[100, 500, 1_000, 5_000, 10_000]; +const DEFAULT_PAGE_SIZE: u64 = 1_000; +/// A delete of fewer rows never asks, since Undo and Discard bring them back. +pub(crate) const BULK_DELETE_CONFIRM_THRESHOLD: usize = 10; + +glib::wrapper! { + pub struct BrowseTab(ObjectSubclass) + @extends adw::BreakpointBin, gtk::Widget, + @implements gtk::Accessible, gtk::Buildable, gtk::ConstraintTarget; +} + +pub struct BrowseTabInit { + pub tracker: TabTrackerHandle, + pub schema: Option, + pub table: String, + pub connection_id: Option, + pub read_only: bool, + pub page_size: u64, + pub initial_offset: u64, + pub initial_sort: Option<(usize, bool)>, + pub column_widths: crate::persistence::ColumnWidthStore, + pub filter_settings: crate::persistence::FilterSettingsStore, + /// The session the tab reads through. The tab belongs to it and is + /// closed before it shuts down. + pub session: Arc, + pub tasks: Tasks, + /// The session's deadlines, which a count that ran out names. + pub deadlines: Arc, + pub settings: Rc, + pub first_load: FirstLoad, +} + +impl BrowseTab { + pub fn new(init: BrowseTabInit) -> Self { + let tab: Self = glib::Object::new(); + let imp = tab.imp(); + + // Restore the saved filter for this (connection, schema, table) + // up front so both the model and the inline strip start with the + // same FilterSet. + let initial_filter = init + .connection_id + .map(|id| init.filter_settings.filters(id, init.schema.as_deref(), &init.table)) + .unwrap_or_default(); + let widgets = layout::build( + &tab, + &layout::LayoutInit { + table_label: &qualified(init.schema.as_deref(), &init.table), + read_only: init.read_only, + page_size: init.page_size, + }, + ); + let filter_strip = layout::filter_strip(&tab, &initial_filter); + let actions = browse_actions::install_browse_actions(&tab, init.page_size); + let link_generation = init.session.link_generation(); + let dialect = init.session.current_dialect(); + let link_changes = init.session.link_changes(); + let model = BrowseModel { + this: tab.downgrade(), + session: init.session, + tasks: init.tasks, + sequencer: RequestSequencer::new(), + failed: FailedRequests::new(), + after_columns: BTreeSet::new(), + recheck: ColumnRecheck::default(), + issued_on: std::collections::BTreeMap::new(), + link_generation, + identity: None, + dialect, + tracker: init.tracker, + schema: init.schema, + table: init.table, + connection_id: init.connection_id, + read_only: init.read_only, + column_widths: init.column_widths, + filter_settings: init.filter_settings, + pager: BrowsePager::new(init.page_size, init.initial_offset), + deadlines: init.deadlines, + settings: init.settings, + current_sort: init.initial_sort, + current_filter: initial_filter, + current_columns: Vec::new(), + page: None, + shown_view: None, + current_selection: None, + total: RowTotal::Unknown, + load: ContentLoad::default(), + tab_loading: false, + content_had_focus: false, + current_column_view: None, + view_columns: Vec::new(), + row_index: GridRowIndex::default(), + rendered_columns: Vec::new(), + was_dirty: Cell::new(false), + pending_restore: None, + filter_strip, + actions, + saving: Cell::new(false), + grid_sink: layout::grid_sink(&tab), + suppress_combo_emit: Cell::new(true), + confirming_delete: None, + next_confirmation: 0, + widgets, + }; + imp.model.replace(Some(model)); + imp.link_watch.replace(Some(WatchForwarder::new( + link_changes, + { + let tab = tab.downgrade(); + move |generation: LinkGeneration| { + if let Some(tab) = tab.upgrade() { + tab.send(DataInput::LinkChanged(generation)); + } + } + }, + |future| { + glib::spawn_future_local(future); + }, + ))); + start_first_load(&tab, init.first_load, { + let tab = tab.downgrade(); + move || { + if let Some(tab) = tab.upgrade() { + tab.send(DataInput::Start); + } + } + }); + tab + } + + /// Hand the tab an input. One that arrives while another is being + /// handled, from a widget signal the handler set off, waits for it + /// to finish rather than running inside it. + pub(crate) fn send(&self, msg: impl Into) { + let imp = self.imp(); + imp.queue.borrow_mut().push_back(msg.into()); + if imp.dispatching.replace(true) { + return; + } + loop { + let next = imp.queue.borrow_mut().pop_front(); + let Some(msg) = next else { + break; + }; + let mut effects = Effects::default(); + { + let mut model = imp.model.borrow_mut(); + let Some(model) = model.as_mut() else { + imp.queue.borrow_mut().clear(); + break; + }; + model.update(msg, &mut effects); + } + for output in effects.outputs { + self.emit_output(output); + } + imp.queue.borrow_mut().extend(effects.inputs); + } + imp.dispatching.set(false); + } + + /// Stop every read the tab has running and stop following + /// reconnects now, rather than at dispose, which waits for the last + /// reference to the widget. + pub fn shutdown(&self) { + let imp = self.imp(); + imp.link_watch.take(); + if let Ok(mut model) = imp.model.try_borrow_mut() + && let Some(model) = model.as_mut() + { + model.sequencer.cancel_all(); + } + } + + /// Commit the cell edit open in the grid, as Return does, so the close + /// prompts count what was typed. + pub fn commit_cell_edit(&self) { + if let Some(view) = self.read(|model| model.current_column_view.clone()).flatten() { + super::grid::commit_open_edit(&view); + } + } + + /// `None` once the tab is closed, and while it is handling an input. + fn read(&self, read: impl FnOnce(&BrowseModel) -> T) -> Option { + self.imp().model.try_borrow().ok()?.as_ref().map(read) + } + + pub fn columns(&self) -> Vec { + self.read(|model| model.current_columns.clone()).unwrap_or_default() + } + + pub fn table_label(&self) -> String { + self.read(|model| qualified(model.schema.as_deref(), &model.table)) + .unwrap_or_default() + } + + pub fn table(&self) -> String { + self.read(|model| model.table.clone()).unwrap_or_default() + } + + pub fn current_offset(&self) -> u64 { + self.read(|model| model.pager.start_ordinal()).unwrap_or_default() + } + + pub fn page_size(&self) -> u64 { + self.read(|model| model.pager.page_size()).unwrap_or(DEFAULT_PAGE_SIZE) + } + + pub fn current_sort(&self) -> Option<(usize, bool)> { + self.read(|model| model.current_sort).flatten() + } + + /// A read of the tab's rows is running. + pub fn is_loading(&self) -> bool { + self.read(|model| model.tab_loading).unwrap_or(false) + } + + pub fn connect_state_changed(&self, f: F) -> glib::SignalHandlerId { + self.connect_closure("state-changed", false, glib::closure_local!(move |tab: &Self| f(tab))) + } + + pub(crate) fn connect_export_results(&self, f: F) -> glib::SignalHandlerId { + self.connect_closure( + "export-results", + false, + glib::closure_local!(move |tab: &Self, payload: glib::BoxedAnyObject| { + f(tab, payload.borrow::().clone()); + }), + ) + } + + pub fn connect_alert(&self, f: F) -> glib::SignalHandlerId { + self.connect_closure( + "alert", + false, + glib::closure_local!(move |tab: &Self, title: String, body: String| f(tab, title, body)), + ) + } + + pub fn connect_toast(&self, f: F) -> glib::SignalHandlerId { + self.connect_closure( + "toast", + false, + glib::closure_local!(move |tab: &Self, text: String| f(tab, text)), + ) + } + + /// The pending changes crossed the empty / non-empty boundary. + pub fn connect_dirty_changed(&self, f: F) -> glib::SignalHandlerId { + self.connect_closure( + "dirty-changed", + false, + glib::closure_local!(move |tab: &Self, dirty: bool| f(tab, dirty)), + ) + } + + /// Save asked the window to write what the tab's tracker holds. + pub fn connect_save_requested(&self, f: F) -> glib::SignalHandlerId { + self.connect_closure("save-requested", false, glib::closure_local!(move |tab: &Self| f(tab))) + } + + /// F5 asked the window to read the tab again. + pub fn connect_refresh_requested(&self, f: F) -> glib::SignalHandlerId { + self.connect_closure( + "refresh-requested", + false, + glib::closure_local!(move |tab: &Self| f(tab)), + ) + } + + /// Stop Saving asked the window to stop the tab's save. + pub fn connect_stop_save_requested(&self, f: F) -> glib::SignalHandlerId { + self.connect_closure( + "stop-save-requested", + false, + glib::closure_local!(move |tab: &Self| f(tab)), + ) + } + + /// A read of the tab's rows started or ended. + pub fn connect_loading_changed(&self, f: F) -> glib::SignalHandlerId { + self.connect_closure( + "loading-changed", + false, + glib::closure_local!(move |tab: &Self, loading: bool| f(tab, loading)), + ) + } + + fn emit_output(&self, output: BrowseTabOutput) { + match output { + BrowseTabOutput::StateChanged => self.emit_by_name::<()>("state-changed", &[]), + BrowseTabOutput::Copy(request) => self.copy(request), + BrowseTabOutput::ShowRowJson(request) => { + if let Some(tasks) = self.read(|model| model.tasks.clone()) { + let tab = self.clone().upcast(); + glib::spawn_future_local(async move { + present_row_json(tab, tasks, request).await; + }); + } + } + BrowseTabOutput::Export(offer) => { + self.emit_by_name::<()>("export-results", &[&glib::BoxedAnyObject::new(*offer)]); + } + BrowseTabOutput::ShowSelectionAlert { title, body } => { + self.emit_by_name::<()>("alert", &[&title, &body]); + } + BrowseTabOutput::ShowToast(text) => self.emit_by_name::<()>("toast", &[&text]), + BrowseTabOutput::DirtyChanged(dirty) => self.emit_by_name::<()>("dirty-changed", &[&dirty]), + BrowseTabOutput::SaveRequested => self.emit_by_name::<()>("save-requested", &[]), + BrowseTabOutput::StopSaveRequested => self.emit_by_name::<()>("stop-save-requested", &[]), + BrowseTabOutput::LoadingChanged(loading) => self.emit_by_name::<()>("loading-changed", &[&loading]), + BrowseTabOutput::RefreshRequested => self.emit_by_name::<()>("refresh-requested", &[]), + } + } + + /// Render a copy off the GTK thread, and toast how it went. + fn copy(&self, request: CopyRequest) { + let Some(tasks) = self.read(|model| model.tasks.clone()) else { + return; + }; + let tab = self.downgrade(); + crate::ui::clipboard::copy_rows(self, &tasks, request, move |text| { + if let Some(tab) = tab.upgrade() { + tab.emit_by_name::<()>("toast", &[&text]); + } + }); + } +} + +/// What one input left to do once it was handled: signals to emit and +/// inputs to handle next. +#[derive(Default)] +pub(crate) struct Effects { + outputs: Vec, + inputs: VecDeque, + /// A bulk delete over rows in the running save says so once. + told_edit_held: bool, +} + +impl Effects { + fn output(&mut self, output: BrowseTabOutput) { + self.outputs.push(output); + } + + fn input(&mut self, input: impl Into) { + self.inputs.push_back(input.into()); + } +} + +/// Which page of the table a read asks for. +#[derive(Debug, Clone, PartialEq)] +struct PageView { + filter: tablepro_core::FilterSet, + sort: Option<(usize, bool)>, + offset: u64, + limit: u64, +} + +pub(crate) struct BrowseModel { + this: glib::WeakRef, + session: Arc, + tasks: Tasks, + sequencer: RequestSequencer, + failed: FailedRequests, + /// Reads that wait for the columns: a page is built from them, and + /// the tab reads nothing else of a table whose columns it could not + /// read. + after_columns: BTreeSet, + recheck: ColumnRecheck, + /// The connection each running request was issued on. A request + /// that fails after the connection was replaced is asked again at + /// once, since the reconnect it would wait for has already landed. + issued_on: std::collections::BTreeMap, + /// The newest connection the tab has heard of, so the watch's first + /// delivery, which is the one the tab opened on, changes nothing. + link_generation: LinkGeneration, + identity: Option, + /// The current connection's dialect, which says whether a filter can + /// be written for it. Read again after every reconnect. + dialect: Option>, + + tracker: TabTrackerHandle, + schema: Option, + table: String, + connection_id: Option, + read_only: bool, + column_widths: crate::persistence::ColumnWidthStore, + filter_settings: crate::persistence::FilterSettingsStore, + + /// Where the page the tab reads starts, and how many rows it holds. + pager: BrowsePager, + deadlines: Arc, + /// Read at each delete, so a change in Preferences reaches open tabs. + settings: Rc, + current_sort: Option<(usize, bool)>, + /// The WHERE every page and count reads under, kept per connection, + /// schema and table. + current_filter: tablepro_core::FilterSet, + current_columns: Vec, + /// The page on screen, whose rows the grid's store holds. + page: Option, + /// What `page` was read under. A read that fails while asking for + /// that same page again leaves it on screen. + shown_view: Option, + current_selection: Option, + /// The table's row count under the filter, as far as the tab knows. + total: RowTotal, + load: ContentLoad, + /// What the tab bar was last told, so it hears only changes. + tab_loading: bool, + /// The rows held the focus when the loading page, which has nothing + /// to focus, took their place. + content_had_focus: bool, + + current_column_view: Option, + /// The view's columns in the order of a row's cells, which a drag in + /// the header leaves alone. + view_columns: Vec, + /// The store's rows by tracker key, so a tracked change finds the + /// row to redraw without a scan of the page. + row_index: GridRowIndex, + /// The columns the current view was built for. A page under the + /// same columns reuses the view and only swaps its rows; a schema + /// change that renames or retypes one builds the view again. + rendered_columns: Vec, + /// Last-emitted dirty state, so App hears only real transitions and + /// not every count change (2 → 3). + was_dirty: Cell, + /// The selection and the focused cell before a sort, save or + /// refresh, so the page read again puts them back. + pending_restore: Option, + filter_strip: FilterStrip, + actions: BrowseActions, + /// Between the save starting and settling. + saving: Cell, + grid_sink: GridSink, + /// Set until the first page lands, so the page-size dropdown being + /// set from a restored state does not count as the user choosing. + suppress_combo_emit: Cell, + confirming_delete: Option, + next_confirmation: u64, + widgets: BrowseTabWidgets, +} + +#[derive(Debug)] +pub(crate) enum BrowseTabOutput { + /// Something the window keeps for the tab changed: the page, the + /// sort, the page size. + StateChanged, + Copy(CopyRequest), + ShowRowJson(RowJsonRequest), + Export(Box), + ShowSelectionAlert { + title: String, + body: String, + }, + ShowToast(String), + DirtyChanged(bool), + SaveRequested, + StopSaveRequested, + /// A read of the tab's rows started or ended, for the tab bar. + LoadingChanged(bool), + /// F5 asked the window to read the tab again. + RefreshRequested, +} + +impl BrowseModel { + fn update(&mut self, msg: BrowseTabInput, fx: &mut Effects) { + match msg { + BrowseTabInput::Data(input) => self.on_data(input, fx), + BrowseTabInput::Paging(input) => self.on_paging(input, fx), + BrowseTabInput::Edit(input) => self.on_edit(input, fx), + BrowseTabInput::Save(input) => self.on_save(input, fx), + BrowseTabInput::Selection(input) => self.on_selection(input), + BrowseTabInput::Clipboard(input) => self.on_clipboard(input, fx), + } + } + + fn table_ref(&self) -> TableRef { + TableRef::new(self.schema.clone(), self.table.clone()) + } + + /// The tracker context the grid renders through. Copy and export + /// read the same context, so what leaves the tab is what the user + /// is looking at, pending edits included. + fn grid_context(&self) -> TabGridContext { + TabGridContext { + tracker: Some(self.tracker.clone()), + editable: !self.read_only, + identity: pk_identity(&self.current_columns), + column_widths: self.connection_id.map(|connection_id| ColumnWidthBinding { + store: self.column_widths.clone(), + connection_id, + schema: self.schema.clone(), + table: self.table.clone(), + }), + } + } + + fn table_label(&self) -> String { + qualified(self.schema.as_deref(), &self.table) + } + + /// The page whose rows the grid shows. A failed read of another page + /// leaves the rows it replaced in the store, behind the error page, + /// where nothing may export, copy or delete them. + fn shown_page(&self) -> Option<&PageState> { + self.page.as_ref().filter(|_| self.load.has_content()) + } + + fn list_store(&self) -> Option { + let selection = self.current_selection.as_ref()?; + selection.model()?.downcast::().ok() + } + + fn column_view_matches_current_columns(&self) -> bool { + self.current_column_view.is_some() && self.rendered_columns == self.current_columns + } + + /// A row event from a grid built for other columns names its cells + /// by positions that now hold other columns. + fn grid_is_stale(&self) -> bool { + let stale = !self.column_view_matches_current_columns(); + if stale { + tracing::debug!("dropped a row event from a grid built for other columns"); + } + stale + } + + fn rows_on_page(&self) -> u64 { + self.shown_page().map_or(0, |page| page.row_count as u64) + } + + fn has_primary_key(&self) -> bool { + self.current_columns.iter().any(|c| c.primary_key) + } +} + +/// A status page reads its description as markup, and a table's name +/// can hold an `&` or a `<`. +fn loading_description(table: &str) -> glib::GString { + glib::markup_escape_text(&crate::i18n::gettext_f( + "Fetching rows from {table}", + &[("table", table)], + )) +} + +fn qualified(schema: Option<&str>, table: &str) -> String { + match schema { + Some(s) => format!("{s}.{table}"), + None => table.to_owned(), + } +} + +/// The one place the store gains or loses draft rows. +fn apply_draft_changes(store: >k::gio::ListStore, index: &mut GridRowIndex, delta: &TrackerDelta) { + for draft_id in &delta.removed_drafts { + let key = RowKey::Draft(*draft_id); + if let Some(position) = index.position_in(store, &key) { + store.remove(position); + } + index.remove(&key); + } + for draft_id in &delta.inserted_drafts { + let row = RowObject::draft(*draft_id); + store.insert(0, &row); + index.insert(&row); + } +} diff --git a/linux/crates/app/src/ui/browse_tab/page_label.rs b/linux/crates/app/src/ui/browse_tab/page_label.rs new file mode 100644 index 0000000000..58a8d080c4 --- /dev/null +++ b/linux/crates/app/src/ui/browse_tab/page_label.rs @@ -0,0 +1,80 @@ +use super::page_state::PageState; +use super::row_total::RowTotal; +use crate::ui::format::format_thousands; + +/// The paginator's text for the page on screen. +pub(super) fn page_range_label(page: &PageState, total: &RowTotal) -> String { + label_with(page, total, format_thousands) +} + +fn label_with(page: &PageState, total: &RowTotal, format: impl Fn(u64) -> String) -> String { + let (start, rows) = (page.start_ordinal, page.row_count); + match (rows, start, page.filtered) { + (0, 0, false) => crate::i18n::gettext("No rows"), + (0, 0, true) => crate::i18n::gettext("No rows match the filter"), + // Past the end of a table that shrank in another session, until + // the count moves the pager back. + (0, _, _) => crate::i18n::gettext("No rows on this page"), + _ => total.label_with(start + 1, start + rows as u64, format), + } +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use super::*; + use crate::ui::format::Thousands; + + fn label(start: u64, rows: usize, total: &RowTotal) -> String { + labelled(start, rows, false, total) + } + + fn labelled(start: u64, rows: usize, filtered: bool, total: &RowTotal) -> String { + let english = Thousands::for_posix_locale("en_US.UTF-8"); + let page = PageState { + result_columns: Arc::from([]), + row_count: rows, + start_ordinal: start, + filtered, + }; + label_with(&page, total, |n| english.format(n)) + } + + #[test] + fn first_page_label() { + assert_eq!(label(0, 100, &RowTotal::Unknown), "Rows 1 – 100"); + } + + #[test] + fn last_partial_page_label() { + assert_eq!(label(900, 42, &RowTotal::Exact(942)), "Rows 901 – 942 of 942"); + } + + #[test] + fn estimated_total_label() { + assert_eq!( + label(0, 100, &RowTotal::Estimated(5_000)), + "Rows 1 – 100 of about 5,000" + ); + } + + #[test] + fn exact_total_uses_thousands_separators() { + assert_eq!( + label(1_000, 1_000, &RowTotal::Exact(1_234_567)), + "Rows 1,001 – 2,000 of 1,234,567" + ); + } + + #[test] + fn an_empty_page_says_so() { + assert_eq!(label(2_000, 0, &RowTotal::Exact(1_500)), "No rows on this page"); + } + + #[test] + fn an_empty_table_or_filter_says_which() { + assert_eq!(label(0, 0, &RowTotal::Exact(0)), "No rows"); + assert_eq!(labelled(0, 0, true, &RowTotal::Unknown), "No rows match the filter"); + } +} diff --git a/linux/crates/app/src/ui/browse_tab/page_reply.rs b/linux/crates/app/src/ui/browse_tab/page_reply.rs new file mode 100644 index 0000000000..37d35c5a26 --- /dev/null +++ b/linux/crates/app/src/ui/browse_tab/page_reply.rs @@ -0,0 +1,46 @@ +use tablepro_core::QueryResult; +use tablepro_core::browse::{PageLayout, PageRead, PagingMode}; +use tablepro_core::meta::PagePosition; + +use super::browse_pager::PageEdges; + +/// A page as the server returned it, the row identity cells still after +/// the visible ones. +#[derive(Debug, Clone)] +pub(crate) struct PageReply { + pub position: PagePosition, + pub result: QueryResult, + pub layout: PageLayout, + pub edges: PageEdges, + /// The table pages by key or heap address rather than by offset. + pub seeks: bool, +} + +impl PageReply { + /// `None` when the page starts at a key or at the table's end. + pub fn offset(&self) -> Option { + match self.position { + PagePosition::First => Some(0), + PagePosition::Offset(offset) => Some(offset), + PagePosition::Last + | PagePosition::AfterKey(_) + | PagePosition::BeforeKey(_) + | PagePosition::CtidWindow(_) => None, + } + } +} + +impl From for PageReply { + fn from(read: PageRead) -> Self { + Self { + position: read.position, + result: read.result, + layout: read.layout, + edges: PageEdges { + previous: read.previous, + next: read.next, + }, + seeks: read.mode != PagingMode::Offset, + } + } +} diff --git a/linux/crates/app/src/ui/browse_tab/page_state.rs b/linux/crates/app/src/ui/browse_tab/page_state.rs new file mode 100644 index 0000000000..2bc8d32870 --- /dev/null +++ b/linux/crates/app/src/ui/browse_tab/page_state.rs @@ -0,0 +1,16 @@ +use std::sync::Arc; + +use tablepro_core::column::ResultColumn; + +/// What the tab knows of the page on screen. Its rows are the grid's: +/// the store holds the only copy. +#[derive(Debug, Clone)] +pub(crate) struct PageState { + /// The page's visible columns. + pub result_columns: Arc<[ResultColumn]>, + pub row_count: usize, + /// How many rows of the table come before the page. + pub start_ordinal: u64, + /// The page was read under a filter. + pub filtered: bool, +} diff --git a/linux/crates/app/src/ui/browse_tab/pager_bar.rs b/linux/crates/app/src/ui/browse_tab/pager_bar.rs new file mode 100644 index 0000000000..ce917f61c6 --- /dev/null +++ b/linux/crates/app/src/ui/browse_tab/pager_bar.rs @@ -0,0 +1,542 @@ +use std::cell::Cell; +use std::sync::OnceLock; + +use glib::subclass::Signal; +use gtk4 as gtk; +use gtk4::prelude::*; +use gtk4::subclass::prelude::*; +use gtk4::{gio, glib}; +use libadwaita as adw; + +use super::browse_pager::BrowsePager; +use super::page_label::page_range_label; +use super::page_state::PageState; +use super::row_total::RowTotal; +use crate::i18n::gettext; +use crate::ui::a11y::{announce_key_shortcuts, describe_icon_button}; + +/// What a click in the bar asks the tab for, one signal each. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum PagerRequest { + First, + Previous, + Next, + Last, + CountRows, +} + +impl PagerRequest { + const ALL: [Self; 5] = [Self::First, Self::Previous, Self::Next, Self::Last, Self::CountRows]; + + fn signal(self) -> &'static str { + match self { + Self::First => "first", + Self::Previous => "previous", + Self::Next => "next", + Self::Last => "last", + Self::CountRows => "count-rows", + } + } +} + +mod imp { + use super::*; + + #[derive(Default, gtk::CompositeTemplate, glib::Properties)] + #[properties(wrapper_type = super::PagerBar)] + #[template(resource = "/app/tablepro/TablePro/ui/pager-bar.ui")] + pub struct PagerBar { + #[template_child] + pub first_button: TemplateChild, + #[template_child] + pub prev_button: TemplateChild, + #[template_child] + pub next_button: TemplateChild, + #[template_child] + pub last_button: TemplateChild, + #[template_child] + pub paginator_label: TemplateChild, + #[template_child] + pub count_button: TemplateChild, + #[template_child] + pub count_spinner: TemplateChild, + #[template_child] + pub count_label: TemplateChild, + /// Below the tab's breakpoint, Count Rows is in the tab's More + /// menu instead. + #[property(get, set = Self::set_compact)] + compact: Cell, + count_offered: Cell, + } + + impl PagerBar { + fn set_compact(&self, compact: bool) { + self.compact.set(compact); + self.show_count_button(); + } + + pub(super) fn offer_count(&self, offered: bool) { + self.count_offered.set(offered); + self.show_count_button(); + } + + fn show_count_button(&self) { + self.count_button + .set_visible(self.count_offered.get() && !self.compact.get()); + } + } + + #[glib::object_subclass] + impl ObjectSubclass for PagerBar { + const NAME: &'static str = "TableProPagerBar"; + type Type = super::PagerBar; + type ParentType = gtk::Box; + + fn class_init(klass: &mut Self::Class) { + klass.bind_template(); + } + + fn instance_init(object: &glib::subclass::InitializingObject) { + object.init_template(); + } + } + + #[glib::derived_properties] + impl ObjectImpl for PagerBar { + fn constructed(&self) { + self.parent_constructed(); + let bar = self.obj(); + bar.describe_buttons(); + bar.connect_buttons(); + } + + fn signals() -> &'static [Signal] { + static SIGNALS: OnceLock> = OnceLock::new(); + SIGNALS.get_or_init(|| { + PagerRequest::ALL + .into_iter() + .map(|request| Signal::builder(request.signal()).build()) + .collect() + }) + } + } + + impl WidgetImpl for PagerBar {} + impl BoxImpl for PagerBar {} +} + +glib::wrapper! { + /// The browse tab's pager: First, Previous, Next and Last, the rows + /// on the page, and Count Rows. It shows what the tab tells it and + /// sends each click back as a signal. + pub struct PagerBar(ObjectSubclass) + @extends gtk::Box, gtk::Widget, + @implements gtk::Accessible, gtk::Buildable, gtk::ConstraintTarget, gtk::Orientable; +} + +impl Default for PagerBar { + fn default() -> Self { + glib::Object::new() + } +} + +impl PagerBar { + pub(super) fn connect_request(&self, request: PagerRequest, f: F) -> glib::SignalHandlerId { + self.connect_closure(request.signal(), false, glib::closure_local!(move |bar: &Self| f(bar))) + } + + /// A page turn moves the pager before its read lands, so a failed + /// read leaves the pager on a page the grid does not show. The way + /// back goes from the pager, and Next waits for a page on screen. + pub(super) fn show_position(&self, pager: &BrowsePager, rows_on_page: u64) { + let imp = self.imp(); + self.turn(&[ + (imp.first_button.get(), pager.first().is_some()), + (imp.prev_button.get(), pager.prev().is_some()), + (imp.next_button.get(), pager.next(rows_on_page).is_some()), + ]); + } + + /// Turn each button on or off. The focus on a button turned off goes + /// to the nearest one left on, as Next at the last page hands it to + /// Previous; GTK would move it to the window's first control. + fn turn(&self, changes: &[(gtk::Button, bool)]) { + for (button, _) in changes.iter().filter(|(_, on)| *on) { + button.set_sensitive(true); + } + let usable = |button: >k::Button| { + changes + .iter() + .find(|(changed, _)| changed == button) + .map_or(button.is_sensitive(), |(_, on)| *on) + && button.is_visible() + }; + let buttons = self.buttons(); + if let Some(from) = buttons + .iter() + .position(|button| crate::ui::a11y::holds_focus(button) && !usable(button)) + { + let nearest = (1..buttons.len()) + .flat_map(|distance| [from.checked_sub(distance), Some(from + distance)]) + .flatten() + .filter_map(|index| buttons.get(index)) + .find(|button| usable(button)); + if let Some(nearest) = nearest { + nearest.grab_focus(); + } + } + for (button, _) in changes.iter().filter(|(_, on)| !*on) { + button.set_sensitive(false); + } + } + + /// The bar's buttons in the order they sit. + fn buttons(&self) -> [gtk::Button; 5] { + let imp = self.imp(); + [ + imp.first_button.get(), + imp.prev_button.get(), + imp.next_button.get(), + imp.last_button.get(), + imp.count_button.get(), + ] + } + + /// The rows on the page, Count Rows and Last, which only an exact + /// total enables. + pub(super) fn show_total(&self, pager: &BrowsePager, shown: Option<&PageState>, total: &RowTotal) { + let imp = self.imp(); + let label = shown.map(|page| page_range_label(page, total)).unwrap_or_default(); + crate::ui::a11y::show_status(&imp.paginator_label, &label); + let last_tooltip = match total.exact() { + Some(_) => gettext("Last Page"), + None => gettext("Count rows to go to the last page"), + }; + imp.last_button.set_tooltip_text(Some(&last_tooltip)); + + let counting = matches!(total, RowTotal::Counting { .. }); + let count_offered = shown.is_some() && total.exact().is_none(); + if count_offered { + imp.offer_count(true); + } + self.turn(&[ + (imp.last_button.get(), pager.last(total).is_some()), + (imp.count_button.get(), count_offered && !counting), + ]); + imp.offer_count(count_offered); + imp.count_spinner.set_visible(counting); + let (label, reason) = match total { + RowTotal::Counting { .. } => (gettext("Counting…"), None), + RowTotal::Unavailable { reason, .. } => (gettext("Try Again"), Some(reason.as_str())), + RowTotal::Unknown | RowTotal::Estimated(_) | RowTotal::Exact(_) => (gettext("Count Rows"), None), + }; + imp.count_label.set_label(&label); + self.show_count_failure(reason); + } + + /// A count asked for and refused says why, on Try Again as well as + /// in its tooltip, which no key shows. + fn show_count_failure(&self, reason: Option<&str>) { + let button = self.imp().count_button.get(); + let news = reason.is_some() && button.tooltip_text().as_deref() != reason; + button.set_tooltip_text(reason); + let widget = button.upcast_ref::(); + match reason { + Some(reason) => widget.update_property(&[gtk::accessible::Property::Description(reason)]), + None => widget.reset_property(gtk::AccessibleProperty::Description), + } + if let Some(reason) = reason.filter(|_| news) { + crate::ui::a11y::announce( + &button, + &crate::i18n::gettext_f("Couldn't count the rows. {reason}", &[("reason", reason)]), + gtk::AccessibleAnnouncementPriority::High, + ); + } + } + + /// The keys for Previous and Next turn a page only where the button + /// would, so the tab's actions follow the buttons, and the buttons + /// announce the actions' keys. + pub(super) fn follow_page_actions(&self, group: &str, previous: &gio::SimpleAction, next: &gio::SimpleAction) { + let imp = self.imp(); + for (button, action) in [(&imp.prev_button, previous), (&imp.next_button, next)] { + button + .bind_property("sensitive", action, "enabled") + .sync_create() + .build(); + announce_key_shortcuts(&button.get(), &format!("{group}.{}", action.name())); + } + } + + fn describe_buttons(&self) { + let imp = self.imp(); + for (button, label) in [ + (&imp.first_button, gettext("First Page")), + (&imp.prev_button, gettext("Previous Page")), + (&imp.next_button, gettext("Next Page")), + (&imp.last_button, gettext("Last Page")), + ] { + describe_icon_button(&button.get(), &label, None); + } + } + + fn connect_buttons(&self) { + let imp = self.imp(); + for (button, request) in [ + (&imp.first_button, PagerRequest::First), + (&imp.prev_button, PagerRequest::Previous), + (&imp.next_button, PagerRequest::Next), + (&imp.last_button, PagerRequest::Last), + (&imp.count_button, PagerRequest::CountRows), + ] { + let bar = self.downgrade(); + button.connect_clicked(move |_| { + if let Some(bar) = bar.upgrade() { + bar.emit_by_name::<()>(request.signal(), &[]); + } + }); + } + } +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use gtk4::AccessibleProperty; + + use super::*; + use crate::test_support::SignalLog; + + fn bar() -> PagerBar { + libadwaita::init().expect("libadwaita initialises under the test backend"); + crate::register_test_resources(); + PagerBar::default() + } + + fn page(start_ordinal: u64, row_count: usize) -> PageState { + PageState { + result_columns: Arc::from([]), + row_count, + start_ordinal, + filtered: false, + } + } + + #[gtk4::test] + fn first_and_previous_insensitive_at_start() { + let bar = bar(); + let imp = bar.imp(); + + bar.show_position(&BrowsePager::new(100, 0), 100); + + assert!(!imp.first_button.is_sensitive()); + assert!(!imp.prev_button.is_sensitive()); + assert!(imp.next_button.is_sensitive(), "a full page has a next one"); + + bar.show_position(&BrowsePager::new(100, 200), 40); + + assert!(imp.first_button.is_sensitive()); + assert!(imp.prev_button.is_sensitive()); + assert!(!imp.next_button.is_sensitive(), "a short page was the last one"); + } + + #[gtk4::test] + fn page_actions_follow_the_buttons_and_announce_their_keys() { + let bar = bar(); + let imp = bar.imp(); + let previous = gio::SimpleAction::new("previous-page", None); + let next = gio::SimpleAction::new("next-page", None); + assert!(!gtk::test_accessible_has_property( + &imp.next_button.get(), + AccessibleProperty::KeyShortcuts + )); + + bar.follow_page_actions("browse", &previous, &next); + bar.show_position(&BrowsePager::new(100, 0), 100); + + assert!(!previous.is_enabled(), "Previous turned a page before the first"); + assert!(next.is_enabled()); + for button in [imp.prev_button.get(), imp.next_button.get()] { + assert!( + gtk::test_accessible_has_property(&button, AccessibleProperty::KeyShortcuts), + "{:?} announces no keys", + button.tooltip_text() + ); + } + + bar.show_position(&BrowsePager::new(100, 200), 40); + + assert!(previous.is_enabled()); + assert!(!next.is_enabled(), "Next turned past the last page"); + } + + #[gtk4::test] + fn next_click_emits_next() { + let bar = bar(); + let next = SignalLog::connect(&bar, "next", |_| ()); + let previous = SignalLog::connect(&bar, "previous", |_| ()); + bar.show_position(&BrowsePager::new(100, 0), 100); + + bar.imp().next_button.emit_clicked(); + + assert_eq!(next.count(), 1); + assert_eq!(previous.count(), 0); + } + + #[gtk4::test] + fn last_insensitive_until_total_is_exact() { + let bar = bar(); + let last = bar.imp().last_button.get(); + let pager = BrowsePager::new(100, 0); + let shown = page(0, 100); + + bar.show_total(&pager, Some(&shown), &RowTotal::Estimated(5_000)); + + assert!(!last.is_sensitive()); + assert_eq!( + last.tooltip_text().as_deref(), + Some("Count rows to go to the last page") + ); + + bar.show_total(&pager, Some(&shown), &RowTotal::Exact(5_000)); + + assert!(last.is_sensitive()); + assert_eq!(last.tooltip_text().as_deref(), Some("Last Page")); + assert!(!bar.imp().count_button.is_visible(), "an exact total offers no count"); + } + + #[gtk4::test] + fn count_rows_click_emits_count_rows() { + let bar = bar(); + let counts = SignalLog::connect(&bar, "count-rows", |_| ()); + bar.show_total(&BrowsePager::new(100, 0), Some(&page(0, 100)), &RowTotal::Unknown); + let imp = bar.imp(); + assert!(imp.count_button.is_visible() && imp.count_button.is_sensitive()); + assert_eq!(imp.count_label.label(), "Count Rows"); + + imp.count_button.emit_clicked(); + + assert_eq!(counts.count(), 1); + } + + /// The bar in a window, so its buttons can hold the focus. + fn shown_bar() -> (PagerBar, crate::test_support::OwnedWindow) { + let bar = bar(); + let window = crate::test_support::OwnedWindow::presented(gtk::Window::builder().child(&bar).build()); + (bar, window) + } + + fn focus(window: >k::Window) -> Option { + gtk::prelude::GtkWindowExt::focus(window) + } + + #[gtk4::test] + fn next_at_the_last_page_hands_the_focus_to_previous() { + let (bar, window) = shown_bar(); + let imp = bar.imp(); + bar.show_position(&BrowsePager::new(100, 0), 100); + assert!(imp.next_button.grab_focus()); + + bar.show_position(&BrowsePager::new(100, 100), 40); + + assert!(!imp.next_button.is_sensitive()); + assert_eq!(focus(&window).as_ref(), Some(imp.prev_button.upcast_ref())); + } + + #[gtk4::test] + fn a_count_keeps_the_focus_in_the_bar() { + let (bar, window) = shown_bar(); + let imp = bar.imp(); + let pager = BrowsePager::new(100, 0); + let shown = page(0, 100); + bar.show_position(&pager, 100); + bar.show_total(&pager, Some(&shown), &RowTotal::Estimated(5_000)); + assert!(imp.count_button.grab_focus()); + + bar.show_total(&pager, Some(&shown), &RowTotal::Counting { estimate: Some(5_000) }); + assert_eq!(focus(&window).as_ref(), Some(imp.next_button.upcast_ref())); + + let refused = RowTotal::Unavailable { + estimate: Some(5_000), + reason: "refused".to_owned(), + }; + bar.show_total(&pager, Some(&shown), &refused); + assert!(imp.count_button.grab_focus()); + bar.show_total(&pager, Some(&shown), &RowTotal::Exact(5_000)); + assert!(!imp.count_button.is_visible()); + assert_eq!(focus(&window).as_ref(), Some(imp.last_button.upcast_ref())); + } + + #[gtk4::test] + fn a_failed_count_says_why_on_try_again() { + let bar = bar(); + let pager = BrowsePager::new(100, 0); + let failed = RowTotal::Unavailable { + estimate: None, + reason: "Counting took longer than 45 seconds and was stopped.".to_owned(), + }; + + bar.show_total(&pager, Some(&page(0, 100)), &failed); + + let button = bar.imp().count_button.get(); + assert!(gtk::test_accessible_has_property( + &button, + AccessibleProperty::Description + )); + assert_eq!( + crate::ui::a11y::announced_in(&bar) + .last() + .map(|(said, priority)| (said.clone(), *priority)), + Some(( + "Couldn't count the rows. Counting took longer than 45 seconds and was stopped.".to_owned(), + gtk::AccessibleAnnouncementPriority::High + )) + ); + + bar.show_total(&pager, Some(&page(0, 100)), &RowTotal::Exact(500)); + + assert!(!gtk::test_accessible_has_property( + &button, + AccessibleProperty::Description + )); + } + + #[gtk4::test] + fn a_compact_bar_leaves_count_rows_to_the_menu() { + let bar = bar(); + let count = bar.imp().count_button.get(); + bar.show_total(&BrowsePager::new(100, 0), Some(&page(0, 100)), &RowTotal::Unknown); + + bar.set_compact(true); + assert!(!count.is_visible()); + bar.show_total(&BrowsePager::new(100, 0), Some(&page(0, 100)), &RowTotal::Unknown); + assert!(!count.is_visible(), "a new total put Count Rows back on a compact bar"); + + bar.set_compact(false); + assert!(count.is_visible()); + } + + #[gtk4::test] + fn the_range_label_waits_for_a_page_on_screen() { + let bar = bar(); + let pager = BrowsePager::new(100, 0); + + bar.show_total(&pager, None, &RowTotal::Exact(5)); + + assert_eq!(bar.imp().paginator_label.label(), ""); + assert!(!bar.imp().count_button.is_visible()); + + bar.show_total(&pager, Some(&page(0, 5)), &RowTotal::Exact(5)); + + assert_eq!(bar.imp().paginator_label.label(), "Rows 1 – 5 of 5"); + assert_eq!( + crate::ui::a11y::announced_in(&bar), + [( + "Rows 1 – 5 of 5".to_owned(), + gtk::AccessibleAnnouncementPriority::Medium + )], + "a page turn is said once" + ); + } +} diff --git a/linux/crates/app/src/ui/browse_tab/paging_handlers.rs b/linux/crates/app/src/ui/browse_tab/paging_handlers.rs new file mode 100644 index 0000000000..072b915460 --- /dev/null +++ b/linux/crates/app/src/ui/browse_tab/paging_handlers.rs @@ -0,0 +1,140 @@ +use gtk4::prelude::*; +use tablepro_core::FilterSet; + +use super::browse_pager::PageTarget; +use super::columns_fetch::ColumnFreshness; +use super::fetch::filter_failure; +use super::{BrowseModel, BrowseRequestKind, BrowseTabOutput, Effects, PagingInput, RowTotal}; + +impl BrowseModel { + pub(super) fn on_paging(&mut self, input: PagingInput, fx: &mut Effects) { + match input { + PagingInput::Refresh => self.reload(fx), + PagingInput::RequestRefresh => fx.output(BrowseTabOutput::RefreshRequested), + PagingInput::ToggleFilterStrip => self.filter_strip.toggle(), + PagingInput::ShowFilterStrip => self.filter_strip.open(), + PagingInput::FilterApplied(set) => self.apply_filter(set, fx), + PagingInput::FirstPage => self.turn_page(self.pager.first(), fx), + PagingInput::PrevPage => self.turn_page(self.pager.prev(), fx), + PagingInput::NextPage => self.turn_page(self.pager.next(self.rows_on_page()), fx), + PagingInput::LastPage => self.turn_page(self.pager.last(&self.total), fx), + PagingInput::SortChanged { col_idx, ascending } => self.sort_by(Some((col_idx, ascending)), fx), + PagingInput::SortCleared => self.sort_by(None, fx), + PagingInput::PageSizeChanged(size) => { + if self.suppress_combo_emit.get() || self.pager.page_size() == size { + return; + } + self.pager.resize(size); + self.show_page_size(size); + self.fetch(BrowseRequestKind::Page, fx); + fx.output(BrowseTabOutput::StateChanged); + } + PagingInput::CountRows => self.count_rows(fx), + } + } + + /// F5: read the columns again, in case the table changed under the + /// tab, then the page and the count built from them. The rows on + /// screen stay until the new page lands. + pub(super) fn reload(&mut self, fx: &mut Effects) { + self.capture_selection(); + self.begin_read(fx); + self.stop_count(true); + self.reload_from_columns(ColumnFreshness::Refetch); + } + + fn apply_filter(&mut self, set: FilterSet, fx: &mut Effects) { + // The same rules again would only duplicate F5. + if set == self.current_filter { + return; + } + self.current_filter = set.clone(); + if let Some(conn_id) = self.connection_id { + self.filter_settings + .set_filters(conn_id, self.schema.as_deref(), &self.table, set.clone()); + } + // Filtered counts shift, so back to page 1. + self.pager.restart(); + self.refresh_filter_chrome(); + self.filter_strip.set_filter(&set); + self.stop_count(false); + self.fetch(BrowseRequestKind::Page, fx); + self.fetch(BrowseRequestKind::Estimate, fx); + fx.output(BrowseTabOutput::StateChanged); + } + + fn sort_by(&mut self, next: Option<(usize, bool)>, fx: &mut Effects) { + // The sorter notifies twice for one click on a new column + // (column, then order), with the same final pair. + if self.current_sort == next { + return; + } + self.current_sort = next; + self.pager.restart(); + self.capture_selection(); + self.fetch(BrowseRequestKind::Page, fx); + fx.output(BrowseTabOutput::StateChanged); + } + + fn count_rows(&mut self, fx: &mut Effects) { + if let Some(problem) = self.filter_problem() { + let reason = filter_failure(problem).message(); + let estimate = self.total.estimate(); + self.set_total(RowTotal::Unavailable { estimate, reason }, fx); + return; + } + if self.total.exact().is_none() && !self.sequencer.is_pending(BrowseRequestKind::Count) { + self.start_count(self.total.estimate(), fx); + } + } + + /// Read the page a paginator move landed on, if it moved. + fn turn_page(&mut self, target: Option, fx: &mut Effects) { + let Some(target) = target else { + return; + }; + self.pager.move_to(target); + self.fetch(BrowseRequestKind::Page, fx); + fx.output(BrowseTabOutput::StateChanged); + } + + /// The dropdown and the More menu each pick a size, so each shows + /// the one the other picked. + fn show_page_size(&self, size: u64) { + if let Some(position) = self.widgets.page_sizes.iter().position(|n| *n == size) { + self.widgets + .page_size_dropdown + .set_selected(u32::try_from(position).unwrap_or_default()); + } + self.actions.page_size.set_state(&size.to_variant()); + } + + pub(super) fn refresh_filter_chrome(&self) { + let n = self.current_filter.len(); + if n == 0 { + self.widgets.filter_badge.set_visible(false); + self.widgets.filter_badge.set_label(""); + self.widgets + .filter_button + .set_tooltip_text(Some(&crate::i18n::gettext("Filter Rows"))); + } else { + self.widgets.filter_badge.set_label(&n.to_string()); + self.widgets.filter_badge.set_visible(true); + self.widgets + .filter_button + .set_tooltip_text(Some(&crate::i18n::ngettext_f( + "{n} Filter Rule Active", + "{n} Filter Rules Active", + u32::try_from(n).unwrap_or(u32::MAX), + &[("n", &n.to_string())], + ))); + } + } + + /// A page turn moves the pager before its read lands, so a failed + /// read leaves the pager on a page the grid does not show. The way + /// back goes from the pager, and Next waits for a page on screen. + pub(super) fn sync_pager_buttons(&self) { + self.widgets.pager_bar.show_position(&self.pager, self.rows_on_page()); + } +} diff --git a/linux/crates/app/src/ui/browse_tab/paging_input.rs b/linux/crates/app/src/ui/browse_tab/paging_input.rs new file mode 100644 index 0000000000..9ffb8efaa9 --- /dev/null +++ b/linux/crates/app/src/ui/browse_tab/paging_input.rs @@ -0,0 +1,29 @@ +use tablepro_core::FilterSet; + +/// What moves the tab to other rows of the table: the page, its size, +/// the sort, the filter, a refresh and the row count. +#[derive(Debug)] +pub(crate) enum PagingInput { + /// F5. + Refresh, + /// F5: the window asks first when the tab has unsaved changes. + RequestRefresh, + /// The Filter button. + ToggleFilterStrip, + /// Ctrl+F, which opens the strip or takes the focus into it. + ShowFilterStrip, + /// The strip's rules, or an empty set for Clear All. + FilterApplied(FilterSet), + FirstPage, + PrevPage, + NextPage, + LastPage, + SortChanged { + col_idx: usize, + ascending: bool, + }, + SortCleared, + PageSizeChanged(u64), + /// Count Rows, or Try Again after a count failed. + CountRows, +} diff --git a/linux/crates/app/src/ui/browse_tab/pending_changes_bar.rs b/linux/crates/app/src/ui/browse_tab/pending_changes_bar.rs new file mode 100644 index 0000000000..3dcee46344 --- /dev/null +++ b/linux/crates/app/src/ui/browse_tab/pending_changes_bar.rs @@ -0,0 +1,293 @@ +use std::sync::OnceLock; + +use glib::subclass::Signal; +use gtk4 as gtk; +use gtk4::prelude::*; +use gtk4::subclass::prelude::*; +use gtk4::{gio, glib}; +use libadwaita as adw; +use libadwaita::subclass::prelude::*; + +use crate::ui::a11y::announce_key_shortcuts; + +/// What a click in the bar asks the tab for, one signal each. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum PendingChangesRequest { + Save, + Discard, + StopSaving, +} + +impl PendingChangesRequest { + const ALL: [Self; 3] = [Self::Save, Self::Discard, Self::StopSaving]; + + fn signal(self) -> &'static str { + match self { + Self::Save => "save", + Self::Discard => "discard", + Self::StopSaving => "stop-saving", + } + } +} + +mod imp { + use super::*; + + #[derive(Default, gtk::CompositeTemplate)] + #[template(resource = "/app/tablepro/TablePro/ui/pending-changes-bar.ui")] + pub struct PendingChangesBar { + #[template_child] + pub revealer: TemplateChild, + #[template_child] + pub pending_label: TemplateChild, + #[template_child] + pub saving_spinner: TemplateChild, + #[template_child] + pub save_button: TemplateChild, + #[template_child] + pub discard_button: TemplateChild, + #[template_child] + pub stop_button: TemplateChild, + } + + #[glib::object_subclass] + impl ObjectSubclass for PendingChangesBar { + const NAME: &'static str = "TableProPendingChangesBar"; + type Type = super::PendingChangesBar; + type ParentType = adw::Bin; + + fn class_init(klass: &mut Self::Class) { + klass.bind_template(); + } + + fn instance_init(object: &glib::subclass::InitializingObject) { + object.init_template(); + } + } + + impl ObjectImpl for PendingChangesBar { + fn constructed(&self) { + self.parent_constructed(); + self.obj().connect_buttons(); + } + + fn signals() -> &'static [Signal] { + static SIGNALS: OnceLock> = OnceLock::new(); + SIGNALS.get_or_init(|| { + PendingChangesRequest::ALL + .into_iter() + .map(|request| Signal::builder(request.signal()).build()) + .collect() + }) + } + } + + impl WidgetImpl for PendingChangesBar {} + impl BinImpl for PendingChangesBar {} +} + +glib::wrapper! { + /// The browse tab's footer while it has unsaved changes: how many, + /// Save and Discard, and Stop Saving while a save runs. It sends + /// each click back as a signal. + pub struct PendingChangesBar(ObjectSubclass) + @extends adw::Bin, gtk::Widget, + @implements gtk::Accessible, gtk::Buildable, gtk::ConstraintTarget; +} + +impl Default for PendingChangesBar { + fn default() -> Self { + glib::Object::new() + } +} + +impl PendingChangesBar { + pub(super) fn connect_request( + &self, + request: PendingChangesRequest, + f: F, + ) -> glib::SignalHandlerId { + self.connect_closure(request.signal(), false, glib::closure_local!(move |bar: &Self| f(bar))) + } + + pub(super) fn announce_save_keys(&self, group: &str, save: &gio::SimpleAction) { + announce_key_shortcuts(&self.imp().save_button.get(), &format!("{group}.{}", save.name())); + } + + pub(super) fn show_pending(&self, count: usize) { + let imp = self.imp(); + let visible = count > 0; + if visible { + let label = if count == 1 { + crate::i18n::gettext("1 unsaved change") + } else { + crate::i18n::ngettext_f( + "{n} unsaved change", + "{n} unsaved changes", + u32::try_from(count).unwrap_or(u32::MAX), + &[("n", &count.to_string())], + ) + }; + crate::ui::a11y::show_status(&imp.pending_label, &label); + } + imp.revealer.set_reveal_child(visible); + } + + /// While a save runs, Save and Discard are off, so a double click + /// cannot send two, and Stop Saving takes their place, the focus + /// with it. + pub(super) fn show_saving(&self, saving: bool) { + let imp = self.imp(); + let save = imp.save_button.get(); + let stop = imp.stop_button.get(); + if saving { + imp.saving_spinner.set_visible(true); + stop.set_visible(true); + for button in [&save, &imp.discard_button.get()] { + crate::ui::a11y::pass_focus(button, &stop); + button.set_sensitive(false); + } + crate::ui::a11y::announce( + &imp.saving_spinner.get(), + &crate::i18n::gettext("Saving…"), + gtk::AccessibleAnnouncementPriority::Medium, + ); + } else { + save.set_sensitive(true); + imp.discard_button.set_sensitive(true); + crate::ui::a11y::pass_focus(&stop, &save); + stop.set_visible(false); + imp.saving_spinner.set_visible(false); + } + } + + fn connect_buttons(&self) { + let imp = self.imp(); + for (button, request) in [ + (&imp.save_button, PendingChangesRequest::Save), + (&imp.discard_button, PendingChangesRequest::Discard), + (&imp.stop_button, PendingChangesRequest::StopSaving), + ] { + let bar = self.downgrade(); + button.connect_clicked(move |_| { + if let Some(bar) = bar.upgrade() { + bar.emit_by_name::<()>(request.signal(), &[]); + } + }); + } + } +} + +#[cfg(test)] +mod tests { + use gtk4::AccessibleProperty; + + use super::*; + use crate::test_support::SignalLog; + + fn bar() -> PendingChangesBar { + libadwaita::init().expect("libadwaita initialises under the test backend"); + crate::register_test_resources(); + PendingChangesBar::default() + } + + #[gtk4::test] + fn hidden_without_pending_changes() { + let bar = bar(); + let imp = bar.imp(); + assert!(!imp.revealer.reveals_child()); + + bar.show_pending(2); + assert!(imp.revealer.reveals_child()); + assert_eq!(imp.pending_label.label(), "2 unsaved changes"); + + bar.show_pending(0); + assert!(!imp.revealer.reveals_child()); + } + + #[gtk4::test] + fn save_announces_the_keys_of_the_action_it_is_given() { + let bar = bar(); + let save = bar.imp().save_button.get(); + assert!(!gtk::test_accessible_has_property( + &save, + AccessibleProperty::KeyShortcuts + )); + + bar.announce_save_keys("browse", &gio::SimpleAction::new("save", None)); + + assert!(gtk::test_accessible_has_property( + &save, + AccessibleProperty::KeyShortcuts + )); + } + + #[gtk4::test] + fn save_click_emits_save() { + let bar = bar(); + let saves = SignalLog::connect(&bar, "save", |_| ()); + let discards = SignalLog::connect(&bar, "discard", |_| ()); + bar.show_pending(1); + + bar.imp().save_button.emit_clicked(); + + assert_eq!(saves.count(), 1); + assert_eq!(discards.count(), 0); + } + + #[gtk4::test] + fn stop_saving_visible_only_while_saving() { + let bar = bar(); + let stops = SignalLog::connect(&bar, "stop-saving", |_| ()); + let imp = bar.imp(); + bar.show_pending(1); + assert!(!imp.stop_button.is_visible() && !imp.saving_spinner.is_visible()); + + bar.show_saving(true); + assert!(imp.stop_button.is_visible() && imp.saving_spinner.is_visible()); + assert!(!imp.save_button.is_sensitive(), "Save stayed on while a save ran"); + assert!(!imp.discard_button.is_sensitive()); + imp.stop_button.emit_clicked(); + assert_eq!(stops.count(), 1); + + bar.show_saving(false); + assert!(!imp.stop_button.is_visible() && !imp.saving_spinner.is_visible()); + assert!(imp.save_button.is_sensitive() && imp.discard_button.is_sensitive()); + } + + #[gtk4::test] + fn the_focus_follows_save_to_stop_saving_and_back() { + let bar = bar(); + let window = crate::test_support::OwnedWindow::presented(gtk::Window::builder().child(&bar).build()); + let imp = bar.imp(); + bar.show_pending(2); + assert!(imp.save_button.grab_focus()); + let focus = || gtk::prelude::GtkWindowExt::focus(&*window); + + bar.show_saving(true); + assert_eq!(focus().as_ref(), Some(imp.stop_button.upcast_ref())); + assert!( + crate::ui::a11y::announced_in(&bar) + .iter() + .any(|(said, _)| said == "Saving…") + ); + + bar.show_saving(false); + assert_eq!(focus().as_ref(), Some(imp.save_button.upcast_ref())); + } + + #[gtk4::test] + fn a_new_count_of_changes_is_said() { + let bar = bar(); + + bar.show_pending(1); + bar.show_pending(1); + bar.show_pending(2); + + let said: Vec = crate::ui::a11y::announced_in(&bar) + .into_iter() + .map(|(said, _)| said) + .collect(); + assert_eq!(said, ["1 unsaved change", "2 unsaved changes"]); + } +} diff --git a/linux/crates/app/src/ui/browse_tab/row_count.rs b/linux/crates/app/src/ui/browse_tab/row_count.rs new file mode 100644 index 0000000000..589b8d2a6d --- /dev/null +++ b/linux/crates/app/src/ui/browse_tab/row_count.rs @@ -0,0 +1,154 @@ +use tablepro_core::browse::build_count; +use tablepro_core::meta::{RowCountEstimate, TableEstimate}; +use tablepro_session::{OperationError, OperationKind}; + +use super::count_failure_text::count_failure_text; +use super::count_policy::{CountAction, decide}; +use super::row_total::RowTotal; +use super::{BrowseCommand, BrowseModel, BrowseRequestKind, BrowseTabOutput, Effects}; + +impl BrowseModel { + /// The server's guess at the table's size, which is all the "of N" + /// total needs, and which decides whether a count runs on its own. + pub(super) fn fetch_estimate(&mut self) { + let ticket = self.issue(BrowseRequestKind::Estimate); + let session = self.session.clone(); + let table = self.table_ref(); + let cancel = ticket.cancel.clone(); + let work = async move { + session + .run_cancellable(OperationKind::Metadata, &cancel, move |lease, options| { + let table = table.clone(); + async move { + lease + .table_estimate(&table, options) + .await + .map_err(OperationError::from) + } + }) + .await + }; + self.deliver(ticket, work, BrowseCommand::Estimate, OperationError::from); + } + + pub(super) fn on_estimate(&mut self, value: Result, fx: &mut Effects) { + let estimate = value.map(|estimate| estimate.rows).map_err(|error| { + tracing::warn!(category = ?error.category(), "row estimate unavailable"); + }); + let filtered = !self.current_filter.is_empty(); + // The count gives the total, so an estimate that lands while it + // runs only fills in what shows meanwhile. + if self.sequencer.is_pending(BrowseRequestKind::Count) { + let shown = estimate + .ok() + .and_then(RowCountEstimate::value) + .filter(|_| !filtered) + .or(self.total.estimate()); + self.set_total(RowTotal::Counting { estimate: shown }, fx); + return; + } + match decide(estimate, filtered) { + CountAction::UseExact(rows) => self.set_total(RowTotal::Exact(rows), fx), + CountAction::UseEstimate(rows) => self.set_total(RowTotal::Estimated(rows), fx), + CountAction::CountNow => { + // The whole table's estimate is no total for a filtered view. + let estimate = estimate + .ok() + .and_then(|estimate| estimate.value()) + .filter(|_| !filtered); + self.start_count(estimate, fx); + } + CountAction::OfferCount => self.set_total(RowTotal::Unknown, fx), + } + } + + /// Count the rows under the tab's filter. `estimate` shows while it + /// runs. + pub(super) fn start_count(&mut self, estimate: Option, fx: &mut Effects) { + let Some(plan) = self.read_plan() else { + return; + }; + // The page says why such a filter cannot be read. + if self.filter_problem().is_some() { + return; + } + let ticket = self.issue(BrowseRequestKind::Count); + let session = self.session.clone(); + let cancel = ticket.cancel.clone(); + let work = async move { + session + .run_cancellable(OperationKind::Count, &cancel, move |lease, options| { + let plan = plan.clone(); + async move { + let statement = build_count(&*lease.dialect(), &plan.table, &plan.columns, &plan.filter)?; + Ok(lease.count_rows(&statement, options).await?) + } + }) + .await + }; + self.deliver(ticket, work, BrowseCommand::Count, OperationError::from); + self.set_total(RowTotal::Counting { estimate }, fx); + } + + pub(super) fn on_count(&mut self, value: Result, fx: &mut Effects) { + match value { + Ok(rows) => self.set_total(RowTotal::Exact(rows), fx), + Err(error) => { + let reason = count_failure_text(&error, self.deadlines.deadline(OperationKind::Count)); + let estimate = self.total.estimate(); + self.set_total(RowTotal::Unavailable { estimate, reason }, fx); + } + } + } + + /// A refresh or a new filter reads other rows than the running count + /// counts, so it stops. A refresh keeps what the count started from; + /// a filter has no total until its own estimate lands. + pub(super) fn stop_count(&mut self, keep_total: bool) { + self.sequencer.cancel(BrowseRequestKind::Count); + self.total = match (keep_total, &self.total) { + (false, _) => RowTotal::Unknown, + ( + true, + RowTotal::Counting { + estimate: Some(estimate), + }, + ) => RowTotal::Estimated(*estimate), + (true, RowTotal::Counting { estimate: None }) => RowTotal::Unknown, + (true, total) => total.clone(), + }; + self.sync_total_chrome(); + } + + pub(super) fn set_total(&mut self, total: RowTotal, fx: &mut Effects) { + self.total = total; + if self.clamp_to_total(fx) { + self.fetch(BrowseRequestKind::Page, fx); + } + self.sync_total_chrome(); + } + + /// Only an exact total says where the table ends, so only an exact + /// one moves a page left past the end back to the last page. Says + /// whether it moved, for the caller to read that page. + pub(super) fn clamp_to_total(&mut self, fx: &mut Effects) -> bool { + let Some(target) = self.total.exact().and_then(|rows| self.pager.clamp(rows)) else { + return false; + }; + self.pager.move_to(target); + fx.output(BrowseTabOutput::StateChanged); + true + } + + /// The paginator's label, the Count Rows button and Last, which only + /// an exact total enables. + pub(super) fn sync_total_chrome(&self) { + self.widgets + .pager_bar + .show_total(&self.pager, self.shown_page(), &self.total); + let counting = matches!(self.total, RowTotal::Counting { .. }); + self.actions + .count_rows + .set_enabled(self.shown_page().is_some() && self.total.exact().is_none() && !counting); + } +} diff --git a/linux/crates/app/src/ui/browse_tab/row_deletion.rs b/linux/crates/app/src/ui/browse_tab/row_deletion.rs new file mode 100644 index 0000000000..bf0f56331a --- /dev/null +++ b/linux/crates/app/src/ui/browse_tab/row_deletion.rs @@ -0,0 +1,102 @@ +use std::sync::Arc; + +use tablepro_core::Value; + +use crate::services::change_tracker::RowKey; +use crate::ui::grid::{GridRowId, RowObject}; + +#[derive(Debug, Clone, Default, PartialEq)] +pub struct RowDeletion { + /// Each with the row as it was read. + pub persisted: Vec<(RowKey, Arc<[Value]>)>, + pub drafts: Vec, + /// Rows a DELETE has no key to name by. + pub unkeyed: usize, +} + +impl RowDeletion { + pub fn len(&self) -> usize { + self.persisted.len() + self.drafts.len() + } + + pub fn is_empty(&self) -> bool { + self.len() == 0 + } + + /// A draft is dropped at once with its undo history, so a set that + /// holds one cannot be described as waiting for Save. + pub fn confirmation_body(&self) -> String { + match (self.persisted.is_empty(), self.drafts.is_empty()) { + (_, true) => crate::i18n::gettext( + "These rows will be marked for deletion. They aren't removed until you click Save.", + ), + (true, false) => crate::i18n::gettext( + "These rows were never saved. They're removed now, and Undo won't bring them back.", + ), + (false, false) => crate::i18n::gettext( + "Saved rows will be marked for deletion and aren't removed until you click Save. Rows never saved are removed now, and Undo won't bring them back.", + ), + } + } +} + +pub fn plan_row_deletion(rows: &[RowObject]) -> RowDeletion { + let mut deletion = RowDeletion::default(); + for row in rows { + match row.id() { + GridRowId::Draft(draft_id) => deletion.drafts.push(*draft_id), + GridRowId::Persisted(key) => deletion.persisted.push((key.clone(), row.cells().unwrap_or_default())), + GridRowId::Unkeyed | GridRowId::Result => deletion.unkeyed += 1, + } + } + deletion +} + +#[cfg(test)] +mod tests { + use tablepro_core::Value; + + use super::plan_row_deletion; + use crate::services::change_tracker::RowKey; + use crate::ui::grid::RowObject; + + #[test] + fn plan_row_deletion_partitions_drafts_persisted_and_unkeyed() { + let key = RowKey::from_pk_values(&[Value::Int(1)]).expect("a key"); + let rows = [ + RowObject::draft(4), + RowObject::persisted(Some(key.clone()), vec![Value::Int(1), Value::Text("a".into())].into()), + RowObject::persisted(None, vec![Value::Int(2)].into()), + RowObject::result(vec![Value::Int(3)].into()), + RowObject::draft(5), + ]; + + let deletion = plan_row_deletion(&rows); + + assert_eq!(deletion.drafts, vec![4, 5]); + assert_eq!( + deletion.persisted, + vec![(key, vec![Value::Int(1), Value::Text("a".into())].into())] + ); + assert_eq!(deletion.unkeyed, 2); + assert_eq!(deletion.len(), 3); + } + + #[test] + fn the_confirmation_says_drafts_go_at_once() { + let saved = RowObject::persisted( + Some(RowKey::from_pk_values(&[Value::Int(1)]).expect("a key")), + vec![Value::Int(1)].into(), + ); + + let only_saved = plan_row_deletion(std::slice::from_ref(&saved)).confirmation_body(); + let only_drafts = plan_row_deletion(&[RowObject::draft(1)]).confirmation_body(); + let mixed = plan_row_deletion(&[saved, RowObject::draft(1)]).confirmation_body(); + + assert!(only_saved.contains("click Save"), "{only_saved}"); + assert!(!only_saved.contains("removed now"), "{only_saved}"); + assert!(only_drafts.contains("removed now"), "{only_drafts}"); + assert!(!only_drafts.contains("click Save"), "{only_drafts}"); + assert!(mixed.contains("click Save") && mixed.contains("removed now"), "{mixed}"); + } +} diff --git a/linux/crates/app/src/ui/browse_tab/row_total.rs b/linux/crates/app/src/ui/browse_tab/row_total.rs new file mode 100644 index 0000000000..5878364946 --- /dev/null +++ b/linux/crates/app/src/ui/browse_tab/row_total.rs @@ -0,0 +1,147 @@ +/// What the tab knows of how many rows the table has under its filter. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub(crate) enum RowTotal { + #[default] + Unknown, + /// The server's estimate, which the label says is about that many. + Estimated(u64), + /// A count is running. `estimate` shows meanwhile. + Counting { + estimate: Option, + }, + Exact(u64), + /// The count failed, and `reason` says why. + Unavailable { + estimate: Option, + reason: String, + }, +} + +impl RowTotal { + /// "Rows 1 – 1,000 of 5,000", "of about 5,000,000", or no total. + pub(super) fn label_with(&self, start: u64, end: u64, format: impl Fn(u64) -> String) -> String { + let (start, end) = (format(start), format(end)); + match self { + Self::Exact(total) => crate::i18n::gettext_f( + "Rows {start} – {end} of {total}", + &[("start", &start), ("end", &end), ("total", &format(*total))], + ), + Self::Estimated(estimate) + | Self::Counting { + estimate: Some(estimate), + } + | Self::Unavailable { + estimate: Some(estimate), + .. + } => crate::i18n::gettext_f( + "Rows {start} – {end} of about {total}", + &[("start", &start), ("end", &end), ("total", &format(*estimate))], + ), + Self::Unknown | Self::Counting { estimate: None } | Self::Unavailable { estimate: None, .. } => { + crate::i18n::gettext_f("Rows {start} – {end}", &[("start", &start), ("end", &end)]) + } + } + } + + pub(crate) fn exact(&self) -> Option { + match self { + Self::Exact(total) => Some(*total), + _ => None, + } + } + + /// The estimate a count started from, or the total it replaces. + pub(crate) fn estimate(&self) -> Option { + match self { + Self::Exact(total) | Self::Estimated(total) => Some(*total), + Self::Counting { estimate } | Self::Unavailable { estimate, .. } => *estimate, + Self::Unknown => None, + } + } + + /// A committed save moved the total by what it inserted and deleted. + /// Under a filter, an inserted or updated row may not match it, so + /// an exact total becomes an estimate there. + pub(crate) fn adjust_after_save(&mut self, inserted: u64, deleted: u64, filtered: bool) { + let moved = |total: u64| total.saturating_add(inserted).saturating_sub(deleted); + *self = match std::mem::take(self) { + Self::Exact(total) if !filtered => Self::Exact(moved(total)), + Self::Exact(total) | Self::Estimated(total) => Self::Estimated(moved(total)), + Self::Counting { estimate } => Self::Counting { + estimate: estimate.map(moved), + }, + Self::Unavailable { estimate, reason } => Self::Unavailable { + estimate: estimate.map(moved), + reason, + }, + Self::Unknown => Self::Unknown, + }; + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ui::format::Thousands; + + fn label(total: &RowTotal, start: u64, end: u64) -> String { + let english = Thousands::for_posix_locale("en_US.UTF-8"); + total.label_with(start, end, |n| english.format(n)) + } + + #[test] + fn row_total_labels_use_thousands_and_about() { + assert_eq!(label(&RowTotal::Exact(5_000), 1, 1_000), "Rows 1 – 1,000 of 5,000"); + assert_eq!( + label(&RowTotal::Estimated(5_000_000), 1, 1_000), + "Rows 1 – 1,000 of about 5,000,000" + ); + assert_eq!(label(&RowTotal::Unknown, 1, 1_000), "Rows 1 – 1,000"); + assert_eq!( + label(&RowTotal::Counting { estimate: Some(40) }, 1, 40), + "Rows 1 – 40 of about 40" + ); + assert_eq!(label(&RowTotal::Counting { estimate: None }, 1, 40), "Rows 1 – 40"); + assert_eq!( + label( + &RowTotal::Unavailable { + estimate: None, + reason: "Stopped.".to_owned() + }, + 1_001, + 2_000 + ), + "Rows 1,001 – 2,000" + ); + } + + #[test] + fn adjust_after_save_keeps_exact_only_when_unfiltered() { + let mut unfiltered = RowTotal::Exact(100); + unfiltered.adjust_after_save(3, 1, false); + assert_eq!(unfiltered, RowTotal::Exact(102)); + + let mut filtered = RowTotal::Exact(100); + filtered.adjust_after_save(3, 1, true); + assert_eq!(filtered, RowTotal::Estimated(102)); + + let mut estimated = RowTotal::Estimated(100); + estimated.adjust_after_save(0, 10, false); + assert_eq!(estimated, RowTotal::Estimated(90)); + + let mut unknown = RowTotal::Unknown; + unknown.adjust_after_save(5, 0, false); + assert_eq!(unknown, RowTotal::Unknown); + } + + #[test] + fn row_total_adjust_is_saturating() { + let mut total = RowTotal::Exact(2); + total.adjust_after_save(0, 5, false); + assert_eq!(total, RowTotal::Exact(0)); + + let mut total = RowTotal::Exact(u64::MAX); + total.adjust_after_save(1, 0, false); + assert_eq!(total, RowTotal::Exact(u64::MAX)); + } +} diff --git a/linux/crates/app/src/ui/browse_tab/save_handlers.rs b/linux/crates/app/src/ui/browse_tab/save_handlers.rs new file mode 100644 index 0000000000..c534e060a8 --- /dev/null +++ b/linux/crates/app/src/ui/browse_tab/save_handlers.rs @@ -0,0 +1,158 @@ +use gtk4 as gtk; +use gtk4::glib; +use gtk4::prelude::*; + +use super::{BrowseModel, BrowseRequestKind, BrowseTabOutput, Effects, SaveInput}; +use crate::services::change_origins::ChangeOrigins; +use crate::services::change_tracker::{RowKey, StatementSource}; +use crate::ui::discard_alert::confirm_discard; + +impl BrowseModel { + pub(super) fn on_save(&mut self, input: SaveInput, fx: &mut Effects) { + match input { + SaveInput::CommitSave => fx.output(BrowseTabOutput::SaveRequested), + SaveInput::SaveStarted => self.show_saving(true), + SaveInput::StopSaving => fx.output(BrowseTabOutput::StopSaveRequested), + SaveInput::ConfirmDiscard => self.confirm_discard(), + SaveInput::DiscardAll => { + let delta = self.tracker.discard(); + self.apply_tracker_delta(&delta, fx); + self.fetch(BrowseRequestKind::Page, fx); + } + SaveInput::DiscardAndRefresh => { + let delta = self.tracker.discard(); + self.apply_tracker_delta(&delta, fx); + self.reload(fx); + } + SaveInput::SaveCommitted(origins) => self.save_committed(&origins, fx), + SaveInput::SaveFailed(message) => { + self.show_saving(false); + fx.output(BrowseTabOutput::ShowSelectionAlert { + title: crate::i18n::gettext("Save Failed"), + body: message, + }); + } + SaveInput::SaveOutcomeUnknown(message) => { + self.show_saving(false); + fx.output(BrowseTabOutput::ShowSelectionAlert { + title: crate::i18n::gettext("Save Result Unknown"), + body: message, + }); + } + SaveInput::FlashErrorRow(source) => self.flash_error_row(&source), + SaveInput::ErrorFlashEnded(key) => { + if let Some(row) = self.row_index.get(&key) { + row.bump_revision(); + } + } + } + } + + fn confirm_discard(&self) { + let pending = self.tracker.pending_count(); + if pending == 0 { + return; + } + let tab = self.this.clone(); + confirm_discard(&self.widgets.inner_stack, pending, move || { + if let Some(tab) = tab.upgrade() { + tab.send(SaveInput::DiscardAll); + } + }); + } + + fn save_committed(&mut self, origins: &ChangeOrigins, fx: &mut Effects) { + self.show_saving(false); + // The tracker already let go of what the save wrote. The + // reload replaces every row; dropping the saved drafts + // from the store first would blank them until it lands. + self.sync_pending_chrome(self.tracker.pending_count(), fx); + self.capture_selection(); + // The save says how the total moved, so nothing is + // counted again. A delete that emptied the last page + // leaves an exact total that moves the tab back a page. + self.total + .adjust_after_save(origins.inserts, origins.deletes, !self.current_filter.is_empty()); + self.clamp_to_total(fx); + self.sync_total_chrome(); + self.fetch(BrowseRequestKind::Page, fx); + } + + /// While a save runs, Save and Discard are off, so a double click + /// cannot send two, and Stop Saving takes their place. + fn show_saving(&self, saving: bool) { + self.saving.set(saving); + self.actions.follow_tracker(&self.tracker, saving); + self.widgets.pending_bar.show_saving(saving); + } + + pub(super) fn refresh_pending_bar(&self, count: usize) { + // The footer slides away once nothing is left to save, and the + // grid holds what the user was working on. + if count == 0 + && let Some(grid) = self.current_column_view.as_ref() + { + crate::ui::a11y::pass_focus(&self.widgets.pending_bar, grid); + } + self.widgets.pending_bar.show_pending(count); + self.refresh_banner_visibility(); + } + + /// The footer and the tab's dirty flag, which App turns into the + /// title bullet. App hears only real transitions. + pub(super) fn sync_pending_chrome(&self, count: usize, fx: &mut Effects) { + self.refresh_pending_bar(count); + self.actions.follow_tracker(&self.tracker, self.saving.get()); + let dirty = count > 0; + if dirty != self.was_dirty.replace(dirty) { + fx.output(BrowseTabOutput::DirtyChanged(dirty)); + } + } + + /// Scroll to the row behind a failed statement and flash it. A row + /// paged, sorted or filtered away is not found, and the alert alone + /// tells the user which statement failed. + fn flash_error_row(&mut self, source: &StatementSource) { + let key = match source { + StatementSource::Insert { draft_id } => RowKey::Draft(*draft_id), + StatementSource::Update { row_key } | StatementSource::Delete { row_key } => row_key.clone(), + }; + let Some(store) = self.list_store() else { + return; + }; + let Some(row) = self.row_index.get(&key) else { + return; + }; + let Some(position) = store.find(&row) else { + return; + }; + if let Some(selection) = self.current_selection.as_ref() { + selection.select_item(position, true); + } + if let Some(cv) = self.current_column_view.as_ref() { + cv.scroll_to( + position, + None, + gtk::ListScrollFlags::FOCUS | gtk::ListScrollFlags::SELECT, + None, + ); + } + let flash = self.tracker.flash_error_row(key.clone()); + row.bump_revision(); + if let Some(replaced) = flash.replaced.and_then(|replaced| self.row_index.get(&replaced)) { + replaced.bump_revision(); + } + let generation = flash.generation; + let tracker = self.tracker.clone(); + let tab = self.this.clone(); + // Matches the length of the flash animation in the stylesheet. The + // row is looked up again then, since a reload may have replaced it. + glib::timeout_add_local_once(std::time::Duration::from_millis(1800), move || { + if tracker.clear_error_row_if(generation) + && let Some(tab) = tab.upgrade() + { + tab.send(SaveInput::ErrorFlashEnded(key)); + } + }); + } +} diff --git a/linux/crates/app/src/ui/browse_tab/save_input.rs b/linux/crates/app/src/ui/browse_tab/save_input.rs new file mode 100644 index 0000000000..e7b11dbd71 --- /dev/null +++ b/linux/crates/app/src/ui/browse_tab/save_input.rs @@ -0,0 +1,28 @@ +use crate::services::change_origins::ChangeOrigins; +use crate::services::change_tracker::{RowKey, StatementSource}; + +/// What changes where the tab's save stands: Save, Discard, Stop Saving +/// and how the window's save of the tab went. +#[derive(Debug)] +pub(crate) enum SaveInput { + CommitSave, + /// The window started saving the tab's changes. + SaveStarted, + StopSaving, + /// Discard clicked: ask before the changes go. + ConfirmDiscard, + DiscardAll, + DiscardAndRefresh, + /// The save landed, adding and removing these rows. Edits made + /// while it ran stay pending. + SaveCommitted(ChangeOrigins), + /// The pending changes stay, so the user can fix the cause and + /// save again. + SaveFailed(String), + /// The save may have landed. The changes stay, and the tracker + /// refuses to save them again until the table is read again. + SaveOutcomeUnknown(String), + FlashErrorRow(StatementSource), + /// The flash on a failed statement's row ran its course. + ErrorFlashEnded(RowKey), +} diff --git a/linux/crates/app/src/ui/browse_tab/selection_handlers.rs b/linux/crates/app/src/ui/browse_tab/selection_handlers.rs new file mode 100644 index 0000000000..f6880e29e2 --- /dev/null +++ b/linux/crates/app/src/ui/browse_tab/selection_handlers.rs @@ -0,0 +1,134 @@ +use gtk4 as gtk; +use gtk4::gio; +use gtk4::prelude::*; + +use super::{BrowseModel, SelectionInput, SelectionSnapshot}; +use crate::ui::grid::{DeleteAvailability, RowObject, delete_availability}; + +impl BrowseModel { + pub(super) fn on_selection(&self, input: SelectionInput) { + match input { + SelectionInput::ClearSelection => { + let Some(sel) = self.current_selection.as_ref() else { + return; + }; + // Only a true multi-row selection; clearing a single + // focused row strands the focus indicator. + if sel.selection().size() < 2 { + return; + } + sel.unselect_all(); + } + } + } + + pub(super) fn selected_rows(&self) -> Vec { + self.current_selection.as_ref().map(selected_rows).unwrap_or_default() + } + + /// GtkMultiSelection emits no selection-changed when its rows are + /// spliced, so a page that drops the selected rows comes through here. + pub(super) fn sync_selection(&self) { + let selected = self + .current_selection + .as_ref() + .map_or(0, |sel| u32::try_from(sel.selection().size()).unwrap_or(u32::MAX)); + update_selection_chrome(&self.widgets.selection_label, selected); + self.sync_delete_action(); + } + + /// The rows on screen can change under the selection without it + /// changing, so this runs on every delta, page and load state too. + /// Rows behind an error page count as none. + pub(super) fn sync_delete_action(&self) { + let rows = match self.shown_page() { + Some(_) => self.selected_rows(), + None => Vec::new(), + }; + self.actions + .follow_selection(u32::try_from(rows.len()).unwrap_or(u32::MAX)); + show_delete_availability( + &self.actions.delete_rows, + &self.widgets.delete_button, + &rows, + self.read_only, + ); + } + + /// Keep the selection and the focused cell by row key before a sort, + /// save or refresh reads the page again. + pub(super) fn capture_selection(&mut self) { + // A capture still pending (sort and save in quick succession) + // is kept: the user was where the chain started. + if self.pending_restore.is_some() { + return; + } + let (Some(selection), Some(column_view)) = (self.current_selection.as_ref(), self.current_column_view.as_ref()) + else { + return; + }; + let snapshot = SelectionSnapshot::capture(selection, column_view); + if !snapshot.is_empty() { + self.pending_restore = Some(snapshot); + } + } + + /// Put back the selection captured before the page was read again, + /// on the rows that are still on it. The capture is used up either + /// way, so it cannot steer a later read. + pub(super) fn restore_selection(&mut self) { + let Some(snapshot) = self.pending_restore.take() else { + return; + }; + let (Some(store), Some(selection), Some(column_view)) = ( + self.list_store(), + self.current_selection.clone(), + self.current_column_view.clone(), + ) else { + return; + }; + snapshot.restore(&store, &selection, &column_view, &self.view_columns); + } +} + +pub(super) fn selected_rows(selection: >k::MultiSelection) -> Vec { + crate::ui::grid::selected_positions(selection) + .into_iter() + .filter_map(|position| selection.item(position).and_downcast::()) + .collect() +} + +/// The Delete key and button follow the action; the button's tooltip +/// says why it is off. +pub(super) fn show_delete_availability( + action: &gio::SimpleAction, + button: >k::Button, + rows: &[RowObject], + read_only: bool, +) { + let availability = delete_availability(rows.iter().map(RowObject::id), read_only); + action.set_enabled(availability == DeleteAvailability::Available); + let explanation = match availability { + DeleteAvailability::Unavailable(block) => block.explanation(), + DeleteAvailability::Available => None, + }; + let tooltip = explanation.unwrap_or_else(|| crate::i18n::gettext("Delete Selected Rows")); + button.set_tooltip_text(Some(&tooltip)); +} + +/// Update the selection-count badge in response to a `MultiSelection` +/// change. Hidden for 0–1 rows; shows "{n} selected · press Delete to +/// remove" once the user multi-selects, so the affordance stays +/// discoverable without a toolbar Delete button. +pub(super) fn update_selection_chrome(label: >k::Label, n: u32) { + if n <= 1 { + label.set_visible(false); + return; + } + let count = n.to_string(); + label.set_label(&crate::i18n::gettext_f( + "{n} selected · press Delete to remove", + &[("n", &count)], + )); + label.set_visible(true); +} diff --git a/linux/crates/app/src/ui/browse_tab/selection_input.rs b/linux/crates/app/src/ui/browse_tab/selection_input.rs new file mode 100644 index 0000000000..49a2ff9c03 --- /dev/null +++ b/linux/crates/app/src/ui/browse_tab/selection_input.rs @@ -0,0 +1,7 @@ +/// What changes the rows selected in the grid. +#[derive(Debug)] +pub(crate) enum SelectionInput { + /// Esc. A single selected row stays selected: clearing it would + /// strand the keyboard focus indicator. + ClearSelection, +} diff --git a/linux/crates/app/src/ui/browse_tab/selection_snapshot.rs b/linux/crates/app/src/ui/browse_tab/selection_snapshot.rs new file mode 100644 index 0000000000..83b6d21667 --- /dev/null +++ b/linux/crates/app/src/ui/browse_tab/selection_snapshot.rs @@ -0,0 +1,155 @@ +use std::collections::{HashMap, HashSet}; + +use gtk4 as gtk; +use gtk4::gio; +use gtk4::prelude::*; + +use crate::services::change_tracker::RowKey; +use crate::ui::grid::{GridCell, RowObject, selected_positions}; + +/// The selected rows and the focused cell, by row key, so a page read +/// again can put them back wherever the rows land in it. +#[derive(Debug, Clone, Default, PartialEq)] +pub(crate) struct SelectionSnapshot { + keys: Vec, + focus: Option<(RowKey, usize)>, +} + +impl SelectionSnapshot { + pub(crate) fn capture(selection: >k::MultiSelection, column_view: >k::ColumnView) -> Self { + let keys = selected_positions(selection) + .into_iter() + .filter_map(|position| selection.item(position).and_downcast::()) + .filter_map(|row| row.id().tracker_key()) + .collect(); + let focus = column_view + .root() + .and_downcast::() + .and_then(|window| gtk::prelude::GtkWindowExt::focus(&window)) + .filter(|focus| focus.is_ancestor(column_view)) + .and_then(|focus| GridCell::from_focus(&focus)) + .and_then(|cell| Some((cell.row()?.id().tracker_key()?, cell.column()))); + Self { keys, focus } + } + + pub(crate) fn is_empty(&self) -> bool { + self.keys.is_empty() && self.focus.is_none() + } + + /// Rows no longer on the page stay unselected. `columns` are the + /// view's columns in the order of the row's cells, which a drag in + /// the header does not change. + pub(crate) fn restore( + &self, + store: &gio::ListStore, + selection: >k::MultiSelection, + column_view: >k::ColumnView, + columns: &[gtk::ColumnViewColumn], + ) { + let positions = self.positions_in(store); + let selected = gtk::Bitset::new_empty(); + for key in &self.keys { + if let Some(position) = positions.get(key) { + selected.add(*position); + } + } + let mask = gtk::Bitset::new_range(0, selection.n_items()); + selection.set_selection(&selected, &mask); + let focused = self + .focus + .as_ref() + .and_then(|(key, column)| Some((*positions.get(key)?, *column))); + match focused { + Some((position, column)) => { + column_view.scroll_to(position, columns.get(column), gtk::ListScrollFlags::FOCUS, None); + } + // A sort can move the selection anywhere on the page. + None if !selected.is_empty() => { + column_view.scroll_to(selected.minimum(), None, gtk::ListScrollFlags::NONE, None); + } + None => {} + } + } + + /// Where each captured row sits in `store` now, in one pass over it: + /// a lookup per row would scan the page once for every row of a + /// Select All. + fn positions_in(&self, store: &gio::ListStore) -> HashMap { + let wanted: HashSet<&RowKey> = self + .keys + .iter() + .chain(self.focus.as_ref().map(|(key, _)| key)) + .collect(); + (0..store.n_items()) + .filter_map(|position| { + let key = store.item(position).and_downcast::()?.id().tracker_key()?; + wanted.contains(&key).then_some((key, position)) + }) + .collect() + } +} + +#[cfg(test)] +mod tests { + use std::time::{Duration, Instant}; + + use gtk4 as gtk; + use gtk4::gio; + use tablepro_core::Value; + + use super::SelectionSnapshot; + use crate::services::change_tracker::RowKey; + use crate::ui::grid::{RowObject, selected_positions}; + + fn key(id: i64) -> RowKey { + RowKey::from_pk_values(&[Value::Int(id)]).expect("a key") + } + + fn page(ids: impl Iterator) -> gio::ListStore { + let store = gio::ListStore::new::(); + let rows: Vec = ids + .map(|id| RowObject::persisted(Some(key(id)), vec![Value::Int(id)].into())) + .collect(); + store.splice(0, 0, &rows); + store + } + + fn restored(snapshot: &SelectionSnapshot, store: &gio::ListStore) -> Vec { + let selection = gtk::MultiSelection::new(Some(store.clone())); + let view = gtk::ColumnView::new(Some(selection.clone())); + snapshot.restore(store, &selection, &view, &[]); + selected_positions(&selection) + } + + #[gtk4::test] + fn the_selection_follows_its_rows_to_where_they_landed() { + let snapshot = SelectionSnapshot { + keys: vec![key(3), key(9), key(1)], + focus: None, + }; + + assert_eq!(restored(&snapshot, &page([9, 5, 3, 7].into_iter())), [0, 2]); + } + + /// Select All on a large page, then a refresh: each row's place used + /// to be a scan of the whole page. + #[gtk4::test] + fn a_whole_large_page_selection_comes_back_in_one_pass() { + let rows = 20_000; + let snapshot = SelectionSnapshot { + keys: (0..rows).map(key).collect(), + focus: Some((key(rows - 1), 0)), + }; + let store = page((0..rows).rev()); + let started = Instant::now(); + + let selected = restored(&snapshot, &store); + + assert_eq!(selected.len(), 20_000); + assert!( + started.elapsed() < Duration::from_secs(2), + "restoring the selection took {:?}", + started.elapsed() + ); + } +} diff --git a/linux/crates/app/src/ui/browse_tab/tests/load.rs b/linux/crates/app/src/ui/browse_tab/tests/load.rs new file mode 100644 index 0000000000..316b567e6d --- /dev/null +++ b/linux/crates/app/src/ui/browse_tab/tests/load.rs @@ -0,0 +1,575 @@ +use std::cell::RefCell; +use std::rc::Rc; +use std::time::Duration; + +use gtk4::prelude::*; +use gtk4::subclass::prelude::ObjectSubclassIsExt; +use libadwaita as adw; +use tablepro_core::{ColumnInfo, DriverError, LossPhase, QueryResult, Value}; +use tablepro_test_support::{CallCounters, FakeReply}; + +use super::super::{ClipboardInput, PagingInput}; +use super::{ + Harness, Shown, WAIT, column, column_view, delete_action, full_page, header_titles, key, not_null_name, rows, + serving_table, text, toasts, +}; +use crate::test_support::{after_next_frame, drain_main_context, wait_until}; +use crate::ui::grid::GridCell; + +fn banner(harness: &Harness) -> adw::Banner { + harness.tab().imp().stale_banner.get() +} + +/// Every value the tab's loading signal carried, in order. +fn loading_log(harness: &Harness) -> Rc>> { + let log = Rc::new(RefCell::new(Vec::new())); + let sink = log.clone(); + harness + .tab() + .connect_loading_changed(move |_, loading| sink.borrow_mut().push(loading)); + log +} + +/// A page whose rows are keyed `ids`, each named after its key. +fn keyed(ids: &[i64]) -> QueryResult { + let names: Vec = ids.iter().map(|id| format!("row {id}")).collect(); + let names: Vec<&str> = names.iter().map(String::as_str).collect(); + let mut page = rows(&names); + for (row, id) in page.rows.iter_mut().zip(ids) { + row[0] = Value::Int(*id); + } + page +} + +#[gtk4::test] +fn the_grid_is_named_after_its_table() { + let mut script = serving_table(); + script.query_bound.always(FakeReply::ok(rows(&["Ada"]))); + let harness = Harness::open(script, []); + wait_until(WAIT, || harness.shown().len() == 1).expect("the first page"); + + assert!(gtk4::test_accessible_has_property( + &column_view(harness.tab()), + gtk4::AccessibleProperty::Label + )); +} + +#[gtk4::test] +fn refresh_keeps_grid_visible_and_sets_tab_loading() { + let mut script = serving_table(); + script + .query_bound + .once(FakeReply::ok(rows(&["before"]))) + .always(FakeReply::Late { + after: Duration::from_millis(300), + outcome: Ok(rows(&["after"])), + }); + let harness = Harness::open(script, []); + wait_until(WAIT, || harness.shown() == text(&["before"])).expect("the first page"); + let loading = loading_log(&harness); + + harness.tab().send(PagingInput::Refresh); + + assert_eq!(harness.visible_page().as_deref(), Some("grid")); + assert_eq!(harness.shown(), text(&["before"]), "the refresh blanked the rows"); + assert!(harness.tab().is_loading()); + wait_until(WAIT, || harness.shown() == text(&["after"])).expect("the refreshed page"); + assert!(!harness.tab().is_loading()); + assert_eq!(*loading.borrow(), [true, false]); +} + +#[gtk4::test] +fn failed_refresh_keeps_rows_and_reveals_stale_banner() { + let mut script = serving_table(); + script + .query_bound + .once(FakeReply::ok(rows(&["kept"]))) + .always(FakeReply::err(DriverError::ConnectionLost { + during: LossPhase::Statement, + })); + let harness = Harness::open(script, []); + wait_until(WAIT, || harness.shown() == text(&["kept"])).expect("the first page"); + let counters = harness.connection.counters(); + + harness.tab().send(PagingInput::Refresh); + wait_until(WAIT, || banner(&harness).is_revealed()).expect("the failed refresh was not shown"); + + assert_eq!(CallCounters::get(&counters.query_bound), 2); + assert_eq!(harness.shown(), text(&["kept"]), "the failed refresh dropped the rows"); + assert_eq!(harness.visible_page().as_deref(), Some("grid")); + assert!(!harness.tab().is_loading()); + let tooltip = banner(&harness).tooltip_text().map(|text| text.to_string()); + assert_eq!(tooltip.as_deref(), Some("The connection was closed. Try reconnecting.")); + // The reason shows only on hover, away from the rows the focus is in. + assert_eq!( + crate::ui::a11y::announced_in(&banner(&harness)), + [( + format!( + "{} The connection was closed. Try reconnecting.", + banner(&harness).title() + ), + gtk4::AccessibleAnnouncementPriority::High + )] + ); +} + +#[gtk4::test] +fn stale_banner_retry_reloads_and_hides_banner() { + let mut script = serving_table(); + script + .query_bound + .once(FakeReply::ok(rows(&["kept"]))) + .once(FakeReply::err(DriverError::server("denied"))) + .always(FakeReply::ok(rows(&["fresh"]))); + let harness = Harness::open(script, []); + wait_until(WAIT, || harness.shown() == text(&["kept"])).expect("the first page"); + harness.tab().send(PagingInput::Refresh); + wait_until(WAIT, || banner(&harness).is_revealed()).expect("the stale banner"); + + banner(&harness).emit_by_name::<()>("button-clicked", &[]); + + wait_until(WAIT, || harness.shown() == text(&["fresh"])).expect("Retry read the page again"); + assert!( + !banner(&harness).is_revealed(), + "the banner outlived the read that landed" + ); +} + +#[gtk4::test] +fn first_load_failure_shows_error_page_with_try_again() { + let mut script = serving_table(); + script + .query_bound + .once(FakeReply::err(DriverError::server("relation is gone"))) + .always(FakeReply::ok(rows(&["back"]))); + let harness = Harness::open(script, []); + + wait_until(WAIT, || harness.visible_page().as_deref() == Some("error")).expect("the error page"); + let imp = harness.tab().imp(); + let description = imp + .error_page + .description() + .map(|text| text.to_string()) + .unwrap_or_default(); + assert!(description.contains("relation <users> is gone"), "{description}"); + assert!(!banner(&harness).is_revealed()); + assert!(imp.retry_button.has_css_class("pill") && imp.retry_button.has_css_class("suggested-action")); + assert!( + crate::ui::a11y::announced_in(&imp.error_page.get()) + .iter() + .any(|(said, priority)| said.starts_with("Couldn't Load Rows. ") + && said.contains("relation is gone") + && *priority == gtk4::AccessibleAnnouncementPriority::High), + "the error page took the grid's place unsaid" + ); + + imp.retry_button.emit_clicked(); + + wait_until(WAIT, || harness.shown() == text(&["back"])).expect("Try Again read the page"); + assert_eq!(harness.visible_page().as_deref(), Some("grid")); +} + +#[gtk4::test] +fn refresh_restores_multi_row_selection_and_focus_by_key() { + let mut script = serving_table(); + script + .query_bound + .once(FakeReply::ok(keyed(&[0, 1, 2, 3, 4]))) + .always(FakeReply::ok(keyed(&[9, 0, 1, 2, 3, 4]))); + let harness = Harness::open(script, []); + wait_until(WAIT, || harness.shown().len() == 5).expect("the first page"); + let shown = Shown::new(harness.tab()); + let view = column_view(harness.tab()); + let name = crate::ui::grid::data_columns(&view) + .into_iter() + .nth(1) + .expect("the name column"); + view.scroll_to(3, Some(&name), gtk4::ListScrollFlags::FOCUS, None); + view.grab_focus(); + let focused_cell = || gtk4::prelude::GtkWindowExt::focus(&shown.0).and_then(|focus| GridCell::from_focus(&focus)); + wait_until(WAIT, || focused_cell().is_some_and(|cell| cell.position() == Some(3))).expect("a cell takes the focus"); + let selection = harness.selection(); + let picked = gtk4::Bitset::new_empty(); + picked.add(1); + picked.add(3); + selection.set_selection(&picked, >k4::Bitset::new_range(0, selection.n_items())); + + harness.tab().send(PagingInput::Refresh); + wait_until(WAIT, || harness.shown().len() == 6).expect("the refreshed page"); + after_next_frame(&shown.0); + + let selected: Vec<_> = crate::ui::grid::selected_positions(&harness.selection()) + .into_iter() + .map(|position| harness.grid_row(position).key().cloned()) + .collect(); + assert_eq!(selected, [Some(key(1)), Some(key(3))]); + wait_until(WAIT, || { + focused_cell() + .is_some_and(|cell| cell.column() == 1 && cell.row().and_then(|row| row.key().cloned()) == Some(key(3))) + }) + .expect("the focus did not come back to the cell it was in"); +} + +#[gtk4::test] +fn a_selection_is_not_put_back_after_a_failed_refresh() { + let mut script = serving_table(); + script + .query_bound + .once(FakeReply::ok(keyed(&[0, 1, 2]))) + .once(FakeReply::err(DriverError::server("denied"))) + .always(FakeReply::ok(keyed(&[0, 1, 2]))); + let harness = Harness::open(script, []); + wait_until(WAIT, || harness.shown().len() == 3).expect("the first page"); + let selection = harness.selection(); + selection.select_item(0, true); + harness.tab().send(PagingInput::Refresh); + wait_until(WAIT, || banner(&harness).is_revealed()).expect("the stale banner"); + selection.select_item(2, true); + + harness.tab().send(PagingInput::Refresh); + wait_until(WAIT, || !banner(&harness).is_revealed()).expect("the second refresh"); + drain_main_context(); + + let selected: Vec<_> = crate::ui::grid::selected_positions(&harness.selection()) + .into_iter() + .map(|position| harness.grid_row(position).key().cloned()) + .collect(); + assert_eq!( + selected, + [Some(key(2))], + "the selection from before the failed refresh came back" + ); +} + +/// The loading page has nothing to focus, and the error page that +/// follows it hands the focus the rows had to Try Again. +#[gtk4::test] +fn a_failed_page_turn_hands_the_focus_to_try_again() { + let mut script = super::paging(full_page(0), rows(&["unused"])); + script.query_bound = Default::default(); + script + .query_bound + .once(FakeReply::ok(full_page(0))) + .always(FakeReply::err(DriverError::server("denied"))); + let harness = Harness::open(script, []); + wait_until(WAIT, || harness.shown().len() == 100).expect("the first page"); + let shown = Shown::new(harness.tab()); + let view = column_view(harness.tab()); + view.grab_focus(); + let focus = || gtk4::prelude::GtkWindowExt::focus(&shown.0); + wait_until(WAIT, || focus().is_some_and(|focus| focus.is_ancestor(&view))).expect("the grid takes the focus"); + + harness.tab().send(PagingInput::NextPage); + + wait_until(WAIT, || harness.visible_page().as_deref() == Some("error")).expect("the error page"); + let retry = harness.tab().imp().retry_button.get(); + assert_eq!(focus().as_ref(), Some(retry.upcast_ref())); +} + +fn error_text(harness: &Harness) -> String { + harness + .tab() + .imp() + .error_page + .description() + .map(|text| text.to_string()) + .unwrap_or_default() +} + +#[gtk4::test] +fn columns_failure_shows_error_page_not_spinner() { + let mut script = serving_table(); + script.fetch_columns = Default::default(); + script + .fetch_columns + .always(FakeReply::err(DriverError::server("permission denied"))); + let harness = Harness::open(script, []); + + wait_until(WAIT, || harness.visible_page().as_deref() == Some("error")).expect("the tab stayed on its spinner"); + + assert!( + error_text(&harness).contains("permission denied"), + "{}", + error_text(&harness) + ); + assert!(!harness.tab().is_loading()); + assert_eq!(CallCounters::get(&harness.connection.counters().query_bound), 0); +} + +#[gtk4::test] +fn empty_column_list_is_a_load_failure() { + let mut script = serving_table(); + script.fetch_columns = Default::default(); + script.fetch_columns.always(FakeReply::ok(Vec::::new())); + let harness = Harness::open(script, []); + + wait_until(WAIT, || harness.visible_page().as_deref() == Some("error")).expect("the tab stayed on its spinner"); + + assert_eq!( + error_text(&harness), + gtk4::glib::markup_escape_text(&crate::ui::tab_load::LoadFailure::NoColumns.message()).as_str() + ); + assert_eq!(CallCounters::get(&harness.connection.counters().query_bound), 0); +} + +#[gtk4::test] +fn refresh_after_columns_failure_refetches_columns() { + let mut script = serving_table(); + script.fetch_columns = Default::default(); + script + .fetch_columns + .once(FakeReply::err(DriverError::server("busy"))) + .always(FakeReply::ok(vec![column("id", true), column("name", false)])); + script.query_bound.always(FakeReply::ok(rows(&["back"]))); + let harness = Harness::open(script, []); + wait_until(WAIT, || harness.visible_page().as_deref() == Some("error")).expect("the error page"); + + harness.tab().imp().retry_button.emit_clicked(); + + wait_until(WAIT, || harness.shown() == text(&["back"])).expect("the grid after the columns were read again"); + assert_eq!(CallCounters::get(&harness.connection.counters().fetch_columns), 2); + assert_eq!(harness.visible_page().as_deref(), Some("grid")); +} + +#[gtk4::test] +fn refresh_failure_of_columns_keeps_cached_columns() { + let mut script = serving_table(); + script.fetch_columns = Default::default(); + script + .fetch_columns + .once(FakeReply::ok(vec![column("id", true), column("name", false)])) + .always(FakeReply::err(DriverError::server("busy"))); + script.query_bound.always(FakeReply::ok(rows(&["kept"]))); + let harness = Harness::open(script, []); + wait_until(WAIT, || harness.shown() == text(&["kept"])).expect("the first page"); + + harness.tab().send(PagingInput::Refresh); + wait_until(WAIT, || banner(&harness).is_revealed()).expect("the failed refresh was not shown"); + + assert_eq!(header_titles(harness.tab()), ["id", "name"]); + assert_eq!(harness.tab().columns(), [column("id", true), column("name", false)]); + assert_eq!(harness.shown(), text(&["kept"])); + assert_eq!(CallCounters::get(&harness.connection.counters().query_bound), 1); +} + +#[gtk4::test] +fn page_column_mismatch_refetches_columns_once() { + let renamed = [column("id", true), column("full_name", false)]; + let mut script = serving_table(); + script.query_bound.always(FakeReply::ok(QueryResult::new( + renamed.iter().map(ColumnInfo::result_column).collect::>(), + vec![vec![Value::Int(0), Value::Text("Ada".to_owned())]], + ))); + let harness = Harness::open(script, []); + + wait_until(WAIT, || harness.visible_page().as_deref() == Some("error")).expect("the mismatched page was shown"); + + let counters = harness.connection.counters(); + assert_eq!(CallCounters::get(&counters.fetch_columns), 2); + assert_eq!(CallCounters::get(&counters.query_bound), 2); + assert!( + harness.shown().is_empty(), + "rows read under other columns reached the grid" + ); + assert!( + error_text(&harness).contains("columns changed"), + "{}", + error_text(&harness) + ); +} + +/// The page under the new filter could not be read, so the error page +/// shows. The rows read without the filter are still in the store, +/// and nothing may act on them from behind it. +#[gtk4::test] +fn the_rows_behind_the_error_page_take_no_action() { + let mut script = serving_table(); + script + .query_bound + .when_sql_contains("IS NOT NULL", FakeReply::err(DriverError::server("denied"))) + .always(FakeReply::ok(rows(&["unfiltered"]))); + let harness = Harness::open(script, []); + wait_until(WAIT, || harness.shown() == text(&["unfiltered"])).expect("the first page"); + harness.selection().select_item(0, true); + assert!(delete_action(harness.tab()).is_enabled()); + let exported = Rc::new(RefCell::new(0)); + let sink = exported.clone(); + harness + .tab() + .connect_export_results(move |_, _| *sink.borrow_mut() += 1); + let toasts = toasts(harness.tab()); + + harness.tab().send(PagingInput::FilterApplied(not_null_name())); + wait_until(WAIT, || harness.visible_page().as_deref() == Some("error")).expect("the error page"); + harness.tab().send(ClipboardInput::Export(None)); + + assert_eq!(*exported.borrow(), 0, "Export wrote rows the filter does not match"); + assert!( + !copy_rows_action(harness.tab()).is_enabled(), + "Copy stayed on for rows behind the error page" + ); + assert_eq!(*toasts.borrow(), ["Nothing to export"]); + assert!( + !delete_action(harness.tab()).is_enabled(), + "Delete stayed on for rows behind the error page" + ); + assert_eq!(harness.tab().imp().pager_bar.imp().paginator_label.label(), ""); +} + +/// Next moved the pager before its read failed, so the way back goes +/// from where the pager is, and the key for it with the button. +#[gtk4::test] +fn a_failed_next_page_leaves_the_way_back_open() { + let mut script = serving_table(); + // A count below the second page would clamp the pager back to the + // first before the failed read lands. + script.count_rows.always(FakeReply::ok(1_000)); + script + .query_bound + .once(FakeReply::ok(full_page(0))) + .once(FakeReply::err(DriverError::server("denied"))) + .always(FakeReply::ok(full_page(0))); + let harness = Harness::open(script, []); + wait_until(WAIT, || harness.shown().len() == 100).expect("the first page"); + let imp = harness.tab().imp().pager_bar.imp(); + assert!(!imp.prev_button.is_sensitive()); + + harness.tab().send(PagingInput::NextPage); + wait_until(WAIT, || harness.visible_page().as_deref() == Some("error")).expect("the error page"); + + assert_eq!(harness.tab().current_offset(), 100); + assert!(imp.first_button.is_sensitive(), "First stayed off past the first page"); + assert!( + imp.prev_button.is_sensitive(), + "Previous stayed off past the first page" + ); + assert!( + !imp.next_button.is_sensitive(), + "Next stayed on past a page that was not read" + ); + harness + .tab() + .activate_action("browse.previous-page", None) + .expect("the action"); + wait_until(WAIT, || harness.visible_page().as_deref() == Some("grid")).expect("the first page again"); + assert_eq!(harness.tab().current_offset(), 0); + assert!(!imp.prev_button.is_sensitive()); + assert!(imp.next_button.is_sensitive()); +} + +fn renamed_page() -> QueryResult { + let renamed = [column("id", true), column("full_name", false)]; + QueryResult::new( + renamed.iter().map(ColumnInfo::result_column).collect::>(), + vec![vec![Value::Int(0), Value::Text("Ada".to_owned())]], + ) +} + +/// The columns read again for a page that came back with others failed. +/// Try Again starts over, so a page that comes back with other columns +/// then gets its own read of them. +#[gtk4::test] +fn try_again_after_a_failed_recheck_reads_the_columns_once_more() { + let mut script = serving_table(); + script.fetch_columns = Default::default(); + script + .fetch_columns + .once(FakeReply::ok(vec![column("id", true), column("name", false)])) + .once(FakeReply::err(DriverError::server("busy"))) + .always(FakeReply::ok(vec![column("id", true), column("name", false)])); + script + .query_bound + .once(FakeReply::ok(renamed_page())) + .once(FakeReply::ok(renamed_page())) + .always(FakeReply::ok(rows(&["back"]))); + let harness = Harness::open(script, []); + wait_until(WAIT, || error_text(&harness).contains("busy")).expect("the failed read of the columns"); + + harness.tab().imp().retry_button.emit_clicked(); + + wait_until(WAIT, || harness.shown() == text(&["back"])).expect("the page after the columns were read again"); + assert_eq!(CallCounters::get(&harness.connection.counters().fetch_columns), 4); +} + +/// A refresh whose columns failed asked for no page and no estimate. A +/// later read of the columns, for a page that came back with others, +/// must not start them. +#[gtk4::test] +fn a_failed_column_read_leaves_no_read_for_a_later_one_to_start() { + let mut script = serving_table(); + script.fetch_columns = Default::default(); + script + .fetch_columns + .once(FakeReply::ok(vec![column("id", true), column("name", false)])) + .once(FakeReply::err(DriverError::server("busy"))) + .always(FakeReply::ok(vec![column("id", true), column("name", false)])); + script + .query_bound + .once(FakeReply::ok(rows(&["first"]))) + .once(FakeReply::ok(renamed_page())) + .always(FakeReply::ok(rows(&["back"]))); + let harness = Harness::open(script, []); + let counters = harness.connection.counters(); + wait_until(WAIT, || harness.shown() == text(&["first"])).expect("the first page"); + wait_until(WAIT, || CallCounters::get(&counters.count_rows) == 1).expect("the count"); + harness.tab().send(PagingInput::Refresh); + wait_until(WAIT, || banner(&harness).is_revealed()).expect("the failed refresh"); + + harness.tab().send(PagingInput::SortChanged { + col_idx: 0, + ascending: false, + }); + wait_until(WAIT, || harness.shown() == text(&["back"])).expect("the page after the columns were read again"); + std::thread::sleep(Duration::from_millis(200)); + drain_main_context(); + + assert_eq!(CallCounters::get(&counters.fetch_columns), 3); + assert_eq!( + CallCounters::get(&counters.table_estimate), + 1, + "the failed refresh's estimate ran under a later read of the columns" + ); +} + +fn copy_rows_action(tab: &super::super::BrowseTab) -> gtk4::gio::SimpleAction { + use gtk4::subclass::prelude::ObjectSubclassIsExt; + tab.imp() + .model + .borrow() + .as_ref() + .map(|model| model.actions.copy_rows.clone()) + .expect("the tab has a model") +} + +/// The status page reads its description as markup, where the `&` +/// would leave it empty. +#[gtk4::test] +fn the_loading_page_names_a_table_with_an_ampersand() { + libadwaita::init().expect("libadwaita initialises under the test backend"); + crate::register_test_resources(); + let runtime = crate::test_support::test_runtime(); + let mut script = serving_table(); + script.query_bound.always(FakeReply::Late { + after: Duration::from_secs(5), + outcome: Ok(rows(&["late"])), + }); + let fake = crate::test_support::FakeSession::start(script, [], runtime.tasks()); + let stores = super::TabStores::new(); + let tab = super::table_tab( + &fake.session, + &runtime, + &stores, + "R&D", + Default::default(), + Default::default(), + false, + ); + let shown = || crate::test_support::status_page_description(&tab.imp().loading_page); + + assert_eq!(shown().as_deref(), Some("Fetching rows from R&D")); + wait_until(WAIT, || tab.is_loading()).expect("the read to start"); + assert_eq!(shown().as_deref(), Some("Fetching rows from R&D")); + + drop(tab); + runtime.shutdown(); +} diff --git a/linux/crates/app/src/ui/browse_tab/tests/mod.rs b/linux/crates/app/src/ui/browse_tab/tests/mod.rs new file mode 100644 index 0000000000..432871a5e4 --- /dev/null +++ b/linux/crates/app/src/ui/browse_tab/tests/mod.rs @@ -0,0 +1,3166 @@ +mod load; +mod total; + +use std::sync::Arc; +use std::time::Duration; + +use gtk4::glib; +use gtk4::prelude::*; +use tablepro_core::column::{ColumnDefault, ColumnType}; +use tablepro_core::edit::CellInput; +use tablepro_core::filter::{FilterOp, FilterRule}; +use tablepro_core::meta::{RowCountEstimate, RowIdentity, TableEstimate}; +use tablepro_core::{ColumnInfo, DriverError, FilterSet, LossPhase, QueryResult, Value}; +use tablepro_session::Session; +use tablepro_session::fakes::FakeEstablish; +use tablepro_test_support::{CallCounters, ConnectionScript, FakeConnection, FakeReply}; + +use super::row_deletion::RowDeletion; +use super::{ + BrowseTab, BrowseTabInit, ClipboardInput, DataInput, EditInput, Effects, PagingInput, RowTotal, SaveInput, + apply_draft_changes, +}; +use crate::services::change_tracker::{RowKey, StatementSource}; +use crate::services::tab_tracker_handle::TabTrackerHandle; +use crate::services::tracker_delta::TrackerDelta; +use crate::services::tracker_edit::TrackerEdit; +use crate::test_support::{FakeSession, MemorySettings, after_next_frame, drain_main_context, wait_until}; +use crate::ui::clipboard::InsertRow; +use crate::ui::grid::{GridCell, GridRowIndex, RowObject}; + +pub(super) const WAIT: Duration = Duration::from_secs(10); + +/// A column carrying the server's spelling and the kind it means. +#[expect( + clippy::disallowed_methods, + reason = "a test fixture stands in for the catalogue, which is where this text comes from in production" +)] +fn col(data_type: &str, nullable: bool) -> ColumnInfo { + let kind = tablepro_core::column::classify_type_name(data_type); + ColumnInfo { + name: "x".into(), + column_type: tablepro_core::column::ColumnType { + name: data_type.to_owned(), + dynamic_storage: tablepro_core::column::has_dynamic_storage(&kind), + kind, + catalog: Some(tablepro_core::column::CatalogType { + declaration: tablepro_core::column::SqlTypeExpr::from_catalog_text(data_type), + cast_target: tablepro_core::column::SqlTypeExpr::from_catalog_text(data_type), + }), + read_form: tablepro_core::column::ReadForm::Native, + }, + nullable, + primary_key: false, + is_auto_increment: false, + is_generated: false, + default: tablepro_core::column::ColumnDefault::None, + comment: None, + } +} + +#[expect( + clippy::disallowed_methods, + reason = "a test fixture stands in for the catalogue, which is where this text comes from in production" +)] +fn col_with_default(data_type: &str, default: &str) -> ColumnInfo { + let mut c = col(data_type, false); + c.default = tablepro_core::column::ColumnDefault::Expression( + tablepro_core::column::SqlExpression::from_catalog_text(default), + ); + c +} + +fn row_at(store: >k4::gio::ListStore, position: u32) -> RowObject { + store.item(position).and_downcast::().expect("a row") +} + +#[test] +fn draft_changes_add_and_remove_draft_rows() { + let store = gtk4::gio::ListStore::new::(); + store.append(&RowObject::persisted(None, vec![Value::Int(1)].into())); + let mut index = GridRowIndex::default(); + let inserted = TrackerDelta { + inserted_drafts: vec![4], + ..TrackerDelta::default() + }; + let removed = TrackerDelta { + removed_drafts: vec![4], + ..TrackerDelta::default() + }; + + apply_draft_changes(&store, &mut index, &inserted); + assert_eq!(row_at(&store, 0).draft_id(), Some(4)); + assert_eq!(index.get(&RowKey::Draft(4)), Some(row_at(&store, 0))); + + apply_draft_changes(&store, &mut index, &removed); + assert_eq!(store.n_items(), 1); + assert_eq!(row_at(&store, 0).draft_id(), None); + assert_eq!(index.get(&RowKey::Draft(4)), None); +} + +pub(super) fn column(name: &str, primary_key: bool) -> ColumnInfo { + ColumnInfo { + name: name.to_owned(), + column_type: ColumnType::untyped_text("text"), + nullable: !primary_key, + primary_key, + is_auto_increment: false, + is_generated: false, + default: ColumnDefault::None, + comment: None, + } +} + +pub(super) fn rows(names: &[&str]) -> QueryResult { + let columns = [column("id", true), column("name", false)]; + QueryResult::new( + columns.iter().map(ColumnInfo::result_column).collect::>(), + names + .iter() + .enumerate() + .map(|(id, name)| vec![Value::Int(i64::try_from(id).unwrap()), Value::Text((*name).to_owned())]) + .collect(), + ) +} + +/// A connection that serves the table's catalog and count, with the +/// page left to the test. +pub(super) fn serving_table() -> ConnectionScript { + let mut script = ConnectionScript::default(); + script + .fetch_columns + .always(FakeReply::ok(vec![column("id", true), column("name", false)])); + script.row_identity.always(FakeReply::ok(RowIdentity::UniqueKey { + columns: vec!["id".to_owned()], + })); + script.count_rows.always(FakeReply::ok(2)); + script + .table_estimate + .always(FakeReply::ok(estimate(RowCountEstimate::Approximate(2)))); + script.probe.always(FakeReply::ok(())); + script +} + +pub(super) fn estimate(rows: RowCountEstimate) -> TableEstimate { + TableEstimate { + rows, + heap_blocks: None, + } +} + +pub(super) struct Harness { + pub(super) tab: Option, + pub(super) tracker: TabTrackerHandle, + others: Vec, + pub(super) session: Arc, + pub(super) connection: Arc, + pub(super) fake: FakeSession, + pub(super) stores: TabStores, + runtime: Option, +} + +impl Harness { + pub(super) fn open(script: ConnectionScript, reconnects: impl IntoIterator) -> Self { + Self::open_filtered(script, reconnects, FilterSet::default()) + } + + /// The tab opens under `saved`, the filter the table was left with. + pub(super) fn open_filtered( + script: ConnectionScript, + reconnects: impl IntoIterator, + saved: FilterSet, + ) -> Self { + Self::open_with(script, reconnects, saved, false) + } + + fn open_read_only(script: ConnectionScript) -> Self { + Self::open_with(script, [], FilterSet::default(), true) + } + + fn open_with( + script: ConnectionScript, + reconnects: impl IntoIterator, + saved: FilterSet, + read_only: bool, + ) -> Self { + libadwaita::init().expect("libadwaita initialises under the test backend"); + crate::register_test_resources(); + let runtime = crate::test_support::test_runtime(); + let fake = FakeSession::start(script, reconnects, runtime.tasks()); + let stores = TabStores::new(); + let tracker = TabTrackerHandle::default(); + let tab = filtered_users_tab(&fake.session, &runtime, &stores, saved, tracker.clone(), read_only); + Self { + tab: Some(tab), + tracker, + others: Vec::new(), + session: fake.session.clone(), + connection: fake.connection.clone(), + fake, + stores, + runtime: Some(runtime), + } + } + + pub(super) fn tab(&self) -> &BrowseTab { + self.tab.as_ref().expect("the tab is open") + } + + /// Another tab on the same table and session. + pub(super) fn open_another(&mut self) -> BrowseTab { + let runtime = self.runtime.as_ref().expect("the runtime is running"); + let tab = users_tab(&self.session, runtime, &self.stores); + self.others.push(tab.clone()); + tab + } + + /// The second cell of each saved row on screen, empty while the + /// tab shows no page. + pub(super) fn shown(&self) -> Vec { + self.saved_rows() + .iter() + .filter_map(|row| row.cell(1).cloned()) + .collect() + } + + /// The saved rows the grid holds for the page on screen. + pub(super) fn saved_rows(&self) -> Vec { + use gtk4::subclass::prelude::ObjectSubclassIsExt; + let model = self.tab().imp().model.borrow(); + let Some(selection) = model + .as_ref() + .filter(|model| model.page.is_some()) + .and_then(|model| model.current_selection.clone()) + else { + return Vec::new(); + }; + (0..selection.n_items()) + .filter_map(|position| selection.item(position).and_downcast::()) + .filter(|row| row.draft_id().is_none()) + .collect() + } + + pub(super) fn visible_page(&self) -> Option { + self.tab().imp_stack().visible_child_name().map(|name| name.to_string()) + } + + /// The row the grid shows at `position`, drafts included. + pub(super) fn grid_row(&self, position: u32) -> RowObject { + self.selection() + .item(position) + .and_downcast::() + .expect("a row") + } + + pub(super) fn selection(&self) -> gtk4::MultiSelection { + use gtk4::subclass::prelude::ObjectSubclassIsExt; + self.tab() + .imp() + .model + .borrow() + .as_ref() + .and_then(|model| model.current_selection.clone()) + .expect("the grid is built") + } + + pub(super) fn store(&self) -> gtk4::gio::ListStore { + self.selection() + .model() + .and_downcast::() + .expect("a list store") + } + + /// What the tab does with the delta of an edit it made itself. + fn apply_delta(&self, delta: &TrackerDelta) { + use gtk4::subclass::prelude::ObjectSubclassIsExt; + let mut effects = Effects::default(); + if let Some(model) = self.tab().imp().model.borrow_mut().as_mut() { + model.apply_tracker_delta(delta, &mut effects); + } + } +} + +/// The tab in a window, so its grid draws cells. +pub(super) struct Shown(pub(super) gtk4::Window); + +impl Shown { + pub(super) fn new(tab: &BrowseTab) -> Self { + let window = gtk4::Window::new(); + window.set_default_size(600, 400); + window.set_child(Some(tab)); + window.present(); + Self(window) + } +} + +impl Drop for Shown { + fn drop(&mut self) { + crate::test_support::drop_focus(&self.0); + self.0.set_child(None::<>k4::Widget>); + self.0.destroy(); + } +} + +/// The files and preferences a test tab reads. +pub(super) struct TabStores { + root: tempfile::TempDir, + pub(super) settings: MemorySettings, +} + +impl TabStores { + pub(super) fn new() -> Self { + Self { + root: tempfile::tempdir().expect("tempdir"), + settings: MemorySettings::new(), + } + } +} + +pub(super) fn users_tab(session: &Arc, runtime: &crate::runtime::AppRuntime, stores: &TabStores) -> BrowseTab { + filtered_users_tab( + session, + runtime, + stores, + FilterSet::default(), + TabTrackerHandle::default(), + false, + ) +} + +pub(super) fn filtered_users_tab( + session: &Arc, + runtime: &crate::runtime::AppRuntime, + stores: &TabStores, + saved: FilterSet, + tracker: TabTrackerHandle, + read_only: bool, +) -> BrowseTab { + table_tab(session, runtime, stores, "users", saved, tracker, read_only) +} + +pub(super) fn table_tab( + session: &Arc, + runtime: &crate::runtime::AppRuntime, + stores: &TabStores, + table: &str, + saved: FilterSet, + tracker: TabTrackerHandle, + read_only: bool, +) -> BrowseTab { + let tasks = runtime.tasks(); + let connection_id = uuid::Uuid::new_v4(); + let root = stores.root.path(); + let filter_settings = crate::persistence::FilterSettingsStore::load(root.join("filters.json"), &tasks); + filter_settings.set_filters(connection_id, None, table, saved); + BrowseTab::new(BrowseTabInit { + tracker, + schema: None, + table: table.to_owned(), + connection_id: Some(connection_id), + read_only, + page_size: 100, + initial_offset: 0, + initial_sort: None, + column_widths: crate::persistence::ColumnWidthStore::load(root.join("column-widths.json"), &tasks), + filter_settings, + session: session.clone(), + tasks, + deadlines: Arc::new(tablepro_session::operation::DefaultDeadlines::new( + tablepro_core::LivenessPolicy::DESKTOP, + )), + settings: stores.settings.get().clone(), + first_load: crate::ui::tab_load::FirstLoad::Now, + }) +} + +impl Drop for Harness { + fn drop(&mut self) { + self.tab.take(); + self.others.clear(); + drain_main_context(); + if let Some(runtime) = self.runtime.take() { + runtime.shutdown(); + } + } +} + +impl BrowseTab { + fn imp_stack(&self) -> gtk4::Stack { + use gtk4::subclass::prelude::ObjectSubclassIsExt; + self.imp().inner_stack.get() + } +} + +pub(super) fn text(values: &[&str]) -> Vec { + values.iter().map(|value| Value::Text((*value).to_owned())).collect() +} + +pub(super) fn not_null(column: &str) -> FilterSet { + FilterSet { + rules: vec![FilterRule { + column: column.to_owned(), + op: FilterOp::IsNotNull, + value: None, + }], + ..FilterSet::default() + } +} + +pub(super) fn not_null_name() -> FilterSet { + not_null("name") +} + +#[gtk4::test] +fn stale_page_reply_ignored() { + let mut script = serving_table(); + script + .query_bound + .when_sql_contains("IS NOT NULL", FakeReply::ok(rows(&["filtered"]))) + .once(FakeReply::Late { + after: Duration::from_millis(300), + outcome: Ok(rows(&["unfiltered", "stale"])), + }); + let harness = Harness::open(script, []); + let counters = harness.connection.counters(); + wait_until(WAIT, || CallCounters::get(&counters.query_bound) == 1).expect("the first page was asked for"); + + harness.tab().send(PagingInput::FilterApplied(not_null_name())); + wait_until(WAIT, || harness.shown() == text(&["filtered"])).expect("the filtered page"); + std::thread::sleep(Duration::from_millis(500)); + drain_main_context(); + + assert_eq!(CallCounters::get(&counters.query_bound), 2); + assert_eq!( + harness.shown(), + text(&["filtered"]), + "the reply to the page the filter replaced was shown" + ); +} + +#[gtk4::test] +fn closing_tab_cancels_call_options_token() { + let mut script = serving_table(); + script.query_bound.always(FakeReply::UntilStopped); + let mut harness = Harness::open(script, []); + let counters = harness.connection.counters(); + wait_until(WAIT, || CallCounters::get(&counters.query_bound) == 1).expect("the page was asked for"); + let weak = harness.tab().downgrade(); + + harness.tab.take(); + drain_main_context(); + + assert!(weak.upgrade().is_none(), "something kept the closed tab alive"); + wait_until(WAIT, || CallCounters::get(&counters.stopped) == 1).expect("closing the tab did not stop its page read"); +} + +#[gtk4::test] +fn failed_load_refetches_after_link_replace() { + let mut first = serving_table(); + first.query_bound.always(FakeReply::err(DriverError::ConnectionLost { + during: LossPhase::Statement, + })); + let mut second = serving_table(); + second.query_bound.always(FakeReply::ok(rows(&["after reconnect"]))); + let harness = Harness::open(first, [FakeEstablish::Connect(second)]); + + wait_until(WAIT, || harness.shown() == text(&["after reconnect"])) + .expect("the page read again after the reconnect"); + + let replacement = harness.fake.replacement(); + assert_eq!(harness.session.link_generation().get(), 1); + assert_eq!(CallCounters::get(&replacement.counters().query_bound), 1); + assert_eq!(harness.visible_page().as_deref(), Some("grid")); +} + +#[gtk4::test] +fn loaded_tab_keeps_data_on_link_replace() { + let mut first = serving_table(); + first.query_bound.always(FakeReply::ok(rows(&["kept"]))); + let harness = Harness::open(first, [FakeEstablish::Connect(serving_table())]); + wait_until(WAIT, || harness.shown() == text(&["kept"])).expect("the first page"); + let counters = harness.connection.counters(); + + harness.connection.mark_lost(); + wait_until(WAIT, || harness.session.link_generation().get() == 1).expect("the reconnect"); + std::thread::sleep(Duration::from_millis(200)); + drain_main_context(); + + let replacement = harness.fake.replacement(); + assert_eq!(CallCounters::get(&counters.query_bound), 1); + assert_eq!( + CallCounters::get(&replacement.counters().query_bound), + 0, + "a tab with nothing failed read its page again" + ); + assert_eq!(harness.shown(), text(&["kept"])); + assert_eq!(harness.visible_page().as_deref(), Some("grid")); +} + +#[gtk4::test] +fn schema_change_reads_the_columns_again_then_the_page() { + let mut script = serving_table(); + script.query_bound.always(FakeReply::ok(rows(&["row"]))); + let harness = Harness::open(script, []); + wait_until(WAIT, || harness.shown() == text(&["row"])).expect("the first page"); + let counters = harness.connection.counters(); + + harness + .session + .invalidate_table(&tablepro_core::TableRef::unqualified("users")); + harness.tab().send(DataInput::SchemaChanged); + wait_until(WAIT, || CallCounters::get(&counters.query_bound) == 2).expect("the page after the columns"); + + assert_eq!(CallCounters::get(&counters.fetch_columns), 2); +} + +pub(super) fn header_titles(tab: &BrowseTab) -> Vec { + let Some(view) = crate::test_support::first_descendant_of_type::(tab) else { + return Vec::new(); + }; + let columns = view.columns(); + (0..columns.n_items()) + .filter_map(|position| columns.item(position).and_downcast::()) + .filter_map(|column| column.title().map(|title| title.to_string())) + .collect() +} + +#[gtk4::test] +fn a_renamed_column_builds_the_grid_again() { + let renamed = [column("id", true), column("full_name", false)]; + let mut script = serving_table(); + script + .fetch_columns + .once(FakeReply::ok(vec![column("id", true), column("name", false)])) + .always(FakeReply::ok(renamed.to_vec())); + script + .query_bound + .when_sql_contains( + "full_name", + FakeReply::ok(QueryResult::new( + renamed.iter().map(ColumnInfo::result_column).collect::>(), + vec![vec![Value::Int(0), Value::Text("Ada".to_owned())]], + )), + ) + .always(FakeReply::ok(rows(&["Ada"]))); + let harness = Harness::open(script, []); + wait_until(WAIT, || header_titles(harness.tab()).contains(&"name".to_owned())).expect("the first grid"); + + harness + .session + .invalidate_table(&tablepro_core::TableRef::unqualified("users")); + harness.tab().send(DataInput::SchemaChanged); + + wait_until(WAIT, || header_titles(harness.tab()).contains(&"full_name".to_owned())) + .expect("the grid kept the old column's header"); +} + +#[gtk4::test] +fn a_tab_that_opens_reads_the_columns_the_table_has_now() { + let altered = [column("id", true), column("name", false), column("email", false)]; + let mut script = serving_table(); + script + .fetch_columns + .once(FakeReply::ok(vec![column("id", true), column("name", false)])) + .always(FakeReply::ok(altered.to_vec())); + script + .query_bound + .when_sql_contains( + "email", + FakeReply::ok(QueryResult::new( + altered.iter().map(ColumnInfo::result_column).collect::>(), + vec![vec![ + Value::Int(0), + Value::Text("Ada".to_owned()), + Value::Text("ada@example.com".to_owned()), + ]], + )), + ) + .always(FakeReply::ok(rows(&["Ada"]))); + let mut harness = Harness::open(script, []); + wait_until(WAIT, || header_titles(harness.tab()).contains(&"name".to_owned())).expect("the first grid"); + + // The column was added by a client the catalog never hears from. + let reopened = harness.open_another(); + + wait_until(WAIT, || header_titles(&reopened).contains(&"email".to_owned())) + .expect("the new tab was built from the columns cached when the first one opened"); + assert_eq!(CallCounters::get(&harness.connection.counters().fetch_columns), 2); +} + +pub(super) fn toasts(tab: &BrowseTab) -> std::rc::Rc>> { + let toasts = std::rc::Rc::new(std::cell::RefCell::new(Vec::new())); + let sink = toasts.clone(); + tab.connect_toast(move |_, text| sink.borrow_mut().push(text)); + toasts +} + +#[gtk4::test] +fn a_failed_page_under_a_new_filter_shows_the_error() { + let mut script = serving_table(); + script + .query_bound + .when_sql_contains("IS NOT NULL", FakeReply::err(DriverError::server("denied"))) + .always(FakeReply::ok(rows(&["unfiltered"]))); + let harness = Harness::open(script, []); + wait_until(WAIT, || harness.shown() == text(&["unfiltered"])).expect("the first page"); + + harness.tab().send(PagingInput::FilterApplied(not_null_name())); + + wait_until(WAIT, || harness.visible_page().as_deref() == Some("error")) + .expect("rows read without the filter stayed under it"); + assert_eq!( + harness.shown(), + text(&["unfiltered"]), + "the error page let go of the page the paginator still points back to" + ); +} + +#[gtk4::test] +fn a_saved_filter_on_a_dropped_column_shows_the_error() { + let mut script = serving_table(); + script.query_bound.always(FakeReply::ok(rows(&["unfiltered"]))); + let harness = Harness::open_filtered(script, [], not_null("nickname")); + let counters = harness.connection.counters(); + + wait_until(WAIT, || harness.visible_page().as_deref() == Some("error")).expect("the tab stayed on its spinner"); + + assert_eq!(CallCounters::get(&counters.query_bound), 0); + assert_eq!(CallCounters::get(&counters.count_rows), 0); +} + +#[gtk4::test] +fn a_filter_column_renamed_away_drops_the_grid_for_the_error() { + let renamed = [column("id", true), column("full_name", false)]; + let mut script = serving_table(); + script + .fetch_columns + .once(FakeReply::ok(vec![column("id", true), column("name", false)])) + .always(FakeReply::ok(renamed.to_vec())); + script.query_bound.always(FakeReply::ok(rows(&["Ada"]))); + let harness = Harness::open_filtered(script, [], not_null_name()); + wait_until(WAIT, || harness.shown() == text(&["Ada"])).expect("the first page"); + + harness + .session + .invalidate_table(&tablepro_core::TableRef::unqualified("users")); + harness.tab().send(DataInput::SchemaChanged); + + wait_until(WAIT, || harness.visible_page().as_deref() == Some("error")) + .expect("the grid read under the old columns stayed on screen"); + assert!(harness.shown().is_empty()); + assert_eq!(harness.tab().columns(), renamed); +} + +#[gtk4::test] +fn a_refresh_under_a_filter_that_no_longer_fits_keeps_the_rows() { + let mut script = serving_table(); + script.query_bound.always(FakeReply::ok(rows(&["kept"]))); + let harness = Harness::open_filtered(script, [], not_null_name()); + wait_until(WAIT, || harness.shown() == text(&["kept"])).expect("the first page"); + let toasts = toasts(harness.tab()); + let banner = { + use gtk4::subclass::prelude::ObjectSubclassIsExt; + harness.tab().imp().stale_banner.get() + }; + + harness.tab().send(PagingInput::FilterApplied(not_null("nickname"))); + harness.tab().send(PagingInput::Refresh); + wait_until(WAIT, || banner.is_revealed()).expect("the filter that does not fit was not reported"); + drain_main_context(); + + let reason = banner.tooltip_text().map(|text| text.to_string()).unwrap_or_default(); + assert!(reason.starts_with("This filter cannot be applied"), "{reason}"); + assert!( + toasts.borrow().is_empty(), + "a toast that fades said it: {:?}", + toasts.borrow() + ); + assert_eq!(harness.shown(), text(&["kept"])); + assert_eq!(harness.visible_page().as_deref(), Some("grid")); +} + +#[gtk4::test] +fn no_dim_label_class_remains() { + let mut script = serving_table(); + script.query_bound.always(FakeReply::ok(rows(&["Ada", "Grace"]))); + let harness = Harness::open(script, []); + let _shown = Shown::new(harness.tab()); + wait_until(WAIT, || { + crate::test_support::descendants(harness.tab()) + .iter() + .any(|widget| widget.is::()) + }) + .expect("the grid draws its cells"); + harness.tab().send(PagingInput::ToggleFilterStrip); + + let dimmed: Vec = crate::test_support::descendants(harness.tab()) + .into_iter() + .filter(|widget| widget.has_css_class("dim-label")) + .map(|widget| widget.type_().name().to_owned()) + .collect(); + + assert!(dimmed.is_empty(), "still dim-label: {dimmed:?}"); +} + +#[gtk4::test] +fn export_and_save_reach_the_window_as_signals() { + let mut script = serving_table(); + script.query_bound.always(FakeReply::ok(rows(&["Ada", "Grace"]))); + let harness = Harness::open(script, []); + wait_until(WAIT, || harness.shown().len() == 2).expect("the first page"); + let exported = std::rc::Rc::new(std::cell::RefCell::new(Vec::new())); + let saved = std::rc::Rc::new(std::cell::Cell::new(0)); + let export_sink = exported.clone(); + harness.tab().connect_export_results(move |_, offer| { + export_sink + .borrow_mut() + .push((offer.name, offer.origin.shown().row_count())); + }); + let save_sink = saved.clone(); + harness + .tab() + .connect_save_requested(move |_| save_sink.set(save_sink.get() + 1)); + + harness.tab().send(ClipboardInput::Export(None)); + harness.tab().send(EditInput::InsertRow); + harness.tab().send(SaveInput::CommitSave); + + assert_eq!(*exported.borrow(), [("users".to_owned(), Some(2))]); + assert_eq!(saved.get(), 1); +} + +#[gtk4::test] +fn current_page_export_includes_pending_edits() { + let mut script = serving_table(); + script.query_bound.always(FakeReply::ok(rows(&["Ada", "Grace"]))); + let harness = Harness::open(script, []); + wait_until(WAIT, || harness.shown().len() == 2).expect("the first page"); + // The total is read after the page, on its own. + wait_until(WAIT, || { + use gtk4::subclass::prelude::ObjectSubclassIsExt; + harness + .tab() + .imp() + .model + .borrow() + .as_ref() + .is_some_and(|model| model.total != RowTotal::Unknown) + }) + .expect("the table's total"); + harness.apply_delta(&harness.tracker.apply(rename(0, "Ada", "Ada Lovelace"))); + let offers = std::rc::Rc::new(std::cell::RefCell::new(Vec::new())); + let sink = offers.clone(); + harness + .tab() + .connect_export_results(move |_, offer| sink.borrow_mut().push(offer)); + + harness.tab().send(ClipboardInput::Export(None)); + + let offers = offers.borrow(); + let [offer] = offers.as_slice() else { + panic!("one export, not {}", offers.len()); + }; + let crate::ui::export::ExportOrigin::Browse { + page: crate::ui::export::ExportSource::Rows { rows, .. }, + query: crate::ui::export::ExportQuery::Table(table), + total, + has_unsaved_changes, + } = &offer.origin + else { + panic!("not a browse page: {:?}", offer.origin); + }; + let names: Vec = rows.iter().map(|row| row.to_values()[1].clone()).collect(); + assert_eq!( + names, + [Value::Text("Ada Lovelace".to_owned()), Value::Text("Grace".to_owned())] + ); + assert!(*has_unsaved_changes, "the pending rename went unmentioned"); + assert_eq!(table.table.name, "users"); + assert_eq!( + table + .columns + .iter() + .map(|column| column.name.as_str()) + .collect::>(), + ["id", "name"] + ); + assert_ne!(*total, RowTotal::Unknown, "the table's total was not handed over"); + assert!(offer.session.is_some(), "a read of every row has no session"); +} + +/// The bar's own states are tested with the bar; this is the tab's wiring. +#[gtk4::test] +fn stop_saving_follows_the_save_and_reaches_the_window() { + use gtk4::subclass::prelude::ObjectSubclassIsExt; + + let mut script = serving_table(); + script.query_bound.always(FakeReply::ok(rows(&["Ada"]))); + let harness = Harness::open(script, []); + wait_until(WAIT, || harness.shown().len() == 1).expect("the first page"); + let stops = std::rc::Rc::new(std::cell::Cell::new(0)); + let stop_sink = stops.clone(); + harness + .tab() + .connect_stop_save_requested(move |_| stop_sink.set(stop_sink.get() + 1)); + let stop = harness.tab().imp().pending_bar.imp().stop_button.get(); + harness.tab().send(EditInput::InsertRow); + + harness.tab().send(SaveInput::SaveStarted); + assert!(stop.is_visible(), "a running save offered no Stop Saving"); + + stop.emit_clicked(); + assert_eq!(stops.get(), 1); + + harness.tab().send(SaveInput::SaveFailed("rolled back".to_owned())); + assert!(!stop.is_visible(), "Stop Saving outlived the save"); +} + +#[gtk4::test] +fn an_edit_held_for_the_running_save_says_so_once() { + use gtk4::subclass::prelude::ObjectSubclassIsExt; + + let mut script = serving_table(); + script.query_bound.always(FakeReply::ok(rows(&["Ada"]))); + let harness = Harness::open(script, []); + wait_until(WAIT, || harness.shown().len() == 1).expect("the first page"); + let tracker = harness + .tab() + .imp() + .model + .borrow() + .as_ref() + .map(|model| model.tracker.clone()) + .expect("the tab has a model"); + let toasts = toasts(harness.tab()); + harness.tab().send(EditInput::InsertRow); + harness.tab().send(EditInput::InsertRow); + let _saving = tracker.begin_save().expect("a save").expect("the drafts"); + let drafts: Vec = tracker.drafts().iter().map(|draft| draft.draft_id).collect(); + + harness.tab().send(EditInput::MarkRowsDeleted(RowDeletion { + drafts, + ..RowDeletion::default() + })); + + assert_eq!(tracker.drafts().len(), 2, "a draft the save holds was dropped"); + assert_eq!(toasts.borrow().len(), 1, "{:?}", toasts.borrow()); +} + +#[gtk4::test] +fn browse_tab_and_filter_strip_freed_after_drop() { + let mut script = serving_table(); + script.query_bound.always(FakeReply::ok(rows(&["row"]))); + let mut harness = Harness::open(script, []); + wait_until(WAIT, || harness.shown() == text(&["row"])).expect("the first page"); + let strip = { + use gtk4::subclass::prelude::ObjectSubclassIsExt; + harness + .tab() + .imp() + .model + .borrow() + .as_ref() + .map(|model| model.filter_strip.downgrade()) + .expect("the filter strip") + }; + let tab = harness.tab().downgrade(); + + harness.tab.take(); + drain_main_context(); + + assert!(strip.upgrade().is_none(), "the filter strip outlived its tab"); + assert!(tab.upgrade().is_none(), "the browse tab outlived its last reference"); +} + +#[gtk4::test] +fn duplicate_row_copies_pending_edits_of_persisted_row() { + let mut script = serving_table(); + script.query_bound.always(FakeReply::ok(rows(&["Ada", "Grace"]))); + let harness = Harness::open(script, []); + wait_until(WAIT, || harness.shown().len() == 2).expect("the first page"); + let ada = harness.grid_row(0); + + harness.tab().send(typed(&ada, 1, "Ada Lovelace")); + harness.tab().send(EditInput::DuplicateRows(vec![ada])); + + let drafts = harness.tracker.drafts(); + assert_eq!(drafts.len(), 1, "{drafts:?}"); + assert_eq!( + drafts[0].cells, + vec![ + CellInput::Value(Value::Null), + CellInput::Value(Value::Text("Ada Lovelace".to_owned())) + ], + "the duplicate left out the row's pending edit" + ); +} + +#[gtk4::test] +fn duplicating_a_draft_leaves_its_defaulted_cells_to_the_server() { + let mut level = col_with_default("integer", "0"); + level.name = "level".to_owned(); + let (harness, _shown) = one_cell(level, Value::Int(3)); + harness.tab().send(EditInput::InsertRow); + let draft = harness.grid_row(0); + assert!(draft.draft_id().is_some(), "the new row goes on top"); + + harness.tab().send(EditInput::DuplicateRows(vec![draft])); + + let drafts = harness.tracker.drafts(); + assert_eq!(drafts.len(), 2, "{drafts:?}"); + assert_eq!( + drafts[1].cells[1], + CellInput::Default, + "the copy wrote NULL where the server fills in" + ); +} + +#[gtk4::test] +fn duplicating_a_selection_is_one_undo_step() { + let mut script = serving_table(); + script.query_bound.always(FakeReply::ok(rows(&["Ada", "Grace"]))); + let harness = Harness::open(script, []); + wait_until(WAIT, || harness.shown().len() == 2).expect("the first page"); + + harness + .tab() + .send(EditInput::DuplicateRows(vec![harness.grid_row(0), harness.grid_row(1)])); + assert_eq!(draft_rows(&harness.store()), 2); + harness.tab().send(EditInput::Undo); + + assert_eq!(draft_rows(&harness.store()), 0, "one undo left copies behind"); + assert_eq!(harness.tracker.pending_count(), 0); +} + +#[gtk4::test] +fn f004_copy_row_as_insert_uses_row_below_draft() { + let mut script = serving_table(); + script.query_bound.always(FakeReply::ok(rows(&["Ada", "Grace"]))); + let harness = Harness::open(script, []); + wait_until(WAIT, || harness.shown().len() == 2).expect("the first page"); + + harness.tab().send(EditInput::InsertRow); + let below_draft = harness.grid_row(1); + let copied = copied_by(harness.tab(), || { + harness + .tab() + .send(ClipboardInput::GridCopyRowsAsInsert(vec![below_draft])); + }); + + assert_eq!(copied, "INSERT INTO \"users\" (\"id\", \"name\") VALUES (0, 'Ada');"); +} + +/// MySQL reads a backslash in a string as an escape, so the list is +/// written in the server's own literals. +#[gtk4::test] +fn copy_as_in_clause_writes_the_servers_literals() { + let mut script = serving_table(); + script.dialect = Arc::new(tablepro_driver_mysql::MySqlDialect::new( + tablepro_driver_mysql::MySqlFlavour::MySql, + &tablepro_core::ServerInfo::new("MySQL", "8.4.3", 8, 4, 3), + )); + let injected = "\\') OR 1=1 -- "; + script.query_bound.always(FakeReply::ok(rows(&[injected]))); + let harness = Harness::open(script, []); + wait_until(WAIT, || harness.shown().len() == 1).expect("the first page"); + let row = harness.grid_row(0); + let values = crate::ui::clipboard::InClauseRows { + columns: [column("id", true), column("name", false)] + .iter() + .map(ColumnInfo::result_column) + .collect(), + rows: vec![crate::ui::grid::RowSnapshot::new( + row.cells().expect("the row's cells"), + Vec::new(), + )], + column: 1, + }; + + let copied = copied_by(harness.tab(), || { + harness.tab().send(ClipboardInput::GridCopyInClause(values)); + }); + + assert_eq!( + copied, + format!( + "(_utf8mb4 X'{}')", + tablepro_core::hex::encode_lower(injected.as_bytes()) + ) + ); +} + +/// What the clipboard holds once the copy `copy` starts lands there. +fn copied_by(tab: &BrowseTab, copy: impl FnOnce()) -> String { + const BEFORE: &str = "nothing copied yet"; + let clipboard = tab.clipboard(); + clipboard.set_text(BEFORE); + copy(); + let text = || -> Option { clipboard.content()?.value(String::static_type()).ok()?.get().ok() }; + wait_until(WAIT, || text().is_some_and(|text| text != BEFORE)).expect("the copy to land"); + text().unwrap_or_default() +} + +pub(super) fn key(id: i64) -> RowKey { + RowKey::from_pk_values(&[Value::Int(id)]).expect("a key") +} + +/// `names` keyed from `first` on, as the next page of `rows` would be. +pub(super) fn rows_from(first: i64, names: &[&str]) -> QueryResult { + let mut page = rows(names); + for (row, id) in page.rows.iter_mut().zip(first..) { + row[0] = Value::Int(id); + } + page +} + +/// A partitioned PostgreSQL table with no key, whose page carries the +/// partition, the row address and its version after the visible cells. +fn partitioned_table() -> ConnectionScript { + let server = tablepro_core::ServerInfo::new("PostgreSQL", "18.0", 18, 0, 0); + let mut script = ConnectionScript { + dialect: Arc::new(tablepro_driver_postgres::PgDialect::new(&server)), + ..ConnectionScript::default() + }; + script + .fetch_columns + .always(FakeReply::ok(vec![column("id", false), column("name", false)])); + script.row_identity.always(FakeReply::ok(RowIdentity::EngineRowId( + tablepro_core::meta::EngineRowId::PostgresTableoidCtid, + ))); + script.count_rows.always(FakeReply::ok(2)); + script.probe.always(FakeReply::ok(())); + let mut columns: Vec<_> = [column("id", false), column("name", false)] + .iter() + .map(ColumnInfo::result_column) + .collect(); + for hidden in ["__tablepro_row_oid", "__tablepro_row_id", "__tablepro_row_xmin"] { + columns.push(column(hidden, false).result_column()); + } + let rows = [("Ada", "(0,1)"), ("Grace", "(0,1)")] + .iter() + .zip(0..) + .map(|((name, ctid), id)| { + vec![ + Value::Int(id), + Value::Text((*name).to_owned()), + Value::Int(16_401 + id), + Value::Text((*ctid).to_owned()), + Value::Text("812".to_owned()), + ] + }) + .collect(); + script + .query_bound + .always(FakeReply::ok(QueryResult::new(columns, rows))); + script +} + +fn visible_only(values: &[Value]) { + assert_eq!(values.len(), 2, "a row carried hidden cells: {values:?}"); +} + +#[gtk4::test] +fn row_objects_hold_only_visible_cells() { + let harness = Harness::open(partitioned_table(), []); + wait_until(WAIT, || harness.saved_rows().len() == 2).expect("the first page"); + + for row in harness.saved_rows() { + visible_only(&row.cells().expect("a saved row's cells")); + assert_eq!(row.key(), None, "a table with no primary key took edits"); + } + use gtk4::subclass::prelude::ObjectSubclassIsExt; + let model = harness.tab().imp().model.borrow(); + let page = model.as_ref().and_then(|model| model.page.clone()).expect("the page"); + assert_eq!(page.result_columns.len(), 2, "the grid was given the hidden columns"); +} + +#[gtk4::test] +fn page_rows_have_single_owner() { + let harness = Harness::open(partitioned_table(), []); + wait_until(WAIT, || harness.saved_rows().len() == 2).expect("the first page"); + + for row in harness.saved_rows() { + let cells = row.shared_cells().expect("a saved row's cells"); + assert_eq!(Arc::strong_count(cells), 1, "the tab kept its own copy of the page"); + } +} + +#[gtk4::test] +fn copy_as_insert_and_duplicate_contain_no_hidden_columns() { + let harness = Harness::open(partitioned_table(), []); + wait_until(WAIT, || harness.saved_rows().len() == 2).expect("the first page"); + let rows = harness.saved_rows(); + + let copied = copied_by(harness.tab(), || { + harness.tab().send(ClipboardInput::GridCopyRowsAsInsert(rows.clone())); + }); + harness.tab().send(EditInput::DuplicateRows(rows)); + + assert_eq!(copied.lines().count(), 2, "{copied}"); + for hidden in ["16401", "(0,1)", "812", "__tablepro"] { + assert!(!copied.contains(hidden), "{hidden} reached the copy: {copied}"); + } + let drafts = harness.tracker.drafts(); + assert_eq!(drafts.len(), 2); + for draft in drafts { + assert_eq!( + draft.cells.len(), + 2, + "a duplicate carried hidden cells: {:?}", + draft.cells + ); + } +} + +#[gtk4::test] +fn copy_as_insert_shares_saved_rows_and_copies_only_drafts() { + let mut script = serving_table(); + script.query_bound.always(FakeReply::ok(rows(&["Ada", "Grace"]))); + let harness = Harness::open(script, []); + wait_until(WAIT, || harness.saved_rows().len() == 2).expect("the first page"); + harness.tab().send(EditInput::InsertRow); + let saved = harness.saved_rows(); + let mut rows = vec![harness.grid_row(0)]; + rows.extend(saved.iter().cloned()); + + use gtk4::subclass::prelude::ObjectSubclassIsExt; + let model = harness.tab().imp().model.borrow(); + let insert_rows = model.as_ref().expect("the model").insert_rows(&rows); + + let [InsertRow::Draft(_), InsertRow::Saved(first), InsertRow::Saved(second)] = insert_rows.as_slice() else { + panic!("{insert_rows:?}"); + }; + for (snapshot, row) in [first, second].into_iter().zip(&saved) { + let cells = row.shared_cells().expect("a saved row's cells"); + assert!(snapshot.shares_cells_with(cells), "a saved row's cells were copied"); + } +} + +#[gtk4::test] +fn page_export_snapshot_contains_no_hidden_columns() { + let harness = Harness::open(partitioned_table(), []); + wait_until(WAIT, || harness.saved_rows().len() == 2).expect("the first page"); + let exported = std::rc::Rc::new(std::cell::RefCell::new(Vec::new())); + let sink = exported.clone(); + harness + .tab() + .connect_export_results(move |_, offer| sink.borrow_mut().push(offer.origin.shown().clone())); + + harness.tab().send(ClipboardInput::Export(None)); + + let exported = exported.borrow(); + let [crate::ui::export::ExportSource::Rows { columns, rows }] = exported.as_slice() else { + panic!("one export, not {}", exported.len()); + }; + let names: Vec<&str> = columns.iter().map(|column| column.name.as_str()).collect(); + assert_eq!(names, ["id", "name"]); + assert_eq!(rows.len(), 2); + for row in rows { + visible_only(&row.to_values()); + } +} + +/// A browse page served first, then `next` for every page after it. +pub(super) fn paging(first: QueryResult, next: QueryResult) -> ConnectionScript { + let mut script = serving_table(); + script.count_rows.always(FakeReply::ok(1_000)); + script + .query_bound + .once(FakeReply::ok(first)) + .always(FakeReply::ok(next)); + script +} + +/// Rows enough for a full page, so Next has somewhere to go. +pub(super) fn full_page(first: i64) -> QueryResult { + let names: Vec = (0..100).map(|n| format!("row {n}")).collect(); + let names: Vec<&str> = names.iter().map(String::as_str).collect(); + rows_from(first, &names) +} + +#[gtk4::test] +fn page_load_emits_one_items_changed_with_drafts_prepended() { + let harness = Harness::open(paging(full_page(0), rows_from(100, &["Linus", "Ken"])), []); + wait_until(WAIT, || harness.shown().len() == 100).expect("the first page"); + harness.tab().send(EditInput::InsertRow); + let store = harness.store(); + let changes = std::rc::Rc::new(std::cell::RefCell::new(Vec::new())); + let log = changes.clone(); + store.connect_items_changed(move |_, position, removed, added| { + log.borrow_mut().push((position, removed, added)); + }); + + harness.tab().send(PagingInput::NextPage); + wait_until(WAIT, || harness.shown() == text(&["Linus", "Ken"])).expect("the next page"); + + assert_eq!(*changes.borrow(), [(0, 101, 3)], "the page was not one splice"); + assert!(harness.grid_row(0).draft_id().is_some(), "the draft left the top"); + assert_eq!(harness.grid_row(1).key(), Some(&key(100))); +} + +#[gtk4::test] +fn second_page_load_drops_previous_rows() { + let harness = Harness::open(paging(full_page(0), rows_from(100, &["Linus"])), []); + wait_until(WAIT, || harness.shown().len() == 100).expect("the first page"); + let _shown = Shown::new(harness.tab()); + let _drawn = grid_cell_of(harness.tab(), 0, 1); + let first: Vec> = harness.saved_rows().iter().map(ObjectExt::downgrade).collect(); + + harness.tab().send(PagingInput::NextPage); + wait_until(WAIT, || harness.shown() == text(&["Linus"])).expect("the next page"); + + wait_until(WAIT, || first.iter().all(|row| row.upgrade().is_none())).expect("the first page's rows were let go"); +} + +/// The row holding the focus leaves in the splice, which takes the +/// focus from it first: the open edit ends as a focus change ends it. +#[gtk4::test] +fn a_page_that_replaces_the_row_being_edited_keeps_the_edit_and_the_focus() { + let harness = Harness::open(paging(full_page(0), rows_from(100, &["Linus"])), []); + wait_until(WAIT, || harness.shown().len() == 100).expect("the first page"); + let shown = Shown::new(harness.tab()); + let view = column_view(harness.tab()); + let name = crate::ui::grid::data_columns(&view) + .into_iter() + .nth(1) + .expect("the name column"); + view.scroll_to(1, Some(&name), gtk4::ListScrollFlags::FOCUS, None); + view.grab_focus(); + let focused_cell = || gtk4::prelude::GtkWindowExt::focus(&shown.0).and_then(|focus| GridCell::from_focus(&focus)); + wait_until(WAIT, || focused_cell().is_some_and(|cell| cell.position() == Some(1))).expect("a cell takes the focus"); + let cell = focused_cell().expect("the focused cell"); + let _editor = type_into(&cell, "typed"); + // A focus left in a row the splice took out of the window is what a + // window focus-in then trips over. + let stranded = std::rc::Rc::new(std::cell::Cell::new(false)); + let seen = stranded.clone(); + let window = shown.0.downgrade(); + harness.store().connect_items_changed(move |_, _, _, _| { + let focus = window + .upgrade() + .and_then(|window| gtk4::prelude::GtkWindowExt::focus(&window)); + seen.set(seen.get() || focus.is_some_and(|focus| focus.root().is_none())); + }); + + harness.tab().send(PagingInput::NextPage); + wait_until(WAIT, || harness.shown() == text(&["Linus"])).expect("the next page"); + after_next_frame(&shown.0); + + assert!(!stranded.get(), "the focus stayed in a row the page removed"); + + assert!(!cell.is_editing(), "the edit outlived its row"); + let pending = harness.tracker.cell_overlay(&key(1), 1).pending; + assert!( + matches!(&pending, Some(CellInput::Value(Value::Other(typed))) if typed.text == "typed"), + "what was typed was lost, or landed on another row: {pending:?}" + ); + assert_eq!(harness.tracker.pending_count(), 1); + let focus = gtk4::prelude::GtkWindowExt::focus(&shown.0); + assert!( + focus.is_some_and(|focus| focus == *view.upcast_ref::() || focus.is_ancestor(&view)), + "the focus left the grid" + ); +} + +/// With no edit open the focus is on the column view's cell widget, +/// around the grid cell rather than in it, and the row still takes it +/// out of the window when the splice removes it. +#[gtk4::test] +fn a_page_that_replaces_the_focused_row_takes_the_focus_out_first() { + let harness = Harness::open(paging(full_page(0), rows_from(100, &["Linus"])), []); + wait_until(WAIT, || harness.shown().len() == 100).expect("the first page"); + let shown = Shown::new(harness.tab()); + let view = column_view(harness.tab()); + let name = crate::ui::grid::data_columns(&view) + .into_iter() + .nth(1) + .expect("the name column"); + view.scroll_to(1, Some(&name), gtk4::ListScrollFlags::FOCUS, None); + view.grab_focus(); + let focus = || gtk4::prelude::GtkWindowExt::focus(&shown.0); + wait_until(WAIT, || { + focus() + .and_then(|focus| GridCell::from_focus(&focus)) + .is_some_and(|cell| cell.position() == Some(1)) + }) + .expect("a cell takes the focus"); + assert!( + focus().is_some_and(|focus| focus.ancestor(GridCell::static_type()).is_none()), + "the focus is inside the grid cell, not on the cell widget around it" + ); + let stranded = std::rc::Rc::new(std::cell::Cell::new(false)); + let seen = stranded.clone(); + let window = shown.0.downgrade(); + harness.store().connect_items_changed(move |_, _, _, _| { + let focus = window + .upgrade() + .and_then(|window| gtk4::prelude::GtkWindowExt::focus(&window)); + seen.set(seen.get() || focus.is_some_and(|focus| focus.root().is_none())); + }); + + harness.tab().send(PagingInput::NextPage); + wait_until(WAIT, || harness.shown() == text(&["Linus"])).expect("the next page"); + after_next_frame(&shown.0); + + assert!(!stranded.get(), "the focus stayed in a row the page removed"); + assert!( + focus().is_some_and(|focus| focus == *view.upcast_ref::() || focus.is_ancestor(&view)), + "the focus left the grid" + ); +} + +/// The cell showing `column` of the saved row keyed `id`, once drawn. +pub(super) fn grid_cell_of(tab: &BrowseTab, id: i64, column: usize) -> GridCell { + let key = key(id); + let mut found = None; + wait_until(WAIT, || { + found = crate::test_support::descendants(tab) + .into_iter() + .filter_map(|widget| widget.downcast::().ok()) + .find(|cell| cell.column() == column && cell.row().is_some_and(|row| row.key() == Some(&key))); + found.is_some() + }) + .expect("the row's cell is drawn"); + found.expect("a cell") +} + +fn cell_text(cell: &GridCell) -> String { + cell.display() + .and_then(|display| display.text()) + .map(|text| text.to_string()) + .unwrap_or_default() +} + +/// What the grid sends once `text` is typed into `column` of a saved +/// row and committed. +fn typed(row: &RowObject, column: usize, text: &str) -> EditInput { + EditInput::GridEdit { + row: row.clone(), + column, + edit: TrackerEdit::Cell { + row_key: row.key().cloned().expect("a saved row"), + col: column, + original: row.cell(column).cloned().unwrap_or(Value::Null), + value: Value::Text(text.to_owned()), + }, + } +} + +/// Open the cell's one-line editor and type `text` over its value. +fn type_into(cell: &GridCell, text: &str) -> gtk4::Text { + crate::ui::grid::begin_edit(cell); + let editor = cell.inline_text().expect("the cell did not open its one-line editor"); + editor.set_text(text); + editor +} + +fn has_class(cell: &GridCell, class: &str) -> bool { + cell.content().is_some_and(|content| content.has_css_class(class)) +} + +fn rename(id: i64, from: &str, to: &str) -> TrackerEdit { + TrackerEdit::Cell { + row_key: key(id), + col: 1, + original: Value::Text(from.to_owned()), + value: Value::Text(to.to_owned()), + } +} + +#[gtk4::test] +fn a_commit_is_tracked_at_once_and_leaves_its_cell_in_the_window() { + let mut script = serving_table(); + script.query_bound.always(FakeReply::ok(rows(&["Ada", "Grace"]))); + let harness = Harness::open(script, []); + wait_until(WAIT, || harness.shown().len() == 2).expect("the first page"); + let _shown = Shown::new(harness.tab()); + let cell = grid_cell_of(harness.tab(), 0, 1); + let editor = type_into(&cell, "Ada Lovelace"); + + editor.emit_activate(); + + assert!( + cell.root().is_some(), + "the commit took its own cell out of the window before Tab could move on" + ); + assert_eq!(harness.tracker.pending_count(), 1, "the edit waited for the main loop"); + assert_eq!(cell_text(&cell), "Ada Lovelace"); + assert!( + has_class(&cell, "tp-cell-modified"), + "the cell does not show the edit as pending" + ); +} + +#[gtk4::test] +fn tracked_edit_keeps_multi_row_selection() { + let mut script = serving_table(); + script + .query_bound + .always(FakeReply::ok(rows(&["Ada", "Grace", "Linus", "Ken"]))); + let harness = Harness::open(script, []); + wait_until(WAIT, || harness.shown().len() == 4).expect("the first page"); + let _shown = Shown::new(harness.tab()); + let cell = grid_cell_of(harness.tab(), 2, 1); + let selection = harness.selection(); + selection.select_range(1, 3, true); + + let delta = harness.tracker.apply(rename(2, "Linus", "Linus Torvalds")); + harness.apply_delta(&delta); + + assert_eq!( + crate::ui::grid::selected_positions(&selection), + vec![1, 2, 3], + "the edited row left the selection" + ); + assert_eq!(cell_text(&cell), "Linus Torvalds"); + assert!(has_class(&cell, "tp-cell-modified")); +} + +#[gtk4::test] +fn tracked_edit_keeps_row_object_identity() { + let mut script = serving_table(); + script + .query_bound + .always(FakeReply::ok(rows(&["Ada", "Grace", "Linus"]))); + let harness = Harness::open(script, []); + wait_until(WAIT, || harness.shown().len() == 3).expect("the first page"); + let store = harness.store(); + let before = store.item(2); + let changes = std::rc::Rc::new(std::cell::Cell::new(0)); + let counter = changes.clone(); + store.connect_items_changed(move |_, _, _, _| counter.set(counter.get() + 1)); + + let delta = harness.tracker.apply(rename(2, "Linus", "Linus Torvalds")); + harness.apply_delta(&delta); + + assert!(before.is_some()); + assert_eq!(store.item(2), before, "the edited row was swapped for another object"); + assert_eq!(changes.get(), 0, "the store announced a change to its rows"); +} + +#[gtk4::test] +fn rejected_edit_restores_cell_display() { + let mut age = col("integer", true); + age.name = "age".to_owned(); + let columns = vec![column("id", true), age]; + let mut script = serving_table(); + script.fetch_columns.always(FakeReply::ok(columns.clone())); + script.query_bound.always(FakeReply::ok(QueryResult::new( + columns.iter().map(ColumnInfo::result_column).collect::>(), + vec![vec![Value::Int(0), Value::Int(36)]], + ))); + let harness = Harness::open(script, []); + wait_until(WAIT, || harness.saved_rows().len() == 1).expect("the first page"); + let toasts = toasts(harness.tab()); + let _shown = Shown::new(harness.tab()); + let cell = grid_cell_of(harness.tab(), 0, 1); + let editor = type_into(&cell, "thirty-six"); + + editor.emit_activate(); + + wait_until(WAIT, || cell_text(&cell) == "36").expect("the cell kept the text that was refused"); + assert_eq!(toasts.borrow().len(), 1, "{:?}", toasts.borrow()); + assert_eq!(harness.tracker.pending_count(), 0); +} + +#[gtk4::test] +fn refused_edit_restores_cell_display() { + let mut script = serving_table(); + script.query_bound.always(FakeReply::ok(rows(&["Ada"]))); + let harness = Harness::open(script, []); + wait_until(WAIT, || harness.shown().len() == 1).expect("the first page"); + let _shown = Shown::new(harness.tab()); + // A saved row's key, which the tab refuses to change in place. + let cell = grid_cell_of(harness.tab(), 0, 0); + let row = cell.row().expect("a bound row"); + cell.display().expect("a text cell").set_text(Some("7")); + + harness.tab().send(EditInput::GridEdit { + row: row.clone(), + column: 0, + edit: TrackerEdit::Cell { + row_key: key(0), + col: 0, + original: Value::Int(0), + value: Value::Int(7), + }, + }); + + assert_eq!(cell_text(&cell), "0", "the cell kept the edit the tab refused"); + assert_eq!(harness.tracker.pending_count(), 0); +} + +#[gtk4::test] +fn undo_under_an_open_editor_leaves_nothing_pending() { + let mut script = serving_table(); + script.query_bound.always(FakeReply::ok(rows(&["Ada", "Grace"]))); + let harness = Harness::open(script, []); + wait_until(WAIT, || harness.shown().len() == 2).expect("the first page"); + let _shown = Shown::new(harness.tab()); + let cell = grid_cell_of(harness.tab(), 0, 1); + harness.apply_delta(&harness.tracker.apply(rename(0, "Ada", "Ada Lovelace"))); + crate::ui::grid::begin_edit(&cell); + assert!(cell.is_editing()); + + harness.tab().send(EditInput::Undo); + cell.cancel_edit(); + + assert_eq!( + harness.tracker.pending_count(), + 0, + "closing the editor sent the undone value back" + ); + assert_eq!(cell_text(&cell), "Ada"); +} + +#[gtk4::test] +fn error_flash_sets_and_clears_on_the_failed_row() { + let mut script = serving_table(); + script.query_bound.always(FakeReply::ok(rows(&["Ada", "Grace"]))); + let harness = Harness::open(script, []); + wait_until(WAIT, || harness.shown().len() == 2).expect("the first page"); + let _shown = Shown::new(harness.tab()); + let cell = grid_cell_of(harness.tab(), 1, 0); + + harness + .tab() + .send(SaveInput::FlashErrorRow(StatementSource::Update { row_key: key(1) })); + + assert!( + has_class(&cell, "tp-row-leftmost-error-flash"), + "the failed row did not flash" + ); + assert!(!has_class( + &grid_cell_of(harness.tab(), 0, 0), + "tp-row-leftmost-error-flash" + )); + wait_until(WAIT, || !has_class(&cell, "tp-row-leftmost-error-flash")).expect("the flash never ended"); +} + +/// The first flash's timer cannot clear a flash that is no longer its +/// own, so the second one has to. +#[gtk4::test] +fn a_second_failed_row_takes_the_mark_off_the_first() { + let mut script = serving_table(); + script.query_bound.always(FakeReply::ok(rows(&["Ada", "Grace"]))); + let harness = Harness::open(script, []); + wait_until(WAIT, || harness.shown().len() == 2).expect("the first page"); + let _shown = Shown::new(harness.tab()); + let first = grid_cell_of(harness.tab(), 1, 0); + let row = first.parent().and_then(|cell| cell.parent()).expect("the row widget"); + let icon = crate::test_support::first_descendant_of_type::(&row).expect("the row's state icon"); + let fail = |id| { + harness + .tab() + .send(SaveInput::FlashErrorRow(StatementSource::Update { row_key: key(id) })); + }; + + fail(1); + assert_eq!(icon.icon_name().as_deref(), Some("dialog-error-symbolic")); + fail(0); + + assert!( + first.row().is_some_and(|row| row.key() == Some(&key(1))), + "the cell moved to another row" + ); + assert!(has_class( + &grid_cell_of(harness.tab(), 0, 0), + "tp-row-leftmost-error-flash" + )); + assert!(!has_class(&first, "tp-row-leftmost-error-flash")); + assert_eq!(icon.icon_name(), None, "the first row still reads as not saved"); + assert!(!gtk4::test_accessible_has_property( + &row, + gtk4::AccessibleProperty::Description + )); +} + +#[gtk4::test] +fn copy_row_as_insert_on_a_draft_leaves_what_the_server_fills_out() { + let mut id = col("integer", false); + id.name = "id".to_owned(); + id.primary_key = true; + id.is_auto_increment = true; + let mut name = col("text", true); + name.name = "name".to_owned(); + let mut created_at = col_with_default("timestamptz", "now()"); + created_at.name = "created_at".to_owned(); + let columns = vec![id, name, created_at]; + let mut script = ConnectionScript::default(); + script.fetch_columns.always(FakeReply::ok(columns.clone())); + script.row_identity.always(FakeReply::ok(RowIdentity::UniqueKey { + columns: vec!["id".to_owned()], + })); + script.count_rows.always(FakeReply::ok(1)); + script.probe.always(FakeReply::ok(())); + script.query_bound.always(FakeReply::ok(QueryResult::new( + columns.iter().map(ColumnInfo::result_column).collect::>(), + vec![vec![Value::Int(1), Value::Text("Ada".into()), Value::Null]], + ))); + let harness = Harness::open(script, []); + wait_until(WAIT, || harness.shown().len() == 1).expect("the first page"); + let toasts = toasts(harness.tab()); + + harness.tab().send(EditInput::InsertRow); + let draft = harness.grid_row(0); + assert!(draft.draft_id().is_some(), "the new row goes on top"); + harness.tab().send(EditInput::GridEdit { + row: draft.clone(), + column: 1, + edit: TrackerEdit::DraftCell { + draft_id: draft.draft_id().expect("a draft"), + col: 1, + input: CellInput::Value(Value::Text("x".into())), + }, + }); + let copied = copied_by(harness.tab(), || { + harness.tab().send(ClipboardInput::GridCopyRowsAsInsert(vec![draft])); + }); + + assert_eq!( + copied, + "INSERT INTO \"users\" (\"name\") VALUES ('x');", + "toasts: {:?}", + toasts.borrow() + ); +} + +fn people(names: &[&str]) -> Vec { + std::iter::once(column("id", true)) + .chain(names.iter().map(|name| column(name, false))) + .collect() +} + +/// One row, id 0, each cell holding its column's name. +fn people_page(columns: &[ColumnInfo]) -> QueryResult { + QueryResult::new( + columns.iter().map(ColumnInfo::result_column).collect::>(), + vec![ + std::iter::once(Value::Int(0)) + .chain(columns[1..].iter().map(|column| Value::Text(column.name.clone()))) + .collect(), + ], + ) +} + +/// A table whose `nickname` another client drops before the tab reads +/// its columns again. `after` is what the page under the new columns +/// answers. +fn losing_nickname(after: FakeReply) -> ConnectionScript { + let before = people(&["nickname", "name", "email"]); + let mut script = serving_table(); + script + .fetch_columns + .once(FakeReply::ok(before.clone())) + .always(FakeReply::ok(people(&["name", "email"]))); + script + .query_bound + .when_sql_contains("nickname", FakeReply::ok(people_page(&before))) + .always(after); + script +} + +fn without_nickname() -> FakeReply { + FakeReply::ok(people_page(&people(&["name", "email"]))) +} + +/// Each assignment of the save, by the name of the column it writes. +fn saved_cells(tracker: &TabTrackerHandle) -> Vec<(String, Option)> { + let batch = tracker.begin_save().expect("a save").expect("changes to save"); + batch + .changes + .changes + .changes + .iter() + .flat_map(|change| match change { + tablepro_core::save::RowChange::Update(update) => update.assignments.clone(), + other => panic!("only a cell was edited: {other:?}"), + }) + .map(|(col, value)| { + ( + batch.columns[col].name.clone(), + tablepro_core::export::value_to_text(&value), + ) + }) + .collect() +} + +fn edit_first_row(harness: &Harness, column: usize, input: &str) { + harness.tab().send(typed(&harness.grid_row(0), column, input)); + assert_eq!(harness.tracker.pending_count(), 1); +} + +fn headers_are(tab: &BrowseTab, expected: &[&str]) -> bool { + header_titles(tab) == expected +} + +#[gtk4::test] +fn refresh_after_a_column_is_dropped_keeps_an_edit_on_its_column() { + let harness = Harness::open(losing_nickname(without_nickname()), []); + wait_until(WAIT, || { + headers_are(harness.tab(), &["id", "nickname", "name", "email"]) + }) + .expect("the first grid"); + edit_first_row(&harness, 2, "Grace"); + + harness.tab().send(PagingInput::Refresh); + wait_until(WAIT, || headers_are(harness.tab(), &["id", "name", "email"])).expect("the grid under the new columns"); + + assert_eq!( + saved_cells(&harness.tracker), + vec![("name".to_owned(), Some("Grace".to_owned()))] + ); +} + +#[gtk4::test] +fn a_structure_save_that_drops_the_edited_column_refuses_the_save() { + let harness = Harness::open(losing_nickname(without_nickname()), []); + wait_until(WAIT, || { + headers_are(harness.tab(), &["id", "nickname", "name", "email"]) + }) + .expect("the first grid"); + edit_first_row(&harness, 1, "countess"); + + harness + .session + .invalidate_table(&tablepro_core::TableRef::unqualified("users")); + harness.tab().send(DataInput::SchemaChanged); + wait_until(WAIT, || headers_are(harness.tab(), &["id", "name", "email"])).expect("the grid under the new columns"); + + let refused = harness + .tracker + .begin_save() + .map(|batch| batch.map(|batch| batch.changes.changes)); + assert!( + refused + .as_ref() + .is_err_and(|rejection| rejection.message().contains("nickname")), + "{refused:?}" + ); + assert_eq!( + harness.tracker.pending_count(), + 1, + "the edit was dropped without a word" + ); +} + +#[gtk4::test] +fn a_grid_built_for_columns_the_table_lost_takes_no_edits() { + let harness = Harness::open(losing_nickname(FakeReply::UntilStopped), []); + wait_until(WAIT, || { + headers_are(harness.tab(), &["id", "nickname", "name", "email"]) + }) + .expect("the first grid"); + let row = harness.grid_row(0); + + harness + .session + .invalidate_table(&tablepro_core::TableRef::unqualified("users")); + harness.tab().send(DataInput::SchemaChanged); + wait_until(WAIT, || harness.tab().columns() == people(&["name", "email"])).expect("the new columns"); + // Column 2 was name in the grid the event came from, and is email now. + harness.tab().send(typed(&row, 2, "Grace")); + + assert_eq!( + harness.tracker.pending_count(), + 0, + "an edit in the old grid landed on another column" + ); + assert_eq!(harness.visible_page().as_deref(), Some("loading")); +} + +pub(super) fn delete_action(tab: &BrowseTab) -> gtk4::gio::SimpleAction { + use gtk4::subclass::prelude::ObjectSubclassIsExt; + tab.imp() + .model + .borrow() + .as_ref() + .map(|model| model.actions.delete_rows.clone()) + .expect("the tab has a model") +} + +fn delete_rows(tab: &BrowseTab) { + tab.activate_action("browse.delete-rows", None) + .expect("the tab has a delete action"); +} + +fn draft_rows(store: >k4::gio::ListStore) -> usize { + (0..store.n_items()) + .filter(|position| row_at(store, *position).draft_id().is_some()) + .count() +} + +fn pending_bar_shown(tab: &BrowseTab) -> bool { + use gtk4::subclass::prelude::ObjectSubclassIsExt; + tab.imp().pending_bar.imp().revealer.reveals_child() +} + +/// Discard empties the footer, which slides away with the focus in it, +/// so the focus goes to the rows. +#[gtk4::test] +fn discard_hands_the_focus_to_the_grid() { + use gtk4::subclass::prelude::ObjectSubclassIsExt; + let mut script = serving_table(); + script.query_bound.always(FakeReply::ok(rows(&["Ada", "Grace"]))); + let harness = Harness::open(script, []); + wait_until(WAIT, || harness.shown().len() == 2).expect("the first page"); + let shown = Shown::new(harness.tab()); + harness.tab().send(EditInput::InsertRow); + wait_until(WAIT, || pending_bar_shown(harness.tab())).expect("the footer"); + let discard = harness.tab().imp().pending_bar.imp().discard_button.get(); + assert!(discard.grab_focus()); + + harness.tab().send(SaveInput::DiscardAll); + + let view = column_view(harness.tab()); + let focus = gtk4::prelude::GtkWindowExt::focus(&shown.0); + assert!( + focus.is_some_and(|focus| focus == *view.upcast_ref::() || focus.is_ancestor(&view)), + "the focus left with the footer" + ); +} + +#[gtk4::test] +fn deleting_a_draft_removes_its_row() { + let mut script = serving_table(); + script.query_bound.always(FakeReply::ok(rows(&["Ada", "Grace"]))); + let harness = Harness::open(script, []); + wait_until(WAIT, || harness.shown().len() == 2).expect("the first page"); + harness.tab().send(EditInput::InsertRow); + let store = harness.store(); + assert_eq!(store.n_items(), 3); + harness.selection().select_item(0, true); + assert!( + delete_action(harness.tab()).is_enabled(), + "a draft could not be deleted" + ); + + delete_rows(harness.tab()); + + assert_eq!(store.n_items(), 2, "the dropped draft stayed in the grid"); + assert_eq!(draft_rows(&store), 0); + assert_eq!(harness.tracker.pending_count(), 0); + assert!( + !pending_bar_shown(harness.tab()), + "the footer still counts the dropped draft" + ); +} + +fn named_actions(controller: >k4::ShortcutController) -> Vec { + crate::test_support::shortcut_keys::shortcuts(controller) + .iter() + .filter_map(|shortcut| shortcut.action().and_downcast::()) + .map(|action| action.action_name().to_string()) + .collect() +} + +fn table_actions(owner: crate::application::ShortcutOwner) -> Vec { + use crate::application::{ACCELERATORS, AcceleratorScope}; + ACCELERATORS + .iter() + .filter(|entry| matches!(entry.scope, AcceleratorScope::Local { owner: of, .. } if of == owner)) + .flat_map(|entry| entry.triggers.iter().map(|_| entry.action.to_owned())) + .collect() +} + +/// The grid's keys sit on the column view, so a text field elsewhere in +/// the tab keeps Ctrl+C, Delete and Escape. +#[gtk4::test] +fn browse_grid_shortcuts_live_on_the_column_view() { + use crate::application::ShortcutOwner; + use crate::test_support::shortcut_keys::{owner_controller, shortcut_controllers}; + let mut script = serving_table(); + script.query_bound.always(FakeReply::ok(rows(&["Ada", "Grace"]))); + let harness = Harness::open(script, []); + wait_until(WAIT, || harness.shown().len() == 2).expect("the first page"); + let view = column_view(harness.tab()); + + let grid = owner_controller(&view, ShortcutOwner::BrowseGrid).expect("the grid's shortcuts"); + let tab = owner_controller(harness.tab(), ShortcutOwner::BrowseTab).expect("the tab's shortcuts"); + + assert_eq!(named_actions(&grid), table_actions(ShortcutOwner::BrowseGrid)); + assert_eq!(named_actions(&tab), table_actions(ShortcutOwner::BrowseTab)); + let on_the_tab: Vec = shortcut_controllers(harness.tab()) + .iter() + .flat_map(named_actions) + .collect(); + assert_eq!(on_the_tab, table_actions(ShortcutOwner::BrowseTab), "{on_the_tab:?}"); +} + +/// A bubble key on the tab reaches it only when no class on the way up +/// from the focus binds it first. Text fields keep their own keys, as +/// Ctrl+Z, by design. +#[gtk4::test] +fn browse_tab_keys_are_not_class_bindings_inside_the_tab() { + use crate::application::{ACCELERATORS, AcceleratorScope, ShortcutOwner}; + use crate::test_support::shortcut_keys::{class_keys_on_focus_paths, keystroke}; + let mut script = serving_table(); + script.query_bound.always(FakeReply::ok(rows(&["Ada", "Grace"]))); + let harness = Harness::open(script, []); + wait_until(WAIT, || harness.shown().len() == 2).expect("the first page"); + let _shown = Shown::new(harness.tab()); + + let class_keys = class_keys_on_focus_paths(harness.tab(), |widget| { + widget.is::() || widget.is::() + }); + + assert!( + class_keys.contains(&keystroke("Page_Up")), + "the grid's own bindings were not found, so the check reads nothing" + ); + for entry in ACCELERATORS { + let AcceleratorScope::Local { + owner: ShortcutOwner::BrowseTab, + phase, + } = entry.scope + else { + continue; + }; + assert_eq!(phase, gtk4::PropagationPhase::Bubble, "{}", entry.action); + for accel in entry.triggers { + assert!( + !class_keys.contains(&keystroke(accel)), + "{accel} of {} never reaches the tab", + entry.action + ); + } + } +} + +#[gtk4::test] +fn browse_actions_follow_the_tab() { + let mut script = serving_table(); + script.query_bound.always(FakeReply::ok(rows(&["Ada", "Grace"]))); + let harness = Harness::open(script, []); + wait_until(WAIT, || harness.shown().len() == 2).expect("the first page"); + let activate = |name: &str| { + harness + .tab() + .activate_action(&format!("browse.{name}"), None) + .expect("the browse group"); + }; + + assert!( + action_enabled(harness.tab(), "insert-row"), + "a keyed table refused Ctrl+N" + ); + assert!(!action_enabled(harness.tab(), "save"), "Save with nothing to save"); + assert!(!action_enabled(harness.tab(), "undo")); + assert!( + !action_enabled(harness.tab(), "copy-rows"), + "Ctrl+C with no rows selected" + ); + + harness.selection().select_all(); + assert!(action_enabled(harness.tab(), "copy-rows")); + assert!(action_enabled(harness.tab(), "clear-selection")); + activate("clear-selection"); + assert!( + !action_enabled(harness.tab(), "clear-selection"), + "the selection was not cleared" + ); + + harness.tab().send(EditInput::InsertRow); + assert!(action_enabled(harness.tab(), "save")); + assert!(action_enabled(harness.tab(), "undo")); + assert!(!action_enabled(harness.tab(), "redo")); + activate("undo"); + assert_eq!(harness.tracker.pending_count(), 0, "browse.undo did not undo"); + assert!(action_enabled(harness.tab(), "redo")); + assert!(!action_enabled(harness.tab(), "save")); +} + +/// These press real keys on the X display every test shares; nextest runs +/// them with no other test beside them. +mod real_keys { + use super::*; + + /// A real key press: the field's own undo runs before the tab's, so + /// Ctrl+Z in a filter field takes back typing, not a row edit. + #[gtk4::test] + fn ctrl_z_in_filter_entry_undoes_text_not_rows() { + use gtk4::gdk::{Key, ModifierType}; + // A key reaches a top bar only once its reveal has ended. + if let Some(settings) = gtk4::Settings::default() { + settings.set_gtk_enable_animations(false); + } + let mut script = serving_table(); + script.query_bound.always(FakeReply::ok(rows(&["Ada", "Grace"]))); + let harness = Harness::open(script, []); + wait_until(WAIT, || harness.shown().len() == 2).expect("the first page"); + let shown = Shown::new(harness.tab()); + crate::test_support::KeyInjector::focus_only(&shown.0); + wait_until(Duration::from_secs(2), || shown.0.is_active()).expect("the window to take the focus"); + harness.tab().send(EditInput::DeleteRows(vec![harness.grid_row(0)])); + assert_eq!(harness.tracker.pending_count(), 1); + let strip = { + use gtk4::subclass::prelude::ObjectSubclassIsExt; + let model = harness.tab().imp().model.borrow(); + model + .as_ref() + .map(|model| model.filter_strip.clone()) + .expect("the filter strip") + }; + + harness.tab().send(PagingInput::ToggleFilterStrip); + let mut field = None; + wait_until(Duration::from_secs(2), || { + field = gtk4::prelude::GtkWindowExt::focus(&shown.0) + .filter(|focus| focus.is_ancestor(&strip)) + .and_then(|focus| focus.downcast::().ok()); + field.is_some() + }) + .expect("the strip's field takes the focus"); + let field = field.expect("the focused field"); + let keys = crate::test_support::KeyInjector::connect().expect("an X11 display"); + + keys.press(Key::x, ModifierType::empty()).expect("the key press"); + wait_until(Duration::from_secs(2), || field.text() == "x").expect("the typing to land"); + keys.press(Key::z, ModifierType::CONTROL_MASK).expect("the key press"); + + wait_until(Duration::from_secs(2), || field.text().is_empty()).expect("Ctrl+Z to take back the typing"); + assert_eq!(harness.tracker.pending_count(), 1, "Ctrl+Z undid the row edit"); + } +} + +#[gtk4::test] +fn refresh_asks_the_window_and_filter_opens_the_strip() { + let mut script = serving_table(); + script.query_bound.always(FakeReply::ok(rows(&["Ada", "Grace"]))); + let harness = Harness::open(script, []); + wait_until(WAIT, || harness.shown().len() == 2).expect("the first page"); + let refreshes = crate::test_support::SignalLog::connect(harness.tab(), "refresh-requested", |_| ()); + let strip_open = || { + use gtk4::subclass::prelude::ObjectSubclassIsExt; + let model = harness.tab().imp().model.borrow(); + model.as_ref().is_some_and(|model| model.filter_strip.revealed()) + }; + + harness + .tab() + .activate_action("browse.refresh", None) + .expect("the browse group"); + harness + .tab() + .activate_action("browse.filter", None) + .expect("the browse group"); + + assert_eq!(refreshes.count(), 1); + assert!(strip_open()); +} + +#[gtk4::test] +fn a_read_only_tab_refuses_insert_row() { + let mut script = serving_table(); + script.query_bound.always(FakeReply::ok(rows(&["Ada"]))); + let harness = Harness::open_read_only(script); + wait_until(WAIT, || harness.shown().len() == 1).expect("the first page"); + + assert!(!action_enabled(harness.tab(), "insert-row")); + harness.tab().activate_action("browse.insert-row", None).ok(); + assert_eq!( + harness.tracker.pending_count(), + 0, + "Ctrl+N added a row to a read-only table" + ); + + harness.tab().send(EditInput::InsertRow); + harness.tab().send(EditInput::DuplicateRows(vec![harness.grid_row(0)])); + assert_eq!(harness.tracker.pending_count(), 0, "a read-only table took a new row"); +} + +#[gtk4::test] +fn browse_buttons_announce_their_keys() { + use gtk4::subclass::prelude::ObjectSubclassIsExt; + let mut script = serving_table(); + script.query_bound.always(FakeReply::ok(rows(&["Ada"]))); + let harness = Harness::open(script, []); + wait_until(WAIT, || harness.shown().len() == 1).expect("the first page"); + let imp = harness.tab().imp(); + + for (name, button) in [ + ("insert row", imp.insert_button.get()), + ("delete rows", imp.delete_button.get()), + ("filter", imp.filter_button.get()), + ("previous page", imp.pager_bar.imp().prev_button.get()), + ("next page", imp.pager_bar.imp().next_button.get()), + ("save", imp.pending_bar.imp().save_button.get()), + ] { + assert!( + gtk4::test_accessible_has_property(&button, gtk4::AccessibleProperty::KeyShortcuts), + "the {name} button announces no keys" + ); + } +} + +/// A page splice leaves the selection model without a selection-changed, +/// so the tab reads the selection again. +#[gtk4::test] +fn a_new_page_turns_off_the_selection_keys() { + use gtk4::subclass::prelude::ObjectSubclassIsExt; + let harness = Harness::open(paging(full_page(0), rows_from(100, &["Linus", "Ken"])), []); + wait_until(WAIT, || harness.shown().len() == 100).expect("the first page"); + harness.selection().select_range(0, 2, true); + assert!(action_enabled(harness.tab(), "copy-rows")); + assert!(action_enabled(harness.tab(), "clear-selection")); + + harness.tab().send(PagingInput::NextPage); + wait_until(WAIT, || harness.shown() == text(&["Linus", "Ken"])).expect("the next page"); + + assert!( + harness.selection().selection().is_empty(), + "the new page kept a selection" + ); + assert!( + !action_enabled(harness.tab(), "copy-rows"), + "Ctrl+C stayed on with nothing selected" + ); + assert!( + !action_enabled(harness.tab(), "clear-selection"), + "Escape stayed on with nothing selected" + ); + assert!( + !harness.tab().imp().selection_label.is_visible(), + "the footer still counts the rows that left" + ); +} + +fn action_enabled(tab: &BrowseTab, name: &str) -> bool { + use gtk4::subclass::prelude::ObjectSubclassIsExt; + let model = tab.imp().model.borrow(); + let actions = &model.as_ref().expect("the tab has a model").actions; + let action = match name { + "save" => &actions.save, + "undo" => &actions.undo, + "redo" => &actions.redo, + "insert-row" => &actions.insert_row, + "delete-rows" => &actions.delete_rows, + "copy-rows" => &actions.copy_rows, + "clear-selection" => &actions.clear_selection, + _ => panic!("browse.{name} does not follow the tab"), + }; + action.is_enabled() +} + +#[gtk4::test] +fn deleting_mixed_selection_removes_drafts_and_marks_persisted() { + let mut script = serving_table(); + script.query_bound.always(FakeReply::ok(rows(&["Ada", "Grace"]))); + let harness = Harness::open(script, []); + wait_until(WAIT, || harness.shown().len() == 2).expect("the first page"); + harness.tab().send(EditInput::InsertRow); + harness.selection().select_range(0, 2, true); + + delete_rows(harness.tab()); + + let store = harness.store(); + assert_eq!(store.n_items(), 2); + assert_eq!(draft_rows(&store), 0); + assert_eq!( + harness.tracker.row_state(&key(0)), + crate::services::change_tracker::RowState::PendingDelete + ); + assert_eq!( + harness.tracker.row_state(&key(1)), + crate::services::change_tracker::RowState::Clean + ); + assert_eq!(harness.tracker.pending_count(), 1); + assert!( + crate::ui::grid::selected_positions(&harness.selection()).is_empty(), + "the delete kept the selection it acted on" + ); +} + +fn menu_gesture(cell: &GridCell) -> gtk4::GestureClick { + let controllers = cell.observe_controllers(); + (0..controllers.n_items()) + .filter_map(|index| controllers.item(index).and_downcast::()) + .find(|gesture| gesture.button() == 3) + .expect("a cell answers a right-click") +} + +pub(super) fn column_view(tab: &BrowseTab) -> gtk4::ColumnView { + crate::test_support::first_descendant_of_type::(tab).expect("the grid") +} + +#[gtk4::test] +fn menu_delete_inside_selection_marks_whole_selection() { + let names: Vec = (0..20).map(|n| format!("user {n}")).collect(); + let names: Vec<&str> = names.iter().map(String::as_str).collect(); + let mut script = serving_table(); + script.query_bound.always(FakeReply::ok(rows(&names))); + let harness = Harness::open(script, []); + wait_until(WAIT, || harness.shown().len() == 20).expect("the first page"); + use libadwaita::prelude::{AdwDialogExt, AdwWindowExt}; + let window = libadwaita::Window::new(); + window.set_default_size(600, 400); + window.set_content(Some(harness.tab())); + window.present(); + harness.selection().select_all(); + let cell = grid_cell_of(harness.tab(), 2, 1); + + menu_gesture(&cell).emit_by_name::<()>("pressed", &[&1i32, &1.0f64, &1.0f64]); + column_view(harness.tab()) + .activate_action("cell.delete-row", None) + .expect("the menu's Delete"); + let mut dialog = None; + wait_until(WAIT, || { + dialog = window.visible_dialog().and_downcast::(); + dialog.is_some() + }) + .expect("a delete of 20 rows was not confirmed first"); + assert_eq!( + harness.tracker.pending_count(), + 0, + "the rows were marked before the answer" + ); + let dialog = dialog.expect("the confirmation"); + dialog.emit_by_name::<()>("response", &[&"delete"]); + + wait_until(WAIT, || harness.tracker.pending_count() == 20).expect("the whole selection was not marked"); + dialog.force_close(); + window.set_content(None::<>k4::Widget>); + window.destroy(); +} + +#[gtk4::test] +fn undo_insert_removes_draft_row() { + let mut script = serving_table(); + script.query_bound.always(FakeReply::ok(rows(&["Ada", "Grace"]))); + let harness = Harness::open(script, []); + wait_until(WAIT, || harness.shown().len() == 2).expect("the first page"); + harness.tab().send(EditInput::InsertRow); + let store = harness.store(); + assert_eq!(store.n_items(), 3); + + harness.tab().send(EditInput::Undo); + + assert_eq!(store.n_items(), 2, "the undone draft stayed in the grid"); + assert_eq!(draft_rows(&store), 0); + assert_eq!(harness.tracker.pending_count(), 0); +} + +#[gtk4::test] +fn redo_insert_restores_draft_row_once() { + let mut script = serving_table(); + script.query_bound.always(FakeReply::ok(rows(&["Ada", "Grace"]))); + let harness = Harness::open(script, []); + wait_until(WAIT, || harness.shown().len() == 2).expect("the first page"); + harness.tab().send(EditInput::InsertRow); + harness.tab().send(EditInput::Undo); + + harness.tab().send(EditInput::Redo); + + let store = harness.store(); + assert_eq!(store.n_items(), 3); + assert_eq!(draft_rows(&store), 1, "the redone draft is in the grid more than once"); + assert!(row_at(&store, 0).draft_id().is_some(), "the redone draft is not on top"); +} + +#[gtk4::test] +fn delete_action_disabled_for_unkeyed_selection() { + let columns = [column("id", false), column("name", false)]; + let mut script = serving_table(); + script.fetch_columns.always(FakeReply::ok(columns.to_vec())); + script.query_bound.always(FakeReply::ok(QueryResult::new( + columns.iter().map(ColumnInfo::result_column).collect::>(), + vec![ + vec![Value::Int(0), Value::Text("Ada".into())], + vec![Value::Int(1), Value::Text("Grace".into())], + ], + ))); + let harness = Harness::open(script, []); + wait_until(WAIT, || harness.shown().len() == 2).expect("the first page"); + let _shown = Shown::new(harness.tab()); + let action = delete_action(harness.tab()); + + harness.selection().select_item(0, true); + + assert!(!action.is_enabled(), "Delete was offered for a row with no key"); + let button = { + use gtk4::subclass::prelude::ObjectSubclassIsExt; + harness.tab().imp().delete_button.get() + }; + assert_eq!( + button.tooltip_text().as_deref(), + Some("Rows without a primary key or unique key can't be deleted here") + ); + let view = column_view(harness.tab()); + let mut cell = None; + wait_until(WAIT, || { + cell = crate::test_support::descendants(&view) + .into_iter() + .filter_map(|widget| widget.downcast::().ok()) + .find(|cell| cell.column() == 1 && cell.position() == Some(0)); + cell.is_some() + }) + .expect("the row's cell is drawn"); + menu_gesture(&cell.expect("a cell")).emit_by_name::<()>("pressed", &[&1i32, &1.0f64, &1.0f64]); + // A menu item is a model button bound to the action's enabled state, + // with the item's label as its text. + let items: Vec = crate::test_support::descendants(&view) + .into_iter() + .filter(|widget| widget.type_().name() == "GtkModelButton") + .filter(|widget| widget.property::>("text").as_deref() == Some("Delete")) + .collect(); + assert!(!items.is_empty(), "the menu has no Delete item"); + wait_until(WAIT, || items.iter().all(|item| !item.is_sensitive())) + .expect("the menu offered Delete for a row with no key"); + view.activate_action("cell.delete-row", None) + .expect("the menu's Delete"); + delete_rows(harness.tab()); + assert_eq!(harness.tracker.pending_count(), 0); +} + +fn named_col(name: &str, data_type: &str, nullable: bool) -> ColumnInfo { + let mut column = col(data_type, nullable); + column.name = name.to_owned(); + column +} + +/// A table of an `id` key and `column`, with one row, id 0, holding +/// `value`, drawn in a window. +fn one_cell(column: ColumnInfo, value: Value) -> (Harness, Shown) { + let columns = vec![self::column("id", true), column]; + let mut script = ConnectionScript::default(); + script.fetch_columns.always(FakeReply::ok(columns.clone())); + script.row_identity.always(FakeReply::ok(RowIdentity::UniqueKey { + columns: vec!["id".to_owned()], + })); + script.count_rows.always(FakeReply::ok(1)); + script.probe.always(FakeReply::ok(())); + script.query_bound.always(FakeReply::ok(QueryResult::new( + columns.iter().map(ColumnInfo::result_column).collect::>(), + vec![vec![Value::Int(0), value]], + ))); + let harness = Harness::open(script, []); + wait_until(WAIT, || harness.saved_rows().len() == 1).expect("the first page"); + let shown = Shown::new(harness.tab()); + (harness, shown) +} + +/// The value the tracker holds for row 0's second cell. +fn pending_value(harness: &Harness, fetched: &Value) -> Value { + harness.tracker.cell_overlay(&key(0), 1).shown(fetched.clone()) +} + +/// Open the cell's one-line editor and paste `text` over its value +/// through the clipboard, as Ctrl+V does. +fn paste_into(cell: &GridCell, text: &str) -> gtk4::Text { + crate::ui::grid::begin_edit(cell); + let editor = cell.inline_text().expect("the cell did not open its one-line editor"); + let opened = editor.text(); + editor.clipboard().set_text(text); + editor.emit_paste_clipboard(); + wait_until(WAIT, || editor.text() != opened).expect("the paste never landed"); + editor +} + +fn calendar_of(cell: &GridCell) -> Option { + crate::test_support::first_descendant_of_type::(cell) +} + +#[gtk4::test] +fn f005_unchanged_40kb_multiline_text_records_no_edit() { + let long = "lorem ipsum ".repeat(3_500); + let (harness, _shown) = one_cell(named_col("note", "text", true), Value::Text(long.clone())); + let cell = grid_cell_of(harness.tab(), 0, 1); + + crate::ui::grid::begin_edit(&cell); + + assert!(cell.inline_text().is_none(), "40 KB of text opened in a one-line field"); + let view = + crate::test_support::first_descendant_of_type::(&cell).expect("the multi-line editor"); + let buffer = view.buffer(); + assert_eq!(buffer.text(&buffer.start_iter(), &buffer.end_iter(), true), long); + crate::test_support::descendants(&cell) + .into_iter() + .filter_map(|widget| widget.downcast::().ok()) + .find(|button| button.has_css_class("suggested-action")) + .expect("the Save button") + .emit_clicked(); + assert_eq!( + harness.tracker.pending_count(), + 0, + "saving the text as it was recorded an edit" + ); + assert!(!cell.is_editing()); +} + +#[gtk4::test] +fn bigint_edit_is_exact() { + let fetched = Value::Int(1_234_567_890_123_456_789); + let (harness, _shown) = one_cell(named_col("n", "bigint", false), fetched.clone()); + let cell = grid_cell_of(harness.tab(), 0, 1); + + let editor = type_into(&cell, "1234567890123456790"); + assert_eq!( + editor.input_purpose(), + gtk4::InputPurpose::Number, + "a signed integer field offers a number keypad" + ); + editor.emit_activate(); + + assert_eq!(pending_value(&harness, &fetched), Value::Int(1_234_567_890_123_456_790)); +} + +#[gtk4::test] +#[expect( + clippy::approx_constant, + reason = "a double with every digit it can hold, which is the point, not pi" +)] +fn f005_pressing_enter_on_float_keeps_full_precision() { + let fetched = Value::Float64(3.141_592_653_589_79); + let (harness, _shown) = one_cell(named_col("x", "double precision", false), fetched); + let cell = grid_cell_of(harness.tab(), 0, 1); + + crate::ui::grid::begin_edit(&cell); + let editor = cell.inline_text().expect("a one-line field"); + assert_eq!(editor.text(), "3.14159265358979"); + editor.emit_activate(); + + assert_eq!( + harness.tracker.pending_count(), + 0, + "Enter rounded the value it opened on" + ); +} + +#[gtk4::test] +fn null_int_opens_empty_with_null_placeholder() { + let (harness, _shown) = one_cell(named_col("n", "integer", true), Value::Null); + let cell = grid_cell_of(harness.tab(), 0, 1); + + crate::ui::grid::begin_edit(&cell); + + let editor = cell.inline_text().expect("a one-line field"); + assert_eq!(editor.text(), "", "NULL opened as the text of its sentinel"); + assert_eq!(editor.placeholder_text().as_deref(), Some("NULL")); +} + +#[gtk4::test] +fn clearing_not_null_int_without_default_shows_required_toast() { + let fetched = Value::Int(5); + let (harness, _shown) = one_cell(named_col("n", "integer", false), fetched.clone()); + let toasts = toasts(harness.tab()); + let cell = grid_cell_of(harness.tab(), 0, 1); + + let editor = type_into(&cell, ""); + assert_eq!(editor.placeholder_text().as_deref(), Some("Required")); + editor.emit_activate(); + + assert_eq!(*toasts.borrow(), ["This column needs a value".to_owned()]); + assert_eq!(harness.tracker.pending_count(), 0); + assert_eq!(cell_text(&cell), "5"); +} + +#[gtk4::test] +fn draft_not_null_with_default_placeholder_is_default() { + let mut level = col_with_default("integer", "0"); + level.name = "level".to_owned(); + let (harness, _shown) = one_cell(level, Value::Int(3)); + harness.tab().send(EditInput::InsertRow); + let mut cell = None; + wait_until(WAIT, || { + cell = crate::test_support::descendants(harness.tab()) + .into_iter() + .filter_map(|widget| widget.downcast::().ok()) + .find(|cell| cell.column() == 1 && cell.row().is_some_and(|row| row.draft_id().is_some())); + cell.is_some() + }) + .expect("the new row's cell is drawn"); + let cell = cell.expect("a cell"); + cell.cancel_edit(); + assert_eq!(cell_text(&cell), "DEFAULT", "a cell left to the server read as NULL"); + + crate::ui::grid::begin_edit(&cell); + + let editor = cell.inline_text().expect("a one-line field"); + assert_eq!(editor.text(), ""); + assert_eq!(editor.placeholder_text().as_deref(), Some("DEFAULT")); +} + +#[gtk4::test] +fn f005_pasted_newlines_in_text_cell_are_kept() { + let fetched = Value::Text("a".into()); + let (harness, _shown) = one_cell(named_col("note", "text", true), fetched.clone()); + let cell = grid_cell_of(harness.tab(), 0, 1); + + let editor = paste_into(&cell, "first line\nsecond line"); + editor.emit_activate(); + + assert_eq!( + pending_value(&harness, &fetched), + Value::Text("first line\nsecond line".into()) + ); +} + +#[gtk4::test] +fn a_line_break_pasted_into_a_number_keeps_the_first_line() { + let fetched = Value::Int(1); + let (harness, _shown) = one_cell(named_col("n", "integer", true), fetched.clone()); + let cell = grid_cell_of(harness.tab(), 0, 1); + + let editor = paste_into(&cell, "42\n43"); + assert_eq!(editor.text(), "42"); + editor.emit_activate(); + + assert_eq!(pending_value(&harness, &fetched), Value::Int(42)); +} + +#[gtk4::test] +fn tinyint1_value_2_renders_inconsistent_with_exact_tooltip() { + let mut flag = named_col("flag", "tinyint(1)", false); + flag.column_type.kind = tablepro_core::column::ColumnKind::Bool(tablepro_core::column::BoolStorage::Integer); + let (harness, _shown) = one_cell(flag, Value::Int(2)); + let cell = grid_cell_of(harness.tab(), 0, 1); + + let check = cell.check_button().expect("a check button"); + + assert!(check.is_inconsistent(), "2 showed as true or false"); + assert_eq!(check.tooltip_text().as_deref(), Some("2")); + check.set_active(true); + assert_eq!(pending_value(&harness, &Value::Int(2)), Value::Int(1)); +} + +fn a_date(year: i32, month: u32, day: u32) -> Value { + Value::Date(tablepro_core::value::Temporal::Finite( + chrono::NaiveDate::from_ymd_opt(year, month, day).expect("a date"), + )) +} + +#[gtk4::test] +fn calendar_preselects_date_value() { + let (harness, _shown) = one_cell(named_col("day", "date", true), a_date(2024, 2, 29)); + let cell = grid_cell_of(harness.tab(), 0, 1); + + crate::ui::grid::begin_edit(&cell); + + let calendar = calendar_of(&cell).expect("a date opened its calendar"); + assert_eq!(calendar.date().ymd(), (2024, 2, 29)); +} + +#[gtk4::test] +fn picking_a_day_commits_it() { + let fetched = a_date(2024, 2, 29); + let (harness, _shown) = one_cell(named_col("day", "date", true), fetched.clone()); + let cell = grid_cell_of(harness.tab(), 0, 1); + crate::ui::grid::begin_edit(&cell); + let calendar = calendar_of(&cell).expect("the calendar"); + + let picked = gtk4::glib::DateTime::from_utc(2024, 3, 1, 0, 0, 0.0).expect("a day"); + calendar.set_date(&picked); + + assert_eq!(pending_value(&harness, &fetched), a_date(2024, 3, 1)); + assert!(!cell.is_editing()); +} + +/// The middle of the first of the calendar's labels that `matches`, +/// once the popover has laid it out. +fn calendar_label(calendar: >k4::Calendar, matches: impl Fn(>k4::Widget) -> bool) -> (f64, f64) { + let mut found = None; + wait_until(WAIT, || { + found = crate::test_support::descendants(calendar) + .into_iter() + .find(|widget| widget.width() > 0 && matches(widget)); + found.is_some() + }) + .expect("the label is drawn"); + let label = found.expect("a label"); + let middle = gtk4::graphene::Point::new(label.width() as f32 / 2.0, label.height() as f32 / 2.0); + let point = label + .compute_point(calendar, &middle) + .expect("the label is inside the calendar"); + (f64::from(point.x()), f64::from(point.y())) +} + +fn selected_day(calendar: >k4::Calendar) -> (f64, f64) { + calendar_label(calendar, |label| { + label.has_css_class("day-number") && label.state_flags().contains(gtk4::StateFlags::SELECTED) + }) +} + +/// What the calendar hears when a primary click at `(x, y)` ends. +fn release_on(calendar: >k4::Calendar, (x, y): (f64, f64)) { + let controllers = calendar.observe_controllers(); + for gesture in + (0..controllers.n_items()).filter_map(|index| controllers.item(index).and_downcast::()) + { + gesture.emit_by_name::<()>("released", &[&1i32, &x, &y]); + } +} + +fn release_space(calendar: >k4::Calendar) { + use gtk4::glib::translate::IntoGlib; + let controllers = calendar.observe_controllers(); + for keys in (0..controllers.n_items()) + .filter_map(|index| controllers.item(index).and_downcast::()) + { + keys.emit_by_name::<()>( + "key-released", + &[ + >k4::gdk::Key::space.into_glib(), + &0u32, + >k4::gdk::ModifierType::empty(), + ], + ); + } +} + +/// Open the cell's calendar until its popover shows. Test processes +/// share one X display, where another test's window can close an +/// autohide popover before it maps. +fn open_calendar(cell: &GridCell) -> gtk4::Calendar { + for _ in 0..5 { + crate::ui::grid::begin_edit(cell); + if let Some(calendar) = calendar_of(cell) + && wait_until(Duration::from_secs(2), || calendar.is_mapped() && calendar.width() > 0).is_ok() + { + return calendar; + } + cell.cancel_edit(); + drain_main_context(); + } + panic!("the calendar never showed"); +} + +fn shown_day(calendar: >k4::Calendar) -> Value { + let (year, month, day) = calendar.date().ymd(); + a_date(year, u32::try_from(month).unwrap(), u32::try_from(day).unwrap()) +} + +#[gtk4::test] +fn clicking_today_on_a_null_date_commits_it() { + let (harness, _shown) = one_cell(named_col("day", "date", true), Value::Null); + let cell = grid_cell_of(harness.tab(), 0, 1); + let calendar = open_calendar(&cell); + let today = shown_day(&calendar); + + release_on( + &calendar, + calendar_label(&calendar, |label| label.has_css_class("day-name")), + ); + assert!(cell.is_editing(), "a click on a weekday name picked a day"); + release_on(&calendar, selected_day(&calendar)); + + assert_eq!(pending_value(&harness, &Value::Null), today); + assert!(!cell.is_editing()); +} + +#[gtk4::test] +fn picking_the_same_day_in_another_month_commits_it() { + let fetched = a_date(2024, 3, 15); + let (harness, _shown) = one_cell(named_col("day", "date", true), fetched.clone()); + let cell = grid_cell_of(harness.tab(), 0, 1); + let calendar = open_calendar(&cell); + + // As the next-month arrow does, this moves the date without a + // day-selected. + calendar.set_date(>k4::glib::DateTime::from_utc(2024, 4, 15, 0, 0, 0.0).expect("a day")); + assert!(cell.is_editing(), "a month change picked a day"); + release_on(&calendar, selected_day(&calendar)); + + assert_eq!(pending_value(&harness, &fetched), a_date(2024, 4, 15)); +} + +#[gtk4::test] +fn space_on_the_selected_day_commits_it() { + let (harness, _shown) = one_cell(named_col("day", "date", true), Value::Null); + let cell = grid_cell_of(harness.tab(), 0, 1); + let calendar = open_calendar(&cell); + let today = shown_day(&calendar); + + release_space(&calendar); + + assert_eq!(pending_value(&harness, &Value::Null), today); +} + +#[gtk4::test] +fn opening_calendar_on_existing_date_records_no_edit() { + let (harness, _shown) = one_cell(named_col("day", "date", true), a_date(2024, 2, 29)); + let cell = grid_cell_of(harness.tab(), 0, 1); + crate::ui::grid::begin_edit(&cell); + let popover = crate::test_support::first_descendant_of_type::(&cell).expect("the calendar popover"); + + popover.popdown(); + wait_until(WAIT, || popover.parent().is_none()).expect("the popover closes"); + + assert_eq!( + harness.tracker.pending_count(), + 0, + "opening the calendar recorded its own day" + ); + assert!(!cell.is_editing()); +} + +#[gtk4::test] +fn out_of_range_date_opens_inline_editor() { + let (harness, _shown) = one_cell(named_col("day", "date", true), a_date(12_000, 1, 1)); + let cell = grid_cell_of(harness.tab(), 0, 1); + + crate::ui::grid::begin_edit(&cell); + + assert!( + calendar_of(&cell).is_none(), + "the calendar opened on a year it cannot show" + ); + let editor = cell.inline_text().expect("a one-line field"); + assert!(editor.text().contains("12000"), "{}", editor.text()); +} + +#[gtk4::test] +fn read_only_cells_use_inscription_not_selectable_label() { + let mut script = serving_table(); + script.query_bound.always(FakeReply::ok(rows(&["Ada"]))); + let harness = Harness::open(script, []); + wait_until(WAIT, || harness.shown().len() == 1).expect("the first page"); + let _shown = Shown::new(harness.tab()); + // A saved row's key, which the row is named by and cannot change. + let cell = grid_cell_of(harness.tab(), 0, 0); + + assert!(cell.lock().is_some()); + assert!( + cell.display().is_some(), + "a locked cell does not draw in an Inscription" + ); + assert!( + crate::test_support::descendants(&cell) + .iter() + .all(|widget| !widget.is::()), + "a locked cell carries a selectable label" + ); + crate::ui::grid::begin_edit(&cell); + assert!(!cell.is_editing(), "a locked cell opened for editing"); +} + +/// More rows than a delete marks without asking first. +const DOZEN: [&str; 12] = ["a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l"]; + +/// The confirmation a bulk delete is waiting on, by its ticket. +fn confirming(tab: &BrowseTab) -> Option<(u64, libadwaita::AlertDialog)> { + use gtk4::subclass::prelude::ObjectSubclassIsExt; + tab.imp().model.borrow().as_ref().and_then(|model| { + model + .confirming_delete + .as_ref() + .map(|pending| (pending.ticket, pending.dialog.clone())) + }) +} + +/// Every row selected and Delete pressed, which asks before marking. +fn ask_to_delete_every_row(harness: &Harness) -> libadwaita::AlertDialog { + wait_until(WAIT, || harness.shown().len() == DOZEN.len()).expect("the first page"); + harness.selection().select_all(); + delete_rows(harness.tab()); + let (_, dialog) = confirming(harness.tab()).expect("a bulk delete asks first"); + assert_eq!(harness.tracker.pending_count(), 0); + dialog +} + +fn answer_delete(dialog: &libadwaita::AlertDialog) { + dialog.emit_by_name::<()>("response", &[&"delete"]); + drain_main_context(); +} + +fn change_schema(harness: &Harness, done: impl Fn(&[ColumnInfo]) -> bool) { + harness + .session + .invalidate_table(&tablepro_core::TableRef::unqualified("users")); + harness.tab().send(DataInput::SchemaChanged); + wait_until(WAIT, || done(&harness.tab().columns())).expect("the new columns"); +} + +/// Closing the dialog after the answer answers again with its close +/// response, and the rows stay marked. +#[gtk4::test] +fn a_confirmed_bulk_delete_marks_every_selected_row() { + let mut script = serving_table(); + script.query_bound.always(FakeReply::ok(rows(&DOZEN))); + let harness = Harness::open(script, []); + let _shown = Shown::new(harness.tab()); + let dialog = ask_to_delete_every_row(&harness); + + answer_delete(&dialog); + libadwaita::prelude::AdwDialogExt::force_close(&dialog); + drain_main_context(); + + assert_eq!(harness.tracker.pending_count(), DOZEN.len()); + assert!(confirming(harness.tab()).is_none()); +} + +#[gtk4::test] +fn a_bulk_delete_waiting_while_the_primary_key_moves_is_cancelled() { + let mut script = serving_table(); + script + .fetch_columns + .once(FakeReply::ok(vec![column("id", true), column("name", false)])) + .always(FakeReply::ok(vec![column("id", false), column("name", true)])); + script.query_bound.always(FakeReply::ok(rows(&DOZEN))); + let harness = Harness::open(script, []); + let _shown = Shown::new(harness.tab()); + let toasts = toasts(harness.tab()); + let dialog = ask_to_delete_every_row(&harness); + + change_schema(&harness, |columns| { + columns.iter().any(|column| column.name == "name" && column.primary_key) + }); + answer_delete(&dialog); + + assert!(confirming(harness.tab()).is_none(), "the confirmation outlived the key"); + assert_eq!( + harness.tracker.pending_count(), + 0, + "rows were marked by the key they had before" + ); + assert_eq!(toasts.borrow().len(), 1, "{:?}", toasts.borrow()); +} + +#[gtk4::test] +fn a_bulk_delete_waiting_while_a_column_is_added_marks_its_rows_under_the_new_columns() { + let widened = [column("id", true), column("name", false), column("email", false)]; + let mut script = serving_table(); + script + .fetch_columns + .once(FakeReply::ok(vec![column("id", true), column("name", false)])) + .always(FakeReply::ok(widened.to_vec())); + script.query_bound.always(FakeReply::ok(rows(&DOZEN))); + let harness = Harness::open(script, []); + let _shown = Shown::new(harness.tab()); + let dialog = ask_to_delete_every_row(&harness); + + change_schema(&harness, |columns| columns.len() == widened.len()); + assert!( + confirming(harness.tab()).is_some(), + "a change that keeps the key cancelled the delete" + ); + answer_delete(&dialog); + + assert_eq!(harness.tracker.pending_count(), DOZEN.len()); + assert_eq!( + harness.tracker.row_state(&key(0)), + crate::services::change_tracker::RowState::PendingDelete + ); +} + +/// What this thread logs, as text. +#[derive(Clone, Default)] +pub(super) struct LogLines(Arc>>); + +impl LogLines { + pub(super) fn text(&self) -> String { + let bytes = self.0.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + String::from_utf8_lossy(&bytes).into_owned() + } +} + +impl std::io::Write for LogLines { + fn write(&mut self, bytes: &[u8]) -> std::io::Result { + self.0 + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .extend_from_slice(bytes); + Ok(bytes.len()) + } + + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } +} + +#[gtk4::test] +fn a_failed_row_count_logs_its_category_and_not_the_server_message() { + let lines = LogLines::default(); + let subscriber = tracing_subscriber::fmt() + .with_ansi(false) + .with_max_level(tracing::Level::DEBUG) + .with_writer({ + let lines = lines.clone(); + move || lines.clone() + }) + .finish(); + let _logging = tracing::subscriber::set_default(subscriber); + let mut script = serving_table(); + script.query_bound.always(FakeReply::ok(rows(&["Ada"]))); + script.count_rows.always(FakeReply::err(DriverError::reported( + tablepro_core::ServerDiagnostics::new(None, "You have an error in your SQL syntax near 'hunter2' at line 1"), + ))); + let _harness = Harness::open(script, []); + + wait_until(WAIT, || lines.text().contains("the row count could not be read")).expect("the failure is logged"); + + let text = lines.text(); + assert!(!text.contains("hunter2"), "{text}"); + assert!(text.contains("category=Some(Server)"), "{text}"); +} + +#[gtk4::test] +fn a_second_delete_replaces_the_waiting_confirmation() { + let mut script = serving_table(); + script.query_bound.always(FakeReply::ok(rows(&DOZEN))); + let harness = Harness::open(script, []); + let _shown = Shown::new(harness.tab()); + ask_to_delete_every_row(&harness); + let (first, _) = confirming(harness.tab()).expect("the first confirmation"); + + delete_rows(harness.tab()); + drain_main_context(); + let (second, dialog) = confirming(harness.tab()).expect("the closed confirmation cancelled the new one"); + assert_ne!(first, second); + harness.tab().send(EditInput::BulkDeleteAnswered { + ticket: first, + confirmed: true, + }); + drain_main_context(); + + assert_eq!( + harness.tracker.pending_count(), + 0, + "an answer to the replaced confirmation marked the rows" + ); + assert_eq!(confirming(harness.tab()).map(|(ticket, _)| ticket), Some(second)); + answer_delete(&dialog); + assert_eq!(harness.tracker.pending_count(), DOZEN.len()); +} + +#[gtk4::test] +fn a_bulk_delete_with_the_confirmation_off_marks_its_rows_at_once() { + let mut script = serving_table(); + script.query_bound.always(FakeReply::ok(rows(&DOZEN))); + let harness = Harness::open(script, []); + let _shown = Shown::new(harness.tab()); + wait_until(WAIT, || harness.shown().len() == DOZEN.len()).expect("the first page"); + + harness + .stores + .settings + .get() + .set_confirm_bulk_delete(false) + .expect("turn the confirmation off"); + harness.selection().select_all(); + delete_rows(harness.tab()); + drain_main_context(); + + assert!( + confirming(harness.tab()).is_none(), + "the delete asked with the switch off" + ); + assert_eq!(harness.tracker.pending_count(), DOZEN.len()); +} + +#[gtk4::test] +fn turning_the_confirmation_back_on_asks_again_in_an_open_tab() { + let mut script = serving_table(); + script.query_bound.always(FakeReply::ok(rows(&DOZEN))); + let harness = Harness::open(script, []); + let _shown = Shown::new(harness.tab()); + let settings = harness.stores.settings.get(); + settings + .set_confirm_bulk_delete(false) + .expect("turn the confirmation off"); + wait_until(WAIT, || harness.shown().len() == DOZEN.len()).expect("the first page"); + harness.selection().select_all(); + delete_rows(harness.tab()); + drain_main_context(); + assert_eq!(harness.tracker.pending_count(), DOZEN.len()); + harness.tab().send(EditInput::Undo); + assert_eq!(harness.tracker.pending_count(), 0); + + settings + .set_confirm_bulk_delete(true) + .expect("turn the confirmation on"); + let dialog = ask_to_delete_every_row(&harness); + crate::test_support::respond(&dialog, "delete"); + drain_main_context(); + + assert_eq!(harness.tracker.pending_count(), DOZEN.len()); +} + +#[gtk4::test] +fn insert_row_opens_the_new_rows_first_editable_cell() { + let (harness, shown) = one_cell(named_col("name", "text", true), Value::Text("Ada".into())); + grid_cell_of(harness.tab(), 0, 1); + + harness.tab().send(EditInput::InsertRow); + + let mut editing = None; + wait_until(WAIT, || { + editing = crate::test_support::descendants(harness.tab()) + .into_iter() + .filter_map(|widget| widget.downcast::().ok()) + .find(|cell| cell.inline_text().is_some()); + editing.is_some() + }) + .expect("the new row opens for editing"); + let cell = editing.expect("an editing cell"); + assert!(cell.row().is_some_and(|row| row.draft_id().is_some())); + assert_eq!(cell.column(), 0, "a new row sets its own key first"); + let focus = gtk4::prelude::GtkWindowExt::focus(&shown.0).expect("a focused widget"); + assert_eq!(GridCell::from_focus(&focus).as_ref(), Some(&cell)); +} + +#[gtk4::test] +fn icon_buttons_are_labelled_without_accelerator_hints() { + use gtk4::subclass::prelude::ObjectSubclassIsExt; + let harness = Harness::open(paging(full_page(0), rows_from(100, &["Linus"])), []); + wait_until(WAIT, || harness.shown().len() == 100).expect("the first page"); + let imp = harness.tab().imp(); + + assert_eq!( + crate::test_support::unlabelled_icon_buttons(harness.tab()), + Vec::::new() + ); + assert_eq!( + crate::test_support::tooltips_with_keys(harness.tab()), + Vec::::new() + ); + for button in [ + imp.insert_button.get(), + imp.pager_bar.imp().next_button.get(), + imp.pending_bar.imp().save_button.get(), + ] { + assert!( + gtk4::test_accessible_has_property(&button, gtk4::AccessibleProperty::KeyShortcuts), + "{:?} does not announce its keys", + button.tooltip_text() + ); + } +} + +#[gtk4::test] +fn filter_button_tooltip_counts_the_active_rules() { + use gtk4::subclass::prelude::ObjectSubclassIsExt; + let mut script = serving_table(); + script.query_bound.always(FakeReply::ok(rows(&["Ada"]))); + let mut two_rules = not_null_name(); + two_rules.rules.extend(not_null("id").rules); + let harness = Harness::open_filtered(script, [], two_rules); + wait_until(WAIT, || harness.shown().len() == 1).expect("the first page"); + let button = harness.tab().imp().filter_button.get(); + assert_eq!(button.tooltip_text().as_deref(), Some("2 Filter Rules Active")); + + harness.tab().send(PagingInput::FilterApplied(not_null_name())); + wait_until(WAIT, || { + button.tooltip_text().as_deref() == Some("1 Filter Rule Active") + }) + .expect("the tooltip kept the old count"); + + harness.tab().send(PagingInput::FilterApplied(FilterSet::default())); + wait_until(WAIT, || button.tooltip_text().as_deref() == Some("Filter Rows")) + .expect("the tooltip still counted rules after they were cleared"); +} + +/// The pending bar's Discard drops the undo history with the edits, so +/// it asks first, as F5 does. +#[gtk4::test] +fn discard_asks_before_it_drops_the_pending_changes() { + use gtk4::subclass::prelude::ObjectSubclassIsExt; + use libadwaita::prelude::{AdwDialogExt, AdwWindowExt, AlertDialogExt}; + let mut script = serving_table(); + script.query_bound.always(FakeReply::ok(rows(&["Ada", "Grace"]))); + let harness = Harness::open(script, []); + wait_until(WAIT, || harness.shown().len() == 2).expect("the first page"); + let window = crate::test_support::OwnedWindow::presented(libadwaita::Window::new()); + window.set_content(Some(harness.tab())); + harness.tab().send(EditInput::InsertRow); + let discard = harness.tab().imp().pending_bar.imp().discard_button.get(); + let alert = || { + let mut shown = None; + wait_until(WAIT, || { + shown = window.visible_dialog().and_downcast::(); + shown.is_some() + }) + .expect("Discard asks first"); + shown.expect("the alert") + }; + + discard.emit_clicked(); + let first = alert(); + assert_eq!(harness.tracker.pending_count(), 1, "the edit went before the answer"); + assert_eq!(first.close_response(), "cancel"); + first.emit_by_name::<()>("response", &[&"cancel"]); + first.force_close(); + drain_main_context(); + assert_eq!(harness.tracker.pending_count(), 1, "Cancel dropped the edit"); + + discard.emit_clicked(); + let second = alert(); + second.emit_by_name::<()>("response", &[&"discard"]); + second.force_close(); + + wait_until(WAIT, || harness.tracker.pending_count() == 0).expect("the confirmed discard"); + assert_eq!(draft_rows(&harness.store()), 0); + window.set_content(None::<>k4::Widget>); +} + +/// The page queries a tab sent, in order. +fn page_queries(harness: &Harness) -> Vec { + harness + .connection + .observed_sql("query_bound") + .into_iter() + .flatten() + .collect() +} + +/// A table with a key pages past the edge row of the page on screen, +/// so a page deep into it costs what the first does. The row numbers +/// still count from the first page. +#[gtk4::test] +fn a_keyed_table_pages_by_the_edge_rows_key_not_by_offset() { + let mut script = serving_table(); + script.count_rows.always(FakeReply::ok(1_000)); + script.query_bound.always(FakeReply::ok(full_page(0))); + let harness = Harness::open(script, []); + wait_until(WAIT, || harness.shown().len() == 100).expect("the first page"); + let read = |count: usize| { + wait_until(WAIT, || { + page_queries(&harness).len() == count && !harness.tab().is_loading() + }) + .expect("the page read"); + }; + + harness.tab().send(PagingInput::NextPage); + read(2); + harness.tab().send(PagingInput::NextPage); + read(3); + harness.tab().send(PagingInput::PrevPage); + read(4); + + let queries = page_queries(&harness); + for query in &queries[1..] { + assert!(!query.contains("OFFSET"), "{query}"); + } + assert!(queries[1].contains(" > ("), "{}", queries[1]); + assert!( + queries[3].contains(" < (") && queries[3].contains(" DESC"), + "Previous reads back from the page's first row: {}", + queries[3] + ); + assert_eq!(harness.tab().current_offset(), 100); +} + +/// A sort of the user's has ties a key cannot resume from, so it pages +/// by offset. +#[gtk4::test] +fn a_sorted_table_pages_by_offset() { + let mut script = serving_table(); + // A count below the second page would move it back to the first. + script.count_rows.always(FakeReply::ok(1_000)); + script.query_bound.always(FakeReply::ok(full_page(0))); + let harness = Harness::open(script, []); + wait_until(WAIT, || harness.shown().len() == 100).expect("the first page"); + harness.tab().send(PagingInput::SortChanged { + col_idx: 1, + ascending: true, + }); + wait_until(WAIT, || { + page_queries(&harness).len() == 2 && !harness.tab().is_loading() + }) + .expect("the sorted page"); + + harness.tab().send(PagingInput::NextPage); + wait_until(WAIT, || page_queries(&harness).len() == 3).expect("the next page"); + + let next = page_queries(&harness).pop().unwrap_or_default(); + assert!(next.ends_with("LIMIT 100 OFFSET 100"), "{next}"); +} diff --git a/linux/crates/app/src/ui/browse_tab/tests/total.rs b/linux/crates/app/src/ui/browse_tab/tests/total.rs new file mode 100644 index 0000000000..2266369ea5 --- /dev/null +++ b/linux/crates/app/src/ui/browse_tab/tests/total.rs @@ -0,0 +1,404 @@ +use std::time::Duration; + +use gtk4::prelude::*; +use gtk4::subclass::prelude::ObjectSubclassIsExt; +use tablepro_core::meta::RowCountEstimate; +use tablepro_core::{DriverError, TimeoutPhase}; +use tablepro_session::fakes::FakeEstablish; +use tablepro_test_support::{CallCounters, ConnectionScript, FakeReply}; + +use super::super::{PagingInput, SaveInput}; +use super::{ + Harness, LogLines, WAIT, estimate, full_page, not_null, not_null_name, paging, rows, rows_from, serving_table, text, +}; +use crate::services::change_origins::ChangeOrigins; +use crate::test_support::{drain_main_context, wait_until}; + +fn paginator(harness: &Harness) -> String { + harness.tab().imp().pager_bar.imp().paginator_label.label().to_string() +} + +fn count_button(harness: &Harness) -> gtk4::Button { + harness.tab().imp().pager_bar.imp().count_button.get() +} + +fn count_label(harness: &Harness) -> String { + harness.tab().imp().pager_bar.imp().count_label.label().to_string() +} + +fn count_calls(harness: &Harness) -> usize { + CallCounters::get(&harness.connection.counters().count_rows) +} + +/// A one-row table whose estimate is `estimate`. +fn estimated(rows_estimate: RowCountEstimate) -> ConnectionScript { + let mut script = serving_table(); + script.query_bound.always(FakeReply::ok(rows(&["Ada", "Grace"]))); + script.table_estimate = Default::default(); + script.table_estimate.always(FakeReply::ok(estimate(rows_estimate))); + script +} + +/// Long enough for a count the tab ran on its own to have been asked. +fn settle() { + std::thread::sleep(Duration::from_millis(200)); + drain_main_context(); +} + +#[gtk4::test] +fn large_estimate_offers_count_without_counting() { + let harness = Harness::open(estimated(RowCountEstimate::Approximate(5_000_000)), []); + wait_until(WAIT, || paginator(&harness).contains("of about")).expect("the estimate in the paginator"); + settle(); + + assert_eq!(count_calls(&harness), 0, "a large table was counted on its own"); + assert!(count_button(&harness).is_visible() && count_button(&harness).is_sensitive()); + assert_eq!(count_label(&harness), "Count Rows"); + assert!(!harness.tab().imp().pager_bar.imp().last_button.is_sensitive()); +} + +#[gtk4::test] +fn unknown_estimate_offers_count_without_counting() { + let harness = Harness::open(estimated(RowCountEstimate::Unknown), []); + wait_until(WAIT, || harness.shown().len() == 2).expect("the first page"); + wait_until(WAIT, || count_button(&harness).is_visible()).expect("Count Rows offered"); + settle(); + + assert_eq!( + count_calls(&harness), + 0, + "a table with no estimate was counted on its own" + ); + assert_eq!(paginator(&harness), "Rows 1 – 2"); +} + +#[gtk4::test] +fn estimate_error_offers_count_and_logs() { + let lines = LogLines::default(); + let subscriber = tracing_subscriber::fmt() + .with_ansi(false) + .with_writer({ + let lines = lines.clone(); + move || lines.clone() + }) + .finish(); + let _logging = tracing::subscriber::set_default(subscriber); + let mut script = estimated(RowCountEstimate::Unknown); + script.table_estimate = Default::default(); + script + .table_estimate + .always(FakeReply::err(DriverError::server("pg_class is hidden from hunter2"))); + let harness = Harness::open(script, []); + + wait_until(WAIT, || lines.text().contains("row estimate unavailable")).expect("the failure is logged"); + wait_until(WAIT, || count_button(&harness).is_visible()).expect("Count Rows offered"); + settle(); + + let text = lines.text(); + assert!(text.contains("category=Some(Server)"), "{text}"); + assert!(!text.contains("hunter2"), "{text}"); + assert_eq!(count_calls(&harness), 0); +} + +#[gtk4::test] +fn small_estimate_counts_automatically() { + let harness = Harness::open(estimated(RowCountEstimate::Approximate(40)), []); + + wait_until(WAIT, || paginator(&harness) == "Rows 1 – 2 of 2").expect("the exact total"); + + assert_eq!(count_calls(&harness), 1); + assert!( + !count_button(&harness).is_visible(), + "Count Rows stayed for an exact total" + ); +} + +#[gtk4::test] +fn count_rows_button_counts_and_shows_progress() { + let mut script = estimated(RowCountEstimate::Unknown); + script.count_rows = Default::default(); + script.count_rows.always(FakeReply::Late { + after: Duration::from_millis(300), + outcome: Ok(2), + }); + let harness = Harness::open(script, []); + wait_until(WAIT, || count_button(&harness).is_visible()).expect("Count Rows offered"); + + count_button(&harness).emit_clicked(); + + assert_eq!(count_label(&harness), "Counting…"); + assert!(!count_button(&harness).is_sensitive()); + assert!(harness.tab().imp().pager_bar.imp().count_spinner.is_visible()); + wait_until(WAIT, || paginator(&harness) == "Rows 1 – 2 of 2").expect("the counted total"); + assert!(!count_button(&harness).is_visible()); +} + +/// The table is too large to count on its own, so under the filter +/// nothing replaces the count: only the filter itself can stop it. +#[gtk4::test] +fn count_is_cancelled_when_filter_changes() { + let mut script = estimated(RowCountEstimate::Approximate(5_000_000)); + script.count_rows = Default::default(); + script.count_rows.always(FakeReply::UntilStopped); + let harness = Harness::open(script, []); + wait_until(WAIT, || paginator(&harness).contains("of about")).expect("the estimate in the paginator"); + count_button(&harness).emit_clicked(); + wait_until(WAIT, || count_calls(&harness) == 1).expect("the count was asked for"); + let counters = harness.connection.counters(); + + harness.tab().send(PagingInput::FilterApplied(not_null_name())); + + wait_until(WAIT, || CallCounters::get(&counters.stopped) == 1).expect("the filter left the count running"); + wait_until(WAIT, || count_label(&harness) == "Count Rows").expect("the stopped count still shows"); + settle(); + assert_eq!(count_calls(&harness), 1, "the filter counted on its own"); +} + +#[gtk4::test] +fn stale_count_reply_does_not_overwrite_newer_total() { + let mut script = estimated(RowCountEstimate::Approximate(40)); + script.count_rows = Default::default(); + script + .count_rows + .once(FakeReply::Late { + after: Duration::from_millis(300), + outcome: Ok(999), + }) + .always(FakeReply::ok(5)); + let harness = Harness::open(script, []); + wait_until(WAIT, || count_calls(&harness) == 1).expect("the first count was asked for"); + + harness.tab().send(PagingInput::FilterApplied(not_null_name())); + wait_until(WAIT, || paginator(&harness) == "Rows 1 – 2 of 5").expect("the filtered total"); + std::thread::sleep(Duration::from_millis(500)); + drain_main_context(); + + assert_eq!( + paginator(&harness), + "Rows 1 – 2 of 5", + "the replaced count's reply was shown" + ); +} + +#[gtk4::test] +fn count_failure_shows_unavailable_with_reason() { + let mut script = estimated(RowCountEstimate::Approximate(40)); + script.count_rows = Default::default(); + script + .count_rows + .once(FakeReply::err(DriverError::server("statement denied"))) + .always(FakeReply::ok(2)); + let harness = Harness::open(script, []); + + // The count and the page are separate reads, and either can land first. + wait_until(WAIT, || { + count_label(&harness) == "Try Again" && paginator(&harness) == "Rows 1 – 2 of about 40" + }) + .expect("the failed count under the first page"); + let tooltip = count_button(&harness) + .tooltip_text() + .map(|text| text.to_string()) + .unwrap_or_default(); + assert!(tooltip.contains("statement denied"), "{tooltip}"); + assert!(count_button(&harness).is_sensitive()); + + count_button(&harness).emit_clicked(); + + wait_until(WAIT, || paginator(&harness) == "Rows 1 – 2 of 2").expect("Try Again counted"); +} + +#[gtk4::test] +fn save_adjusts_unfiltered_total_without_recount() { + let harness = Harness::open(estimated(RowCountEstimate::Approximate(40)), []); + wait_until(WAIT, || paginator(&harness) == "Rows 1 – 2 of 2").expect("the exact total"); + let queries = CallCounters::get(&harness.connection.counters().query_bound); + + harness + .tab() + .send(SaveInput::SaveCommitted(ChangeOrigins { inserts: 2, deletes: 1 })); + wait_until(WAIT, || { + CallCounters::get(&harness.connection.counters().query_bound) == queries + 1 + }) + .expect("the page read after the save"); + settle(); + + assert_eq!(count_calls(&harness), 1, "the save counted the table again"); + assert_eq!(paginator(&harness), "Rows 1 – 2 of 3"); +} + +#[gtk4::test] +fn save_on_filtered_view_demotes_total_to_about() { + let harness = Harness::open_filtered(estimated(RowCountEstimate::Approximate(40)), [], not_null_name()); + wait_until(WAIT, || paginator(&harness) == "Rows 1 – 2 of 2").expect("the exact filtered total"); + + harness + .tab() + .send(SaveInput::SaveCommitted(ChangeOrigins { inserts: 1, deletes: 0 })); + + assert_eq!(paginator(&harness), "Rows 1 – 2 of about 3"); + assert_eq!(count_label(&harness), "Count Rows"); + assert!(count_button(&harness).is_visible()); +} + +#[gtk4::test] +fn closing_tab_cancels_running_count() { + let mut script = estimated(RowCountEstimate::Approximate(40)); + script.count_rows = Default::default(); + script.count_rows.always(FakeReply::UntilStopped); + let mut harness = Harness::open(script, []); + wait_until(WAIT, || count_calls(&harness) == 1).expect("the count was asked for"); + let counters = harness.connection.counters(); + + harness.tab.take(); + drain_main_context(); + + wait_until(WAIT, || CallCounters::get(&counters.stopped) == 1).expect("closing the tab left the count running"); +} + +#[gtk4::test] +fn last_page_waits_for_an_exact_total() { + let mut script = paging(full_page(0), rows_from(900, &["Linus"])); + // The last page is read back from the table's end, so the count + // agrees with the one row it holds. + script.count_rows = Default::default(); + script.count_rows.always(FakeReply::ok(901)); + script.table_estimate = Default::default(); + script + .table_estimate + .always(FakeReply::ok(estimate(RowCountEstimate::Approximate(5_000_000)))); + let harness = Harness::open(script, []); + wait_until(WAIT, || harness.shown().len() == 100).expect("the first page"); + wait_until(WAIT, || count_button(&harness).is_visible()).expect("Count Rows offered"); + let last = harness.tab().imp().pager_bar.imp().last_button.get(); + assert!(!last.is_sensitive()); + assert_eq!( + last.tooltip_text().as_deref(), + Some("Count rows to go to the last page") + ); + harness.tab().send(PagingInput::LastPage); + drain_main_context(); + assert_eq!(CallCounters::get(&harness.connection.counters().query_bound), 1); + + count_button(&harness).emit_clicked(); + wait_until(WAIT, || last.is_sensitive()).expect("Last after the count"); + last.emit_clicked(); + + wait_until(WAIT, || harness.shown() == text(&["Linus"])).expect("the last page"); + assert_eq!(harness.tab().current_offset(), 900); +} + +fn timed_out() -> DriverError { + DriverError::Timeout { + phase: TimeoutPhase::Statement, + server_cancelled: true, + } +} + +/// A count the user asked for ran out of time. Nothing counts again +/// until they ask, a reconnect included, and least of all under the +/// filter they applied since. +#[gtk4::test] +fn a_failed_count_does_not_run_again_after_a_reconnect() { + let mut first = estimated(RowCountEstimate::Approximate(5_000_000)); + first.count_rows = Default::default(); + first.count_rows.always(FakeReply::err(timed_out())); + let harness = Harness::open( + first, + [FakeEstablish::Connect(estimated(RowCountEstimate::Approximate( + 5_000_000, + )))], + ); + wait_until(WAIT, || paginator(&harness).contains("of about")).expect("the estimate in the paginator"); + count_button(&harness).emit_clicked(); + wait_until(WAIT, || count_label(&harness) == "Try Again").expect("the count that ran out of time"); + harness.tab().send(PagingInput::FilterApplied(not_null_name())); + wait_until(WAIT, || count_label(&harness) == "Count Rows").expect("the filtered total"); + + harness.connection.mark_lost(); + wait_until(WAIT, || harness.session.link_generation().get() == 1).expect("the reconnect"); + settle(); + settle(); + + assert_eq!( + CallCounters::get(&harness.fake.replacement().counters().count_rows), + 0, + "the reconnect counted the table without being asked" + ); + assert_eq!(count_calls(&harness), 1); +} + +/// Deleting the only row on the last page leaves the table one page +/// shorter, which the exact total says, so the tab moves to the page +/// that is now the last. +#[gtk4::test] +fn saving_away_the_last_page_moves_to_the_new_last_page() { + let mut script = paging(full_page(0), rows_from(100, &["Linus"])); + script.count_rows = Default::default(); + script.count_rows.always(FakeReply::ok(101)); + let harness = Harness::open(script, []); + wait_until(WAIT, || paginator(&harness) == "Rows 1 – 100 of 101").expect("the exact total"); + harness.tab().send(PagingInput::NextPage); + wait_until(WAIT, || harness.shown() == text(&["Linus"])).expect("the last page"); + let queries = CallCounters::get(&harness.connection.counters().query_bound); + + harness + .tab() + .send(SaveInput::SaveCommitted(ChangeOrigins { inserts: 0, deletes: 1 })); + wait_until(WAIT, || { + CallCounters::get(&harness.connection.counters().query_bound) == queries + 1 + }) + .expect("the page read after the save"); + settle(); + + assert_eq!(harness.tab().current_offset(), 0, "the tab stayed past the end"); + assert_eq!( + CallCounters::get(&harness.connection.counters().query_bound), + queries + 1, + "the save read a page twice" + ); + assert_eq!(count_calls(&harness), 1); +} + +/// The estimate can land after the user asked for a count. The count +/// gives the total, so it keeps running and keeps its spinner. +#[gtk4::test] +fn an_estimate_that_lands_during_a_count_keeps_it_counting() { + let mut script = estimated(RowCountEstimate::Unknown); + script.table_estimate = Default::default(); + script.table_estimate.always(FakeReply::Late { + after: Duration::from_millis(300), + outcome: Ok(estimate(RowCountEstimate::Approximate(5_000_000))), + }); + script.count_rows = Default::default(); + script.count_rows.always(FakeReply::UntilStopped); + let harness = Harness::open(script, []); + wait_until(WAIT, || count_button(&harness).is_visible()).expect("Count Rows offered"); + + count_button(&harness).emit_clicked(); + wait_until(WAIT, || paginator(&harness).contains("of about")).expect("the estimate"); + + assert_eq!(count_label(&harness), "Counting…"); + assert!(!count_button(&harness).is_sensitive()); + assert!(harness.tab().imp().pager_bar.imp().count_spinner.is_visible()); + assert_eq!(count_calls(&harness), 1); + assert_eq!(CallCounters::get(&harness.connection.counters().stopped), 0); +} + +#[gtk4::test] +fn count_rows_under_a_filter_that_no_longer_fits_says_why() { + let harness = Harness::open(estimated(RowCountEstimate::Approximate(5_000_000)), []); + wait_until(WAIT, || paginator(&harness).contains("of about")).expect("the estimate in the paginator"); + let banner = harness.tab().imp().stale_banner.get(); + harness.tab().send(PagingInput::FilterApplied(not_null("nickname"))); + wait_until(WAIT, || banner.is_revealed()).expect("the filter that does not fit"); + + count_button(&harness).emit_clicked(); + + assert_eq!( + count_label(&harness), + "Try Again", + "Count Rows did nothing and said nothing" + ); + assert_eq!(count_button(&harness).tooltip_text(), banner.tooltip_text()); + assert_eq!(count_calls(&harness), 0); +} diff --git a/linux/crates/app/src/ui/clipboard/clipboard_copy.rs b/linux/crates/app/src/ui/clipboard/clipboard_copy.rs new file mode 100644 index 0000000000..a2fbb2be01 --- /dev/null +++ b/linux/crates/app/src/ui/clipboard/clipboard_copy.rs @@ -0,0 +1,185 @@ +use std::cell::Cell; +use std::rc::Rc; +use std::sync::atomic::{AtomicU64, Ordering}; + +use gtk4 as gtk; +use gtk4::prelude::*; +use gtk4::{gdk, glib}; +use tablepro_core::export::{EncodeError, RenderInsertError}; +use tablepro_session::runtime::Tasks; + +use super::clipboard_writer::{holds_nul, write}; +use super::{ClipboardError, ClipboardPayload, CopyError, CopyKind, CopyRequest, render_copy}; +use crate::i18n::{gettext, gettext_f, ngettext_f}; +use crate::ui::format::{format_thousands, plural_count}; +use crate::ui::handler_guard::HandlerGuard; + +/// The copy asked for last, in the whole process: every window copies +/// to the same display clipboard, and a large copy renders long enough +/// for a later, smaller one to land first. +static LATEST_COPY: AtomicU64 = AtomicU64::new(0); + +#[derive(Debug, Clone, Copy)] +struct CopyTicket(u64); + +impl CopyTicket { + fn take() -> Self { + Self(LATEST_COPY.fetch_add(1, Ordering::Relaxed) + 1) + } + + fn is_latest(self) -> bool { + LATEST_COPY.load(Ordering::Relaxed) == self.0 + } +} + +/// Whether this process put something else on the clipboard since the +/// watch began: a copy that writes at once, or a text field's own Copy, +/// which no ticket sees. Another application's copy is not counted, +/// since Wayland announces the clipboard again on every focus change. +struct LocalWrite { + seen: Rc>, + _handler: HandlerGuard, +} + +impl LocalWrite { + fn watch(clipboard: &gdk::Clipboard) -> Self { + let seen = Rc::new(Cell::new(false)); + let flag = seen.clone(); + let handler = clipboard.connect_changed(move |clipboard| { + if clipboard.is_local() { + flag.set(true); + } + }); + Self { + seen, + _handler: HandlerGuard::new(clipboard, handler), + } + } + + fn seen(&self) -> bool { + self.seen.get() + } +} + +/// What the toast after a copy counts. +#[derive(Debug, Clone, Copy)] +enum Copied { + Value, + Rows, + InList, +} + +impl Copied { + fn of(request: &CopyRequest) -> Self { + match request { + CopyRequest::Rows { + kind: CopyKind::Value(_), + .. + } => Self::Value, + CopyRequest::InClause { .. } => Self::InList, + CopyRequest::Rows { .. } | CopyRequest::Insert(_) => Self::Rows, + } + } + + fn text(self, rows: usize) -> String { + let n = rows as u64; + match self { + Self::Value => gettext("Copied to clipboard"), + Self::Rows => ngettext_f( + "Copied {n} row", + "Copied {n} rows", + plural_count(n), + &[("n", &format_thousands(n))], + ), + Self::InList => ngettext_f( + "Copied {n} value", + "Copied {n} values", + plural_count(n), + &[("n", &format_thousands(n))], + ), + } + } +} + +/// Render `request` off the GTK thread and put it on `widget`'s +/// clipboard, then tell `report` how it went. Anything copied in this +/// process after this one wins, and this one then ends without a word. +/// The handle resolves once the copy has landed or given way. +pub(crate) fn copy_rows( + widget: &impl IsA, + tasks: &Tasks, + request: CopyRequest, + report: impl Fn(&str) + 'static, +) -> glib::JoinHandle<()> { + let ticket = CopyTicket::take(); + let clipboard = widget.as_ref().clipboard(); + let overtaken = LocalWrite::watch(&clipboard); + let copied = Copied::of(&request); + let rendering = render_copy(tasks, request); + glib::spawn_future_local(async move { + let rendered = rendering.await; + if !ticket.is_latest() || overtaken.seen() { + tracing::debug!("a copy finished after a later one and was dropped"); + return; + } + let payload = match rendered { + Ok(Ok(payload)) => payload, + Ok(Err(error)) => return report(©_error_text(&error)), + Err(failure) => { + tracing::warn!(%failure, "a copy could not be rendered"); + return report(&gettext("Couldn't copy the selection")); + } + }; + let (rows, skipped) = (payload.rows, payload.skipped); + if let Err(error) = write(&clipboard, payload) { + return report(&error.message()); + } + report(&copied.text(rows)); + // NULL and binary values have no place in an IN list, so the + // copy says how many it left out instead of handing back a + // shorter list that quietly selects different rows. + if skipped > 0 { + report(&gettext_f( + "{n} NULL or binary values left out of the IN clause", + &[("n", &skipped.to_string())], + )); + } + }) +} + +/// Put `text` on `widget`'s clipboard as it is, and tell `report`. +pub(crate) fn copy_text(widget: &impl IsA, text: String, report: impl Fn(&str)) { + copy_payload(widget, ClipboardPayload::plain(text), report); +} + +/// Put a payload rendered already on `widget`'s clipboard, and tell +/// `report`. +pub(crate) fn copy_payload(widget: &impl IsA, payload: ClipboardPayload, report: impl Fn(&str)) { + if holds_nul(&payload.text) { + return report(&ClipboardError::ContainsNul.message()); + } + match write(&widget.as_ref().clipboard(), payload) { + Ok(()) => report(&gettext("Copied to clipboard")), + Err(error) => report(&error.message()), + } +} + +fn copy_error_text(error: &CopyError) -> String { + match error { + CopyError::Encode(EncodeError::Undecodable { column }) => gettext_f( + "Couldn't copy: the column “{column}” holds a value TablePro couldn't read", + &[("column", column)], + ), + CopyError::Encode(EncodeError::NotXmlText { column }) => gettext_f( + "Couldn't copy: the column “{column}” holds a control character", + &[("column", column)], + ), + CopyError::Insert(RenderInsertError::Literal(_)) => { + gettext("This row has a value that can't be written as SQL") + } + CopyError::Insert(RenderInsertError::NoValues) => gettext("This row has no value to put in an INSERT"), + CopyError::Literal(_) => gettext("This column has a value that can't be written as SQL"), + CopyError::EmptyInClause => gettext("Nothing to copy: an IN clause can't carry NULL or binary values"), + CopyError::ContainsNul => ClipboardError::ContainsNul.message(), + } +} diff --git a/linux/crates/app/src/ui/clipboard/clipboard_format.rs b/linux/crates/app/src/ui/clipboard/clipboard_format.rs new file mode 100644 index 0000000000..973d4e6b36 --- /dev/null +++ b/linux/crates/app/src/ui/clipboard/clipboard_format.rs @@ -0,0 +1,40 @@ +/// What a copy holds, so an application that reads that type can take +/// it as such: a spreadsheet pastes tab-separated values into cells. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ClipboardFormat { + PlainText, + Tsv, + Csv, + Json, + Markdown, + Sql, +} + +impl ClipboardFormat { + /// The type offered next to plain text, or `None` for text alone. + pub(crate) fn mime_type(self) -> Option<&'static str> { + match self { + Self::PlainText => None, + Self::Tsv => Some("text/tab-separated-values"), + Self::Csv => Some("text/csv"), + Self::Json => Some("application/json"), + Self::Markdown => Some("text/markdown"), + Self::Sql => Some("application/sql"), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn clipboard_format_mime_types() { + assert_eq!(ClipboardFormat::PlainText.mime_type(), None); + assert_eq!(ClipboardFormat::Tsv.mime_type(), Some("text/tab-separated-values")); + assert_eq!(ClipboardFormat::Csv.mime_type(), Some("text/csv")); + assert_eq!(ClipboardFormat::Json.mime_type(), Some("application/json")); + assert_eq!(ClipboardFormat::Markdown.mime_type(), Some("text/markdown")); + assert_eq!(ClipboardFormat::Sql.mime_type(), Some("application/sql")); + } +} diff --git a/linux/crates/app/src/ui/clipboard/clipboard_payload.rs b/linux/crates/app/src/ui/clipboard/clipboard_payload.rs new file mode 100644 index 0000000000..f747bc9401 --- /dev/null +++ b/linux/crates/app/src/ui/clipboard/clipboard_payload.rs @@ -0,0 +1,23 @@ +use super::ClipboardFormat; + +/// A rendered copy, ready for the clipboard. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct ClipboardPayload { + pub text: String, + pub format: ClipboardFormat, + /// The rows it holds, or the values for an IN list. + pub rows: usize, + /// Values an IN list had to leave out. + pub skipped: usize, +} + +impl ClipboardPayload { + pub(crate) fn plain(text: String) -> Self { + Self { + text, + format: ClipboardFormat::PlainText, + rows: 1, + skipped: 0, + } + } +} diff --git a/linux/crates/app/src/ui/clipboard/clipboard_writer.rs b/linux/crates/app/src/ui/clipboard/clipboard_writer.rs new file mode 100644 index 0000000000..237d9a41cc --- /dev/null +++ b/linux/crates/app/src/ui/clipboard/clipboard_writer.rs @@ -0,0 +1,78 @@ +use gtk4::prelude::*; +use gtk4::{gdk, glib}; +use thiserror::Error; + +use super::ClipboardPayload; + +#[derive(Debug, Error)] +pub(crate) enum ClipboardError { + #[error("the text holds a NUL character")] + ContainsNul, + #[error("the clipboard refused the copy: {0}")] + Refused(glib::BoolError), +} + +impl ClipboardError { + pub(crate) fn message(&self) -> String { + match self { + Self::ContainsNul => crate::i18n::gettext("Values with a NUL character can't be copied"), + Self::Refused(_) => crate::i18n::gettext("Couldn't copy to the clipboard"), + } + } +} + +/// The text type is a C string, so a NUL would cut the copy short, and +/// a cut copy pasted back would change the value without a word. +pub(super) fn holds_nul(text: &str) -> bool { + text.contains('\0') +} + +/// Put `payload` on the clipboard as its own type and as plain text, so +/// an application that knows the type takes it, and any other gets the +/// text. The caller has already refused text that `holds_nul`. +pub(super) fn write(clipboard: &gdk::Clipboard, payload: ClipboardPayload) -> Result<(), ClipboardError> { + let Some(mime_type) = payload.format.mime_type() else { + clipboard.set_text(&payload.text); + return Ok(()); + }; + let text = payload.text.to_value(); + let bytes = glib::Bytes::from_owned(payload.text.into_bytes()); + let provider = gdk::ContentProvider::new_union(&[ + gdk::ContentProvider::for_bytes(mime_type, &bytes), + gdk::ContentProvider::for_value(&text), + ]); + clipboard.set_content(Some(&provider)).map_err(|error| { + tracing::warn!(%error, "the clipboard refused a copy"); + ClipboardError::Refused(error) + }) +} + +#[cfg(test)] +mod tests { + use gtk4 as gtk; + + use super::*; + use crate::ui::clipboard::ClipboardFormat; + + fn local_text(clipboard: &gdk::Clipboard) -> Option { + clipboard.content()?.value(String::static_type()).ok()?.get().ok() + } + + #[gtk4::test] + fn a_typed_copy_offers_its_type_and_plain_text() { + let clipboard = gtk::Label::new(None).clipboard(); + let payload = ClipboardPayload { + text: "a,b\n1,2\n".to_owned(), + format: ClipboardFormat::Csv, + rows: 1, + skipped: 0, + }; + + write(&clipboard, payload).expect("written"); + + let formats = clipboard.formats(); + assert!(formats.contain_mime_type("text/csv"), "{}", formats.to_str()); + assert!(formats.contains_type(String::static_type()), "{}", formats.to_str()); + assert_eq!(local_text(&clipboard).as_deref(), Some("a,b\n1,2\n")); + } +} diff --git a/linux/crates/app/src/ui/clipboard/copy_renderer.rs b/linux/crates/app/src/ui/clipboard/copy_renderer.rs new file mode 100644 index 0000000000..2468210b44 --- /dev/null +++ b/linux/crates/app/src/ui/clipboard/copy_renderer.rs @@ -0,0 +1,227 @@ +use std::sync::Arc; + +use tablepro_core::column::{ColumnInfo, ColumnType, ResultColumn}; +use tablepro_core::dialect::{LiteralError, SqlDialect}; +use tablepro_core::edit::CellInput; +use tablepro_core::export::{ + self, CsvEncoder, CsvOptions, EncodeError, JsonArrayEncoder, MarkdownEncoder, RenderInsertError, RowEncoder, + TsvEncoder, +}; +use tablepro_core::meta::TableRef; +use tablepro_core::value::Value; +use tablepro_session::runtime::{TaskResult, Tasks}; +use thiserror::Error; + +use super::clipboard_writer::holds_nul; +use super::{ClipboardFormat, ClipboardPayload}; +use crate::ui::grid::RowSnapshot; + +/// What a copy holds, taken on the GTK thread and rendered off it. +#[derive(Debug)] +pub enum CopyRequest { + /// Rows as the grid shows them, under its visible columns. + Rows { + columns: Arc<[ResultColumn]>, + rows: Vec, + kind: CopyKind, + }, + Insert(InsertCopy), + /// One column's values as an IN list, in the spelling of the engine + /// the rows came from. + InClause { + dialect: Arc, + values: InClauseRows, + }, +} + +/// One column of the rows a user picked, for an IN list. +#[derive(Debug)] +pub struct InClauseRows { + pub columns: Arc<[ResultColumn]>, + pub rows: Vec, + pub column: usize, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CopyKind { + /// One cell of the first row. + Value(usize), + Rows, + RowsWithHeaders, + Csv, + CsvWithHeaders, + Json, + Markdown, +} + +/// Rows as INSERT statements for the server the tab reads. +#[derive(Debug)] +pub struct InsertCopy { + pub dialect: Arc, + pub table: TableRef, + pub columns: Arc<[ColumnInfo]>, + pub rows: Vec, +} + +#[derive(Debug)] +pub enum InsertRow { + /// A row the table holds, with its pending edits. + Saved(RowSnapshot), + /// A draft's cells as its save would write them, so the columns the + /// server fills stay out of its statement. + Draft(Vec), +} + +#[derive(Debug, Error)] +pub(crate) enum CopyError { + #[error(transparent)] + Encode(#[from] EncodeError), + #[error(transparent)] + Insert(#[from] RenderInsertError), + #[error(transparent)] + Literal(#[from] LiteralError), + /// Every value was NULL or binary, and `()` is not SQL anywhere. + #[error("no value can go in an IN list")] + EmptyInClause, + #[error("the text holds a NUL character")] + ContainsNul, +} + +/// Render `request` on the blocking pool: a copy of every row can take +/// long enough to stall the window. +pub(crate) fn render_copy(tasks: &Tasks, request: CopyRequest) -> TaskResult> { + tasks.spawn_blocking_task(move || render(request)) +} + +pub(crate) fn render(request: CopyRequest) -> Result { + let payload = match request { + CopyRequest::Rows { columns, rows, kind } => render_rows(columns, &rows, kind), + CopyRequest::Insert(insert) => render_insert(insert), + CopyRequest::InClause { dialect, values } => render_in_clause(&*dialect, &values), + }?; + if holds_nul(&payload.text) { + return Err(CopyError::ContainsNul); + } + Ok(payload) +} + +fn render_rows( + columns: Arc<[ResultColumn]>, + rows: &[RowSnapshot], + kind: CopyKind, +) -> Result { + match kind { + CopyKind::Value(column) => value_text(&columns, rows, column).map(ClipboardPayload::plain), + CopyKind::Rows => encode(TsvEncoder::new(columns, false), rows, ClipboardFormat::Tsv), + CopyKind::RowsWithHeaders => encode(TsvEncoder::new(columns, true), rows, ClipboardFormat::Tsv), + CopyKind::Csv => encode( + CsvEncoder::new(columns, clipboard_csv(false)), + rows, + ClipboardFormat::Csv, + ), + CopyKind::CsvWithHeaders => encode( + CsvEncoder::new(columns, clipboard_csv(true)), + rows, + ClipboardFormat::Csv, + ), + CopyKind::Json => encode(JsonArrayEncoder::new(columns), rows, ClipboardFormat::Json), + CopyKind::Markdown => encode(MarkdownEncoder::new(columns), rows, ClipboardFormat::Markdown), + } +} + +/// CSV as the clipboard carries it. Copied text goes as the user sees +/// it: the apostrophe an export puts before `-` or `@alice` would be +/// pasted into every other place the text goes too. +fn clipboard_csv(header_row: bool) -> CsvOptions { + CsvOptions { + header_row, + sanitize_formulas: false, + ..CsvOptions::default() + } +} + +fn render_in_clause(dialect: &dyn SqlDialect, values: &InClauseRows) -> Result { + let InClauseRows { columns, rows, column } = values; + let column_type = columns + .get(*column) + .map_or_else(|| ColumnType::untyped_text("text"), |column| column.column_type.clone()); + let clause = export::render_in_clause(dialect, &column_type, rows.iter().map(|row| row.value(*column)))?; + if clause.sql.is_empty() { + return Err(CopyError::EmptyInClause); + } + Ok(ClipboardPayload { + text: clause.sql, + format: ClipboardFormat::Sql, + rows: rows.len() - clause.skipped, + skipped: clause.skipped, + }) +} + +/// The cell's full text, never the grid's display form, which stands in +/// `` for a NULL and cuts long values. +fn value_text(columns: &[ResultColumn], rows: &[RowSnapshot], column: usize) -> Result { + let Some(value) = rows.first().map(|row| row.value(column)) else { + return Ok(String::new()); + }; + if let Value::Undecodable(_) = value { + return Err(EncodeError::Undecodable { + column: columns + .get(column) + .map_or_else(|| (column + 1).to_string(), |column| column.name.clone()), + } + .into()); + } + Ok(export::cell_text(columns, column, value).unwrap_or_default()) +} + +fn encode( + mut encoder: impl RowEncoder, + rows: &[RowSnapshot], + format: ClipboardFormat, +) -> Result { + let mut out = Vec::new(); + encoder.begin(&mut out); + for row in rows { + encoder.row(&mut out, &mut row.values())?; + } + encoder.finish(&mut out); + Ok(ClipboardPayload { + // Every encoder writes UTF-8, so the lossy branch is never taken. + text: String::from_utf8(out).unwrap_or_else(|error| String::from_utf8_lossy(error.as_bytes()).into_owned()), + format, + rows: rows.len(), + skipped: 0, + }) +} + +fn render_insert(insert: InsertCopy) -> Result { + let InsertCopy { + dialect, + table, + columns, + rows, + } = insert; + let statements = rows + .iter() + .map(|row| match row { + InsertRow::Saved(snapshot) => { + // A row id read after the columns has no place in the + // statement, so it is not copied. + let cells: Vec = snapshot + .values() + .take(columns.len()) + .cloned() + .map(CellInput::Value) + .collect(); + export::render_insert(&*dialect, &table, &columns, &cells) + } + InsertRow::Draft(cells) => export::render_insert(&*dialect, &table, &columns, cells), + }) + .collect::, _>>()?; + Ok(ClipboardPayload { + text: statements.join("\n"), + format: ClipboardFormat::Sql, + rows: rows.len(), + skipped: 0, + }) +} diff --git a/linux/crates/app/src/ui/clipboard/mod.rs b/linux/crates/app/src/ui/clipboard/mod.rs new file mode 100644 index 0000000000..cc8a6a31dd --- /dev/null +++ b/linux/crates/app/src/ui/clipboard/mod.rs @@ -0,0 +1,19 @@ +//! Copies from the grids: rendered off the GTK thread, and put on the +//! clipboard as their own type beside plain text. + +mod clipboard_copy; +mod clipboard_format; +mod clipboard_payload; +mod clipboard_writer; +mod copy_renderer; + +pub(crate) use clipboard_copy::{copy_payload, copy_rows, copy_text}; +pub(crate) use clipboard_format::ClipboardFormat; +pub(crate) use clipboard_payload::ClipboardPayload; +pub(crate) use clipboard_writer::ClipboardError; +#[cfg(test)] +use copy_renderer::render; +pub(crate) use copy_renderer::{CopyError, CopyKind, CopyRequest, InClauseRows, InsertCopy, InsertRow, render_copy}; + +#[cfg(test)] +mod tests; diff --git a/linux/crates/app/src/ui/clipboard/tests.rs b/linux/crates/app/src/ui/clipboard/tests.rs new file mode 100644 index 0000000000..8b35c26b92 --- /dev/null +++ b/linux/crates/app/src/ui/clipboard/tests.rs @@ -0,0 +1,373 @@ +use std::cell::{Cell, RefCell}; +use std::rc::Rc; +use std::sync::Arc; +use std::time::Duration; + +use gtk4 as gtk; +use gtk4::prelude::*; +use gtk4::{gdk, glib}; +use tablepro_core::Value; +use tablepro_core::column::{ColumnDefault, ColumnInfo, ColumnType, ResultColumn}; +use tablepro_core::edit::CellInput; +use tablepro_core::export::{CsvOptions, render_csv}; +use tablepro_core::meta::TableRef; +use tablepro_test_support::FakeDialect; + +use super::*; +use crate::test_support::{finish_on_main, test_runtime, wait_until}; +use crate::ui::grid::RowSnapshot; + +const WAIT: Duration = Duration::from_secs(30); + +fn columns(names: &[&str]) -> Arc<[ResultColumn]> { + names + .iter() + .map(|name| ResultColumn::new(*name, ColumnType::untyped_text("text"))) + .collect() +} + +fn snapshots(rows: &[Vec]) -> Vec { + rows.iter() + .map(|row| RowSnapshot::new(row.clone().into(), Vec::new())) + .collect() +} + +fn people() -> Vec> { + vec![ + vec![Value::Int(-5), Value::Text("=cmd".into())], + vec![Value::Int(7), Value::Text("Ada, \"the first\"".into())], + ] +} + +fn rows_request(rows: &[Vec], kind: CopyKind) -> CopyRequest { + CopyRequest::Rows { + columns: columns(&["id", "name"]), + rows: snapshots(rows), + kind, + } +} + +fn local_text(clipboard: &gdk::Clipboard) -> Option { + clipboard.content()?.value(String::static_type()).ok()?.get().ok() +} + +/// Copy through a widget the way a tab does, and wait for the toast +/// that says it landed. +fn copied(request: CopyRequest) -> (gdk::Clipboard, Vec) { + let widget = gtk::Label::new(None); + let clipboard = widget.clipboard(); + clipboard.set_text("before"); + let runtime = test_runtime(); + let toasts = Rc::new(RefCell::new(Vec::new())); + let sink = toasts.clone(); + + copy_rows(&widget, &runtime.tasks(), request, move |text| { + sink.borrow_mut().push(text.to_owned()); + }); + wait_until(WAIT, || !toasts.borrow().is_empty()).expect("the copy to land"); + + runtime.shutdown(); + (clipboard, toasts.take()) +} + +fn column_info(name: &str, primary_key: bool) -> ColumnInfo { + ColumnInfo { + name: name.to_owned(), + column_type: ColumnType::untyped_text("text"), + nullable: !primary_key, + primary_key, + is_auto_increment: false, + is_generated: false, + default: ColumnDefault::None, + comment: None, + } +} + +#[test] +fn copy_renderer_csv_matches_render_csv() { + let rows = people(); + let cases = [ + ( + CopyKind::Csv, + CsvOptions { + header_row: false, + sanitize_formulas: false, + ..CsvOptions::default() + }, + ), + ( + CopyKind::CsvWithHeaders, + CsvOptions { + sanitize_formulas: false, + ..CsvOptions::default() + }, + ), + ]; + for (kind, options) in cases { + let payload = render(rows_request(&rows, kind)).expect("the copy"); + + let expected = render_csv(&columns(&["id", "name"]), &rows, &options).expect("the csv"); + assert_eq!(payload.text, expected, "{kind:?}"); + assert_eq!(payload.format, ClipboardFormat::Csv); + assert_eq!(payload.rows, 2); + } +} + +#[test] +fn a_copy_keeps_text_that_starts_like_a_formula_as_it_is() { + let rows = vec![vec![Value::Text("-".into()), Value::Text("@alice".into())]]; + + let tsv = render(rows_request(&rows, CopyKind::Rows)).expect("the copy"); + let csv = render(rows_request(&rows, CopyKind::Csv)).expect("the copy"); + + assert_eq!(tsv.text, "-\t@alice\n"); + assert_eq!(csv.text, "-,@alice\n"); +} + +#[test] +fn copy_insert_uses_visible_columns_only() { + let request = CopyRequest::Insert(InsertCopy { + dialect: Arc::new(FakeDialect), + table: TableRef::new(None, "users"), + columns: vec![column_info("id", true), column_info("name", false)].into(), + rows: vec![ + // A row read with its engine row id after the visible cells. + InsertRow::Saved(RowSnapshot::new( + vec![Value::Int(1), Value::Text("Ada".into()), Value::Text("(0,1)".into())].into(), + Vec::new(), + )), + InsertRow::Draft(vec![CellInput::Default, CellInput::Value(Value::Text("Grace".into()))]), + ], + }); + + let payload = render(request).expect("the copy"); + + assert_eq!( + payload.text, + "INSERT INTO \"users\" (\"id\", \"name\") VALUES (1, 'Ada');\n\ + INSERT INTO \"users\" (\"name\") VALUES ('Grace');" + ); + assert_eq!(payload.format, ClipboardFormat::Sql); + assert_eq!(payload.rows, 2); +} + +/// MySQL reads a backslash in a string as an escape, so a value ending +/// in one would close the string and run the rest as SQL. +#[test] +fn an_in_clause_copy_is_written_in_the_engines_literals() { + let dialect = Arc::new(tablepro_driver_mysql::MySqlDialect::new( + tablepro_driver_mysql::MySqlFlavour::MySql, + &tablepro_core::ServerInfo::new("MySQL", "8.4.3", 8, 4, 3), + )); + let injected = "\\') OR 1=1 -- "; + let rows = vec![ + vec![Value::Int(1), Value::Text(injected.into())], + vec![Value::Int(2), Value::Null], + ]; + + let payload = render(CopyRequest::InClause { + dialect, + values: InClauseRows { + columns: columns(&["id", "name"]), + rows: snapshots(&rows), + column: 1, + }, + }) + .expect("the copy"); + + assert_eq!( + payload.text, + format!( + "(_utf8mb4 X'{}')", + tablepro_core::hex::encode_lower(injected.as_bytes()) + ) + ); + assert_eq!( + (payload.format, payload.rows, payload.skipped), + (ClipboardFormat::Sql, 1, 1) + ); +} + +#[test] +fn a_copy_holding_a_nul_is_refused_off_the_gtk_thread() { + let rows = vec![vec![Value::Int(1), Value::Text("a\0b".into())]]; + + let refused = render(rows_request(&rows, CopyKind::Rows)).expect_err("a NUL"); + + assert!(matches!(refused, CopyError::ContainsNul), "{refused:?}"); +} + +#[gtk4::test] +fn a_slower_earlier_copy_gives_way_to_a_later_one() { + let widget = gtk::Label::new(None); + let clipboard = widget.clipboard(); + let runtime = test_runtime(); + let toasts = Rc::new(RefCell::new(Vec::new())); + let copy = |request| { + let sink = toasts.clone(); + copy_rows(&widget, &runtime.tasks(), request, move |text| { + sink.borrow_mut().push(text.to_owned()); + }) + }; + let slow = copy(rows_request(&many_rows(), CopyKind::Json)); + let fast = copy(rows_request(&people()[..1], CopyKind::Rows)); + finish_on_main(WAIT, fast) + .expect("the later copy to end") + .expect("the later copy"); + assert_eq!(local_text(&clipboard).as_deref(), Some("-5\t=cmd\n")); + let landed = clipboard.content(); + finish_on_main(WAIT, slow) + .expect("the earlier copy to end") + .expect("the earlier copy"); + + assert_eq!(toasts.take(), ["Copied 1 row"]); + // Other test processes share the X server and can take the + // clipboard meanwhile, which leaves it with no local content. + let now = clipboard.content(); + assert!( + now.is_none() || now == landed, + "the earlier copy replaced the later one" + ); + runtime.shutdown(); +} + +fn many_rows() -> Vec> { + (0..100_000) + .map(|row| vec![Value::Int(row), Value::Text(format!("name {row}"))]) + .collect() +} + +/// A copy written at once, as Copy Query in the history does, while a +/// large copy still renders. +#[gtk4::test] +fn a_copy_still_rendering_gives_way_to_one_written_at_once() { + let widget = gtk::Label::new(None); + let clipboard = widget.clipboard(); + let runtime = test_runtime(); + let toasts = Rc::new(RefCell::new(Vec::new())); + let sink = toasts.clone(); + + let slow = copy_rows( + &widget, + &runtime.tasks(), + rows_request(&many_rows(), CopyKind::Json), + move |text| sink.borrow_mut().push(text.to_owned()), + ); + copy_text(&widget, "SELECT 1".to_owned(), |text| { + toasts.borrow_mut().push(text.to_owned()) + }); + let landed = clipboard.content(); + finish_on_main(WAIT, slow) + .expect("the earlier copy to end") + .expect("the earlier copy"); + + assert_eq!(toasts.take(), ["Copied to clipboard"]); + let now = clipboard.content(); + assert!( + now.is_none() || now == landed, + "the earlier copy replaced the later one" + ); + runtime.shutdown(); +} + +/// A text field copies through GTK, past every ticket. +#[gtk4::test] +fn a_copy_still_rendering_gives_way_to_a_text_field_copy() { + let widget = gtk::Label::new(None); + let clipboard = widget.clipboard(); + let runtime = test_runtime(); + let toasts = Rc::new(RefCell::new(Vec::new())); + let sink = toasts.clone(); + let field = gtk::Text::new(); + field.set_text("typed"); + field.select_region(0, -1); + + let slow = copy_rows( + &widget, + &runtime.tasks(), + rows_request(&many_rows(), CopyKind::Json), + move |text| sink.borrow_mut().push(text.to_owned()), + ); + field.emit_by_name::<()>("copy-clipboard", &[]); + assert_eq!(local_text(&clipboard).as_deref(), Some("typed")); + let landed = clipboard.content(); + finish_on_main(WAIT, slow) + .expect("the earlier copy to end") + .expect("the earlier copy"); + + assert!(toasts.borrow().is_empty(), "{:?}", toasts.borrow()); + let now = clipboard.content(); + assert!( + now.is_none() || now == landed, + "the earlier copy replaced the text field's" + ); + runtime.shutdown(); +} + +#[gtk4::test] +fn copy_as_csv_offers_text_csv_and_plain_text() { + let (clipboard, toasts) = copied(rows_request(&people(), CopyKind::CsvWithHeaders)); + + let formats = clipboard.formats(); + assert!(formats.contain_mime_type("text/csv"), "{}", formats.to_str()); + assert!(formats.contains_type(String::static_type()), "{}", formats.to_str()); + assert_eq!( + local_text(&clipboard).as_deref(), + Some("id,name\n-5,=cmd\n7,\"Ada, \"\"the first\"\"\"\n") + ); + assert_eq!(toasts, ["Copied 2 rows"]); +} + +#[gtk4::test] +fn copy_rows_offers_tab_separated_values() { + let (clipboard, toasts) = copied(rows_request(&people(), CopyKind::Rows)); + + let formats = clipboard.formats(); + assert!( + formats.contain_mime_type("text/tab-separated-values"), + "{}", + formats.to_str() + ); + assert_eq!( + local_text(&clipboard).as_deref(), + Some("-5\t=cmd\n7\t\"Ada, \"\"the first\"\"\"\n") + ); + assert_eq!(toasts, ["Copied 2 rows"]); +} + +#[gtk4::test] +fn copy_as_json_offers_application_json() { + let (clipboard, _) = copied(rows_request(&people(), CopyKind::Json)); + + let formats = clipboard.formats(); + assert!(formats.contain_mime_type("application/json"), "{}", formats.to_str()); + let text = local_text(&clipboard).expect("the copy"); + let parsed: serde_json::Value = serde_json::from_str(&text).expect("JSON"); + assert_eq!(parsed[1]["name"], serde_json::json!("Ada, \"the first\"")); +} + +#[gtk4::test] +fn large_copy_renders_on_blocking_pool() { + let rows: Vec> = (0..200_000) + .map(|row| vec![Value::Int(row), Value::Text(format!("name {row}"))]) + .collect(); + let ticks = Rc::new(Cell::new(0_u32)); + let counted = ticks.clone(); + let ticking = glib::timeout_add_local(Duration::from_millis(1), move || { + counted.set(counted.get() + 1); + glib::ControlFlow::Continue + }); + + let (clipboard, toasts) = copied(rows_request(&rows, CopyKind::Json)); + ticking.remove(); + + assert!( + ticks.get() >= 5, + "the main loop stood still while the copy rendered: {} ticks", + ticks.get() + ); + assert_eq!(toasts, ["Copied 200,000 rows"]); + let parsed: Vec = + serde_json::from_str(&local_text(&clipboard).expect("the copy")).expect("JSON"); + assert_eq!(parsed.len(), 200_000); +} diff --git a/linux/crates/app/src/ui/combo_value.rs b/linux/crates/app/src/ui/combo_value.rs new file mode 100644 index 0000000000..f0851b5539 --- /dev/null +++ b/linux/crates/app/src/ui/combo_value.rs @@ -0,0 +1,98 @@ +use gtk4 as gtk; +use gtk4::prelude::*; +use libadwaita as adw; +use libadwaita::prelude::*; + +/// GTK keeps a long subtitle on one line while libadwaita's value label can still ellipsize to one +/// character. This value label never shrinks, so the subtitle wraps instead. +pub(crate) fn show_whole_value(row: &adw::ComboRow) { + // The popup keeps libadwaita's own items, which mark the selected one. + if row.list_factory().is_none() { + row.set_list_factory(row.factory().as_ref()); + } + row.set_factory(Some(&value_factory())); +} + +fn value_factory() -> gtk::SignalListItemFactory { + let factory = gtk::SignalListItemFactory::new(); + factory.connect_setup(|_, item| { + if let Some(item) = item.downcast_ref::() { + let label = gtk::Label::builder().xalign(0.0).valign(gtk::Align::Center).build(); + item.set_child(Some(&label)); + } + }); + factory.connect_bind(|_, item| { + let Some(item) = item.downcast_ref::() else { + return; + }; + if let (Some(label), Some(choice)) = ( + item.child().and_downcast::(), + item.item().and_downcast::(), + ) { + label.set_label(&choice.string()); + } + }); + factory +} + +/// The label that shows the selected value in the row, not one in the +/// popup. +#[cfg(test)] +pub(crate) fn shown_value(row: &adw::ComboRow) -> Option { + crate::test_support::descendants(row) + .into_iter() + .filter_map(|widget| widget.downcast::().ok()) + .find(|label| { + label.ancestor(gtk::Popover::static_type()).is_none() + && label.ancestor(gtk::ListView::static_type()).is_some() + }) +} + +#[cfg(test)] +mod tests { + use std::time::Duration; + + use gtk4 as gtk; + use gtk4::prelude::*; + use libadwaita as adw; + use libadwaita::prelude::*; + + use super::{show_whole_value, shown_value}; + use crate::test_support::wait_until; + + const WAIT: Duration = Duration::from_secs(10); + + #[gtk4::test] + fn the_value_never_asks_for_less_than_its_whole_width() { + let _ = adw::init(); + let row = adw::ComboRow::builder() + .title("Encryption") + .model(>k::StringList::new(&["Disable", "Verify full"])) + .selected(1) + .build(); + show_whole_value(&row); + let group = adw::PreferencesGroup::new(); + group.add(&row); + let _window = crate::test_support::OwnedWindow::presented(adw::Window::builder().content(&group).build()); + wait_until(WAIT, || shown_value(&row).is_some_and(|label| label.width() > 0)).expect("the row is laid out"); + + let value = shown_value(&row).expect("the value label"); + let (minimum, natural, _, _) = value.measure(gtk::Orientation::Horizontal, -1); + + assert_eq!(value.label(), "Verify full"); + assert_eq!(minimum, natural, "the value can be squeezed into an ellipsis"); + } + + #[gtk4::test] + fn the_popup_keeps_libadwaita_s_own_items() { + let _ = adw::init(); + let row = adw::ComboRow::new(); + let libadwaita_items = row.factory(); + + show_whole_value(&row); + + assert!(libadwaita_items.is_some()); + assert_eq!(row.list_factory(), libadwaita_items); + assert_ne!(row.factory(), libadwaita_items); + } +} diff --git a/linux/crates/app/src/ui/connect_dialog/connect_dialog_mode.rs b/linux/crates/app/src/ui/connect_dialog/connect_dialog_mode.rs new file mode 100644 index 0000000000..a70ecc28bd --- /dev/null +++ b/linux/crates/app/src/ui/connect_dialog/connect_dialog_mode.rs @@ -0,0 +1,22 @@ +use tablepro_storage::SavedConnection; + +/// What the connect dialog is opened for. +#[derive(Debug, Clone, Default)] +pub enum ConnectDialogMode { + /// Connect, and save the connection once the connect works. + #[default] + Create, + /// Change a saved connection without connecting to it. Its secrets + /// stay in the keyring, and an empty password row keeps them. + Edit(Box), +} + +impl ConnectDialogMode { + /// The saved connection an Edit changes. + pub fn editing(&self) -> Option<&SavedConnection> { + match self { + Self::Create => None, + Self::Edit(saved) => Some(saved), + } + } +} diff --git a/linux/crates/app/src/ui/connect_dialog/connect_form.rs b/linux/crates/app/src/ui/connect_dialog/connect_form.rs new file mode 100644 index 0000000000..fbb1388989 --- /dev/null +++ b/linux/crates/app/src/ui/connect_dialog/connect_form.rs @@ -0,0 +1,1116 @@ +use std::path::{Path, PathBuf}; + +use secrecy::{ExposeSecret, SecretString}; +use tablepro_core::credentials::{SecretKind, SecretSave}; +use tablepro_core::{ + AuthMode, ClientIdentity, ConfigError, Credentials, DriverCapabilities, FileEndpoint, NetworkEndpoint, + ServerNameOverride, TlsCapabilities, TlsConfig, TlsMode, TransportClass, +}; +use tablepro_session::RequestTarget; +use tablepro_storage::{SavedConnection, SavedSshAuth, SavedSshConfig}; +use uuid::Uuid; + +use super::form_errors::FormErrors; +use super::form_field::FormField; +use crate::services::secret_change::SecretChange; +use crate::services::secret_changes::SecretChanges; +use crate::services::secret_labels; +use crate::ui::error_text::config_message; +use crate::ui::ssh_inputs::{SshFormState, SshInputError, SshSecretToStore}; + +/// The Security rows as chosen. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TlsFormState { + pub mode: TlsMode, + pub ca_file: Option, + pub client_certificate: Option, + pub client_key: Option, + pub server_name: String, +} + +impl TlsFormState { + pub fn new(caps: Option<&TlsCapabilities>) -> Self { + Self { + mode: default_tls_mode(caps), + ca_file: None, + client_certificate: None, + client_key: None, + server_name: String::new(), + } + } + + pub fn from_config(config: &TlsConfig) -> Self { + Self { + mode: config.mode, + ca_file: config.ca_file.clone(), + client_certificate: config + .client_identity + .as_ref() + .map(|identity| identity.certificate.clone()), + client_key: config.client_identity.as_ref().map(|identity| identity.key.clone()), + server_name: config.server_name.clone().unwrap_or_default(), + } + } + + /// The settings a request carries. A row the driver or the mode does + /// not show is left out rather than cleared, so looking at another + /// driver or mode and coming back keeps what was chosen. + pub fn config(&self, caps: &TlsCapabilities, route: TransportClass) -> TlsConfig { + if self.mode == TlsMode::Disable { + return TlsConfig::disabled(); + } + let client_identity = (caps.client_identity + && (self.client_certificate.is_some() || self.client_key.is_some())) + .then(|| ClientIdentity { + certificate: self.client_certificate.clone().unwrap_or_default(), + key: self.client_key.clone().unwrap_or_default(), + }); + TlsConfig { + mode: self.mode, + ca_file: self + .ca_file + .clone() + .filter(|_| caps.ca_file && self.mode == TlsMode::VerifyFull), + client_identity, + server_name: Some(self.server_name.trim().to_owned()) + .filter(|name| !name.is_empty() && shows_server_name(caps, route)), + } + } +} + +/// Verify full is the only mode that proves who is on the other end, +/// so a new connection starts there whenever the driver offers it. +pub fn default_tls_mode(caps: Option<&TlsCapabilities>) -> TlsMode { + match caps { + Some(caps) if caps.modes.contains(&TlsMode::VerifyFull) => TlsMode::VerifyFull, + Some(caps) => caps.modes.last().copied().unwrap_or(TlsMode::Disable), + None => TlsMode::Disable, + } +} + +/// Whether the driver can check the certificate against another name +/// on this route. MySQL only can over an SSH forward, where the name it +/// dials is the forward's and never the certificate's. +pub fn shows_server_name(caps: &TlsCapabilities, route: TransportClass) -> bool { + match caps.server_name_override { + ServerNameOverride::AnyRoute => true, + ServerNameOverride::SshForwardOnly => route == TransportClass::SshForward, + ServerNameOverride::Unsupported => false, + } +} + +/// Whether the dialog, opened on a connection to `driver` over `route`, +/// shows the server name row. A driver without TLS shows none. +pub fn driver_shows_server_name(driver: Option<&DriverCapabilities>, route: TransportClass) -> bool { + driver + .and_then(|caps| caps.tls.as_ref()) + .is_some_and(|tls| shows_server_name(tls, route)) +} + +/// What the connect dialog holds, read from its rows in one go so a +/// connect works from a snapshot. +#[derive(Debug, Clone)] +pub struct ConnectForm { + /// What the list calls the connection. Empty takes the name from + /// where it connects, which then follows an edit of the host. + pub name: String, + pub driver_id: String, + pub host: String, + pub port: u16, + /// Set once the user types a port. A driver switch only moves the + /// port while this is false, so a deliberate 6543 survives a look + /// at another driver. + pub port_edited: bool, + pub database: String, + pub auth: AuthMode, + pub username: String, + pub password: SecretString, + pub remember_password: bool, + pub tls: TlsFormState, + pub read_only: bool, + pub ssh: Option, + pub file: Option, +} + +/// A form that passed every check, ready to connect and to save. +#[derive(Clone)] +pub struct ValidatedForm { + pub target: RequestTarget, + pub ssh: Option<(SavedSshConfig, SshSecretToStore)>, +} + +impl ConnectForm { + pub fn new(driver_id: &str, caps: &DriverCapabilities) -> Self { + Self { + name: String::new(), + driver_id: driver_id.to_owned(), + host: String::new(), + port: caps.default_port().unwrap_or(1), + port_edited: false, + database: String::new(), + auth: caps.auth_modes.first().copied().unwrap_or_default(), + username: String::new(), + password: SecretString::from(String::new()), + remember_password: true, + tls: TlsFormState::new(caps.tls.as_ref()), + read_only: false, + ssh: None, + file: None, + } + } + + /// The form a saved connection fills, to edit it. Secrets are not + /// read: they live in the keyring and never enter a widget. A name + /// that is only where the connection goes is left empty, so it keeps + /// following the host and user. + pub fn from_saved(saved: &SavedConnection, caps: &DriverCapabilities) -> Self { + let mut form = Self::new(&saved.driver_id, caps); + form.read_only = saved.read_only; + if caps.is_file_based() { + form.file = FileEndpoint::open_existing(PathBuf::from(&saved.database)).ok(); + } else { + form.host = saved.host.clone(); + form.port_edited = caps.default_port() != Some(saved.port); + form.port = saved.port; + form.database = saved.database.clone(); + form.username = saved.username.clone(); + if caps.supports(saved.auth_mode) { + form.auth = saved.auth_mode; + } + form.tls = TlsFormState::from_config(&saved.tls); + form.ssh = saved.ssh.as_ref().map(SshFormState::from_saved); + } + if saved.name != form.label(caps) { + saved.name.clone_into(&mut form.name); + } + form + } + + /// Look at another driver. Host, database and username stay: a look + /// at another driver must not wipe what the user filled in. + pub fn switch_driver(&mut self, driver_id: &str, caps: &DriverCapabilities) { + driver_id.clone_into(&mut self.driver_id); + if !self.port_edited + && let Some(port) = caps.default_port() + { + self.port = port; + } + if !caps.auth_modes.is_empty() && !caps.supports(self.auth) { + self.auth = caps.auth_modes.first().copied().unwrap_or_default(); + } + if let Some(tls) = caps.tls.as_ref() + && !tls.modes.contains(&self.tls.mode) + { + self.tls.mode = default_tls_mode(Some(tls)); + } + } + + /// Check every row the driver shows and build where the connect + /// goes, or say what is wrong with each row that is. + pub fn validate(&self, caps: &DriverCapabilities) -> Result { + let mut errors = FormErrors::default(); + if caps.is_file_based() { + return match &self.file { + Some(endpoint) => Ok(ValidatedForm { + target: RequestTarget::File(endpoint.clone()), + ssh: None, + }), + None => { + errors.push( + FormField::File, + crate::i18n::gettext("Choose a database file, or create a new one."), + true, + ); + Err(errors) + } + }; + } + + let endpoint = match NetworkEndpoint::new(&self.host, self.port) { + Ok(endpoint) => Some(endpoint), + Err(error) => { + let (field, missing) = match error { + ConfigError::InvalidPort => (FormField::Port, false), + ConfigError::EmptyHost => (FormField::Host, true), + _ => (FormField::Host, false), + }; + errors.push(field, config_message(&error), missing); + None + } + }; + + if !caps.supports(self.auth) { + errors.push( + FormField::AuthMode, + config_message(&ConfigError::AuthModeUnsupported), + false, + ); + } + let username = self.username.trim(); + let credentials = match self.auth { + AuthMode::Password => { + if username.is_empty() { + errors.push( + FormField::Username, + crate::i18n::gettext("Enter the username to log in with."), + true, + ); + } + Credentials::Password { + username: username.to_owned(), + password: self.password.clone(), + } + } + AuthMode::Kerberos => Credentials::Integrated, + }; + + let database = self.database.trim(); + if caps.database_required && database.is_empty() { + errors.push( + FormField::Database, + config_message(&ConfigError::DatabaseRequired), + true, + ); + } + + let ssh = match self.ssh.as_ref().map(SshFormState::inputs).transpose() { + Ok(ssh) => ssh, + Err(error) => { + errors.push(ssh_field(&error), error.message(), error.is_missing()); + None + } + }; + let route = if self.ssh.is_some() { + TransportClass::SshForward + } else { + TransportClass::DirectTcp + }; + + let tls = match caps.tls.as_ref() { + Some(tls_caps) => { + let config = self.tls.config(tls_caps, route); + if let Err(error) = config.validate(tls_caps, route) { + errors.push(tls_field(&error, &config), config_message(&error), false); + } + config + } + None => TlsConfig::disabled(), + }; + + let Some(endpoint) = endpoint.filter(|_| errors.is_empty()) else { + return Err(errors); + }; + let (cfg, saved_ssh) = match ssh { + Some(inputs) => (Some(inputs.cfg), Some((inputs.saved, inputs.secret_to_store))), + None => (None, None), + }; + Ok(ValidatedForm { + target: RequestTarget::Network { + endpoint, + ssh: cfg, + credentials, + database: Some(database.to_owned()).filter(|name| !name.is_empty()), + tls, + }, + ssh: saved_ssh, + }) + } + + /// How the entry is named in the connection list: the name typed, + /// or where it connects. + pub fn display_name(&self, caps: &DriverCapabilities) -> String { + match self.name.trim() { + "" => self.label(caps), + name => name.to_owned(), + } + } + + /// Where the entry connects, as a name. + pub fn label(&self, caps: &DriverCapabilities) -> String { + if caps.is_file_based() { + return self + .file + .as_ref() + .map(|file| file.path().display().to_string()) + .unwrap_or_default(); + } + let host = self.host.trim(); + match self.auth { + AuthMode::Kerberos => host.to_owned(), + AuthMode::Password => format!("{}@{host}", self.username.trim()), + } + } + + /// The entry a successful connect writes. Colour, group and the + /// last-opened time come from the entry it replaces, because this + /// form does not show them. + pub fn saved_connection( + &self, + caps: &DriverCapabilities, + valid: &ValidatedForm, + id: Uuid, + existing: Option<&SavedConnection>, + ) -> SavedConnection { + let (host, port, database, username, tls, auth_mode) = match &valid.target { + RequestTarget::File(endpoint) => ( + String::new(), + 0, + endpoint.path().display().to_string(), + String::new(), + TlsConfig::disabled(), + AuthMode::Password, + ), + RequestTarget::Network { + endpoint, + credentials, + database, + tls, + .. + } => ( + endpoint.host().to_owned(), + endpoint.port(), + database.clone().unwrap_or_default(), + match credentials { + Credentials::Password { username, .. } => username.clone(), + Credentials::Integrated => String::new(), + }, + tls.clone(), + credentials.mode(), + ), + }; + SavedConnection { + id, + name: self.display_name(caps), + driver_id: self.driver_id.clone(), + host, + port, + database, + username, + tls, + read_only: self.read_only, + auth_mode, + ssh: valid.ssh.as_ref().map(|(saved, _)| saved.clone()), + last_opened_at: existing.and_then(|saved| saved.last_opened_at), + color: existing.and_then(|saved| saved.color), + group: existing.and_then(|saved| saved.group.clone()), + } + } + + /// The entry a connect writes over `existing`, the entry the dialog + /// was reopened on. An empty Name row there means no name was typed, + /// so a name the user gave the entry stays; one that was only where + /// it connects follows the form. + pub fn submitted_connection( + &self, + caps: &DriverCapabilities, + valid: &ValidatedForm, + id: Uuid, + existing: Option<&SavedConnection>, + ) -> SavedConnection { + let mut connection = self.saved_connection(caps, valid, id, existing); + if let Some(existing) = existing + && self.name.trim().is_empty() + && existing.name != Self::from_saved(existing, caps).label(caps) + { + existing.name.clone_into(&mut connection.name); + } + connection + } + + /// The secrets the entry is saved with. The database password goes + /// in even when empty: a login with no password is still a login + /// the keyring answers for. + pub fn secrets_to_store(&self, valid: &ValidatedForm, saved: &SavedConnection) -> Vec { + let save = |kind, secret: &SecretString| SecretSave { + kind, + secret: secret.clone(), + label: secret_labels::secret_label(kind, saved), + }; + let mut secrets = Vec::new(); + if self.keeps_database_password(valid) { + secrets.push(save(SecretKind::DatabasePassword, &self.password)); + } + match valid.ssh.as_ref().map(|(_, secret)| secret) { + Some(SshSecretToStore::Password(secret)) => secrets.push(save(SecretKind::SshPassword, secret)), + Some(SshSecretToStore::Passphrase(secret)) => secrets.push(save(SecretKind::SshPassphrase, secret)), + Some(SshSecretToStore::None) | None => {} + } + secrets + } + + /// Secrets an earlier save of this entry may hold that the user now + /// asked not to keep, so the next connect asks instead of using them. + pub fn secrets_to_forget(&self, valid: &ValidatedForm) -> Vec { + let password_login = matches!( + valid.target, + RequestTarget::Network { + credentials: Credentials::Password { .. }, + .. + } + ); + if password_login && !self.remember_password { + vec![SecretKind::DatabasePassword] + } else { + Vec::new() + } + } + + /// What saving this form over `original` does to each secret. A + /// typed secret replaces the stored one and an empty row keeps it; a + /// secret the edited connection no longer logs in with is removed, + /// as is the password when Remember is off. `original_caps` are the + /// capabilities of the driver `original` was saved with. + pub fn secret_changes( + &self, + original: &SavedConnection, + original_caps: &DriverCapabilities, + valid: &ValidatedForm, + ) -> SecretChanges { + let had_password = !original_caps.is_file_based() && original.auth_mode == AuthMode::Password; + let typed = |secret: &SecretString| !secret.expose_secret().is_empty(); + let database_password = match valid.target { + RequestTarget::Network { + credentials: Credentials::Password { .. }, + .. + } if self.remember_password && typed(&self.password) => SecretChange::Replace(self.password.clone()), + RequestTarget::Network { + credentials: Credentials::Password { .. }, + .. + } if self.remember_password => SecretChange::Keep, + _ if had_password => SecretChange::Remove, + _ => SecretChange::Keep, + }; + let original_ssh = original.ssh.as_ref().map(|ssh| &ssh.auth); + let (ssh_password, ssh_passphrase) = match valid.ssh.as_ref() { + Some((saved, SshSecretToStore::Password(secret))) => ( + if typed(secret) { + SecretChange::Replace(secret.clone()) + } else { + SecretChange::Keep + }, + passphrase_left_unused(original, &saved.auth), + ), + Some((saved, SshSecretToStore::Passphrase(secret))) => ( + password_left_unused(original_ssh), + if typed(secret) { + SecretChange::Replace(secret.clone()) + } else { + passphrase_left_unused(original, &saved.auth) + }, + ), + Some((saved, SshSecretToStore::None)) => ( + password_left_unused(original_ssh), + passphrase_left_unused(original, &saved.auth), + ), + None => ( + password_left_unused(original_ssh), + if original.has_stored_passphrase() { + SecretChange::Remove + } else { + SecretChange::Keep + }, + ), + }; + SecretChanges { + database_password, + ssh_password, + ssh_passphrase, + } + } + + /// The secrets whose rows hold a typed value. A Test logs in with + /// these over the keyring's whatever Remember says, since the + /// keyring may still hold the one they replace. + pub fn typed_secrets(&self, valid: &ValidatedForm) -> Vec { + let typed = |secret: &SecretString| !secret.expose_secret().is_empty(); + let password_login = matches!( + valid.target, + RequestTarget::Network { + credentials: Credentials::Password { .. }, + .. + } + ); + let mut kinds = Vec::new(); + if password_login && typed(&self.password) { + kinds.push(SecretKind::DatabasePassword); + } + match valid.ssh.as_ref().map(|(_, secret)| secret) { + Some(SshSecretToStore::Password(secret)) if typed(secret) => kinds.push(SecretKind::SshPassword), + Some(SshSecretToStore::Passphrase(secret)) if typed(secret) => kinds.push(SecretKind::SshPassphrase), + _ => {} + } + kinds + } + + fn keeps_database_password(&self, valid: &ValidatedForm) -> bool { + self.remember_password + && matches!( + valid.target, + RequestTarget::Network { + credentials: Credentials::Password { .. }, + .. + } + ) + } +} + +/// An SSH password the edited connection no longer logs in with. +fn password_left_unused(original: Option<&SavedSshAuth>) -> SecretChange { + match original { + Some(SavedSshAuth::Password) => SecretChange::Remove, + _ => SecretChange::Keep, + } +} + +/// A stored passphrase unlocks one key file, so it goes once the edited +/// connection logs in another way or with another key. +fn passphrase_left_unused(original: &SavedConnection, edited: &SavedSshAuth) -> SecretChange { + let same_key = + matches!(edited, SavedSshAuth::PrivateKey { path, .. } if path.as_deref() == original.ssh_key_path()); + if original.has_stored_passphrase() && !same_key { + SecretChange::Remove + } else { + SecretChange::Keep + } +} + +fn ssh_field(error: &SshInputError) -> FormField { + match error { + SshInputError::HostRequired | SshInputError::HostInvalid(_) => FormField::SshHost, + SshInputError::PortOutOfRange => FormField::SshPort, + SshInputError::UserInvalid(_) => FormField::SshUser, + SshInputError::JumpHostsInvalid(_) => FormField::SshJumpHosts, + SshInputError::KeyPathRequired => FormField::SshKeyPath, + } +} + +/// The Security row a TLS problem belongs to. +pub fn tls_field(error: &ConfigError, config: &TlsConfig) -> FormField { + let identity = config.client_identity.as_ref(); + let is_certificate = |path: &Path| identity.is_some_and(|identity| identity.certificate == path); + match error { + ConfigError::UnsupportedTlsMode => FormField::Encryption, + ConfigError::CaFileUnsupported | ConfigError::CaFile { .. } => FormField::CaFile, + ConfigError::ClientIdentityFile { path, .. } if !is_certificate(path) => FormField::ClientKey, + ConfigError::ClientIdentityIncomplete + if identity.is_some_and(|identity| !identity.certificate.as_os_str().is_empty()) => + { + FormField::ClientKey + } + ConfigError::RelativePath if config.ca_file.as_deref().is_some_and(Path::is_relative) => FormField::CaFile, + ConfigError::RelativePath if identity.is_some_and(|identity| identity.key.is_relative()) => { + FormField::ClientKey + } + ConfigError::InvalidServerName | ConfigError::ServerNameOverrideUnsupported { .. } => FormField::ServerName, + _ => FormField::ClientCertificate, + } +} + +#[cfg(test)] +mod tests { + use secrecy::ExposeSecret; + use tablepro_core::DatabaseDriver; + use tablepro_storage::SavedSshAuth; + + use super::*; + use crate::ui::ssh_inputs::{SshAuthChoice, SshEnvironment}; + + fn caps(driver: &dyn DatabaseDriver) -> &'static DriverCapabilities { + driver.capabilities() + } + + fn postgres() -> &'static DriverCapabilities { + caps(&tablepro_driver_postgres::PgDriver) + } + + fn mysql() -> &'static DriverCapabilities { + caps(&tablepro_driver_mysql::MysqlDriver) + } + + fn mssql() -> &'static DriverCapabilities { + caps(&tablepro_driver_mssql::MssqlDriver) + } + + fn sqlite() -> &'static DriverCapabilities { + caps(&tablepro_driver_sqlite::SqliteDriver) + } + + fn filled(driver_id: &str, caps: &DriverCapabilities) -> ConnectForm { + ConnectForm { + host: "db.example.com".to_owned(), + database: "sales".to_owned(), + username: "app".to_owned(), + password: SecretString::from("s3cret"), + ..ConnectForm::new(driver_id, caps) + } + } + + fn network(target: &RequestTarget) -> (&NetworkEndpoint, &Credentials, Option<&str>, &TlsConfig) { + match target { + RequestTarget::Network { + endpoint, + credentials, + database, + tls, + .. + } => (endpoint, credentials, database.as_deref(), tls), + RequestTarget::File(_) => panic!("expected a network target"), + } + } + + #[test] + fn verify_full_is_default() { + for caps in [postgres(), mysql(), mssql()] { + assert_eq!(ConnectForm::new("x", caps).tls.mode, TlsMode::VerifyFull); + } + let form = filled("postgres", postgres()); + let valid = form.validate(postgres()).expect("a valid form"); + assert_eq!(network(&valid.target).3, &TlsConfig::verify_full()); + } + + #[test] + fn kerberos_rejected_when_driver_lacks_it() { + let form = ConnectForm { + auth: AuthMode::Kerberos, + ..filled("postgres", postgres()) + }; + + let errors = form.validate(postgres()).err().expect("an unsupported login"); + + assert!(errors.has(FormField::AuthMode), "{errors:?}"); + assert!(mssql().supports(AuthMode::Kerberos)); + let kerberos = ConnectForm { + auth: AuthMode::Kerberos, + ..filled("mssql", mssql()) + }; + let valid = kerberos.validate(mssql()).expect("SQL Server takes Kerberos"); + assert!(matches!(network(&valid.target).1, Credentials::Integrated)); + } + + #[test] + fn mysql_requires_database() { + let empty = ConnectForm { + database: " ".to_owned(), + ..filled("mysql", mysql()) + }; + let errors = empty.validate(mysql()).err().expect("MySQL needs a database"); + assert!(errors.has(FormField::Database), "{errors:?}"); + + let postgres_empty = ConnectForm { + database: String::new(), + ..filled("postgres", postgres()) + }; + let valid = postgres_empty + .validate(postgres()) + .expect("PostgreSQL opens the user's own database"); + assert_eq!(network(&valid.target).2, None, "an empty database was sent as a name"); + } + + #[test] + fn client_identity_never_reaches_mssql() { + let mut form = filled("mssql", mssql()); + form.tls.client_certificate = Some(PathBuf::from("/etc/tls/client.pem")); + form.tls.client_key = Some(PathBuf::from("/etc/tls/client.key")); + + let valid = form.validate(mssql()).expect("the hidden rows are left out"); + assert_eq!(network(&valid.target).3.client_identity, None); + + let tls = form + .tls + .config(postgres().tls.as_ref().expect("tls"), TransportClass::DirectTcp); + assert!(tls.client_identity.is_some(), "PostgreSQL lost the client certificate"); + let refused = TlsConfig { + client_identity: tls.client_identity.clone(), + ..TlsConfig::verify_full() + } + .validate(mssql().tls.as_ref().expect("tls"), TransportClass::DirectTcp) + .expect_err("SQL Server takes no client certificate"); + assert_eq!(tls_field(&refused, &tls), FormField::ClientCertificate); + } + + #[test] + fn a_half_chosen_client_identity_points_at_the_missing_file() { + let mut form = filled("postgres", postgres()); + form.tls.client_certificate = Some(PathBuf::from("/etc/tls/client.pem")); + + let errors = form.validate(postgres()).err().expect("an incomplete identity"); + + assert!(errors.has(FormField::ClientKey), "{errors:?}"); + } + + #[test] + fn file_driver_requires_file() { + let form = ConnectForm::new("sqlite", sqlite()); + let errors = form.validate(sqlite()).err().expect("no file chosen"); + assert!(errors.has(FormField::File)); + assert!( + errors.shown_on(FormField::File).is_none(), + "an untouched row is painted red" + ); + + let path = PathBuf::from("/srv/app.db"); + let chosen = ConnectForm { + file: Some(FileEndpoint::create_new(path.clone()).expect("absolute")), + ..form + }; + let valid = chosen.validate(sqlite()).expect("a file was chosen"); + let RequestTarget::File(endpoint) = valid.target else { + panic!("expected a file target"); + }; + assert_eq!(endpoint.path(), path); + assert_eq!(endpoint.mode(), tablepro_core::FileOpenMode::CreateNew); + } + + #[test] + fn ssh_key_auth_requires_path() { + let mut form = filled("postgres", postgres()); + form.ssh = Some(SshFormState { + host: "bastion".to_owned(), + key_path: String::new(), + auth: SshAuthChoice::PrivateKey, + ..SshFormState::new(&SshEnvironment::default()) + }); + + let errors = form.validate(postgres()).err().expect("a key login with no key"); + + assert!(errors.has(FormField::SshKeyPath), "{errors:?}"); + } + + #[test] + fn port_follows_endpoint_default_until_edited() { + let mut form = ConnectForm::new("postgres", postgres()); + assert_eq!(form.port, 5432); + + form.switch_driver("mysql", mysql()); + assert_eq!(form.port, 3306); + + form.port = 6543; + form.port_edited = true; + form.switch_driver("mssql", mssql()); + assert_eq!(form.port, 6543, "a typed port was replaced"); + } + + #[test] + fn driver_switch_keeps_host_database_username() { + let mut form = filled("postgres", postgres()); + + form.switch_driver("mysql", mysql()); + form.switch_driver("sqlite", sqlite()); + form.switch_driver("mssql", mssql()); + + assert_eq!(form.host, "db.example.com"); + assert_eq!(form.database, "sales"); + assert_eq!(form.username, "app"); + } + + #[test] + fn the_server_name_row_shows_where_the_driver_can_check_another_name() { + assert!(!driver_shows_server_name(Some(mysql()), TransportClass::DirectTcp)); + assert!(driver_shows_server_name(Some(mysql()), TransportClass::SshForward)); + assert!(driver_shows_server_name(Some(postgres()), TransportClass::DirectTcp)); + assert!(!driver_shows_server_name(Some(sqlite()), TransportClass::DirectTcp)); + assert!(!driver_shows_server_name(None, TransportClass::SshForward)); + } + + #[test] + fn hidden_fields_are_omitted_from_request() { + let mut form = filled("mssql", mssql()); + form.auth = AuthMode::Kerberos; + form.tls.mode = TlsMode::Disable; + form.tls.ca_file = Some(PathBuf::from("/etc/tls/ca.pem")); + form.tls.server_name = "db.internal".to_owned(); + + let valid = form.validate(mssql()).expect("valid"); + let (_, credentials, _, tls) = network(&valid.target); + assert!(matches!(credentials, Credentials::Integrated), "a password was sent"); + assert_eq!(tls, &TlsConfig::disabled(), "Disable still carried TLS files"); + + let mut mysql_form = filled("mysql", mysql()); + mysql_form.tls.server_name = "db.internal".to_owned(); + let direct = mysql_form.validate(mysql()).expect("valid"); + assert_eq!( + network(&direct.target).3.server_name, + None, + "MySQL cannot override the name over direct TCP" + ); + + let mut file_form = ConnectForm::new("sqlite", sqlite()); + file_form.host = "db.example.com".to_owned(); + file_form.ssh = Some(SshFormState::new(&SshEnvironment::default())); + file_form.file = Some(FileEndpoint::open_existing(PathBuf::from("/srv/app.db")).expect("absolute")); + let file = file_form.validate(sqlite()).expect("valid"); + assert!(matches!(file.target, RequestTarget::File(_))); + assert!(file.ssh.is_none()); + } + + #[test] + fn a_kerberos_form_saves_no_username_and_keeps_no_password() { + let form = ConnectForm { + auth: AuthMode::Kerberos, + ..filled("mssql", mssql()) + }; + let valid = form.validate(mssql()).expect("valid"); + + let entry = form.saved_connection(mssql(), &valid, Uuid::new_v4(), None); + + assert_eq!(entry.username, ""); + assert_eq!(entry.auth_mode, AuthMode::Kerberos); + assert!(form.secrets_to_store(&valid, &entry).is_empty()); + } + + #[test] + fn remember_off_keeps_no_password_and_forgets_the_old_one() { + let remembering = filled("postgres", postgres()); + let valid = remembering.validate(postgres()).expect("valid"); + let entry = remembering.saved_connection(postgres(), &valid, Uuid::new_v4(), None); + let stored = remembering.secrets_to_store(&valid, &entry); + assert_eq!(stored.len(), 1); + assert_eq!(stored[0].kind, SecretKind::DatabasePassword); + assert_eq!(stored[0].secret.expose_secret(), "s3cret"); + assert!(remembering.secrets_to_forget(&valid).is_empty()); + + let forgetting = ConnectForm { + remember_password: false, + ..remembering + }; + assert!(forgetting.secrets_to_store(&valid, &entry).is_empty()); + assert_eq!(forgetting.secrets_to_forget(&valid), vec![SecretKind::DatabasePassword]); + } + + #[test] + fn a_saved_entry_fills_the_form_it_was_saved_from() { + let mut form = filled("postgres", postgres()); + form.port = 6543; + form.port_edited = true; + form.tls.ca_file = Some(PathBuf::from("/etc/tls/ca.pem")); + form.ssh = Some(SshFormState { + host: "bastion".to_owned(), + auth: SshAuthChoice::Agent, + ..SshFormState::new(&SshEnvironment::default()) + }); + let valid = form.validate(postgres()).expect("valid"); + let entry = form.saved_connection(postgres(), &valid, Uuid::new_v4(), None); + + let refilled = ConnectForm::from_saved(&entry, postgres()); + + assert_eq!(refilled.host, "db.example.com"); + assert_eq!(refilled.port, 6543); + assert!(refilled.port_edited); + assert_eq!(refilled.tls, form.tls); + let ssh = refilled.ssh.expect("the SSH section came back"); + assert_eq!(ssh.host, "bastion"); + assert_eq!(entry.ssh.as_ref().map(|ssh| &ssh.auth), Some(&SavedSshAuth::Agent)); + } + + fn saved_from(form: &ConnectForm, caps: &DriverCapabilities) -> SavedConnection { + let valid = form.validate(caps).expect("valid"); + form.saved_connection(caps, &valid, Uuid::new_v4(), None) + } + + fn changes(form: &ConnectForm, original: &SavedConnection, caps: &DriverCapabilities) -> SecretChanges { + let valid = form.validate(caps).expect("valid"); + form.secret_changes(original, postgres(), &valid) + } + + fn is_keep(change: &SecretChange) -> bool { + matches!(change, SecretChange::Keep) + } + + fn is_remove(change: &SecretChange) -> bool { + matches!(change, SecretChange::Remove) + } + + fn replaced_with(change: &SecretChange) -> Option<&str> { + match change { + SecretChange::Replace(secret) => Some(secret.expose_secret()), + SecretChange::Keep | SecretChange::Remove => None, + } + } + + #[test] + fn connect_form_from_saved_prefills_without_secrets() { + let mut typed = filled("postgres", postgres()); + typed.name = "Shop".to_owned(); + typed.read_only = true; + let saved = saved_from(&typed, postgres()); + + let form = ConnectForm::from_saved(&saved, postgres()); + + assert_eq!(form.name, "Shop"); + assert_eq!(form.host, "db.example.com"); + assert_eq!(form.database, "sales"); + assert_eq!(form.username, "app"); + assert!(form.read_only); + assert_eq!(form.password.expose_secret(), "", "a secret reached the form"); + } + + #[test] + fn a_name_that_is_where_it_connects_follows_the_host() { + let saved = saved_from(&filled("postgres", postgres()), postgres()); + assert_eq!(saved.name, "app@db.example.com"); + + let mut form = ConnectForm::from_saved(&saved, postgres()); + assert_eq!(form.name, "", "the derived name was copied into the Name row"); + form.host = "db2.example.com".to_owned(); + + assert_eq!(saved_from(&form, postgres()).name, "app@db2.example.com"); + form.name = " Shop ".to_owned(); + assert_eq!(saved_from(&form, postgres()).name, "Shop"); + } + + #[test] + fn a_reopened_entry_keeps_the_name_the_user_gave_it() { + let form = filled("postgres", postgres()); + let valid = form.validate(postgres()).expect("valid"); + let renamed = SavedConnection { + name: "Shop".to_owned(), + ..saved_from(&form, postgres()) + }; + + let submitted = form.submitted_connection(postgres(), &valid, renamed.id, Some(&renamed)); + assert_eq!(submitted.name, "Shop", "the connect undid the rename"); + + let named = ConnectForm { + name: "Shop (EU)".to_owned(), + ..form.clone() + }; + let valid = named.validate(postgres()).expect("valid"); + assert_eq!( + named + .submitted_connection(postgres(), &valid, renamed.id, Some(&renamed)) + .name, + "Shop (EU)" + ); + } + + #[test] + fn a_derived_name_follows_the_form_on_a_connect() { + let derived = saved_from(&filled("postgres", postgres()), postgres()); + let moved = ConnectForm { + host: "db2.example.com".to_owned(), + ..filled("postgres", postgres()) + }; + let valid = moved.validate(postgres()).expect("valid"); + + let submitted = moved.submitted_connection(postgres(), &valid, derived.id, Some(&derived)); + + assert_eq!(submitted.name, "app@db2.example.com"); + } + + #[test] + fn typed_secrets_ignore_remember() { + let mut form = filled("postgres", postgres()); + form.remember_password = false; + let valid = form.validate(postgres()).expect("valid"); + assert_eq!(form.typed_secrets(&valid), [SecretKind::DatabasePassword]); + + form.password = SecretString::from(String::new()); + let valid = form.validate(postgres()).expect("valid"); + assert!(form.typed_secrets(&valid).is_empty()); + + let tunnelled = tunnelled(SshAuthChoice::PrivateKey, "/home/u/.ssh/id_ed25519", "phrase"); + let valid = tunnelled.validate(postgres()).expect("valid"); + assert_eq!( + tunnelled.typed_secrets(&valid), + [SecretKind::DatabasePassword, SecretKind::SshPassphrase] + ); + } + + #[test] + fn secret_changes_from_form() { + let original = saved_from(&filled("postgres", postgres()), postgres()); + let mut form = ConnectForm::from_saved(&original, postgres()); + + let untouched = changes(&form, &original, postgres()); + assert!(is_keep(&untouched.database_password)); + assert!(is_keep(&untouched.ssh_password)); + assert!(is_keep(&untouched.ssh_passphrase)); + + form.password = SecretString::from("n3w"); + assert_eq!( + replaced_with(&changes(&form, &original, postgres()).database_password), + Some("n3w") + ); + + form.remember_password = false; + assert!(is_remove(&changes(&form, &original, postgres()).database_password)); + + let kerberos_original = saved_from(&filled("mssql", mssql()), mssql()); + let kerberos = ConnectForm { + auth: AuthMode::Kerberos, + ..ConnectForm::from_saved(&kerberos_original, mssql()) + }; + let valid = kerberos.validate(mssql()).expect("valid"); + let moved = kerberos.secret_changes(&kerberos_original, mssql(), &valid); + assert!( + is_remove(&moved.database_password), + "Password to Kerberos left the password stored" + ); + } + + #[test] + fn a_file_connection_has_no_password_to_remove() { + let form = ConnectForm { + file: Some(FileEndpoint::open_existing(PathBuf::from("/srv/app.db")).expect("absolute")), + ..ConnectForm::new("sqlite", sqlite()) + }; + let original = saved_from(&form, sqlite()); + let valid = form.validate(sqlite()).expect("valid"); + + let changes = form.secret_changes(&original, sqlite(), &valid); + + assert!( + is_keep(&changes.database_password), + "a file connection's edit asks the keyring" + ); + } + + fn tunnelled(auth: SshAuthChoice, key_path: &str, passphrase: &str) -> ConnectForm { + ConnectForm { + ssh: Some(SshFormState { + host: "bastion".to_owned(), + auth, + key_path: key_path.to_owned(), + password: SecretString::from(String::new()), + passphrase: SecretString::from(passphrase), + ..SshFormState::new(&SshEnvironment::default()) + }), + ..filled("postgres", postgres()) + } + } + + #[test] + fn an_ssh_secret_the_edit_no_longer_uses_is_removed() { + let mut by_password = tunnelled(SshAuthChoice::Password, "", ""); + if let Some(ssh) = by_password.ssh.as_mut() { + ssh.password = SecretString::from("tunnel"); + } + let original = saved_from(&by_password, postgres()); + + let agent = tunnelled(SshAuthChoice::Agent, "", ""); + assert!(is_remove(&changes(&agent, &original, postgres()).ssh_password)); + let kept = tunnelled(SshAuthChoice::Password, "", ""); + assert!(is_keep(&changes(&kept, &original, postgres()).ssh_password)); + } + + #[test] + fn a_passphrase_goes_with_its_key_file() { + let original = saved_from( + &tunnelled(SshAuthChoice::PrivateKey, "/home/u/.ssh/id_ed25519", "phrase"), + postgres(), + ); + assert!(original.has_stored_passphrase()); + + let same_key = tunnelled(SshAuthChoice::PrivateKey, "/home/u/.ssh/id_ed25519", ""); + assert!(is_keep(&changes(&same_key, &original, postgres()).ssh_passphrase)); + let other_key = tunnelled(SshAuthChoice::PrivateKey, "/home/u/.ssh/id_rsa", ""); + assert!(is_remove(&changes(&other_key, &original, postgres()).ssh_passphrase)); + let typed = tunnelled(SshAuthChoice::PrivateKey, "/home/u/.ssh/id_rsa", "other"); + assert_eq!( + replaced_with(&changes(&typed, &original, postgres()).ssh_passphrase), + Some("other") + ); + let untunnelled = filled("postgres", postgres()); + assert!(is_remove(&changes(&untunnelled, &original, postgres()).ssh_passphrase)); + } +} diff --git a/linux/crates/app/src/ui/connect_dialog/connect_submission.rs b/linux/crates/app/src/ui/connect_dialog/connect_submission.rs new file mode 100644 index 0000000000..b0e9efeb5d --- /dev/null +++ b/linux/crates/app/src/ui/connect_dialog/connect_submission.rs @@ -0,0 +1,10 @@ +use tablepro_session::{ConnectRequest, PendingSave}; + +/// What the connect dialog hands the window: where to connect, and +/// what to save once it works. The dialog itself never connects. +#[derive(Debug, Clone, glib::Boxed)] +#[boxed_type(name = "TableProConnectSubmission")] +pub struct ConnectSubmission { + pub request: ConnectRequest, + pub pending_save: PendingSave, +} diff --git a/linux/crates/app/src/ui/connect_dialog/form_errors.rs b/linux/crates/app/src/ui/connect_dialog/form_errors.rs new file mode 100644 index 0000000000..00f49e9766 --- /dev/null +++ b/linux/crates/app/src/ui/connect_dialog/form_errors.rs @@ -0,0 +1,57 @@ +use super::form_field::FormField; + +/// One thing wrong with the form, pinned to the row that has it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FieldProblem { + pub field: FormField, + pub message: String, + /// Only empty, as opposed to filled in wrong. An empty row is not + /// painted as an error before the user has typed anything: the + /// insensitive Connect button already says the form is incomplete. + pub missing: bool, +} + +/// Every problem the form has, in the order the rows appear. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct FormErrors { + problems: Vec, +} + +impl FormErrors { + pub fn push(&mut self, field: FormField, message: String, missing: bool) { + self.problems.push(FieldProblem { + field, + message, + missing, + }); + } + + pub fn is_empty(&self) -> bool { + self.problems.is_empty() + } + + pub fn problems(&self) -> &[FieldProblem] { + &self.problems + } + + pub fn fields(&self) -> impl Iterator + '_ { + self.problems.iter().map(|problem| problem.field) + } + + pub fn has(&self, field: FormField) -> bool { + self.fields().any(|candidate| candidate == field) + } + + /// The sentence for the first problem, which is the one the user + /// reaches first going down the form. + pub fn message(&self) -> Option<&str> { + self.problems.first().map(|problem| problem.message.as_str()) + } + + /// The problem to paint on `field`, if it is one that should show. + pub fn shown_on(&self, field: FormField) -> Option<&FieldProblem> { + self.problems + .iter() + .find(|problem| problem.field == field && !problem.missing) + } +} diff --git a/linux/crates/app/src/ui/connect_dialog/form_field.rs b/linux/crates/app/src/ui/connect_dialog/form_field.rs new file mode 100644 index 0000000000..937851a7ad --- /dev/null +++ b/linux/crates/app/src/ui/connect_dialog/form_field.rs @@ -0,0 +1,38 @@ +/// A row of the connect dialog that a problem can be pinned to. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum FormField { + Host, + Port, + Database, + File, + AuthMode, + Username, + Encryption, + CaFile, + ClientCertificate, + ClientKey, + ServerName, + SshHost, + SshPort, + SshUser, + SshJumpHosts, + SshKeyPath, +} + +impl FormField { + /// The rows that live in the Security group, so the dialog knows + /// which section to hand a problem to. + pub fn is_tls(self) -> bool { + matches!( + self, + Self::Encryption | Self::CaFile | Self::ClientCertificate | Self::ClientKey | Self::ServerName + ) + } + + pub fn is_ssh(self) -> bool { + matches!( + self, + Self::SshHost | Self::SshPort | Self::SshUser | Self::SshJumpHosts | Self::SshKeyPath + ) + } +} diff --git a/linux/crates/app/src/ui/connect_dialog/imp.rs b/linux/crates/app/src/ui/connect_dialog/imp.rs new file mode 100644 index 0000000000..cfc2d1c1e9 --- /dev/null +++ b/linux/crates/app/src/ui/connect_dialog/imp.rs @@ -0,0 +1,135 @@ +use std::cell::{Cell, OnceCell, RefCell}; +use std::sync::OnceLock; + +use glib::subclass::Signal; +use gtk4::glib; +use gtk4::prelude::*; +use gtk4::subclass::prelude::*; +use libadwaita as adw; +use libadwaita::subclass::prelude::*; +use tablepro_core::{AuthMode, TlsConfig}; +use tablepro_session::ConnectAttempts; + +use super::connect_dialog_mode::ConnectDialogMode; +use super::connect_submission::ConnectSubmission; +use super::pem_validation::PemValidation; +use super::sqlite_file_row::SqliteFileRow; +use super::tls_section::TlsSection; +use super::{DialogServices, DriverEntry}; +use crate::ui::ssh_section::SshSection; + +#[derive(Default, gtk4::CompositeTemplate)] +#[template(resource = "/app/tablepro/TablePro/ui/connect-dialog.ui")] +pub struct ConnectDialog { + #[template_child] + pub test_button: TemplateChild, + #[template_child] + pub connect_button: TemplateChild, + #[template_child] + pub toast_overlay: TemplateChild, + #[template_child] + pub name_row: TemplateChild, + #[template_child] + pub driver_row: TemplateChild, + #[template_child] + pub driver_names: TemplateChild, + #[template_child] + pub host_row: TemplateChild, + #[template_child] + pub port_row: TemplateChild, + #[template_child] + pub database_row: TemplateChild, + #[template_child] + pub file_row: TemplateChild, + #[template_child] + pub auth_group: TemplateChild, + #[template_child] + pub auth_mode_row: TemplateChild, + #[template_child] + pub auth_mode_names: TemplateChild, + #[template_child] + pub username_row: TemplateChild, + #[template_child] + pub password_row: TemplateChild, + #[template_child] + pub remember_row: TemplateChild, + #[template_child] + pub tls_section: TemplateChild, + #[template_child] + pub ssh_section: TemplateChild, + #[template_child] + pub read_only_row: TemplateChild, + + pub(super) services: OnceCell, + pub(super) drivers: RefCell>, + pub auth_modes: RefCell>, + /// Set once the user types a port; see `ConnectForm::port_edited`. + pub port_edited: Cell, + /// Set while the code writes rows, so its writes are not taken for + /// the user's. + pub updating: Cell, + /// The entry the dialog was reopened on, which a submission updates + /// even when the user changed the host it was found by. + pub prefill_id: Cell>, + /// Create, or Edit with the saved connection as it was opened. + pub mode: RefCell, + /// An Edit's save in flight, which keeps Save from running twice. + pub saving: Cell, + /// The user can close the dialog while a save runs, and the save + /// must not close it again when it lands. + pub has_closed: Cell, + pub pem: PemValidation, + /// The TLS settings the last file check read, so a keystroke in an + /// unrelated row does not read the files again. + pub pem_checked: RefCell>, + /// The Test run in flight. Closing the dialog or pressing Stop + /// cancels it, and its late answer is dropped. + pub tests: ConnectAttempts, + pub testing: Cell, + #[cfg(test)] + pub toasts: RefCell>, +} + +#[glib::object_subclass] +impl ObjectSubclass for ConnectDialog { + const NAME: &'static str = "TableProConnectDialog"; + type Type = super::ConnectDialog; + type ParentType = adw::Dialog; + + fn class_init(klass: &mut Self::Class) { + SqliteFileRow::ensure_type(); + TlsSection::ensure_type(); + SshSection::ensure_type(); + klass.bind_template(); + } + + fn instance_init(object: &glib::subclass::InitializingObject) { + object.init_template(); + } +} + +impl ObjectImpl for ConnectDialog { + fn signals() -> &'static [Signal] { + static SIGNALS: OnceLock> = OnceLock::new(); + SIGNALS.get_or_init(|| { + vec![ + Signal::builder("submitted") + .param_types([ConnectSubmission::static_type()]) + .build(), + // An Edit's Save landed. Carries the connection's id. + Signal::builder("saved").param_types([String::static_type()]).build(), + ] + }) + } +} + +impl WidgetImpl for ConnectDialog {} + +impl AdwDialogImpl for ConnectDialog { + fn closed(&self) { + self.has_closed.set(true); + self.tests.cancel(); + self.testing.set(false); + self.parent_closed(); + } +} diff --git a/linux/crates/app/src/ui/connect_dialog/mod.rs b/linux/crates/app/src/ui/connect_dialog/mod.rs new file mode 100644 index 0000000000..1f80c2fa41 --- /dev/null +++ b/linux/crates/app/src/ui/connect_dialog/mod.rs @@ -0,0 +1,868 @@ +//! The connect dialog. It checks what the user typed and hands the +//! window a `ConnectSubmission`; the window connects and saves. The +//! Test button is the one place it connects, and it disconnects again. + +mod connect_dialog_mode; +mod connect_form; +mod connect_submission; +mod form_errors; +mod form_field; +mod imp; +mod pem_validation; +mod read_only_row; +mod row_problem; +mod sqlite_file_row; +#[cfg(test)] +mod tests; +mod tls_section; + +use std::rc::Rc; +use std::sync::Arc; + +use gtk4::glib; +use gtk4::prelude::*; +use gtk4::subclass::prelude::*; +use libadwaita as adw; +use libadwaita::prelude::*; +use secrecy::SecretString; +use tablepro_core::credentials::CredentialInteraction; +use tablepro_core::{ + AuthMode, ConfigError, DatabaseDriver, DriverCapabilities, DriverRegistry, TlsConfig, TransportClass, + WorkspaceSettings, +}; +use tablepro_session::runtime::{ProcessHold, Tasks}; +use tablepro_session::{ConnectError, ConnectRequest, ConnectionMetadata, Connector, CredentialSource, PendingSave}; +use tablepro_storage::{CredentialDemand, SavedConnection}; +use uuid::Uuid; + +pub use self::connect_dialog_mode::ConnectDialogMode; +use self::connect_form::ValidatedForm; +pub use self::connect_form::{ConnectForm, driver_shows_server_name}; +pub use self::connect_submission::ConnectSubmission; +pub use self::form_errors::FormErrors; +pub use self::form_field::FormField; +use self::pem_validation::{PemState, PemValidation}; +pub use self::row_problem::show_problem; +pub(crate) use self::sqlite_file_row::sqlite_file_dialog; +use crate::services::saved_connection_editor::SavedConnectionEditor; +use crate::services::secret_changes::SecretChanges; +use crate::services::session_context::CredentialSourceFactory; +use crate::ui::error_text::TlsRemedy; +use crate::ui::ssh_inputs::SshEnvironment; + +glib::wrapper! { + pub struct ConnectDialog(ObjectSubclass) + @extends adw::Dialog, gtk4::Widget, + @implements gtk4::Accessible, gtk4::Buildable, gtk4::ConstraintTarget, gtk4::ShortcutManager; +} + +/// What the dialog is opened with. It reads no storage itself: the +/// saved list and the remembered driver come from the window. +pub struct ConnectDialogInit { + pub drivers: Arc, + /// Used by Test only; Connect hands the request to the window. + pub connector: Arc, + pub interaction: CredentialInteraction, + pub tasks: Tasks, + pub saved_connections: Arc<[SavedConnection]>, + pub remembered_driver: String, + pub ssh_environment: SshEnvironment, + /// What a Create opens on after a connect from it failed. + pub prefill: Option, + pub mode: ConnectDialogMode, + /// Writes an Edit's Save, secrets first. + pub editor: SavedConnectionEditor, + /// Where an Edit's Test reads the secrets it keeps. + pub credentials: Rc, + /// Keeps the process alive while an Edit's Save is written. + pub hold: Rc, +} + +/// A connection to show again, after a connect from this dialog failed. +#[derive(Debug, Clone)] +pub struct ConnectPrefill { + pub connection: SavedConnection, + /// The password typed last time, held in memory only. + pub password: Option, +} + +pub(crate) struct DialogServices { + connector: Arc, + interaction: CredentialInteraction, + tasks: Tasks, + saved_connections: Arc<[SavedConnection]>, + editor: SavedConnectionEditor, + credentials: Rc, + hold: Rc, +} + +/// A form that passed every check, with the driver it is for. +struct CheckedForm { + entry: DriverEntry, + form: ConnectForm, + valid: ValidatedForm, +} + +#[derive(Clone)] +pub(crate) struct DriverEntry { + id: String, + display_name: String, + driver: Arc, +} + +impl DriverEntry { + fn capabilities(&self) -> &'static DriverCapabilities { + self.driver.capabilities() + } +} + +impl ConnectDialog { + pub fn new(init: ConnectDialogInit) -> Self { + let dialog: Self = glib::Object::new(); + let imp = dialog.imp(); + let mut drivers: Vec = init + .drivers + .iter() + .map(|driver| DriverEntry { + id: driver.id().to_owned(), + display_name: driver.display_name().to_owned(), + driver: driver.clone(), + }) + .collect(); + drivers.sort_by(|a, b| a.display_name.cmp(&b.display_name)); + let names: Vec<&str> = drivers.iter().map(|entry| entry.display_name.as_str()).collect(); + imp.driver_names.splice(0, imp.driver_names.n_items(), &names); + imp.file_row.set_tasks(init.tasks.clone()); + let _ = imp.services.set(DialogServices { + connector: init.connector, + interaction: init.interaction, + tasks: init.tasks, + saved_connections: init.saved_connections, + editor: init.editor, + credentials: init.credentials, + hold: init.hold, + }); + imp.ssh_section.apply_environment(&init.ssh_environment); + + // An Edit opens on what is saved; a Create reopened after a + // failed connect opens on what was typed. + let prefill = match &init.mode { + ConnectDialogMode::Edit(saved) => Some(ConnectPrefill { + connection: (**saved).clone(), + password: None, + }), + ConnectDialogMode::Create => init.prefill, + }; + let wanted = prefill.as_ref().map_or(init.remembered_driver.as_str(), |prefill| { + prefill.connection.driver_id.as_str() + }); + let index = driver_index(&drivers, wanted); + imp.prefill_id + .set(prefill.as_ref().map(|prefill| prefill.connection.id)); + imp.drivers.replace(drivers); + imp.updating.set(true); + imp.driver_row.set_selected(index as u32); + imp.updating.set(false); + dialog.show_mode(&init.mode); + imp.mode.replace(init.mode); + + let form = match (prefill, dialog.entry_at(index)) { + (Some(prefill), Some(entry)) => { + let mut form = ConnectForm::from_saved(&prefill.connection, entry.capabilities()); + if let Some(password) = prefill.password { + form.password = password; + } + form + } + (None, Some(entry)) => ConnectForm::new(&entry.id, entry.capabilities()), + (_, None) => ConnectForm::new("", &NO_DRIVER), + }; + dialog.show_driver(index, &form); + dialog.connect_handlers(); + dialog.recheck_files(); + dialog.refresh(); + dialog + } + + pub fn connect_submitted(&self, f: F) -> glib::SignalHandlerId { + self.connect_closure( + "submitted", + false, + glib::closure_local!(move |dialog: &Self, submission: ConnectSubmission| { + f(dialog, submission); + }), + ) + } + + /// An Edit's Save wrote the connection with id `id`. + pub fn connect_saved(&self, f: F) -> glib::SignalHandlerId { + self.connect_closure( + "saved", + false, + glib::closure_local!(move |dialog: &Self, id: String| match Uuid::parse_str(&id) { + Ok(id) => f(dialog, id), + Err(error) => tracing::warn!(%error, "the connect dialog saved an id it cannot read"), + }), + ) + } + + /// The saved connection an Edit changes, as it was opened. + pub fn editing(&self) -> Option { + self.imp().mode.borrow().editing().cloned() + } + + /// An Edit saves where a Create connects, and says the password rows + /// may stay empty. + fn show_mode(&self, mode: &ConnectDialogMode) { + let imp = self.imp(); + if mode.editing().is_none() { + return; + } + imp.connect_button.set_label(&crate::i18n::gettext("Save")); + imp.auth_group.set_description(Some(&crate::i18n::gettext( + "Leave password fields empty to keep the saved passwords.", + ))); + } + + /// Show a row and put the focus in it, for an error that names the + /// setting that fixes it. + pub fn reveal_field(&self, field: FormField) { + let imp = self.imp(); + if field.is_tls() { + imp.tls_section.reveal(field); + } else if field.is_ssh() { + imp.ssh_section.reveal(field); + } else { + let row: gtk4::Widget = match field { + FormField::Port => imp.port_row.get().upcast(), + FormField::Database => imp.database_row.get().upcast(), + FormField::File => imp.file_row.get().upcast(), + FormField::AuthMode => imp.auth_mode_row.get().upcast(), + FormField::Username => imp.username_row.get().upcast(), + _ => imp.host_row.get().upcast(), + }; + row.grab_focus(); + } + } + + fn services(&self) -> Option<&DialogServices> { + self.imp().services.get() + } + + fn entry_at(&self, index: usize) -> Option { + self.imp().drivers.borrow().get(index).cloned() + } + + fn current_entry(&self) -> Option { + self.entry_at(self.imp().driver_row.selected() as usize) + } + + fn connect_handlers(&self) { + let imp = self.imp(); + let weak = self.downgrade(); + imp.driver_row.connect_selected_notify(move |_| { + if let Some(dialog) = weak.upgrade() + && !dialog.imp().updating.get() + { + dialog.switch_driver(); + } + }); + let weak = self.downgrade(); + imp.port_row.connect_value_notify(move |_| { + if let Some(dialog) = weak.upgrade() + && !dialog.imp().updating.get() + { + dialog.imp().port_edited.set(true); + dialog.refresh(); + } + }); + let weak = self.downgrade(); + imp.auth_mode_row.connect_selected_notify(move |_| { + if let Some(dialog) = weak.upgrade() + && !dialog.imp().updating.get() + { + dialog.show_auth_rows(); + dialog.refresh(); + } + }); + let entries: [&adw::EntryRow; 4] = [&imp.name_row, &imp.host_row, &imp.database_row, &imp.username_row]; + for row in entries { + let weak = self.downgrade(); + row.connect_changed(move |_| { + if let Some(dialog) = weak.upgrade() { + dialog.refresh(); + } + }); + } + let weak = self.downgrade(); + imp.password_row.connect_changed(move |_| { + if let Some(dialog) = weak.upgrade() { + dialog.refresh(); + } + }); + let weak = self.downgrade(); + imp.file_row.connect_changed(move |_| { + if let Some(dialog) = weak.upgrade() { + dialog.refresh(); + } + }); + let weak = self.downgrade(); + imp.tls_section.connect_changed(move |_| { + if let Some(dialog) = weak.upgrade() { + dialog.recheck_files(); + dialog.refresh(); + } + }); + let weak = self.downgrade(); + imp.ssh_section.connect_changed(move |_| { + if let Some(dialog) = weak.upgrade() { + dialog.imp().tls_section.set_route(dialog.route()); + dialog.recheck_files(); + dialog.refresh(); + } + }); + let weak = self.downgrade(); + imp.connect_button.connect_clicked(move |_| { + if let Some(dialog) = weak.upgrade() { + dialog.submit(); + } + }); + let weak = self.downgrade(); + imp.test_button.connect_clicked(move |_| { + if let Some(dialog) = weak.upgrade() { + dialog.toggle_test(); + } + }); + } + + /// The form as the rows hold it now. + pub fn form(&self) -> ConnectForm { + let imp = self.imp(); + let entry = self.current_entry(); + let caps = entry.as_ref().map_or(&NO_DRIVER, DriverEntry::capabilities); + let auth = imp + .auth_modes + .borrow() + .get(imp.auth_mode_row.selected() as usize) + .copied() + .unwrap_or_default(); + ConnectForm { + name: imp.name_row.text().to_string(), + driver_id: entry.map(|entry| entry.id).unwrap_or_default(), + host: imp.host_row.text().to_string(), + port: imp.port_row.value() as u16, + port_edited: imp.port_edited.get(), + database: imp.database_row.text().to_string(), + auth, + username: imp.username_row.text().to_string(), + password: SecretString::from(imp.password_row.text().to_string()), + remember_password: imp.remember_row.is_active(), + tls: imp.tls_section.form_state(), + read_only: imp.read_only_row.is_active(), + ssh: (!caps.is_file_based()).then(|| imp.ssh_section.form_state()).flatten(), + file: imp.file_row.endpoint(), + } + } + + fn shows_server_name(&self) -> bool { + let entry = self.current_entry(); + driver_shows_server_name(entry.as_ref().map(DriverEntry::capabilities), self.route()) + } + + fn route(&self) -> TransportClass { + if self.imp().ssh_section.is_enabled() { + TransportClass::SshForward + } else { + TransportClass::DirectTcp + } + } + + fn switch_driver(&self) { + let imp = self.imp(); + let index = imp.driver_row.selected() as usize; + let Some(entry) = self.entry_at(index) else { + return; + }; + let mut form = self.form(); + form.switch_driver(&entry.id, entry.capabilities()); + self.show_driver(index, &form); + self.recheck_files(); + self.refresh(); + } + + /// Show the rows `index`'s driver uses, filled from `form`. + fn show_driver(&self, index: usize, form: &ConnectForm) { + let imp = self.imp(); + let Some(entry) = self.entry_at(index) else { + return; + }; + let caps = entry.capabilities(); + let file_based = caps.is_file_based(); + imp.updating.set(true); + if imp.name_row.text() != form.name { + imp.name_row.set_text(&form.name); + } + imp.host_row.set_text(&form.host); + imp.port_row.set_value(f64::from(form.port)); + imp.port_edited.set(form.port_edited); + imp.database_row.set_text(&form.database); + imp.username_row.set_text(&form.username); + if imp.password_row.text() != secrecy::ExposeSecret::expose_secret(&form.password) { + imp.password_row + .set_text(secrecy::ExposeSecret::expose_secret(&form.password)); + } + imp.read_only_row.set_active(form.read_only); + if form.file.is_some() || imp.file_row.endpoint().is_some() { + imp.file_row.set_endpoint(form.file.clone()); + } + if let Some(ssh) = &form.ssh { + imp.ssh_section.set_state(Some(ssh)); + } + + let modes: Vec = caps.auth_modes.to_vec(); + let labels: Vec = modes.iter().map(|mode| auth_mode_label(*mode)).collect(); + let labels: Vec<&str> = labels.iter().map(String::as_str).collect(); + imp.auth_mode_names.splice(0, imp.auth_mode_names.n_items(), &labels); + let selected = modes.iter().position(|mode| *mode == form.auth).unwrap_or(0); + imp.auth_mode_row.set_selected(selected as u32); + imp.auth_modes.replace(modes); + imp.updating.set(false); + + imp.host_row.set_visible(!file_based); + imp.port_row.set_visible(!file_based); + imp.database_row.set_visible(!file_based); + imp.database_row.set_title(&if caps.database_required { + crate::i18n::gettext("Database") + } else { + crate::i18n::gettext("Database (optional)") + }); + imp.file_row.set_visible(file_based); + imp.auth_group.set_visible(!file_based && !caps.auth_modes.is_empty()); + imp.ssh_section.set_visible(!file_based); + let tls = if file_based { None } else { caps.tls.as_ref() }; + imp.tls_section.apply(tls, self.route(), &form.tls); + imp.read_only_row + .set_subtitle(&read_only_row::read_only_subtitle(caps.read_only)); + self.show_auth_rows(); + self.set_title(&if imp.mode.borrow().editing().is_some() { + crate::i18n::gettext("Edit Connection") + } else { + crate::i18n::gettext_f("Connect to {name}", &[("name", &entry.display_name)]) + }); + } + + fn show_auth_rows(&self) { + let imp = self.imp(); + imp.auth_mode_row.set_visible(imp.auth_modes.borrow().len() > 1); + let password = self.form().auth == AuthMode::Password; + imp.username_row.set_visible(password); + imp.password_row.set_visible(password); + imp.remember_row.set_visible(password); + } + + /// The TLS settings the request would carry, for the file check. + fn tls_config(&self) -> TlsConfig { + let Some(entry) = self.current_entry() else { + return TlsConfig::disabled(); + }; + match entry.capabilities().tls.as_ref() { + Some(caps) if !entry.capabilities().is_file_based() => { + self.imp().tls_section.form_state().config(caps, self.route()) + } + _ => TlsConfig::disabled(), + } + } + + /// Read the chosen certificate files on the blocking pool. Connect + /// waits for the answer, and only the newest check counts. + fn recheck_files(&self) { + let imp = self.imp(); + let config = self.tls_config(); + if imp.pem_checked.borrow().as_ref() == Some(&config) { + return; + } + imp.pem_checked.replace(Some(config.clone())); + if !PemValidation::needs_check(&config) { + imp.pem.clear(); + return; + } + let Some(services) = self.services() else { + return; + }; + let ticket = imp.pem.begin(); + let fallback_path = config + .ca_file + .clone() + .or_else(|| { + config + .client_identity + .as_ref() + .map(|identity| identity.certificate.clone()) + }) + .unwrap_or_default(); + let check = services + .tasks + .spawn_blocking_task(move || tablepro_net::tls::check_pem_files(&config)); + let weak = self.downgrade(); + glib::spawn_future_local(async move { + let result = check.await.unwrap_or_else(|failure| { + Err(ConfigError::CaFile { + path: fallback_path, + problem: tablepro_core::CertificateFileProblem::Unreadable { + detail: failure.to_string(), + }, + }) + }); + if let Some(dialog) = weak.upgrade() + && dialog.imp().pem.finish(ticket, result) + { + dialog.refresh(); + } + }); + } + + /// Paint the rows, and allow Connect only on a form that would work. + fn refresh(&self) { + let imp = self.imp(); + if imp.updating.get() { + return; + } + let Some(entry) = self.current_entry() else { + imp.connect_button.set_sensitive(false); + imp.test_button.set_sensitive(false); + return; + }; + let errors = self.form().validate(entry.capabilities()).err().unwrap_or_default(); + let shown = |field| errors.shown_on(field).map(|problem| problem.message.as_str()); + show_problem(&*imp.host_row, shown(FormField::Host)); + show_problem(&*imp.port_row, shown(FormField::Port)); + show_problem(&*imp.database_row, shown(FormField::Database)); + show_problem(&*imp.file_row, shown(FormField::File)); + show_problem(&*imp.auth_mode_row, shown(FormField::AuthMode)); + show_problem(&*imp.username_row, shown(FormField::Username)); + let pem = imp.pem.state(); + let file_problem = match &pem { + PemState::Invalid(error) => Some(error), + PemState::Valid | PemState::Pending => None, + }; + imp.tls_section.show_problems(&errors, file_problem); + imp.ssh_section.show_problems(&errors); + + let blocker = match (&pem, errors.message()) { + (_, Some(message)) => Some(message.to_owned()), + (PemState::Invalid(error), None) => Some(crate::ui::error_text::config_message(error)), + (PemState::Pending, None) => Some(crate::i18n::gettext("Checking the certificate files…")), + (PemState::Valid, None) => None, + }; + imp.connect_button.set_sensitive(blocker.is_none() && !imp.saving.get()); + imp.connect_button.set_tooltip_text(blocker.as_deref()); + imp.test_button.set_sensitive(blocker.is_none() || imp.testing.get()); + } + + /// The form, checked, or the reason it cannot be used yet. + fn checked_form(&self) -> Result { + let entry = self + .current_entry() + .ok_or_else(|| crate::i18n::gettext("No driver selected"))?; + let form = self.form(); + let valid = form + .validate(entry.capabilities()) + .map_err(|errors| errors.message().unwrap_or_default().to_owned())?; + match self.imp().pem.state() { + PemState::Valid => {} + PemState::Pending => return Err(crate::i18n::gettext("Checking the certificate files…")), + PemState::Invalid(error) => return Err(crate::ui::error_text::config_message(&error)), + } + Ok(CheckedForm { entry, form, valid }) + } + + /// Everything a Connect or a Test sends, or the reason there is + /// nothing to send yet. + fn build_submission(&self) -> Result { + let CheckedForm { entry, form, valid } = self.checked_form()?; + let caps = entry.capabilities(); + let saved_connections = self + .services() + .map(|services| services.saved_connections.clone()) + .unwrap_or_else(|| Arc::from(Vec::new())); + // Only the entry the dialog was reopened on is written over. One + // that merely reaches the same database may differ in its name + // or its read-only switch, and New Connection must not undo them. + let reopened = self.imp().prefill_id.get(); + let existing = saved_connections + .iter() + .find(|saved| Some(saved.id) == reopened && saved.driver_id == entry.id); + let id = existing.map_or_else(Uuid::new_v4, |saved| saved.id); + let connection = form.submitted_connection(caps, &valid, id, existing); + let request = connect_request(&entry, &form, &valid, &connection); + let pending_save = PendingSave { + secrets: form.secrets_to_store(&valid, &connection), + forget: form.secrets_to_forget(&valid), + connection, + is_new: existing.is_none(), + }; + Ok(ConnectSubmission { request, pending_save }) + } + + /// The saved connection an Edit's form describes, under its own id, + /// and what saving it does to each secret. + fn edited(&self, original: &SavedConnection, checked: &CheckedForm) -> (SavedConnection, SecretChanges) { + let CheckedForm { entry, form, valid } = checked; + let original_caps = self + .imp() + .drivers + .borrow() + .iter() + .find(|candidate| candidate.id == original.driver_id) + .map_or(&NO_DRIVER, DriverEntry::capabilities); + let changes = form.secret_changes(original, original_caps, valid); + let mut connection = form.saved_connection(entry.capabilities(), valid, original.id, Some(original)); + changes.settle_passphrase(&mut connection, original); + (connection, changes) + } + + /// Hand the window what to connect to, and close. The window shows + /// the connect and saves the entry once it works. An Edit saves + /// instead, and closes once the save lands. + fn submit(&self) { + if let Some(original) = self.editing() { + self.save_edit(&original); + return; + } + match self.build_submission() { + Ok(submission) => { + self.stop_test(); + self.emit_by_name::<()>("submitted", &[&submission]); + self.close(); + } + Err(message) => self.show_toast(&message, None), + } + } + + /// Write an Edit, secrets first, and close once it lands. A failure + /// keeps the dialog open with what the user typed, to try again. + fn save_edit(&self, original: &SavedConnection) { + let imp = self.imp(); + if imp.saving.get() { + return; + } + let checked = match self.checked_form() { + Ok(checked) => checked, + Err(message) => { + self.show_toast(&message, None); + return; + } + }; + let Some(services) = self.services() else { + return; + }; + let (connection, changes) = self.edited(original, &checked); + self.stop_test(); + imp.saving.set(true); + self.refresh(); + let editor = services.editor.clone(); + let hold = services.hold.acquire(); + let weak = self.downgrade(); + crate::runtime::spawn_and_deliver( + &services.tasks, + async move { editor.update(connection, changes).await }, + move |outcome| { + drop(hold); + let Some(dialog) = weak.upgrade() else { + return; + }; + dialog.imp().saving.set(false); + dialog.refresh(); + match outcome { + Ok(Ok(saved)) => { + dialog.emit_by_name::<()>("saved", &[&saved.id.to_string()]); + if !dialog.imp().has_closed.get() { + dialog.close(); + } + } + Ok(Err(error)) => { + tracing::warn!(%error, "could not save the edited connection"); + dialog.show_toast(&crate::ui::error_text::saved_connection_error_message(&error), None); + } + Err(failure) => dialog.show_toast(&crate::ui::error_text::task_failure_message(&failure), None), + } + }, + ); + } + + /// What a Test connects with: the request, and for an Edit, where + /// the secrets its empty rows keep are read from. + fn test_request(&self) -> Result<(ConnectRequest, Option>), String> { + let Some(original) = self.editing() else { + return self.build_submission().map(|submission| (submission.request, None)); + }; + let checked = self.checked_form()?; + let (connection, _) = self.edited(&original, &checked); + let request = connect_request(&checked.entry, &checked.form, &checked.valid, &connection); + let typed = checked.form.typed_secrets(&checked.valid); + let kept = self + .services() + .map(|services| services.credentials.for_connection(Some(&connection), &typed)); + Ok((request, kept)) + } + + fn toggle_test(&self) { + if self.imp().testing.get() { + self.stop_test(); + return; + } + let (request, kept) = match self.test_request() { + Ok(test) => test, + Err(message) => { + self.show_toast(&message, None); + return; + } + }; + let Some(services) = self.services() else { + return; + }; + let imp = self.imp(); + let ticket = imp.tests.begin(); + let generation = ticket.generation(); + imp.testing.set(true); + imp.test_button.set_label(&crate::i18n::gettext("Stop")); + imp.test_button + .set_tooltip_text(Some(&crate::i18n::gettext("Stop the Test"))); + self.refresh(); + + // A test entry is never saved, so it gets an id of its own. + let mut request = request; + request.metadata.id = Uuid::new_v4(); + let connector = services.connector.clone(); + let interaction = services.interaction.clone(); + let cancel = ticket.cancel().clone(); + // Closing happens in the same task, so a connect that finishes + // after the user stopped waiting is still closed. + let work = async move { + let request = match kept { + Some(source) => { + let refreshing = source.refresh(request, interaction.clone(), CredentialDemand::default()); + cancel + .run_until_cancelled(refreshing) + .await + .ok_or(ConnectError::Cancelled)?? + .request + } + None => request, + }; + let pending = connector.establish(request, interaction, cancel).await?; + let report = pending.discard().await; + tracing::debug!(?report, "closed the test connection"); + Ok::<(), ConnectError>(()) + }; + let weak = self.downgrade(); + crate::runtime::spawn_and_deliver(&services.tasks, work, move |outcome| { + let Some(dialog) = weak.upgrade() else { + return; + }; + let imp = dialog.imp(); + if !imp.testing.get() || !imp.tests.accepts(generation) { + return; + } + dialog.stop_test(); + match outcome { + Ok(Ok(())) => dialog.show_toast(&crate::i18n::gettext("Connection works"), None), + Ok(Err(ConnectError::Cancelled)) => {} + Ok(Err(error)) => dialog.show_toast( + &crate::ui::error_text::connect_error_message(&error), + crate::ui::error_text::connect_remedy(&error, dialog.shows_server_name()), + ), + Err(failure) => dialog.show_toast(&crate::ui::error_text::task_failure_message(&failure), None), + } + }); + } + + fn stop_test(&self) { + let imp = self.imp(); + imp.tests.cancel(); + if !imp.testing.replace(false) { + return; + } + imp.test_button.set_label(&crate::i18n::gettext("Test")); + imp.test_button + .set_tooltip_text(Some(&crate::i18n::gettext("Connect Once to Check the Settings"))); + self.refresh(); + } + + fn show_toast(&self, message: &str, remedy: Option) { + let toast = crate::ui::plain_toast(message); + if let Some(remedy) = remedy { + toast.set_button_label(Some(&remedy.label())); + let weak = self.downgrade(); + toast.connect_button_clicked(move |_| { + if let Some(dialog) = weak.upgrade() { + dialog.reveal_field(remedy_field(remedy)); + } + }); + } + #[cfg(test)] + self.imp().toasts.borrow_mut().push(message.to_owned()); + self.imp().toast_overlay.add_toast(toast); + } +} + +/// What a connect to `connection`, as the form describes it, sends. +fn connect_request( + entry: &DriverEntry, + form: &ConnectForm, + valid: &ValidatedForm, + connection: &SavedConnection, +) -> ConnectRequest { + ConnectRequest { + metadata: ConnectionMetadata { + id: connection.id, + name: connection.name.clone(), + driver_id: entry.id.clone(), + }, + driver: entry.driver.clone(), + target: valid.target.clone(), + workspace_settings: WorkspaceSettings::workspace(form.read_only), + } +} + +/// The row that fixes a TLS failure. +pub fn remedy_field(remedy: TlsRemedy) -> FormField { + match remedy { + TlsRemedy::ChooseCaCertificate => FormField::CaFile, + TlsRemedy::SetServerName => FormField::ServerName, + } +} + +/// Capabilities for a registry with no drivers at all, so the form +/// still has something to read. +static NO_DRIVER: DriverCapabilities = DriverCapabilities { + endpoint: tablepro_core::EndpointKind::File, + auth_modes: &[], + tls: None, + read_only: tablepro_core::ReadOnlyEnforcement::LoginPrivileges, + dml_writes: tablepro_core::WriteAtomicity::Transactional, + ddl_writes: tablepro_core::WriteAtomicity::Transactional, + script_execution: tablepro_core::ScriptExecution::SingleStatement, + commit_resolution: tablepro_core::CommitResolutionSupport::Unavailable, + row_count_estimate: tablepro_core::EstimateQuality::Unavailable, + object_kinds: &[], + database_required: false, +}; + +fn auth_mode_label(mode: AuthMode) -> String { + match mode { + AuthMode::Password => crate::i18n::gettext("Password"), + AuthMode::Kerberos => crate::i18n::gettext("Windows (Kerberos)"), + } +} + +/// Where the remembered driver sits in the sorted list, falling back to +/// PostgreSQL and then to whatever is first, so an uninstalled driver +/// id in the settings never opens an empty dialog. +fn driver_index(drivers: &[DriverEntry], remembered: &str) -> usize { + drivers + .iter() + .position(|entry| entry.id == remembered) + .or_else(|| drivers.iter().position(|entry| entry.id == "postgres")) + .unwrap_or(0) +} diff --git a/linux/crates/app/src/ui/connect_dialog/pem_validation.rs b/linux/crates/app/src/ui/connect_dialog/pem_validation.rs new file mode 100644 index 0000000000..4e0bb976aa --- /dev/null +++ b/linux/crates/app/src/ui/connect_dialog/pem_validation.rs @@ -0,0 +1,134 @@ +use std::cell::{Cell, RefCell}; + +use tablepro_core::{ConfigError, TlsConfig}; + +/// Where the check of the chosen certificate files stands. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PemState { + /// No file to read, or every chosen file parsed. + Valid, + /// A read is running; Connect waits for it. + Pending, + Invalid(ConfigError), +} + +/// One run of the file check, to be matched against the latest. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct PemTicket(u64); + +/// Reads of the CA and client certificate files, newest wins. +/// +/// The files are read on the blocking pool, so a slow disk can answer +/// after the user has already picked another file. Every request gets +/// a generation and only the latest one may change the state. +#[derive(Debug)] +pub struct PemValidation { + latest: Cell, + state: RefCell, +} + +impl Default for PemValidation { + fn default() -> Self { + Self { + latest: Cell::new(0), + state: RefCell::new(PemState::Valid), + } + } +} + +impl PemValidation { + /// Whether `tls` names any file worth reading. + pub fn needs_check(tls: &TlsConfig) -> bool { + tls.ca_file.is_some() || tls.client_identity.is_some() + } + + /// Start a check, which makes every earlier one stale. + pub fn begin(&self) -> PemTicket { + let generation = self.latest.get().wrapping_add(1); + self.latest.set(generation); + self.state.replace(PemState::Pending); + PemTicket(generation) + } + + /// There is nothing to read any more; whatever is in flight is stale. + pub fn clear(&self) { + self.latest.set(self.latest.get().wrapping_add(1)); + self.state.replace(PemState::Valid); + } + + /// Record a finished check. Returns false, and changes nothing, for + /// a check that a later one replaced. + pub fn finish(&self, ticket: PemTicket, result: Result<(), ConfigError>) -> bool { + if ticket.0 != self.latest.get() { + return false; + } + self.state.replace(match result { + Ok(()) => PemState::Valid, + Err(error) => PemState::Invalid(error), + }); + true + } + + pub fn state(&self) -> PemState { + self.state.borrow().clone() + } +} + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use tablepro_core::CertificateFileProblem; + + use super::*; + + fn unreadable() -> ConfigError { + ConfigError::CaFile { + path: PathBuf::from("/etc/tls/ca.pem"), + problem: CertificateFileProblem::NoCertificates, + } + } + + #[test] + fn stale_generation_result_is_ignored() { + let validation = PemValidation::default(); + let first = validation.begin(); + let second = validation.begin(); + + assert!(!validation.finish(first, Err(unreadable())), "a stale result landed"); + assert_eq!(validation.state(), PemState::Pending); + + assert!(validation.finish(second, Ok(()))); + assert_eq!(validation.state(), PemState::Valid); + } + + #[test] + fn clearing_the_files_drops_the_check_in_flight() { + let validation = PemValidation::default(); + let running = validation.begin(); + + validation.clear(); + + assert!(!validation.finish(running, Err(unreadable()))); + assert_eq!(validation.state(), PemState::Valid); + } + + #[test] + fn a_failed_check_holds_its_error() { + let validation = PemValidation::default(); + let ticket = validation.begin(); + + assert!(validation.finish(ticket, Err(unreadable()))); + + assert_eq!(validation.state(), PemState::Invalid(unreadable())); + } + + #[test] + fn only_a_config_with_files_needs_reading() { + assert!(!PemValidation::needs_check(&TlsConfig::verify_full())); + assert!(PemValidation::needs_check(&TlsConfig { + ca_file: Some(PathBuf::from("/etc/tls/ca.pem")), + ..TlsConfig::verify_full() + })); + } +} diff --git a/linux/crates/app/src/ui/connect_dialog/read_only_row.rs b/linux/crates/app/src/ui/connect_dialog/read_only_row.rs new file mode 100644 index 0000000000..76775e11c8 --- /dev/null +++ b/linux/crates/app/src/ui/connect_dialog/read_only_row.rs @@ -0,0 +1,32 @@ +use tablepro_core::ReadOnlyEnforcement; + +/// What read-only mode really guarantees on this engine. The three +/// differ in whether a statement can still turn it off, so the switch +/// says which one the user is getting. +pub fn read_only_subtitle(enforcement: ReadOnlyEnforcement) -> String { + match enforcement { + ReadOnlyEnforcement::EngineEnforced => crate::i18n::gettext("The database refuses changes on this connection."), + ReadOnlyEnforcement::SessionDefault => crate::i18n::gettext( + "Changes are refused unless a statement turns read-only off. Use a read-only login for a guarantee.", + ), + ReadOnlyEnforcement::LoginPrivileges => { + crate::i18n::gettext("SQL Server cannot enforce this. Use a login granted only SELECT.") + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn each_enforcement_says_something_different() { + let all = [ + ReadOnlyEnforcement::EngineEnforced, + ReadOnlyEnforcement::SessionDefault, + ReadOnlyEnforcement::LoginPrivileges, + ]; + let texts: std::collections::HashSet = all.into_iter().map(read_only_subtitle).collect(); + assert_eq!(texts.len(), all.len()); + } +} diff --git a/linux/crates/app/src/ui/connect_dialog/row_problem.rs b/linux/crates/app/src/ui/connect_dialog/row_problem.rs new file mode 100644 index 0000000000..84592829f2 --- /dev/null +++ b/linux/crates/app/src/ui/connect_dialog/row_problem.rs @@ -0,0 +1,21 @@ +use gtk4::prelude::*; + +/// Mark a row as holding a mistake, or clear the mark. +/// +/// The red style alone says nothing to a screen reader, so the row, and +/// the field inside it that takes the focus, are flagged invalid and +/// carry the sentence. +pub fn show_problem(row: &impl IsA, problem: Option<&str>) { + let row = row.as_ref(); + match problem { + Some(message) => { + row.add_css_class("error"); + row.set_tooltip_text(Some(message)); + } + None => { + row.remove_css_class("error"); + row.set_tooltip_text(None); + } + } + crate::ui::a11y::show_invalid(row, problem); +} diff --git a/linux/crates/app/src/ui/connect_dialog/sqlite_file_row.rs b/linux/crates/app/src/ui/connect_dialog/sqlite_file_row.rs new file mode 100644 index 0000000000..998e122b26 --- /dev/null +++ b/linux/crates/app/src/ui/connect_dialog/sqlite_file_row.rs @@ -0,0 +1,236 @@ +use std::path::{Path, PathBuf}; + +use gtk4::glib; +use gtk4::prelude::*; +use gtk4::subclass::prelude::*; +use libadwaita as adw; +use libadwaita::prelude::*; +use tablepro_core::{FileEndpoint, FileOpenMode}; +use tablepro_session::runtime::Tasks; + +use crate::services::file_location; + +mod imp { + use std::cell::{OnceCell, RefCell}; + use std::sync::OnceLock; + + use glib::subclass::Signal; + use libadwaita::subclass::prelude::*; + + use super::*; + + #[derive(Default, gtk4::CompositeTemplate)] + #[template(resource = "/app/tablepro/TablePro/ui/sqlite-file-row.ui")] + pub struct SqliteFileRow { + #[template_child] + pub choose_button: TemplateChild, + #[template_child] + pub create_button: TemplateChild, + pub endpoint: RefCell>, + /// Reads the file a portal path stands for, off the GTK thread. + pub tasks: OnceCell, + } + + #[glib::object_subclass] + impl ObjectSubclass for SqliteFileRow { + const NAME: &'static str = "TableProSqliteFileRow"; + type Type = super::SqliteFileRow; + type ParentType = adw::ActionRow; + + fn class_init(klass: &mut Self::Class) { + klass.bind_template(); + } + + fn instance_init(object: &glib::subclass::InitializingObject) { + object.init_template(); + } + } + + impl ObjectImpl for SqliteFileRow { + fn signals() -> &'static [Signal] { + static SIGNALS: OnceLock> = OnceLock::new(); + SIGNALS.get_or_init(|| vec![Signal::builder("changed").build()]) + } + + fn constructed(&self) { + self.parent_constructed(); + let row = self.obj(); + let weak = row.downgrade(); + self.choose_button.connect_clicked(move |_| { + if let Some(row) = weak.upgrade() { + glib::spawn_future_local(async move { row.choose_existing().await }); + } + }); + let weak = row.downgrade(); + self.create_button.connect_clicked(move |_| { + if let Some(row) = weak.upgrade() { + glib::spawn_future_local(async move { row.choose_new().await }); + } + }); + } + } + + impl WidgetImpl for SqliteFileRow {} + impl ListBoxRowImpl for SqliteFileRow {} + impl PreferencesRowImpl for SqliteFileRow {} + impl ActionRowImpl for SqliteFileRow {} +} + +glib::wrapper! { + /// The SQLite database file, opened as it is or created on connect. + pub struct SqliteFileRow(ObjectSubclass) + @extends adw::ActionRow, adw::PreferencesRow, gtk4::ListBoxRow, gtk4::Widget, + @implements gtk4::Accessible, gtk4::Actionable, gtk4::Buildable, gtk4::ConstraintTarget; +} + +impl Default for SqliteFileRow { + fn default() -> Self { + glib::Object::new() + } +} + +impl SqliteFileRow { + pub fn endpoint(&self) -> Option { + self.imp().endpoint.borrow().clone() + } + + pub fn set_tasks(&self, tasks: Tasks) { + let _ = self.imp().tasks.set(tasks); + } + + pub fn set_endpoint(&self, endpoint: Option) { + self.set_subtitle(&subtitle(endpoint.as_ref(), None)); + if let Some(file) = &endpoint + && file_location::is_portal_path(file.path()) + { + self.show_host_path(file.path().to_path_buf()); + } + self.imp().endpoint.replace(endpoint); + self.emit_by_name::<()>("changed", &[]); + } + + /// A portal path's folder is an id, so the row names the file the + /// portal stands for once it is read. + fn show_host_path(&self, path: PathBuf) { + let Some(tasks) = self.imp().tasks.get() else { + return; + }; + let lookup = path.clone(); + let looking = tasks.spawn_blocking_task(move || file_location::portal_host_path_blocking(&lookup)); + let row = self.downgrade(); + glib::spawn_future_local(async move { + let (Ok(Some(host)), Some(row)) = (looking.await, row.upgrade()) else { + return; + }; + let endpoint = row.endpoint(); + if endpoint.as_ref().is_some_and(|file| file.path() == path) { + row.set_subtitle(&subtitle(endpoint.as_ref(), Some(&host))); + } + }); + } + + /// Take a path the file chooser returned. The portal only hands out + /// absolute paths, so a refusal here means it gave none at all. + pub fn adopt_choice(&self, path: PathBuf, mode: FileOpenMode) { + let endpoint = match mode { + FileOpenMode::OpenExisting => FileEndpoint::open_existing(path), + FileOpenMode::CreateNew => FileEndpoint::create_new(path), + }; + match endpoint { + Ok(endpoint) => self.set_endpoint(Some(endpoint)), + Err(error) => tracing::warn!(%error, "the file chooser returned an unusable path"), + } + } + + pub fn connect_changed(&self, f: F) -> glib::SignalHandlerId { + self.connect_closure( + "changed", + false, + glib::closure_local!(move |row: &Self| { + f(row); + }), + ) + } + + async fn choose_existing(&self) { + let dialog = sqlite_file_dialog(&crate::i18n::gettext("Open Database File")); + let chosen = dialog.open_future(self.parent_window().as_ref()).await; + if let Some(path) = chosen.ok().and_then(|file| file.path()) { + self.adopt_choice(path, FileOpenMode::OpenExisting); + } + } + + /// The save chooser asks before replacing a file that exists, and + /// the driver then replaces it whole, so no second question here. + async fn choose_new(&self) { + let dialog = sqlite_file_dialog(&crate::i18n::gettext("New Database File")); + dialog.set_initial_name(Some("Untitled.sqlite3")); + let chosen = dialog.save_future(self.parent_window().as_ref()).await; + if let Some(path) = chosen.ok().and_then(|file| file.path()) { + self.adopt_choice(path, FileOpenMode::CreateNew); + } + } + + fn parent_window(&self) -> Option { + self.root().and_downcast::() + } +} + +/// The row's subtitle: the file, named by `host_path` when a portal +/// hands it over. +fn subtitle(endpoint: Option<&FileEndpoint>, host_path: Option<&Path>) -> String { + let Some(file) = endpoint else { + return crate::i18n::gettext("No file chosen"); + }; + let shown = file_location::display_path(file.path(), host_path); + match file.mode() { + FileOpenMode::CreateNew => crate::i18n::gettext_f("{path}, created when you connect", &[("path", &shown)]), + FileOpenMode::OpenExisting => shown, + } +} + +/// The file chooser for a SQLite database, with every file a choice +/// when the filter misses it. +pub(crate) fn sqlite_file_dialog(title: &str) -> gtk4::FileDialog { + let sqlite = gtk4::FileFilter::new(); + sqlite.set_name(Some(&crate::i18n::gettext("SQLite databases"))); + for mime in ["application/vnd.sqlite3", "application/x-sqlite3"] { + sqlite.add_mime_type(mime); + } + for suffix in ["sqlite", "sqlite3", "db", "db3"] { + sqlite.add_suffix(suffix); + } + let any = gtk4::FileFilter::new(); + any.set_name(Some(&crate::i18n::gettext("All files"))); + any.add_pattern("*"); + let filters = gio::ListStore::new::(); + filters.append(&sqlite); + filters.append(&any); + gtk4::FileDialog::builder() + .title(title) + .modal(true) + .filters(&filters) + .default_filter(&sqlite) + .build() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_portal_file_reads_as_its_host_path_or_its_name() { + let portal = FileEndpoint::open_existing(PathBuf::from("/run/user/1000/doc/1a2b3c/app.db")).expect("absolute"); + let created = FileEndpoint::create_new(PathBuf::from("/run/user/1000/doc/1a2b3c/new.db")).expect("absolute"); + let local = FileEndpoint::open_existing(PathBuf::from("/home/me/app.db")).expect("absolute"); + + assert_eq!(subtitle(Some(&portal), None), "app.db"); + assert_eq!( + subtitle(Some(&portal), Some(Path::new("/srv/data/app.db"))), + "/srv/data/app.db" + ); + assert_eq!(subtitle(Some(&created), None), "new.db, created when you connect"); + assert_eq!(subtitle(Some(&local), None), "/home/me/app.db"); + assert_eq!(subtitle(None, None), "No file chosen"); + } +} diff --git a/linux/crates/app/src/ui/connect_dialog/tests.rs b/linux/crates/app/src/ui/connect_dialog/tests.rs new file mode 100644 index 0000000000..e44a0fc9b1 --- /dev/null +++ b/linux/crates/app/src/ui/connect_dialog/tests.rs @@ -0,0 +1,966 @@ +use std::sync::Arc; +use std::time::Duration; + +use gtk4::{AccessibleProperty, AccessibleState}; +use tablepro_core::credentials::{CredentialInteraction, SecretKind, SecretVault}; +use tablepro_core::{DriverError, FileOpenMode, TlsFailure, TlsMode}; +use tablepro_session::RequestTarget; +use tablepro_session::fakes::{FakeConnector, FakeEstablish}; +use tablepro_test_support::ConnectionScript; + +use super::*; +use crate::test_support::{SignalLog, assert_labelled, wait_until}; +use crate::ui::ssh_inputs::SshAuthChoice; + +const WAIT: Duration = Duration::from_secs(10); + +struct Harness { + dialog: ConnectDialog, + connector: FakeConnector, + store: tablepro_storage::ConnectionStore, + vault: Arc, + _runtime: crate::runtime::AppRuntime, + _dir: tempfile::TempDir, +} + +struct Options { + connector: FakeConnector, + remembered: &'static str, + environment: SshEnvironment, + saved: Vec, + prefill: Option, + mode: ConnectDialogMode, +} + +impl Default for Options { + fn default() -> Self { + Self { + connector: FakeConnector::new([]), + remembered: "postgres", + environment: SshEnvironment::default(), + saved: Vec::new(), + prefill: None, + mode: ConnectDialogMode::Create, + } + } +} + +fn registry() -> Arc { + let mut registry = DriverRegistry::new(); + registry.register(Arc::new(tablepro_driver_clickhouse::ClickhouseDriver)); + registry.register(Arc::new(tablepro_driver_mssql::MssqlDriver)); + registry.register(Arc::new(tablepro_driver_mysql::MysqlDriver)); + registry.register(Arc::new(tablepro_driver_postgres::PgDriver)); + registry.register(Arc::new(tablepro_driver_sqlite::SqliteDriver)); + Arc::new(registry) +} + +/// Every driver the app ships, so the combo has the shape a user sees. +fn open(options: Options) -> Harness { + let _ = adw::init(); + crate::register_test_resources(); + let runtime = crate::test_support::test_runtime(); + let dir = tempfile::tempdir().expect("a temporary directory"); + let store = tablepro_storage::ConnectionStore::new(&tablepro_storage::StoragePaths::under( + dir.path(), + "tablepro", + crate::config::APP_ID, + )); + for saved in &options.saved { + store.upsert_blocking(saved.clone()).expect("the saved connection"); + } + let vault = Arc::new(tablepro_test_support::FakeSecretVault::new()); + let dialog = ConnectDialog::new(ConnectDialogInit { + drivers: registry(), + connector: Arc::new(options.connector.clone()), + interaction: CredentialInteraction::Unattended, + tasks: runtime.tasks(), + saved_connections: Arc::from(options.saved), + remembered_driver: options.remembered.to_owned(), + ssh_environment: options.environment, + prefill: options.prefill, + mode: options.mode, + editor: crate::services::saved_connection_editor::SavedConnectionEditor::new( + store.clone(), + vault.clone(), + runtime.tasks(), + ), + credentials: std::rc::Rc::new(crate::services::session_context::KeyringSources::new(vault.clone())), + hold: std::rc::Rc::new(tablepro_session::runtime::CountingHold::new()), + }); + Harness { + dialog, + connector: options.connector, + store, + vault, + _runtime: runtime, + _dir: dir, + } +} + +fn dialog() -> Harness { + open(Options::default()) +} + +fn select(dialog: &ConnectDialog, driver_id: &str) { + let index = dialog + .imp() + .drivers + .borrow() + .iter() + .position(|entry| entry.id == driver_id) + .unwrap_or_else(|| panic!("{driver_id} is not in the combo")); + dialog.imp().driver_row.set_selected(index as u32); +} + +fn fill_network(dialog: &ConnectDialog) { + let imp = dialog.imp(); + imp.host_row.set_text("db.example.com"); + imp.database_row.set_text("sales"); + imp.username_row.set_text("app"); + imp.password_row.set_text("s3cret"); +} + +fn caps_of(driver_id: &str) -> &'static DriverCapabilities { + registry() + .get(driver_id) + .map(|driver| driver.capabilities()) + .unwrap_or_else(|| panic!("{driver_id} is registered")) +} + +/// The dialog in a window, as the app shows it before anyone can press +/// Connect or Save. Without animations it closes before the next line. +fn present(dialog: &ConnectDialog) -> crate::test_support::OwnedWindow { + if let Some(settings) = gtk4::Settings::default() { + settings.set_gtk_enable_animations(false); + } + let window = crate::test_support::OwnedWindow::presented( + adw::Window::builder().default_width(1000).default_height(700).build(), + ); + dialog.present(Some(&*window)); + window +} + +/// Runs `step` with GLib criticals fatal, so a close of a dialog that +/// is not presented aborts the test. +fn without_criticals(step: impl FnOnce()) { + let previous = glib::log_set_always_fatal(glib::LogLevels::LEVEL_CRITICAL); + step(); + crate::test_support::drain_main_context(); + glib::log_set_always_fatal(previous); +} + +#[gtk4::test] +fn dialog_opens_with_remembered_driver_and_its_port() { + let harness = open(Options { + remembered: "clickhouse", + ..Options::default() + }); + let imp = harness.dialog.imp(); + + assert_eq!(harness.dialog.form().driver_id, "clickhouse"); + assert_eq!(imp.port_row.value() as u16, 8123); + assert_eq!(imp.host_row.text(), "", "the host was prefilled instead of left empty"); + assert_eq!(imp.username_row.text(), ""); +} + +#[gtk4::test] +fn an_uninstalled_remembered_driver_falls_back_to_postgres() { + let harness = open(Options { + remembered: "cassandra", + ..Options::default() + }); + + assert_eq!(harness.dialog.form().driver_id, "postgres"); +} + +#[gtk4::test] +fn driver_switch_keeps_user_edited_port() { + let harness = dialog(); + harness.dialog.imp().port_row.set_value(6543.0); + + select(&harness.dialog, "clickhouse"); + + assert_eq!( + harness.dialog.imp().port_row.value() as u16, + 6543, + "a driver switch overwrote a port the user typed" + ); +} + +#[gtk4::test] +fn a_driver_switch_moves_an_untouched_port() { + let harness = dialog(); + assert_eq!(harness.dialog.imp().port_row.value() as u16, 5432); + + select(&harness.dialog, "clickhouse"); + + assert_eq!(harness.dialog.imp().port_row.value() as u16, 8123); +} + +#[gtk4::test] +fn driver_switch_keeps_typed_host_and_user() { + let harness = dialog(); + fill_network(&harness.dialog); + + select(&harness.dialog, "sqlite"); + select(&harness.dialog, "clickhouse"); + + let imp = harness.dialog.imp(); + assert_eq!(imp.host_row.text(), "db.example.com"); + assert_eq!(imp.database_row.text(), "sales"); + assert_eq!(imp.username_row.text(), "app"); +} + +#[gtk4::test] +fn enter_in_any_text_row_reaches_connect() { + // Enter needs both halves: activates-default on the row, which is + // what makes AdwEntryRow call gtk_widget_activate_default, and + // Connect as the dialog's default widget for it to reach. + let harness = dialog(); + let imp = harness.dialog.imp(); + + assert!(imp.host_row.activates_default(), "host"); + assert!(imp.database_row.activates_default(), "database"); + assert!(imp.username_row.activates_default(), "username"); + assert!(imp.password_row.activates_default(), "password"); + assert!( + imp.tls_section.imp().server_name_row.activates_default(), + "TLS server name" + ); + assert_eq!( + harness.dialog.default_widget(), + Some(imp.connect_button.get().upcast::()), + "Connect is not the dialog's default widget" + ); +} + +#[gtk4::test] +fn every_ssh_text_row_submits_on_enter() { + let harness = dialog(); + + for (name, activates) in harness.dialog.imp().ssh_section.rows_activating_default() { + assert!(activates, "{name} does not submit on Enter"); + } +} + +#[gtk4::test] +fn connect_stays_insensitive_until_the_form_is_valid() { + // Enter on an invalid form does nothing because AdwDialog skips an + // insensitive default widget. + let harness = dialog(); + let imp = harness.dialog.imp(); + assert!( + !imp.connect_button.is_sensitive(), + "Connect was sensitive on an empty form" + ); + assert!( + imp.connect_button.tooltip_text().is_some(), + "an insensitive Connect does not say why" + ); + assert!( + !imp.host_row.has_css_class("error"), + "an untouched empty row is painted red" + ); + + fill_network(&harness.dialog); + + assert!( + imp.connect_button.is_sensitive(), + "Connect stayed insensitive on a valid form" + ); + assert!(imp.test_button.is_sensitive()); +} + +#[gtk4::test] +fn a_bad_host_is_marked_for_sight_and_for_screen_readers() { + let harness = dialog(); + fill_network(&harness.dialog); + let host = harness.dialog.imp().host_row.get(); + + // The focus is in the row's text field, which is what a reader reads. + let field = host.delegate().expect("the row's text field"); + + host.set_text("db example.com"); + + assert!(host.has_css_class("error")); + for widget in [host.upcast_ref::(), field.upcast_ref()] { + assert!(gtk4::test_accessible_has_state(widget, AccessibleState::Invalid)); + } + assert!(gtk4::test_accessible_has_property( + &field, + gtk4::AccessibleProperty::Description + )); + assert!(host.tooltip_text().is_some()); + + host.set_text("db.example.com"); + + assert!(!host.has_css_class("error")); + for widget in [host.upcast_ref::(), field.upcast_ref()] { + assert!(!gtk4::test_accessible_has_state(widget, AccessibleState::Invalid)); + } +} + +#[gtk4::test] +fn encryption_combo_lists_only_capability_modes() { + let harness = dialog(); + for driver_id in ["postgres", "mysql", "mssql", "clickhouse"] { + select(&harness.dialog, driver_id); + let section = harness.dialog.imp().tls_section.get(); + let tls = caps_of(driver_id).tls.expect("a network driver with TLS"); + + assert!(section.is_visible(), "{driver_id}"); + assert_eq!( + section.imp().encryption_modes.n_items() as usize, + tls.modes.len(), + "{driver_id}" + ); + assert_eq!(harness.dialog.form().tls.mode, TlsMode::VerifyFull, "{driver_id}"); + } +} + +/// Each encryption mode and SSH method beside the subtitle that +/// describes it, in the dialog at its own width. +#[gtk4::test] +fn a_described_choice_shows_its_whole_value() { + let harness = dialog(); + let window = present(&harness.dialog); + let ssh = harness.dialog.imp().ssh_section.get(); + ssh.enable(); + let rows = [ + harness.dialog.imp().tls_section.imp().encryption_row.get(), + ssh.imp().auth_row.get(), + ]; + + for row in rows { + let choices = row.model().expect("the choices"); + for index in 0..choices.n_items() { + row.set_selected(index); + let choice = row + .selected_item() + .and_downcast::() + .expect("a choice") + .string(); + let whole = wait_until(WAIT, || { + crate::ui::combo_value::shown_value(&row).is_some_and(|value| { + value.label() == choice && value.width() > 0 && !value.layout().is_ellipsized() + }) + }); + assert!(whole.is_ok(), "{choice} was cut beside {:?}", row.subtitle()); + } + } + window.close(); +} + +#[gtk4::test] +fn sqlite_shows_file_row_and_hides_security_and_ssh() { + let harness = dialog(); + + select(&harness.dialog, "sqlite"); + + let imp = harness.dialog.imp(); + assert!(imp.file_row.is_visible()); + assert!(!imp.host_row.is_visible()); + assert!(!imp.port_row.is_visible()); + assert!(!imp.database_row.is_visible()); + assert!(!imp.auth_group.is_visible()); + assert!(!imp.tls_section.is_visible()); + assert!(!imp.ssh_section.is_visible()); + + select(&harness.dialog, "postgres"); + assert!(!imp.file_row.is_visible()); + assert!(imp.tls_section.is_visible()); + assert!(imp.ssh_section.is_visible()); +} + +#[gtk4::test] +fn new_database_sets_create_new_endpoint() { + let harness = dialog(); + select(&harness.dialog, "sqlite"); + let root = tempfile::tempdir().expect("tempdir"); + let path = root.path().join("Untitled.sqlite3"); + + harness + .dialog + .imp() + .file_row + .adopt_choice(path.clone(), FileOpenMode::CreateNew); + + let file = harness.dialog.form().file.expect("a file"); + assert_eq!(file.path(), path); + assert_eq!(file.mode(), FileOpenMode::CreateNew); + assert!(harness.dialog.imp().connect_button.is_sensitive()); +} + +#[gtk4::test] +fn mysql_requires_database() { + let harness = dialog(); + select(&harness.dialog, "mysql"); + fill_network(&harness.dialog); + let imp = harness.dialog.imp(); + assert!(imp.connect_button.is_sensitive()); + + imp.database_row.set_text(""); + + assert!(!imp.connect_button.is_sensitive(), "MySQL connected with no database"); + select(&harness.dialog, "postgres"); + assert!(imp.connect_button.is_sensitive(), "PostgreSQL needs no database name"); +} + +#[gtk4::test] +fn read_only_subtitle_follows_enforcement() { + let harness = dialog(); + for driver_id in ["postgres", "mysql", "mssql", "clickhouse", "sqlite"] { + select(&harness.dialog, driver_id); + + assert_eq!( + harness + .dialog + .imp() + .read_only_row + .subtitle() + .map(|text| text.to_string()), + Some(read_only_row::read_only_subtitle(caps_of(driver_id).read_only)), + "{driver_id}" + ); + } +} + +#[gtk4::test] +fn connect_insensitive_while_pem_check_pending() { + let harness = dialog(); + fill_network(&harness.dialog); + let root = tempfile::tempdir().expect("tempdir"); + let ca = root.path().join("ca.pem"); + std::fs::write(&ca, b"-----BEGIN CERTIFICATE-----\nnot base64\n").expect("write"); + + harness + .dialog + .imp() + .tls_section + .set_file_path(FormField::CaFile, Some(ca)); + + assert_eq!(harness.dialog.imp().pem.state(), PemState::Pending); + assert!( + !harness.dialog.imp().connect_button.is_sensitive(), + "Connect was sensitive before the file was read" + ); +} + +#[gtk4::test] +fn invalid_ca_marks_row_error_and_disables_connect() { + let harness = dialog(); + fill_network(&harness.dialog); + let root = tempfile::tempdir().expect("tempdir"); + let ca = root.path().join("ca.pem"); + std::fs::write(&ca, b"this is not a certificate").expect("write"); + let imp = harness.dialog.imp(); + + imp.tls_section.set_file_path(FormField::CaFile, Some(ca)); + wait_until(WAIT, || matches!(imp.pem.state(), PemState::Invalid(_))).expect("the file check"); + + let ca_row = imp.tls_section.imp().ca_row.get(); + assert!(ca_row.has_css_class("error")); + assert!(gtk4::test_accessible_has_state( + ca_row.upcast_ref::(), + AccessibleState::Invalid + )); + assert!( + ca_row + .subtitle() + .is_some_and(|subtitle| subtitle.contains("no certificates")), + "{:?}", + ca_row.subtitle() + ); + assert!(!imp.connect_button.is_sensitive()); + + imp.tls_section.set_file_path(FormField::CaFile, None); + assert_eq!(imp.pem.state(), PemState::Valid); + assert!( + imp.connect_button.is_sensitive(), + "clearing the bad file did not re-enable Connect" + ); +} + +#[gtk4::test] +fn connect_emits_submission_without_establishing() { + let harness = dialog(); + let _window = present(&harness.dialog); + fill_network(&harness.dialog); + let submissions = SignalLog::connect(&harness.dialog, "submitted", |values| { + values[1].get::().expect("a submission") + }); + let closed = SignalLog::connect(&harness.dialog, "closed", |_| ()); + + without_criticals(|| harness.dialog.imp().connect_button.emit_clicked()); + + assert_eq!(closed.count(), 1, "Connect left the dialog open"); + let mut sent = submissions.take(); + assert_eq!(sent.len(), 1); + assert!(harness.connector.calls().is_empty(), "the dialog connected by itself"); + let submission = sent.remove(0); + assert_eq!(submission.request.metadata.driver_id, "postgres"); + assert_eq!(submission.request.metadata.id, submission.pending_save.connection.id); + assert!(submission.pending_save.is_new); + assert_eq!(submission.pending_save.connection.host, "db.example.com"); + let kinds: Vec = submission + .pending_save + .secrets + .iter() + .map(|secret| secret.kind) + .collect(); + assert_eq!(kinds, vec![SecretKind::DatabasePassword]); + let RequestTarget::Network { tls, .. } = &submission.request.target else { + panic!("expected a network target"); + }; + assert_eq!(tls.mode, TlsMode::VerifyFull); +} + +#[gtk4::test] +fn a_reopened_entry_keeps_its_id_after_the_host_changes() { + let first = dialog(); + let _first_window = present(&first.dialog); + fill_network(&first.dialog); + let log = SignalLog::connect(&first.dialog, "submitted", |values| { + values[1].get::().expect("a submission") + }); + without_criticals(|| first.dialog.imp().connect_button.emit_clicked()); + let saved = log.take().remove(0).pending_save.connection; + drop(log); + + let reopened = open(Options { + saved: vec![saved.clone()], + prefill: Some(ConnectPrefill { + connection: saved.clone(), + password: Some(SecretString::from("s3cret")), + }), + ..Options::default() + }); + let _reopened_window = present(&reopened.dialog); + assert_eq!(reopened.dialog.imp().host_row.text(), "db.example.com"); + assert_eq!(reopened.dialog.imp().password_row.text(), "s3cret"); + reopened.dialog.imp().host_row.set_text("db2.example.com"); + let log = SignalLog::connect(&reopened.dialog, "submitted", |values| { + values[1].get::().expect("a submission") + }); + without_criticals(|| reopened.dialog.imp().connect_button.emit_clicked()); + + let edited = log.take().remove(0).pending_save; + assert_eq!(edited.connection.id, saved.id); + assert!(!edited.is_new); + assert_eq!(edited.connection.host, "db2.example.com"); + assert_eq!(edited.connection.name, "app@db2.example.com"); +} + +#[gtk4::test] +fn a_connect_from_new_connection_never_writes_over_a_saved_entry() { + let saved = SavedConnection { + read_only: true, + ..shop() + }; + let harness = open(Options { + saved: vec![saved.clone()], + ..Options::default() + }); + let _window = present(&harness.dialog); + fill_network(&harness.dialog); + harness.dialog.imp().name_row.set_text("Shop RW"); + let log = SignalLog::connect(&harness.dialog, "submitted", |values| { + values[1].get::().expect("a submission") + }); + + without_criticals(|| harness.dialog.imp().connect_button.emit_clicked()); + + let submitted = log.take().remove(0).pending_save; + assert!(submitted.is_new, "the connect replaced the saved entry"); + assert_ne!(submitted.connection.id, saved.id); + assert_eq!(submitted.connection.name, "Shop RW"); + assert!(!submitted.connection.read_only); +} + +#[gtk4::test] +fn test_button_stop_cancels_establish() { + let harness = open(Options { + connector: FakeConnector::new([]).always(FakeEstablish::UntilCancelled), + ..Options::default() + }); + fill_network(&harness.dialog); + let imp = harness.dialog.imp(); + + imp.test_button.emit_clicked(); + assert_eq!(imp.test_button.label().as_deref(), Some("Stop")); + wait_until(WAIT, || harness.connector.calls().len() == 1).expect("the test connect"); + + imp.test_button.emit_clicked(); + + assert_eq!(imp.test_button.label().as_deref(), Some("Test")); + crate::test_support::drain_main_context(); + assert!(imp.toasts.borrow().is_empty(), "a stopped test reported something"); +} + +#[gtk4::test] +fn test_success_toast_has_no_count() { + let harness = open(Options { + connector: FakeConnector::new([FakeEstablish::Connect(ConnectionScript::default())]), + ..Options::default() + }); + fill_network(&harness.dialog); + let imp = harness.dialog.imp(); + + imp.test_button.emit_clicked(); + wait_until(WAIT, || !imp.toasts.borrow().is_empty()).expect("the test result"); + + assert_eq!(imp.toasts.borrow().as_slice(), ["Connection works"]); + assert_eq!(imp.test_button.label().as_deref(), Some("Test")); + let connections = harness.connector.connections(); + assert_eq!(connections.len(), 1); + assert_eq!(harness.connector.calls()[0].request.metadata.driver_id, "postgres"); +} + +#[gtk4::test] +fn a_failed_test_names_the_problem() { + let harness = open(Options { + connector: FakeConnector::new([FakeEstablish::Fail(ConnectError::Driver(DriverError::Tls { + failure: TlsFailure::UnknownIssuer, + detail: "unknown issuer".to_owned(), + }))]), + ..Options::default() + }); + fill_network(&harness.dialog); + let imp = harness.dialog.imp(); + + imp.test_button.emit_clicked(); + wait_until(WAIT, || !imp.toasts.borrow().is_empty()).expect("the test result"); + + let toast = imp.toasts.borrow()[0].clone(); + assert!(toast.contains("CA certificate"), "{toast}"); +} + +#[gtk4::test] +fn the_tls_remedy_opens_the_certificate_rows() { + let harness = dialog(); + let section = harness.dialog.imp().tls_section.get(); + assert!(!section.imp().certificates_row.is_expanded()); + + harness + .dialog + .reveal_field(remedy_field(TlsRemedy::ChooseCaCertificate)); + + assert!(section.imp().certificates_row.is_expanded()); +} + +#[gtk4::test] +fn a_ca_file_is_offered_and_sent_only_with_verify_full() { + let harness = dialog(); + fill_network(&harness.dialog); + let section = harness.dialog.imp().tls_section.get(); + let ca = std::path::PathBuf::from("/etc/tls/ca.pem"); + section.set_file_path(FormField::CaFile, Some(ca.clone())); + let index_of = |wanted: TlsMode| { + section + .imp() + .modes + .borrow() + .iter() + .position(|mode| *mode == wanted) + .expect("PostgreSQL offers the mode") as u32 + }; + let sent_ca = || { + let valid = harness.dialog.form().validate(caps_of("postgres")).expect("valid"); + let RequestTarget::Network { tls, .. } = valid.target else { + panic!("expected a network target"); + }; + tls.ca_file + }; + + section.imp().encryption_row.set_selected(index_of(TlsMode::Require)); + assert!(!section.imp().ca_row.is_visible(), "Require offered a CA file"); + assert_eq!(sent_ca(), None, "Require sent a CA file it would not read"); + + section.imp().encryption_row.set_selected(index_of(TlsMode::VerifyFull)); + assert!(section.imp().ca_row.is_visible()); + assert_eq!(sent_ca(), Some(ca), "the CA file chosen before was lost"); +} + +#[gtk4::test] +fn agent_is_default_with_ssh_auth_sock() { + let with_agent = open(Options { + environment: SshEnvironment { + agent: true, + default_key: Some("/home/u/.ssh/id_ed25519".into()), + }, + ..Options::default() + }); + let section = with_agent.dialog.imp().ssh_section.get(); + section.enable(); + assert_eq!(section.form_state().map(|state| state.auth), Some(SshAuthChoice::Agent)); + + let key_only = open(Options { + environment: SshEnvironment { + agent: false, + default_key: Some("/home/u/.ssh/id_ed25519".into()), + }, + ..Options::default() + }); + let section = key_only.dialog.imp().ssh_section.get(); + section.enable(); + let state = section.form_state().expect("the tunnel is on"); + assert_eq!(state.auth, SshAuthChoice::PrivateKey); + assert_eq!(state.key_path, "/home/u/.ssh/id_ed25519"); +} + +#[gtk4::test] +fn an_ssh_alias_alone_is_enough_to_connect() { + let harness = open(Options { + environment: SshEnvironment { + agent: true, + default_key: None, + }, + ..Options::default() + }); + fill_network(&harness.dialog); + let section = harness.dialog.imp().ssh_section.get(); + + section.enable(); + assert!( + !harness.dialog.imp().connect_button.is_sensitive(), + "an empty SSH host passed" + ); + section.set_host("prod-bastion"); + + assert!(harness.dialog.imp().connect_button.is_sensitive()); + let RequestTarget::Network { ssh, .. } = harness + .dialog + .form() + .validate(caps_of("postgres")) + .expect("valid") + .target + else { + panic!("expected a network target"); + }; + let ssh = ssh.expect("the tunnel is on"); + assert_eq!(ssh.destination.host(), "prod-bastion"); + assert_eq!(ssh.destination.port(), None); + assert_eq!(ssh.destination.user(), None); +} + +#[gtk4::test] +fn every_row_has_an_accessible_name() { + let harness = dialog(); + let imp = harness.dialog.imp(); + let tls = imp.tls_section.imp(); + let rows: Vec = vec![ + imp.name_row.get().upcast(), + imp.driver_row.get().upcast(), + imp.host_row.get().upcast(), + imp.port_row.get().upcast(), + imp.database_row.get().upcast(), + imp.file_row.get().upcast(), + imp.auth_mode_row.get().upcast(), + imp.username_row.get().upcast(), + imp.password_row.get().upcast(), + imp.remember_row.get().upcast(), + imp.read_only_row.get().upcast(), + tls.encryption_row.get().upcast(), + tls.certificates_row.get().upcast(), + tls.ca_row.get().upcast(), + tls.certificate_row.get().upcast(), + tls.key_row.get().upcast(), + tls.server_name_row.get().upcast(), + ]; + let rows = rows.into_iter().chain(imp.ssh_section.rows()); + for row in rows { + assert_labelled(&row).unwrap_or_else(|problem| panic!("{problem:?}")); + } + + for button in [ + tls.ca_clear_button.get(), + tls.certificate_clear_button.get(), + tls.key_clear_button.get(), + tls.ca_choose_button.get(), + ] { + assert!( + gtk4::test_accessible_has_property(&button, AccessibleProperty::Label), + "{:?} has no accessible name", + button.tooltip_text() + ); + } +} + +fn shop() -> SavedConnection { + let form = ConnectForm { + name: "Shop".to_owned(), + host: "db.example.com".to_owned(), + database: "sales".to_owned(), + username: "app".to_owned(), + ..ConnectForm::new("postgres", caps_of("postgres")) + }; + let valid = form.validate(caps_of("postgres")).expect("valid"); + form.saved_connection(caps_of("postgres"), &valid, Uuid::new_v4(), None) +} + +/// The connect dialog editing `saved`, which is in the list with its +/// password in the keyring. +fn editing(saved: &SavedConnection, connector: FakeConnector) -> Harness { + let harness = open(Options { + connector, + saved: vec![saved.clone()], + mode: ConnectDialogMode::Edit(Box::new(saved.clone())), + ..Options::default() + }); + let (vault, id) = (harness.vault.clone(), saved.id); + crate::test_support::finish_on_main(WAIT, async move { + vault + .store(id, SecretKind::DatabasePassword, &SecretString::from("s3cret"), "label") + .await + }) + .expect("the store to end") + .expect("the password"); + harness +} + +#[gtk4::test] +fn edit_dialog_prefills_and_saves_same_id() { + let saved = shop(); + let harness = editing(&saved, FakeConnector::new([])); + let _window = present(&harness.dialog); + let imp = harness.dialog.imp(); + assert_eq!(harness.dialog.title(), "Edit Connection"); + assert_eq!(imp.connect_button.label().as_deref(), Some("Save")); + assert_eq!(imp.name_row.text(), "Shop"); + assert_eq!(imp.host_row.text(), "db.example.com"); + assert_eq!(imp.password_row.text(), "", "a secret reached the form"); + assert!( + imp.auth_group + .description() + .is_some_and(|text| text.contains("empty to keep")), + "the password rows do not say they may stay empty" + ); + let log = SignalLog::connect(&harness.dialog, "saved", |values| { + values[1].get::().expect("an id") + }); + let closed = SignalLog::connect(&harness.dialog, "closed", |_| ()); + imp.name_row.set_text("Shop (EU)"); + imp.host_row.set_text("db2.example.com"); + + without_criticals(|| { + imp.connect_button.emit_clicked(); + wait_until(WAIT, || log.count() == 1).expect("the save"); + }); + + assert_eq!(closed.count(), 1, "the landed save left the dialog open"); + assert_eq!(log.take(), [saved.id.to_string()]); + let stored = harness.store.load_blocking().expect("the list"); + assert_eq!(stored.len(), 1, "the edit added an entry"); + assert_eq!(stored[0].id, saved.id); + assert_eq!(stored[0].name, "Shop (EU)"); + assert_eq!(stored[0].host, "db2.example.com"); + assert_eq!( + harness + .vault + .stored_secret(saved.id, SecretKind::DatabasePassword) + .as_deref(), + Some("s3cret"), + "the empty password row lost the saved password" + ); + assert!(harness.connector.calls().is_empty(), "Save connected"); +} + +#[gtk4::test] +fn a_refused_save_keeps_the_dialog_open_with_the_reason() { + let saved = shop(); + let harness = editing(&saved, FakeConnector::new([])); + harness.vault.fail_store( + SecretKind::DatabasePassword, + tablepro_core::credentials::SecretError::Locked, + ); + let imp = harness.dialog.imp(); + let log = SignalLog::connect(&harness.dialog, "saved", |_| ()); + imp.password_row.set_text("n3w"); + + imp.connect_button.emit_clicked(); + wait_until(WAIT, || !imp.toasts.borrow().is_empty()).expect("the reason"); + + assert_eq!(log.count(), 0); + assert!(imp.toasts.borrow()[0].contains("locked"), "{:?}", imp.toasts.borrow()); + assert!(imp.connect_button.is_sensitive(), "Save stayed off after the failure"); + assert_eq!(harness.store.load_blocking().expect("the list")[0], saved); +} + +#[gtk4::test] +fn a_save_landing_after_the_user_closed_the_dialog_does_not_close_it_again() { + let saved = shop(); + let harness = editing(&saved, FakeConnector::new([])); + let _window = present(&harness.dialog); + let imp = harness.dialog.imp(); + let log = SignalLog::connect(&harness.dialog, "saved", |_| ()); + let closed = SignalLog::connect(&harness.dialog, "closed", |_| ()); + imp.host_row.set_text("db2.example.com"); + + without_criticals(|| { + imp.connect_button.emit_clicked(); + harness.dialog.close(); + wait_until(WAIT, || log.count() == 1).expect("the save"); + }); + + assert_eq!(closed.count(), 1); + assert_eq!( + harness.store.load_blocking().expect("the list")[0].host, + "db2.example.com", + "closing the dialog lost the save" + ); +} + +#[gtk4::test] +fn an_edit_test_logs_in_with_the_kept_password() { + let saved = shop(); + let harness = editing( + &saved, + FakeConnector::new([FakeEstablish::Connect(ConnectionScript::default())]), + ); + let imp = harness.dialog.imp(); + + imp.test_button.emit_clicked(); + wait_until(WAIT, || !imp.toasts.borrow().is_empty()).expect("the test result"); + + assert_eq!(imp.toasts.borrow().as_slice(), ["Connection works"]); + assert_eq!(tested_password(&harness), "s3cret"); +} + +#[gtk4::test] +fn an_edit_test_logs_in_with_a_typed_password_it_will_not_keep() { + let saved = shop(); + let harness = editing( + &saved, + FakeConnector::new([FakeEstablish::Connect(ConnectionScript::default())]), + ); + let imp = harness.dialog.imp(); + imp.password_row.set_text("n3w"); + imp.remember_row.set_active(false); + + imp.test_button.emit_clicked(); + wait_until(WAIT, || !imp.toasts.borrow().is_empty()).expect("the test result"); + + assert_eq!(tested_password(&harness), "n3w", "Test used the stored password"); + assert_eq!( + harness + .vault + .stored_secret(saved.id, SecretKind::DatabasePassword) + .as_deref(), + Some("s3cret"), + "Test changed the keyring" + ); +} + +/// The password the first connect logged in with. +fn tested_password(harness: &Harness) -> String { + let calls = harness.connector.calls(); + let RequestTarget::Network { + credentials: tablepro_core::Credentials::Password { password, .. }, + .. + } = &calls[0].request.target + else { + panic!("expected a password login"); + }; + secrecy::ExposeSecret::expose_secret(password).to_owned() +} diff --git a/linux/crates/app/src/ui/connect_dialog/tls_section.rs b/linux/crates/app/src/ui/connect_dialog/tls_section.rs new file mode 100644 index 0000000000..ccf102cc44 --- /dev/null +++ b/linux/crates/app/src/ui/connect_dialog/tls_section.rs @@ -0,0 +1,416 @@ +use std::path::PathBuf; + +use gtk4::glib; +use gtk4::prelude::*; +use gtk4::subclass::prelude::*; +use libadwaita as adw; +use libadwaita::prelude::*; +use tablepro_core::{ConfigError, TlsCapabilities, TlsConfig, TlsMode, TransportClass}; + +use super::connect_form::{TlsFormState, default_tls_mode, shows_server_name, tls_field}; +use super::form_errors::FormErrors; +use super::form_field::FormField; +use super::row_problem::show_problem; + +/// Which certificate file a row holds. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum CertificateSlot { + Ca, + Certificate, + Key, +} + +mod imp { + use std::cell::{Cell, RefCell}; + use std::sync::OnceLock; + + use glib::subclass::Signal; + use libadwaita::subclass::prelude::*; + + use super::*; + + #[derive(Default, gtk4::CompositeTemplate)] + #[template(resource = "/app/tablepro/TablePro/ui/tls-section.ui")] + pub struct TlsSection { + #[template_child] + pub encryption_row: TemplateChild, + #[template_child] + pub encryption_modes: TemplateChild, + #[template_child] + pub certificates_row: TemplateChild, + #[template_child] + pub ca_row: TemplateChild, + #[template_child] + pub ca_choose_button: TemplateChild, + #[template_child] + pub ca_clear_button: TemplateChild, + #[template_child] + pub certificate_row: TemplateChild, + #[template_child] + pub certificate_choose_button: TemplateChild, + #[template_child] + pub certificate_clear_button: TemplateChild, + #[template_child] + pub key_row: TemplateChild, + #[template_child] + pub key_choose_button: TemplateChild, + #[template_child] + pub key_clear_button: TemplateChild, + #[template_child] + pub server_name_row: TemplateChild, + pub modes: RefCell>, + pub capabilities: Cell>, + pub route: Cell>, + pub ca_file: RefCell>, + pub client_certificate: RefCell>, + pub client_key: RefCell>, + /// Set while the code writes the rows, so its own writes are not + /// reported as the user changing them. + pub updating: Cell, + } + + #[glib::object_subclass] + impl ObjectSubclass for TlsSection { + const NAME: &'static str = "TableProTlsSection"; + type Type = super::TlsSection; + type ParentType = adw::PreferencesGroup; + + fn class_init(klass: &mut Self::Class) { + klass.bind_template(); + } + + fn instance_init(object: &glib::subclass::InitializingObject) { + object.init_template(); + } + } + + impl ObjectImpl for TlsSection { + fn signals() -> &'static [Signal] { + static SIGNALS: OnceLock> = OnceLock::new(); + SIGNALS.get_or_init(|| vec![Signal::builder("changed").build()]) + } + + fn constructed(&self) { + self.parent_constructed(); + let section = self.obj(); + for button in [ + &self.ca_clear_button, + &self.certificate_clear_button, + &self.key_clear_button, + ] { + button.set_icon_name(crate::ui::icons::EDIT_CLEAR); + } + crate::ui::combo_value::show_whole_value(&self.encryption_row); + + let weak = section.downgrade(); + self.encryption_row.connect_selected_notify(move |_| { + if let Some(section) = weak.upgrade() + && !section.imp().updating.get() + { + section.refresh_rows(); + section.emit_changed(); + } + }); + let weak = section.downgrade(); + self.server_name_row.connect_changed(move |_| { + if let Some(section) = weak.upgrade() + && !section.imp().updating.get() + { + section.emit_changed(); + } + }); + let slots = [ + (CertificateSlot::Ca, &self.ca_choose_button, &self.ca_clear_button), + ( + CertificateSlot::Certificate, + &self.certificate_choose_button, + &self.certificate_clear_button, + ), + (CertificateSlot::Key, &self.key_choose_button, &self.key_clear_button), + ]; + for (slot, choose, clear) in slots { + let weak = section.downgrade(); + choose.connect_clicked(move |_| { + if let Some(section) = weak.upgrade() { + glib::spawn_future_local(async move { section.choose(slot).await }); + } + }); + let weak = section.downgrade(); + clear.connect_clicked(move |_| { + if let Some(section) = weak.upgrade() { + section.set_file(slot, None); + } + }); + } + } + } + + impl WidgetImpl for TlsSection {} + impl PreferencesGroupImpl for TlsSection {} +} + +glib::wrapper! { + /// The Security group: encryption mode, certificate files and the + /// name the certificate is checked against. + pub struct TlsSection(ObjectSubclass) + @extends adw::PreferencesGroup, gtk4::Widget, + @implements gtk4::Accessible, gtk4::Buildable, gtk4::ConstraintTarget; +} + +impl Default for TlsSection { + fn default() -> Self { + glib::Object::new() + } +} + +impl TlsSection { + /// Show the modes and rows this driver offers, filled from `state`. + /// A driver without TLS hides the whole group. + pub fn apply(&self, caps: Option<&TlsCapabilities>, route: TransportClass, state: &TlsFormState) { + let imp = self.imp(); + imp.capabilities.set(caps.copied()); + imp.route.set(Some(route)); + let Some(caps) = caps else { + self.set_visible(false); + return; + }; + self.set_visible(true); + imp.updating.set(true); + let labels: Vec = caps.modes.iter().map(|mode| mode_label(*mode)).collect(); + let labels: Vec<&str> = labels.iter().map(String::as_str).collect(); + imp.encryption_modes.splice(0, imp.encryption_modes.n_items(), &labels); + imp.modes.replace(caps.modes.to_vec()); + let mode = if caps.modes.contains(&state.mode) { + state.mode + } else { + default_tls_mode(Some(caps)) + }; + let index = caps.modes.iter().position(|candidate| *candidate == mode).unwrap_or(0); + imp.encryption_row.set_selected(index as u32); + imp.ca_file.replace(state.ca_file.clone()); + imp.client_certificate.replace(state.client_certificate.clone()); + imp.client_key.replace(state.client_key.clone()); + imp.server_name_row.set_text(&state.server_name); + imp.updating.set(false); + self.refresh_rows(); + } + + /// The route decides whether the server name can be overridden. + pub fn set_route(&self, route: TransportClass) { + self.imp().route.set(Some(route)); + self.refresh_rows(); + } + + pub fn form_state(&self) -> TlsFormState { + let imp = self.imp(); + TlsFormState { + mode: self.mode(), + ca_file: imp.ca_file.borrow().clone(), + client_certificate: imp.client_certificate.borrow().clone(), + client_key: imp.client_key.borrow().clone(), + server_name: imp.server_name_row.text().to_string(), + } + } + + /// Paint each row the form or the file check found a problem in. + /// A file row says what is wrong with the file in its subtitle. + pub fn show_problems(&self, errors: &FormErrors, files: Option<&ConfigError>) { + let imp = self.imp(); + let file_problem = files.map(|error| { + let state = self.form_state(); + let caps = imp.capabilities.get(); + let route = imp.route.get().unwrap_or(TransportClass::DirectTcp); + let config = caps + .map(|caps| state.config(&caps, route)) + .unwrap_or_else(TlsConfig::disabled); + (tls_field(error, &config), crate::ui::error_text::config_message(error)) + }); + let problem = |field: FormField| -> Option { + match &file_problem { + Some((problem_field, message)) if *problem_field == field => Some(message.clone()), + _ => errors.shown_on(field).map(|problem| problem.message.clone()), + } + }; + show_problem(&*imp.encryption_row, problem(FormField::Encryption).as_deref()); + show_problem(&*imp.server_name_row, problem(FormField::ServerName).as_deref()); + for (slot, field) in [ + (CertificateSlot::Ca, FormField::CaFile), + (CertificateSlot::Certificate, FormField::ClientCertificate), + (CertificateSlot::Key, FormField::ClientKey), + ] { + let message = problem(field); + let row = self.row(slot); + show_problem(&row, message.as_deref()); + let subtitle = message.unwrap_or_else(|| self.file_subtitle(slot)); + row.set_subtitle(&subtitle); + } + } + + /// Open the certificate rows and put the focus on `field`, for a + /// toast button that names the setting that fixes a failure. + pub fn reveal(&self, field: FormField) { + let imp = self.imp(); + if self.mode() == TlsMode::Disable { + return; + } + imp.certificates_row.set_expanded(true); + let target: gtk4::Widget = match field { + FormField::CaFile => imp.ca_choose_button.get().upcast(), + FormField::ClientCertificate => imp.certificate_choose_button.get().upcast(), + FormField::ClientKey => imp.key_choose_button.get().upcast(), + FormField::ServerName => imp.server_name_row.get().upcast(), + _ => imp.encryption_row.get().upcast(), + }; + target.grab_focus(); + } + + pub fn connect_changed(&self, f: F) -> glib::SignalHandlerId { + self.connect_closure( + "changed", + false, + glib::closure_local!(move |section: &Self| { + f(section); + }), + ) + } + + /// Put a certificate file in a row, as the file chooser does. + pub fn set_file_path(&self, field: FormField, path: Option) { + let slot = match field { + FormField::ClientCertificate => CertificateSlot::Certificate, + FormField::ClientKey => CertificateSlot::Key, + _ => CertificateSlot::Ca, + }; + self.set_file(slot, path); + } + + fn mode(&self) -> TlsMode { + let imp = self.imp(); + let selected = imp.encryption_row.selected() as usize; + imp.modes.borrow().get(selected).copied().unwrap_or(TlsMode::Disable) + } + + fn emit_changed(&self) { + self.emit_by_name::<()>("changed", &[]); + } + + fn set_file(&self, slot: CertificateSlot, path: Option) { + self.slot(slot).replace(path); + self.refresh_rows(); + self.emit_changed(); + } + + fn slot(&self, slot: CertificateSlot) -> &std::cell::RefCell> { + let imp = self.imp(); + match slot { + CertificateSlot::Ca => &imp.ca_file, + CertificateSlot::Certificate => &imp.client_certificate, + CertificateSlot::Key => &imp.client_key, + } + } + + fn row(&self, slot: CertificateSlot) -> adw::ActionRow { + let imp = self.imp(); + match slot { + CertificateSlot::Ca => imp.ca_row.get(), + CertificateSlot::Certificate => imp.certificate_row.get(), + CertificateSlot::Key => imp.key_row.get(), + } + } + + fn clear_button(&self, slot: CertificateSlot) -> gtk4::Button { + let imp = self.imp(); + match slot { + CertificateSlot::Ca => imp.ca_clear_button.get(), + CertificateSlot::Certificate => imp.certificate_clear_button.get(), + CertificateSlot::Key => imp.key_clear_button.get(), + } + } + + fn file_subtitle(&self, slot: CertificateSlot) -> String { + match self.slot(slot).borrow().as_deref() { + Some(path) => path.display().to_string(), + None if slot == CertificateSlot::Ca => crate::i18n::gettext("The system's certificate authorities"), + None => crate::i18n::gettext("None"), + } + } + + fn refresh_rows(&self) { + let imp = self.imp(); + let Some(caps) = imp.capabilities.get() else { + return; + }; + let mode = self.mode(); + imp.encryption_row.set_subtitle(&mode_description(mode)); + imp.certificates_row.set_visible(mode != TlsMode::Disable); + // Only Verify full checks the certificate, so only it reads a CA. + imp.ca_row.set_visible(caps.ca_file && mode == TlsMode::VerifyFull); + imp.certificate_row.set_visible(caps.client_identity); + imp.key_row.set_visible(caps.client_identity); + let route = imp.route.get().unwrap_or(TransportClass::DirectTcp); + imp.server_name_row.set_visible(shows_server_name(&caps, route)); + for slot in [CertificateSlot::Ca, CertificateSlot::Certificate, CertificateSlot::Key] { + let chosen = self.slot(slot).borrow().is_some(); + self.clear_button(slot).set_visible(chosen); + if !self.row(slot).has_css_class("error") { + self.row(slot).set_subtitle(&self.file_subtitle(slot)); + } + } + } + + async fn choose(&self, slot: CertificateSlot) { + let title = match slot { + CertificateSlot::Ca => crate::i18n::gettext("Choose CA Certificate"), + CertificateSlot::Certificate => crate::i18n::gettext("Choose Client Certificate"), + CertificateSlot::Key => crate::i18n::gettext("Choose Client Key"), + }; + let dialog = pem_dialog(&title); + let parent = self.root().and_downcast::(); + let chosen = dialog.open_future(parent.as_ref()).await; + if let Some(path) = chosen.ok().and_then(|file| file.path()) { + self.set_file(slot, Some(path)); + } + } +} + +pub fn mode_label(mode: TlsMode) -> String { + match mode { + TlsMode::Disable => crate::i18n::gettext("Disable"), + TlsMode::Require => crate::i18n::gettext("Require"), + TlsMode::VerifyFull => crate::i18n::gettext("Verify full"), + } +} + +/// What each mode protects against, so the choice is an informed one. +pub fn mode_description(mode: TlsMode) -> String { + match mode { + TlsMode::Disable => crate::i18n::gettext( + "Not encrypted. Anyone on the network can read or change the traffic, password included.", + ), + TlsMode::Require => crate::i18n::gettext("Encrypted. The server certificate is not checked."), + TlsMode::VerifyFull => crate::i18n::gettext("Encrypted. The certificate must be valid for the server name."), + } +} + +fn pem_dialog(title: &str) -> gtk4::FileDialog { + let pem = gtk4::FileFilter::new(); + pem.set_name(Some(&crate::i18n::gettext("PEM certificates and keys"))); + for mime in ["application/x-pem-file", "application/x-x509-ca-cert"] { + pem.add_mime_type(mime); + } + for suffix in ["pem", "crt", "cer", "key"] { + pem.add_suffix(suffix); + } + let any = gtk4::FileFilter::new(); + any.set_name(Some(&crate::i18n::gettext("All files"))); + any.add_pattern("*"); + let filters = gio::ListStore::new::(); + filters.append(&pem); + filters.append(&any); + gtk4::FileDialog::builder() + .title(title) + .modal(true) + .filters(&filters) + .default_filter(&pem) + .build() +} diff --git a/linux/crates/app/src/ui/connection_list_banner.rs b/linux/crates/app/src/ui/connection_list_banner.rs new file mode 100644 index 0000000000..47ffc5cf65 --- /dev/null +++ b/linux/crates/app/src/ui/connection_list_banner.rs @@ -0,0 +1,90 @@ +use tablepro_storage::{ConnectionListState, DocumentProblem}; + +/// What the welcome view shows when the saved-connection list cannot be +/// read. `None` means there is nothing to say. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct BannerContent { + pub title: String, + pub button_label: String, +} + +pub(crate) fn banner_content(state: &ConnectionListState) -> Option { + let ConnectionListState::Unavailable(problem) = state else { + return None; + }; + Some(BannerContent { + title: title_for(problem), + button_label: crate::i18n::gettext("Reset…"), + }) +} + +fn title_for(problem: &DocumentProblem) -> String { + if problem.is_newer_version() { + // Resetting here loses connections a newer build can still read, + // so the wording points at the version rather than the file. + crate::i18n::gettext("Saved connections were created by a newer version of TablePro") + } else { + crate::i18n::gettext("Saved connections could not be read") + } +} + +/// Names the file the store created, which is only known after the +/// rename. Saying it in advance was how the old wording went stale. +pub(crate) fn reset_toast_text(moved: &std::path::Path) -> String { + let name = moved + .file_name() + .map(|name| name.to_string_lossy().into_owned()) + .unwrap_or_else(|| moved.display().to_string()); + crate::i18n::gettext_f( + "Saved connections were reset. The old file is {name}.", + &[("name", &name)], + ) +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use tablepro_storage::DocumentProblemKind; + + use super::*; + + fn problem(kind: DocumentProblemKind) -> ConnectionListState { + ConnectionListState::Unavailable(Arc::new(DocumentProblem::new("/tmp/connections.json", kind))) + } + + #[test] + fn banner_content_per_state() { + assert_eq!(banner_content(&ConnectionListState::Loading), None); + assert_eq!(banner_content(&ConnectionListState::Ready(Arc::from(Vec::new()))), None); + + let corrupt = banner_content(&problem(DocumentProblemKind::Corrupt { + detail: "x".to_owned(), + line: 1, + column: 2, + })) + .expect("a banner for a corrupt file"); + assert_eq!(corrupt.title, "Saved connections could not be read"); + assert_eq!(corrupt.button_label, "Reset…"); + + let newer = banner_content(&problem(DocumentProblemKind::NewerVersion { found: 2, expected: 1 })) + .expect("a banner for a newer file"); + assert_eq!( + newer.title, + "Saved connections were created by a newer version of TablePro" + ); + } + + #[test] + fn reset_toast_names_returned_path() { + let text = reset_toast_text(std::path::Path::new( + "/home/u/.config/tablepro/connections.json.corrupt-17", + )); + + assert!(text.contains("connections.json.corrupt-17"), "{text}"); + assert!( + !text.contains("/home/u"), + "the toast should not spell out the directory: {text}" + ); + } +} diff --git a/linux/crates/app/src/ui/credential_prompt/mod.rs b/linux/crates/app/src/ui/credential_prompt/mod.rs new file mode 100644 index 0000000000..652e0ef6dd --- /dev/null +++ b/linux/crates/app/src/ui/credential_prompt/mod.rs @@ -0,0 +1,15 @@ +//! Asking the user for what the keyring or the SSH configuration could +//! not supply: a password, a key passphrase, a server's own questions, +//! or whether to trust a host key seen for the first time. +//! +//! The prompter is called from connect work on the runtime; the dialogs +//! live on the GTK thread. A queue joins the two, and the presenter +//! shows one prompt at a time so two connects never stack dialogs. + +mod presenter; +mod prompt_form; +mod prompt_text; +mod prompter; + +pub use presenter::CredentialPromptPresenter; +pub use prompter::{GtkCredentialPrompter, PromptQueue}; diff --git a/linux/crates/app/src/ui/credential_prompt/presenter.rs b/linux/crates/app/src/ui/credential_prompt/presenter.rs new file mode 100644 index 0000000000..b1df2fec7d --- /dev/null +++ b/linux/crates/app/src/ui/credential_prompt/presenter.rs @@ -0,0 +1,237 @@ +use std::pin::pin; + +use adw::prelude::*; +use futures::future::{Either, select}; +use libadwaita as adw; +use tablepro_core::credentials::PromptReply; +use tokio::sync::oneshot; + +use super::prompt_form::PromptForm; +use super::prompter::{PendingPrompt, PromptQueue}; + +/// Shows queued credential prompts over a window, one at a time. +/// +/// Sequential on purpose: two connects asking at once would otherwise +/// stack two password dialogs with nothing saying which is which. +#[derive(Debug)] +pub struct CredentialPromptPresenter; + +impl CredentialPromptPresenter { + /// Drain `queue` over `parent` until the returned handle is aborted + /// or the window goes. Aborting drops the queue, which cancels the + /// prompt being shown and every one still waiting. + pub fn start(mut queue: PromptQueue, parent: &impl IsA) -> glib::JoinHandle<()> { + let parent = parent.upcast_ref::().downgrade(); + glib::spawn_future_local(async move { + while let Some(pending) = queue.next().await { + let PendingPrompt { prompt, mut reply } = pending; + if reply.is_closed() { + continue; + } + let answer = match parent.upgrade() { + Some(parent) => { + let form = PromptForm::build(&prompt); + let Some(response) = response_while_wanted(&form.dialog, &parent, &mut reply).await else { + // The connect that asked was cancelled. Its + // dialog goes with it, or the next prompt + // would wait behind one nobody can answer. + form.abandon(); + continue; + }; + form.reply(&response) + } + None => PromptReply::Cancelled, + }; + // The asker can still go between the response and here; + // nobody is waiting then, which is fine. + let _ = reply.send(answer); + } + }) + } +} + +async fn response_while_wanted( + dialog: &adw::AlertDialog, + parent: >k4::Widget, + reply: &mut oneshot::Sender, +) -> Option { + match select(pin!(crate::workspace::respond(dialog, parent)), pin!(reply.closed())).await { + Either::Left((response, _)) => Some(response), + Either::Right(((), _)) => None, + } +} + +#[cfg(test)] +mod tests { + use std::time::Duration; + + use tablepro_core::credentials::{CredentialPrompt, CredentialPrompter, PromptField, PromptPurpose, PromptReason}; + + use super::*; + use crate::ui::credential_prompt::GtkCredentialPrompter; + + fn prompt(target: &str) -> CredentialPrompt { + CredentialPrompt { + purpose: PromptPurpose::DatabasePassword { + reason: PromptReason::NotStored, + }, + target: target.to_owned(), + fields: vec![PromptField { + label: "Password".to_owned(), + secret: true, + }], + offer_remember: true, + } + } + + fn open_alerts(window: &adw::Window) -> Vec { + window + .dialogs() + .iter::() + .filter_map(|item| item.ok()?.downcast::().ok()) + .collect() + } + + #[gtk4::test] + fn prompts_are_presented_sequentially() { + adw::init().expect("libadwaita initialises under the test backend"); + let window = adw::Window::new(); + window.present(); + let (prompter, queue) = GtkCredentialPrompter::new(); + let presenter = CredentialPromptPresenter::start(queue, &window); + let answered = std::rc::Rc::new(std::cell::Cell::new(0)); + for target in ["first", "second"] { + let asking = prompter.prompt(prompt(target)); + let answered = answered.clone(); + glib::spawn_future_local(async move { + asking.await; + answered.set(answered.get() + 1); + }); + } + + crate::test_support::wait_until(Duration::from_secs(5), || open_alerts(&window).len() == 1) + .expect("the first prompt"); + crate::test_support::drain_main_context(); + let shown = open_alerts(&window); + assert_eq!(shown.len(), 1, "the second prompt did not wait for the first"); + assert!(shown[0].body().contains("first")); + shown[0].close(); + + crate::test_support::wait_until(Duration::from_secs(5), || { + open_alerts(&window).iter().any(|alert| alert.body().contains("second")) + }) + .expect("the second prompt after the first closed"); + for alert in open_alerts(&window) { + alert.close(); + } + crate::test_support::wait_until(Duration::from_secs(5), || answered.get() == 2).expect("both prompts answered"); + presenter.abort(); + window.close(); + crate::test_support::drain_main_context(); + } + + #[gtk4::test] + fn a_prompt_whose_connect_was_cancelled_closes_and_lets_the_next_one_show() { + adw::init().expect("libadwaita initialises under the test backend"); + let window = adw::Window::new(); + window.present(); + let (prompter, queue) = GtkCredentialPrompter::new(); + let presenter = CredentialPromptPresenter::start(queue, &window); + let cancelled = prompter.prompt(prompt("cancelled")); + crate::test_support::wait_until(Duration::from_secs(5), || open_alerts(&window).len() == 1) + .expect("the first prompt"); + + drop(cancelled); + + crate::test_support::wait_until(Duration::from_secs(5), || open_alerts(&window).is_empty()) + .expect("the dialog of a cancelled connect stayed on screen"); + let answered = std::rc::Rc::new(std::cell::Cell::new(false)); + let asking = prompter.prompt(prompt("next")); + let sink = answered.clone(); + glib::spawn_future_local(async move { + asking.await; + sink.set(true); + }); + crate::test_support::wait_until(Duration::from_secs(5), || { + open_alerts(&window).iter().any(|alert| alert.body().contains("next")) + }) + .expect("the next prompt behind the cancelled one"); + for alert in open_alerts(&window) { + alert.close(); + } + crate::test_support::wait_until(Duration::from_secs(5), || answered.get()).expect("the next prompt answered"); + presenter.abort(); + window.close(); + crate::test_support::drain_main_context(); + } + + #[gtk4::test] + fn connect_hands_over_the_typed_password_with_animations_on_or_off() { + use secrecy::ExposeSecret; + + adw::init().expect("libadwaita initialises under the test backend"); + let settings = gtk4::Settings::default().expect("the default settings"); + let animations = settings.is_gtk_enable_animations(); + for enabled in [true, false] { + settings.set_gtk_enable_animations(enabled); + let window = adw::Window::new(); + window.present(); + let (prompter, queue) = GtkCredentialPrompter::new(); + let presenter = CredentialPromptPresenter::start(queue, &window); + let reply = std::rc::Rc::new(std::cell::RefCell::new(None)); + let asking = prompter.prompt(prompt("accepted")); + let sink = reply.clone(); + glib::spawn_future_local(async move { + *sink.borrow_mut() = Some(asking.await); + }); + crate::test_support::wait_until(Duration::from_secs(5), || open_alerts(&window).len() == 1) + .expect("the prompt"); + let rows = open_alerts(&window)[0].extra_child().expect("the prompt's rows"); + crate::test_support::descendants(&rows) + .into_iter() + .find_map(|widget| widget.downcast::().ok()) + .expect("the password row") + .set_text("hunter2"); + + assert!(crate::workspace::answer(&window, super::super::prompt_form::ACCEPT)); + + crate::test_support::wait_until(Duration::from_secs(5), || reply.borrow().is_some()).expect("an answer"); + let answered = reply.borrow_mut().take(); + let Some(PromptReply::Submitted { values, .. }) = answered else { + panic!("Connect answered {answered:?} with animations {enabled}"); + }; + assert_eq!(values[0].expose_secret(), "hunter2"); + presenter.abort(); + window.close(); + crate::test_support::drain_main_context(); + } + settings.set_gtk_enable_animations(animations); + } + + #[gtk4::test] + fn a_prompt_whose_dialog_is_forced_shut_is_cancelled() { + adw::init().expect("libadwaita initialises under the test backend"); + let window = adw::Window::new(); + window.present(); + let (prompter, queue) = GtkCredentialPrompter::new(); + let presenter = CredentialPromptPresenter::start(queue, &window); + let reply = std::rc::Rc::new(std::cell::RefCell::new(None)); + let asking = prompter.prompt(prompt("forced")); + let sink = reply.clone(); + glib::spawn_future_local(async move { + *sink.borrow_mut() = Some(asking.await); + }); + + crate::test_support::wait_until(Duration::from_secs(5), || open_alerts(&window).len() == 1) + .expect("the prompt"); + for alert in open_alerts(&window) { + alert.force_close(); + } + crate::test_support::wait_until(Duration::from_secs(5), || reply.borrow().is_some()).expect("an answer"); + + assert!(matches!(reply.borrow_mut().take(), Some(PromptReply::Cancelled))); + presenter.abort(); + window.close(); + crate::test_support::drain_main_context(); + } +} diff --git a/linux/crates/app/src/ui/credential_prompt/prompt_form.rs b/linux/crates/app/src/ui/credential_prompt/prompt_form.rs new file mode 100644 index 0000000000..3d242d0aff --- /dev/null +++ b/linux/crates/app/src/ui/credential_prompt/prompt_form.rs @@ -0,0 +1,271 @@ +use adw::prelude::*; +use libadwaita as adw; +use secrecy::SecretString; +use tablepro_core::credentials::{CredentialPrompt, PromptField, PromptPurpose, PromptReply}; + +use super::prompt_text::PromptText; + +pub(super) const CANCEL: &str = "cancel"; +pub(super) const ACCEPT: &str = "accept"; + +/// One answer the prompt asks for. A secret field never shows what is +/// typed; an echo field is a server question whose answer is not secret. +#[derive(Debug, Clone)] +pub(super) enum FieldRow { + Secret(adw::PasswordEntryRow), + Echo(adw::EntryRow), +} + +impl FieldRow { + fn build(field: &PromptField) -> Self { + let title = field_title(&field.label); + if field.secret { + Self::Secret( + adw::PasswordEntryRow::builder() + .title(title) + .activates_default(true) + .build(), + ) + } else { + Self::Echo(adw::EntryRow::builder().title(title).activates_default(true).build()) + } + } + + fn widget(&self) -> >k4::Widget { + match self { + Self::Secret(row) => row.upcast_ref(), + Self::Echo(row) => row.upcast_ref(), + } + } + + /// Hand the typed text over and clear the row, so the answer lives + /// in a zeroizing secret and nowhere in the widget tree. + fn take(&self) -> SecretString { + let editable: >k4::Editable = match self { + Self::Secret(row) => row.upcast_ref(), + Self::Echo(row) => row.upcast_ref(), + }; + let secret = SecretString::from(editable.text().as_str().to_owned()); + editable.set_text(""); + secret + } +} + +/// The dialog for one prompt, with the rows its answer is read from. +#[derive(Debug, Clone)] +pub(super) struct PromptForm { + pub dialog: adw::AlertDialog, + pub rows: Vec, + pub remember: Option, +} + +impl PromptForm { + pub fn build(prompt: &CredentialPrompt) -> Self { + let text = PromptText::for_prompt(prompt); + let dialog = adw::AlertDialog::new(Some(&text.heading), Some(&text.body)); + dialog.add_response(CANCEL, &crate::i18n::gettext("Cancel")); + dialog.add_response(ACCEPT, &text.accept); + dialog.set_response_appearance(ACCEPT, adw::ResponseAppearance::Suggested); + // Trusting a host key is a decision to read the fingerprint for, + // so Enter must not make it. + let default = match prompt.purpose { + PromptPurpose::SshHostKeyConfirmation { .. } => CANCEL, + _ => ACCEPT, + }; + dialog.set_default_response(Some(default)); + dialog.set_close_response(CANCEL); + + let rows: Vec = prompt.fields.iter().map(FieldRow::build).collect(); + let remember = prompt.offer_remember.then(|| { + adw::SwitchRow::builder() + .title(text.remember.as_str()) + .active(false) + .build() + }); + if !rows.is_empty() || remember.is_some() { + let group = adw::PreferencesGroup::new(); + for row in &rows { + group.add(row.widget()); + } + if let Some(remember) = &remember { + group.add(remember); + } + dialog.set_extra_child(Some(&group)); + } + Self { dialog, rows, remember } + } + + pub fn abandon(&self) { + for row in &self.rows { + drop(row.take()); + } + self.dialog.force_close(); + } + + /// The reply for the response the dialog closed with. Anything but + /// the accept button, including Escape, is a cancel. + pub fn reply(&self, response: &str) -> PromptReply { + let values: Vec = self.rows.iter().map(FieldRow::take).collect(); + if response != ACCEPT { + return PromptReply::Cancelled; + } + PromptReply::Submitted { + values, + remember: self.remember.as_ref().is_some_and(adw::SwitchRow::is_active), + } + } +} + +/// The storage and SSH crates name their fields in English; the two +/// they use are translated here, and a server's own question is shown +/// as the server wrote it. +fn field_title(label: &str) -> String { + match label { + "Password" => crate::i18n::gettext("Password"), + "Passphrase" => crate::i18n::gettext("Passphrase"), + other => other.to_owned(), + } +} + +#[cfg(test)] +mod tests { + use secrecy::ExposeSecret; + use tablepro_core::credentials::{PromptPurpose, PromptReason}; + + use super::*; + + fn password_prompt(offer_remember: bool) -> CredentialPrompt { + CredentialPrompt { + purpose: PromptPurpose::DatabasePassword { + reason: PromptReason::NotStored, + }, + target: "app@db.example.com:5432/shop".to_owned(), + fields: vec![PromptField { + label: "Password".to_owned(), + secret: true, + }], + offer_remember, + } + } + + #[gtk4::test] + fn presenter_builds_rows_per_field_and_remember_switch() { + adw::init().expect("libadwaita initialises under the test backend"); + + let form = PromptForm::build(&password_prompt(true)); + + assert_eq!(form.rows.len(), 1); + assert!(matches!(form.rows[0], FieldRow::Secret(_))); + assert!(form.remember.is_some(), "a remember switch was not offered"); + assert!(form.dialog.extra_child().is_some()); + assert_eq!(form.dialog.default_response().as_deref(), Some(ACCEPT)); + + let without = PromptForm::build(&password_prompt(false)); + assert!(without.remember.is_none(), "remember was offered where it cannot work"); + } + + #[gtk4::test] + fn enter_never_trusts_a_host_key() { + adw::init().expect("libadwaita initialises under the test backend"); + let prompt = CredentialPrompt { + purpose: PromptPurpose::SshHostKeyConfirmation { + host: "bastion.example.com".to_owned(), + algorithm: "ED25519".to_owned(), + fingerprint: "SHA256:abc".to_owned(), + }, + target: "bastion.example.com".to_owned(), + fields: Vec::new(), + offer_remember: false, + }; + + let form = PromptForm::build(&prompt); + + assert_eq!(form.dialog.default_response().as_deref(), Some(CANCEL)); + } + + #[gtk4::test] + fn keyboard_interactive_echo_field_is_entry_row() { + adw::init().expect("libadwaita initialises under the test backend"); + let prompt = CredentialPrompt { + purpose: PromptPurpose::SshKeyboardInteractive { + name: String::new(), + instructions: String::new(), + }, + target: "bastion".to_owned(), + fields: vec![ + PromptField { + label: "Username:".to_owned(), + secret: false, + }, + PromptField { + label: "Verification code:".to_owned(), + secret: true, + }, + ], + offer_remember: false, + }; + + let form = PromptForm::build(&prompt); + + assert!(matches!(form.rows[0], FieldRow::Echo(_))); + assert!(matches!(form.rows[1], FieldRow::Secret(_))); + } + + #[gtk4::test] + fn accepting_hands_over_the_typed_secret_and_clears_the_row() { + adw::init().expect("libadwaita initialises under the test backend"); + let form = PromptForm::build(&password_prompt(true)); + let FieldRow::Secret(row) = &form.rows[0] else { + panic!("expected a secret row"); + }; + row.set_text("hunter2"); + if let Some(remember) = &form.remember { + remember.set_active(true); + } + + let PromptReply::Submitted { values, remember } = form.reply(ACCEPT) else { + panic!("expected a submitted reply"); + }; + + assert_eq!(values[0].expose_secret(), "hunter2"); + assert!(remember); + assert_eq!(row.text(), "", "the secret stayed in the widget"); + } + + #[gtk4::test] + fn closing_dialog_replies_cancelled() { + adw::init().expect("libadwaita initialises under the test backend"); + let form = PromptForm::build(&password_prompt(true)); + let FieldRow::Secret(row) = &form.rows[0] else { + panic!("expected a secret row"); + }; + row.set_text("hunter2"); + + assert!(matches!(form.reply(CANCEL), PromptReply::Cancelled)); + assert_eq!(row.text(), "", "a cancelled secret stayed in the widget"); + } + + #[gtk4::test] + fn a_host_key_prompt_has_no_rows_and_accepts_with_no_values() { + adw::init().expect("libadwaita initialises under the test backend"); + let prompt = CredentialPrompt { + purpose: PromptPurpose::SshHostKeyConfirmation { + host: "bastion.example.com".to_owned(), + algorithm: "ED25519".to_owned(), + fingerprint: "SHA256:abc".to_owned(), + }, + target: "bastion.example.com".to_owned(), + fields: Vec::new(), + offer_remember: false, + }; + + let form = PromptForm::build(&prompt); + + assert!(form.dialog.extra_child().is_none()); + let PromptReply::Submitted { values, remember } = form.reply(ACCEPT) else { + panic!("expected a submitted reply"); + }; + assert!(values.is_empty()); + assert!(!remember); + } +} diff --git a/linux/crates/app/src/ui/credential_prompt/prompt_text.rs b/linux/crates/app/src/ui/credential_prompt/prompt_text.rs new file mode 100644 index 0000000000..8680878259 --- /dev/null +++ b/linux/crates/app/src/ui/credential_prompt/prompt_text.rs @@ -0,0 +1,219 @@ +use tablepro_core::credentials::{CredentialPrompt, PromptPurpose, PromptReason}; + +/// What a prompt dialog says, worked out before any widget exists. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) struct PromptText { + pub heading: String, + pub body: String, + /// The label of the button that answers the prompt. + pub accept: String, + pub remember: String, +} + +impl PromptText { + pub fn for_prompt(prompt: &CredentialPrompt) -> Self { + let target = prompt.target.as_str(); + let (heading, body, accept) = match &prompt.purpose { + PromptPurpose::DatabasePassword { reason } => ( + crate::i18n::gettext("Password Required"), + with_reason( + crate::i18n::gettext_f("Enter the database password for {target}.", &[("target", target)]), + reason, + Asked::Password, + ), + crate::i18n::gettext("Connect"), + ), + PromptPurpose::SshPassword { reason } => ( + crate::i18n::gettext("Password Required"), + with_reason( + crate::i18n::gettext_f("Enter the SSH password for {target}.", &[("target", target)]), + reason, + Asked::Password, + ), + crate::i18n::gettext("Connect"), + ), + PromptPurpose::SshPassphrase { path, reason } => ( + crate::i18n::gettext("Passphrase Required"), + with_reason( + crate::i18n::gettext_f( + "Enter the passphrase for the SSH key {path}.", + &[("path", &path.display().to_string())], + ), + reason, + Asked::Passphrase, + ), + crate::i18n::gettext("Connect"), + ), + PromptPurpose::SshKeyboardInteractive { name, instructions } => { + let heading = match name.trim() { + "" => crate::i18n::gettext("Authentication Required"), + name => name.to_owned(), + }; + let body = match instructions.trim() { + "" => crate::i18n::gettext_f("The SSH server {target} asks for more.", &[("target", target)]), + instructions => instructions.to_owned(), + }; + (heading, body, crate::i18n::gettext("Continue")) + } + PromptPurpose::SshHostKeyConfirmation { + host, + algorithm, + fingerprint, + } => ( + crate::i18n::gettext_f("Trust {host}?", &[("host", host)]), + crate::i18n::gettext_f( + "This is the first connection to {host}. Its {algorithm} key fingerprint is {fingerprint}. Connect only if it matches the fingerprint the server's administrator gave you.", + &[("host", host), ("algorithm", algorithm), ("fingerprint", fingerprint)], + ), + crate::i18n::gettext("Trust and Connect"), + ), + PromptPurpose::SshConfirmation { text } => ( + crate::i18n::gettext("Authentication Required"), + text.clone(), + crate::i18n::gettext("Continue"), + ), + }; + let remember = match prompt.purpose { + PromptPurpose::SshPassphrase { .. } => crate::i18n::gettext("Remember Passphrase"), + _ => crate::i18n::gettext("Remember Password"), + }; + Self { + heading, + body, + accept, + remember, + } + } +} + +/// What a prompt asks for, which its reason has to name. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Asked { + Password, + Passphrase, +} + +/// Why the user is being asked, which tells them whether something is +/// wrong with the keyring or with what they typed before. +fn with_reason(request: String, reason: &PromptReason, asked: Asked) -> String { + let because = match (reason, asked) { + (PromptReason::NotStored, Asked::Password) => crate::i18n::gettext("No password is saved for this connection."), + (PromptReason::NotStored, Asked::Passphrase) => crate::i18n::gettext("No passphrase is saved for this key."), + (PromptReason::KeyringLocked, _) => { + crate::i18n::gettext("The keyring is locked, so the saved one could not be read.") + } + (PromptReason::KeyringUnavailable { .. }, _) => { + crate::i18n::gettext("No keyring is available, so it cannot be read or saved.") + } + (PromptReason::Rejected, Asked::Password) => crate::i18n::gettext("The server refused the one it was given."), + (PromptReason::Rejected, Asked::Passphrase) => { + crate::i18n::gettext("The one given before did not unlock the key.") + } + }; + format!("{because} {request}") +} + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use tablepro_core::credentials::PromptField; + + use super::*; + + fn prompt(purpose: PromptPurpose) -> CredentialPrompt { + CredentialPrompt { + purpose, + target: "app@db.example.com:5432/shop".to_owned(), + fields: vec![PromptField { + label: "Password".to_owned(), + secret: true, + }], + offer_remember: true, + } + } + + #[test] + fn a_password_prompt_names_its_target_and_its_reason() { + let text = PromptText::for_prompt(&prompt(PromptPurpose::DatabasePassword { + reason: PromptReason::Rejected, + })); + + assert_eq!(text.heading, "Password Required"); + assert!(text.body.contains("app@db.example.com:5432/shop"), "{}", text.body); + assert!(text.body.contains("refused"), "{}", text.body); + } + + #[test] + fn each_reason_reads_differently() { + let reasons = [ + PromptReason::NotStored, + PromptReason::KeyringLocked, + PromptReason::KeyringUnavailable { + detail: "no service".to_owned(), + }, + PromptReason::Rejected, + ]; + + let bodies: std::collections::HashSet = reasons + .into_iter() + .map(|reason| PromptText::for_prompt(&prompt(PromptPurpose::SshPassword { reason })).body) + .collect(); + + assert_eq!(bodies.len(), 4); + } + + #[test] + fn a_passphrase_prompt_names_the_key_file() { + let text = PromptText::for_prompt(&prompt(PromptPurpose::SshPassphrase { + path: PathBuf::from("/home/u/.ssh/id_ed25519"), + reason: PromptReason::NotStored, + })); + + assert_eq!(text.heading, "Passphrase Required"); + assert!(text.body.contains("/home/u/.ssh/id_ed25519"), "{}", text.body); + } + + #[test] + fn a_passphrase_prompt_never_speaks_of_a_password() { + for reason in [PromptReason::NotStored, PromptReason::Rejected] { + let text = PromptText::for_prompt(&prompt(PromptPurpose::SshPassphrase { + path: PathBuf::from("/home/u/.ssh/id_ed25519"), + reason, + })); + + assert!(!text.body.contains("password"), "{}", text.body); + assert!(!text.body.contains("server"), "{}", text.body); + } + } + + #[test] + fn a_host_key_prompt_shows_the_fingerprint() { + let text = PromptText::for_prompt(&prompt(PromptPurpose::SshHostKeyConfirmation { + host: "bastion.example.com".to_owned(), + algorithm: "ED25519".to_owned(), + fingerprint: "SHA256:abc".to_owned(), + })); + + assert!(text.heading.contains("bastion.example.com")); + assert!(text.body.contains("SHA256:abc")); + assert!(text.body.contains("ED25519")); + } + + #[test] + fn keyboard_interactive_shows_the_server_s_own_words() { + let text = PromptText::for_prompt(&prompt(PromptPurpose::SshKeyboardInteractive { + name: "Duo two-factor login".to_owned(), + instructions: "Enter a passcode or select one of the following options".to_owned(), + })); + + assert_eq!(text.heading, "Duo two-factor login"); + assert!(text.body.starts_with("Enter a passcode")); + + let unnamed = PromptText::for_prompt(&prompt(PromptPurpose::SshKeyboardInteractive { + name: String::new(), + instructions: String::new(), + })); + assert_eq!(unnamed.heading, "Authentication Required"); + } +} diff --git a/linux/crates/app/src/ui/credential_prompt/prompter.rs b/linux/crates/app/src/ui/credential_prompt/prompter.rs new file mode 100644 index 0000000000..d729437cbe --- /dev/null +++ b/linux/crates/app/src/ui/credential_prompt/prompter.rs @@ -0,0 +1,119 @@ +use futures::future::BoxFuture; +use tablepro_core::credentials::{CredentialPrompt, CredentialPrompter, PromptReply}; +use tokio::sync::{mpsc, oneshot}; + +/// One prompt waiting for the user, and where the answer goes. +#[derive(Debug)] +pub struct PendingPrompt { + pub prompt: CredentialPrompt, + pub reply: oneshot::Sender, +} + +/// The prompts asked for, in order, until a presenter takes them. +#[derive(Debug)] +pub struct PromptQueue { + receiver: mpsc::UnboundedReceiver, +} + +impl PromptQueue { + pub async fn next(&mut self) -> Option { + self.receiver.recv().await + } +} + +/// The `CredentialPrompter` the app hands to every attended connect. +/// +/// It only queues: the dialog is built on the GTK thread by the +/// presenter. With no presenter left to answer, every prompt is +/// cancelled rather than left hanging, so a connect never waits on a +/// window that is gone. +#[derive(Debug, Clone)] +pub struct GtkCredentialPrompter { + requests: mpsc::UnboundedSender, +} + +impl GtkCredentialPrompter { + pub fn new() -> (Self, PromptQueue) { + let (requests, receiver) = mpsc::unbounded_channel(); + (Self { requests }, PromptQueue { receiver }) + } +} + +impl CredentialPrompter for GtkCredentialPrompter { + fn prompt(&self, request: CredentialPrompt) -> BoxFuture<'static, PromptReply> { + let (reply, answer) = oneshot::channel(); + let queued = self.requests.send(PendingPrompt { prompt: request, reply }); + Box::pin(async move { + if queued.is_err() { + return PromptReply::Cancelled; + } + answer.await.unwrap_or(PromptReply::Cancelled) + }) + } +} + +#[cfg(test)] +mod tests { + use secrecy::{ExposeSecret, SecretString}; + use tablepro_core::credentials::{PromptField, PromptPurpose, PromptReason}; + + use super::*; + + fn prompt() -> CredentialPrompt { + CredentialPrompt { + purpose: PromptPurpose::DatabasePassword { + reason: PromptReason::NotStored, + }, + target: "app@db.example.com:5432/shop".to_owned(), + fields: vec![PromptField { + label: "Password".to_owned(), + secret: true, + }], + offer_remember: true, + } + } + + #[tokio::test] + async fn the_answer_given_to_the_queue_is_the_prompt_reply() { + let (prompter, mut queue) = GtkCredentialPrompter::new(); + + let answering = async { + let pending = queue.next().await.expect("a queued prompt"); + assert_eq!(pending.prompt, prompt()); + pending + .reply + .send(PromptReply::Submitted { + values: vec![SecretString::from("hunter2")], + remember: true, + }) + .expect("the asker is waiting"); + }; + let (reply, ()) = tokio::join!(prompter.prompt(prompt()), answering); + + let PromptReply::Submitted { values, remember } = reply else { + panic!("expected a submitted reply"); + }; + assert!(remember); + assert_eq!(values[0].expose_secret(), "hunter2"); + } + + #[tokio::test] + async fn a_prompt_with_no_presenter_is_cancelled() { + let (prompter, queue) = GtkCredentialPrompter::new(); + drop(queue); + + assert!(matches!(prompter.prompt(prompt()).await, PromptReply::Cancelled)); + } + + #[tokio::test] + async fn a_prompt_dropped_unanswered_is_cancelled() { + let (prompter, mut queue) = GtkCredentialPrompter::new(); + + let dropping = async { + drop(queue.next().await.expect("a queued prompt")); + }; + let (reply, ()) = tokio::join!(prompter.prompt(prompt()), dropping); + + assert!(matches!(reply, PromptReply::Cancelled)); + } +} diff --git a/linux/crates/app/src/ui/delete_connection_dialog.rs b/linux/crates/app/src/ui/delete_connection_dialog.rs new file mode 100644 index 0000000000..e8a520e940 --- /dev/null +++ b/linux/crates/app/src/ui/delete_connection_dialog.rs @@ -0,0 +1,190 @@ +//! What a window asks before a saved connection is deleted. + +use adw::prelude::*; +use libadwaita as adw; + +use crate::services::connection_deletion::DeletionOptions; + +pub(crate) const CANCEL: &str = "cancel"; +pub(crate) const DELETE: &str = "delete"; +pub(crate) const SHOW: &str = "show"; + +/// Asks to delete a saved connection, and whether the queries run on it +/// go too. The history stays unless the user says otherwise: it is the +/// record of what they ran, and the delete has no undo. +#[derive(Debug, Clone)] +pub(crate) struct DeleteConnectionDialog { + pub dialog: adw::AlertDialog, + pub history: adw::SwitchRow, +} + +impl DeleteConnectionDialog { + pub fn new(name: &str) -> Self { + let dialog = adw::AlertDialog::new( + Some(&crate::i18n::gettext_f("Delete “{name}”?", &[("name", name)])), + Some(&crate::i18n::gettext( + "The connection, its stored passwords and its saved queries will be removed. This cannot be undone.", + )), + ); + dialog.add_response(CANCEL, &crate::i18n::gettext("Cancel")); + dialog.add_response(DELETE, &crate::i18n::gettext("Delete")); + dialog.set_response_appearance(DELETE, adw::ResponseAppearance::Destructive); + dialog.set_default_response(Some(CANCEL)); + dialog.set_close_response(CANCEL); + let history = adw::SwitchRow::builder() + .title(crate::i18n::gettext("Delete Query History")) + .active(false) + .build(); + let group = adw::PreferencesGroup::new(); + group.add(&history); + dialog.set_extra_child(Some(&group)); + Self { dialog, history } + } + + /// Say what the switch removes, once the history is counted. + pub fn show_history_count(&self, count: u64) { + if count == 0 { + self.history + .set_subtitle(&crate::i18n::gettext("No queries were run on this connection")); + self.history.set_sensitive(false); + return; + } + self.history.set_subtitle(&crate::i18n::ngettext_f( + "Removes {n} query run on this connection", + "Removes {n} queries run on this connection", + u32::try_from(count).unwrap_or(u32::MAX), + &[("n", &count.to_string())], + )); + } + + /// The history database did not open, so the switch could remove + /// nothing. + pub fn show_history_unavailable(&self) { + self.history.set_active(false); + self.history + .set_subtitle(&crate::i18n::gettext("The query history is not available")); + self.history.set_sensitive(false); + } + + /// Run `chosen` with what the user picked when they press Delete. + pub fn connect_delete(&self, chosen: impl Fn(DeletionOptions) + 'static) { + // The row, not the dialog: the dialog holds this closure. + let history = self.history.clone(); + self.dialog.connect_response(Some(DELETE), move |_, _| { + chosen(DeletionOptions { + delete_history: history.is_active(), + }); + }); + } +} + +/// Says a connection open in a window cannot be deleted from under it, +/// and offers to bring that window forward. +pub(crate) fn is_open_dialog(name: &str) -> adw::AlertDialog { + let dialog = adw::AlertDialog::new( + Some(&crate::i18n::gettext_f("“{name}” Is Open", &[("name", name)])), + Some(&crate::i18n::gettext( + "Disconnect it or close its window, then delete it.", + )), + ); + dialog.add_response(CANCEL, &crate::i18n::gettext("Cancel")); + dialog.add_response(SHOW, &crate::i18n::gettext("Show Window")); + dialog.set_response_appearance(SHOW, adw::ResponseAppearance::Suggested); + dialog.set_default_response(Some(SHOW)); + dialog.set_close_response(CANCEL); + dialog +} + +#[cfg(test)] +mod tests { + use std::cell::Cell; + use std::rc::Rc; + + use super::*; + + #[gtk4::test] + fn delete_dialog_has_history_switch_off_by_default() { + adw::init().expect("libadwaita initialises under the test backend"); + + let asking = DeleteConnectionDialog::new("Prod"); + + assert_eq!(asking.dialog.heading().as_deref(), Some("Delete “Prod”?")); + assert!(!asking.history.is_active(), "the history would go by default"); + assert!(asking.history.is_sensitive()); + assert_eq!(asking.dialog.default_response().as_deref(), Some(CANCEL)); + assert_eq!(asking.dialog.close_response(), CANCEL); + assert_eq!( + asking.dialog.response_appearance(DELETE), + adw::ResponseAppearance::Destructive + ); + } + + #[gtk4::test] + fn the_history_switch_says_how_much_it_removes() { + adw::init().expect("libadwaita initialises under the test backend"); + let asking = DeleteConnectionDialog::new("Prod"); + + asking.show_history_count(12); + assert!(asking.history.subtitle().is_some_and(|text| text.contains("12"))); + + asking.show_history_count(0); + assert!( + !asking.history.is_sensitive(), + "a switch that removes nothing is offered" + ); + } + + #[gtk4::test] + fn an_unavailable_history_offers_no_switch() { + adw::init().expect("libadwaita initialises under the test backend"); + let asking = DeleteConnectionDialog::new("Prod"); + + asking.show_history_unavailable(); + + assert!( + !asking.history.is_sensitive(), + "a switch that can do nothing is offered" + ); + assert!(!asking.history.is_active()); + assert!( + asking + .history + .subtitle() + .is_some_and(|text| text.contains("not available")) + ); + } + + #[gtk4::test] + fn delete_hands_over_the_history_choice() { + adw::init().expect("libadwaita initialises under the test backend"); + let window = crate::test_support::OwnedWindow::presented(adw::Window::new()); + let chosen = Rc::new(Cell::new(None)); + let ask = || { + let asking = DeleteConnectionDialog::new("Prod"); + let sink = chosen.clone(); + asking.connect_delete(move |options| sink.set(Some(options))); + asking.dialog.present(Some(&*window)); + asking + }; + + crate::test_support::respond(&ask().dialog, CANCEL); + assert_eq!(chosen.get(), None, "Cancel deleted"); + let asking = ask(); + asking.history.set_active(true); + crate::test_support::respond(&asking.dialog, DELETE); + + assert_eq!(chosen.get(), Some(DeletionOptions { delete_history: true })); + window.close(); + } + + #[gtk4::test] + fn the_is_open_dialog_offers_the_window() { + adw::init().expect("libadwaita initialises under the test backend"); + + let dialog = is_open_dialog("Prod"); + + assert_eq!(dialog.heading().as_deref(), Some("“Prod” Is Open")); + assert!(dialog.has_response(SHOW)); + assert_eq!(dialog.close_response(), CANCEL); + } +} diff --git a/linux/crates/app/src/ui/discard_alert.rs b/linux/crates/app/src/ui/discard_alert.rs new file mode 100644 index 0000000000..54cd549382 --- /dev/null +++ b/linux/crates/app/src/ui/discard_alert.rs @@ -0,0 +1,32 @@ +use gtk4 as gtk; +use gtk4::prelude::*; +use libadwaita as adw; +use libadwaita::prelude::*; + +/// Ask before Discard drops a tab's pending changes. The undo history +/// goes with them, so the alert is the only chance to keep them. +pub(crate) fn confirm_discard( + parent: &impl IsA, + pending: usize, + discard: impl Fn() + 'static, +) -> adw::AlertDialog { + let body = crate::i18n::ngettext_f( + "{n} unsaved change on this tab will be lost, and Undo cannot bring it back.", + "{n} unsaved changes on this tab will be lost, and Undo cannot bring them back.", + u32::try_from(pending).unwrap_or(u32::MAX), + &[("n", &pending.to_string())], + ); + let dialog = adw::AlertDialog::new(Some(&crate::i18n::gettext("Discard Changes?")), Some(&body)); + dialog.add_response("cancel", &crate::i18n::gettext("Cancel")); + dialog.add_response("discard", &crate::i18n::gettext("Discard")); + dialog.set_response_appearance("discard", adw::ResponseAppearance::Destructive); + dialog.set_default_response(Some("cancel")); + dialog.set_close_response("cancel"); + dialog.connect_response(None, move |_, response| { + if response == "discard" { + discard(); + } + }); + dialog.present(Some(parent)); + dialog +} diff --git a/linux/crates/app/src/ui/editor/buffer_edit.rs b/linux/crates/app/src/ui/editor/buffer_edit.rs new file mode 100644 index 0000000000..d7a25438bd --- /dev/null +++ b/linux/crates/app/src/ui/editor/buffer_edit.rs @@ -0,0 +1,114 @@ +use gtk4::prelude::*; + +/// Replace everything in the buffer as one step Ctrl+Z can take back. +/// +/// `TextBuffer::set_text` is recorded as irreversible, so Format, a +/// query picked from history or a loaded file would wipe what the user +/// had with no way back. A delete and an insert inside one user action +/// are a single undo step instead. +pub fn replace_all_undoable(buffer: &impl IsA, text: &str) { + let buffer = buffer.as_ref(); + buffer.begin_user_action(); + let (mut start, mut end) = buffer.bounds(); + buffer.delete(&mut start, &mut end); + let mut at = buffer.start_iter(); + buffer.insert(&mut at, text); + buffer.end_user_action(); +} + +/// Where the cursor was, by line and column, so it can be put back near +/// the same place after the text around it was rewritten. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct CursorAnchor { + line: i32, + line_offset: i32, +} + +impl CursorAnchor { + pub fn capture(buffer: &impl IsA) -> Self { + let buffer = buffer.as_ref(); + let iter = buffer.iter_at_mark(&buffer.get_insert()); + Self { + line: iter.line(), + line_offset: iter.line_offset(), + } + } + + /// Put the cursor back, clamped to the text that is there now: a + /// shorter line or fewer lines move it to the nearest end. + pub fn restore(self, buffer: &impl IsA) { + let buffer = buffer.as_ref(); + let last_line = (buffer.line_count() - 1).max(0); + let line = self.line.clamp(0, last_line); + let Some(mut iter) = buffer.iter_at_line(line) else { + buffer.place_cursor(&buffer.end_iter()); + return; + }; + let mut line_end = iter; + if !line_end.ends_line() { + line_end.forward_to_line_end(); + } + iter.set_line_offset(self.line_offset.clamp(0, line_end.line_offset())); + buffer.place_cursor(&iter); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn buffer(text: &str) -> gtk4::TextBuffer { + let buffer = gtk4::TextBuffer::new(None); + buffer.set_text(text); + buffer + } + + fn text(buffer: >k4::TextBuffer) -> String { + let (start, end) = buffer.bounds(); + buffer.text(&start, &end, false).to_string() + } + + #[gtk4::test] + fn format_is_one_undo_step() { + let buffer = buffer("select 1;select 2"); + assert!(!buffer.can_undo(), "set_text left something to undo"); + + replace_all_undoable(&buffer, "SELECT\n 1;\n\nSELECT\n 2"); + assert!(buffer.can_undo()); + + buffer.undo(); + + assert_eq!(text(&buffer), "select 1;select 2"); + assert!(!buffer.can_undo(), "the replace took more than one undo step"); + } + + #[gtk4::test] + fn cursor_anchor_clamps_to_shorter_text() { + let buffer = buffer("SELECT 1\nFROM a_long_table_name\nWHERE x = 1"); + let mut iter = buffer.iter_at_line(2).expect("a third line"); + iter.set_line_offset(9); + buffer.place_cursor(&iter); + let anchor = CursorAnchor::capture(&buffer); + + buffer.set_text("SELECT 1\nFROM t"); + anchor.restore(&buffer); + + let cursor = buffer.iter_at_mark(&buffer.get_insert()); + assert_eq!((cursor.line(), cursor.line_offset()), (1, 6)); + } + + #[gtk4::test] + fn cursor_anchor_keeps_a_position_that_still_exists() { + let buffer = buffer("SELECT 1\nFROM t"); + let mut iter = buffer.iter_at_line(1).expect("a second line"); + iter.set_line_offset(2); + buffer.place_cursor(&iter); + let anchor = CursorAnchor::capture(&buffer); + + replace_all_undoable(&buffer, "SELECT\n 1\nFROM\n t"); + anchor.restore(&buffer); + + let cursor = buffer.iter_at_mark(&buffer.get_insert()); + assert_eq!((cursor.line(), cursor.line_offset()), (1, 2)); + } +} diff --git a/linux/crates/app/src/ui/editor/catalog_change.rs b/linux/crates/app/src/ui/editor/catalog_change.rs new file mode 100644 index 0000000000..54c6e75fb3 --- /dev/null +++ b/linux/crates/app/src/ui/editor/catalog_change.rs @@ -0,0 +1,110 @@ +use tablepro_core::sql_syntax::SqlGrammar; +use tablepro_core::sql_syntax::script::{LexicalSettings, ScriptPlan, ScriptTokenKind}; + +/// Words that add, rename or remove what the sidebar lists. They count +/// anywhere in a statement: a SQL Server batch needs no `;` between its +/// statements, and these words appear in no other kind of statement. +const SCHEMA_VERBS: [&str; 7] = ["CREATE", "ALTER", "DROP", "RENAME", "ATTACH", "DETACH", "IMPORT"]; + +/// DDL run inside a transaction reaches the window's own connection only +/// once it commits. +const TRANSACTION_ENDS: [&str; 2] = ["COMMIT", "END"]; + +/// Whether running `text` can change the tables the catalog lists. A +/// commit counts as the statement's first word, and `SELECT … INTO` +/// counts too, since PostgreSQL and SQL Server create its target table. +pub(super) fn changes_catalog(text: &str, grammar: SqlGrammar) -> bool { + let plan = ScriptPlan::build(text, grammar, LexicalSettings::default_for(grammar)); + let is = |word: &str, set: &[&str]| set.iter().any(|verb| word.eq_ignore_ascii_case(verb)); + plan.statements().iter().any(|statement| { + let words: Vec<&str> = plan + .tokens(statement.range.clone()) + .filter(|token| token.kind == ScriptTokenKind::Word) + .filter_map(|token| text.get(token.range)) + .collect(); + let Some(first) = words.first() else { + return false; + }; + words.iter().any(|word| is(word, &SCHEMA_VERBS)) + || is(first, &TRANSACTION_ENDS) + || (is(first, &["SELECT", "WITH"]) && words.iter().any(|word| is(word, &["INTO"]))) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ddl_changes_the_catalog_in_every_grammar() { + for grammar in SqlGrammar::ALL { + for sql in [ + "CREATE TABLE orders (id int)", + "create view v as select 1", + "ALTER TABLE orders ADD note text", + "DROP TABLE orders", + "-- a note first\nCREATE INDEX i ON orders (id)", + "SELECT 1; DROP TABLE orders", + ] { + assert!(changes_catalog(sql, grammar), "{grammar:?}: {sql}"); + } + } + } + + #[test] + fn reads_and_row_writes_leave_it_alone() { + for grammar in SqlGrammar::ALL { + for sql in [ + "SELECT * FROM orders", + "INSERT INTO orders VALUES (1)", + "UPDATE orders SET id = 2", + "DELETE FROM orders", + "SELECT 'CREATE TABLE x' AS text", + "-- DROP TABLE orders\nSELECT 1", + "", + ] { + assert!(!changes_catalog(sql, grammar), "{grammar:?}: {sql}"); + } + } + } + + #[test] + fn ddl_after_another_statement_in_a_sql_server_batch_counts() { + assert!(changes_catalog( + "INSERT INTO log VALUES (1)\nCREATE TABLE orders (id int)", + SqlGrammar::MsSql + )); + } + + #[test] + fn a_case_end_is_not_a_commit() { + assert!(!changes_catalog( + "SELECT CASE WHEN id > 1 THEN 'a' END FROM orders", + SqlGrammar::PostgreSql + )); + } + + #[test] + fn a_commit_publishes_ddl_run_in_a_transaction() { + assert!(changes_catalog("COMMIT", SqlGrammar::PostgreSql)); + assert!(changes_catalog("END", SqlGrammar::PostgreSql)); + assert!(changes_catalog("COMMIT TRANSACTION", SqlGrammar::MsSql)); + assert!(!changes_catalog("BEGIN", SqlGrammar::PostgreSql)); + } + + #[test] + fn select_into_creates_its_target() { + assert!(changes_catalog("SELECT * INTO archive FROM orders", SqlGrammar::MsSql)); + assert!(changes_catalog( + "SELECT id INTO TEMP t FROM orders", + SqlGrammar::PostgreSql + )); + assert!(!changes_catalog("SELECT id FROM orders", SqlGrammar::PostgreSql)); + } + + #[test] + fn sqlite_attach_and_rename_count() { + assert!(changes_catalog("ATTACH DATABASE 'x.db' AS x", SqlGrammar::Sqlite)); + assert!(changes_catalog("RENAME TABLE a TO b", SqlGrammar::MySql)); + } +} diff --git a/linux/crates/app/src/ui/editor/completion/catalog_read.rs b/linux/crates/app/src/ui/editor/completion/catalog_read.rs new file mode 100644 index 0000000000..1c2eede515 --- /dev/null +++ b/linux/crates/app/src/ui/editor/completion/catalog_read.rs @@ -0,0 +1,197 @@ +use std::sync::Arc; + +use tablepro_core::meta::{ObjectInfo, ObjectScope, SchemaInfo}; +use tablepro_core::{ColumnInfo, TableRef}; +use tablepro_session::{OperationError, Session}; + +use super::completion_scope::CompletionScope; + +/// What the session's catalog holds for one completion. +#[derive(Debug, Default)] +pub struct CatalogAnswer { + pub schemas: Option>, + pub objects: Option>, + pub columns: Vec<(TableRef, Arc<[ColumnInfo]>)>, +} + +/// Read what `scope` offers. The catalog shares each read with every +/// other view and keeps what it read, so typing does not reach the +/// server again; a list that cannot be read is left out. +pub async fn read(session: Arc, scope: CompletionScope) -> CatalogAnswer { + let defaults = listed(session.objects(ObjectScope::DefaultSchema).await); + match scope { + CompletionScope::Objects { schema: None } => CatalogAnswer { + schemas: Some(listed(session.schemas().await)), + objects: Some(defaults), + columns: Vec::new(), + }, + CompletionScope::Objects { schema: Some(name) } => { + // `users.` typed before the FROM that names users. + let table = relation_named(&defaults, &name); + CatalogAnswer { + schemas: None, + objects: schema_objects(&session, &name).await, + columns: columns_of(&session, table.into_iter().collect()).await, + } + } + CompletionScope::Qualified { qualifier, name } => { + let table = TableRef::new(Some(qualifier), name.clone()); + match known_table(&session, &defaults, &table).await { + Some(table) => CatalogAnswer { + columns: columns_of(&session, vec![table]).await, + ..CatalogAnswer::default() + }, + None => CatalogAnswer { + objects: schema_objects(&session, &name).await, + ..CatalogAnswer::default() + }, + } + } + CompletionScope::Columns { tables } => CatalogAnswer { + columns: columns_of(&session, known_tables(&session, &defaults, &tables).await).await, + ..CatalogAnswer::default() + }, + CompletionScope::General { tables_in_statement } => CatalogAnswer { + schemas: None, + columns: columns_of(&session, known_tables(&session, &defaults, &tables_in_statement).await).await, + objects: Some(defaults), + }, + } +} + +async fn columns_of(session: &Session, tables: Vec) -> Vec<(TableRef, Arc<[ColumnInfo]>)> { + let reads = tables.into_iter().map(|table| async move { + let columns = listed(session.columns(table.clone()).await); + (table, columns) + }); + futures::future::join_all(reads).await +} + +fn listed(read: Result, Arc>) -> Arc<[T]> { + read.unwrap_or_else(|error| { + tracing::debug!(%error, "completion left out a list it could not read"); + Arc::from([]) + }) +} + +/// The objects of the schema called `name`, or `None` when the catalog +/// has no such schema. +async fn schema_objects(session: &Session, name: &str) -> Option> { + let schemas = listed(session.schemas().await); + let schema = schema_named(&schemas, name)?; + Some(listed(session.objects(ObjectScope::Schema(schema.name.clone())).await)) +} + +/// The catalog's own names for the tables among `tables` it holds. A +/// name it does not hold, such as a CTE's, is left out: reading it +/// could only fail, and a failed read is asked again every time. +async fn known_tables(session: &Session, defaults: &[ObjectInfo], tables: &[TableRef]) -> Vec { + let mut known = Vec::new(); + for table in tables { + if let Some(table) = known_table(session, defaults, table).await + && !known.contains(&table) + { + known.push(table); + } + } + known +} + +/// The catalog's own name for `table`, so the read is the one the +/// table's other views share. +async fn known_table(session: &Session, defaults: &[ObjectInfo], table: &TableRef) -> Option { + let Some(schema) = &table.schema else { + return relation_named(defaults, &table.name); + }; + let schemas = listed(session.schemas().await); + let schema = schema_named(&schemas, schema)?; + if schema.is_default { + return relation_named(defaults, &table.name); + } + let objects = listed(session.objects(ObjectScope::Schema(schema.name.clone())).await); + relation_named(&objects, &table.name) +} + +fn schema_named<'a>(schemas: &'a [SchemaInfo], name: &str) -> Option<&'a SchemaInfo> { + named(schemas, name, |schema| Some(schema.name.as_str())) +} + +fn relation_named(objects: &[ObjectInfo], name: &str) -> Option { + named(objects, name, |object| { + object.kind.is_browsable().then_some(object.name.as_str()) + }) + .map(|object| TableRef::new(object.schema.clone(), object.name.clone())) +} + +/// The item called `name`, as written or, failing that, ignoring case +/// the way an unquoted name is read. `name_of` gives `None` for an item +/// that does not count. +fn named<'a, T>(items: &'a [T], name: &str, name_of: impl Fn(&T) -> Option<&str>) -> Option<&'a T> { + let folded = name.to_lowercase(); + items.iter().find(|item| name_of(item) == Some(name)).or_else(|| { + items + .iter() + .find(|item| name_of(item).is_some_and(|own| own.to_lowercase() == folded)) + }) +} + +#[cfg(test)] +mod tests { + use tablepro_core::meta::ObjectKind; + + use super::*; + + fn object(name: &str, kind: ObjectKind) -> ObjectInfo { + ObjectInfo { + schema: Some("public".to_owned()), + name: name.to_owned(), + kind, + } + } + + #[test] + fn an_unqualified_table_takes_the_catalogs_schema() { + let defaults = [object("users", ObjectKind::Table), object("Orders", ObjectKind::Table)]; + + assert_eq!( + relation_named(&defaults, "users"), + Some(TableRef::new(Some("public".to_owned()), "users")) + ); + assert_eq!( + relation_named(&defaults, "orders"), + Some(TableRef::new(Some("public".to_owned()), "Orders")) + ); + assert_eq!(relation_named(&defaults, "missing"), None); + } + + #[test] + fn a_function_is_not_a_relation() { + let defaults = [object("refresh", ObjectKind::Function)]; + + assert_eq!(relation_named(&defaults, "refresh"), None); + } + + #[test] + fn a_schema_is_matched_as_written_then_ignoring_case() { + let schema = |name: &str| SchemaInfo { + name: name.to_owned(), + is_default: false, + is_system: false, + }; + let schemas = [schema("sales"), schema("Audit"), schema("audit")]; + + assert_eq!( + schema_named(&schemas, "SALES").map(|schema| schema.name.as_str()), + Some("sales") + ); + assert_eq!( + schema_named(&schemas, "Audit").map(|schema| schema.name.as_str()), + Some("Audit") + ); + assert_eq!( + schema_named(&schemas, "audit").map(|schema| schema.name.as_str()), + Some("audit") + ); + assert!(schema_named(&schemas, "hr").is_none()); + } +} diff --git a/linux/crates/app/src/ui/editor/completion/completion_scope.rs b/linux/crates/app/src/ui/editor/completion/completion_scope.rs new file mode 100644 index 0000000000..944252f483 --- /dev/null +++ b/linux/crates/app/src/ui/editor/completion/completion_scope.rs @@ -0,0 +1,654 @@ +use std::ops::Range; + +use tablepro_core::TableRef; +use tablepro_core::sql_syntax::SqlGrammar; +use tablepro_core::sql_syntax::script::{LexicalSettings, ScriptPlan, ScriptToken, ScriptTokenKind}; + +/// What the text before the cursor asks for. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum CompletionScope { + /// A table or view to name, from `schema` or the default one. + Objects { schema: Option }, + /// A column of one of these tables. + Columns { tables: Vec }, + /// After `a.b.`: the columns of table `b` in schema `a`, or the + /// objects of schema `b` when `a` names the database, as SQL + /// Server's `db.dbo.` does. + Qualified { qualifier: String, name: String }, + /// Anything: keywords, objects, and the columns of the tables the + /// statement already names. + General { tables_in_statement: Vec }, +} + +/// The words after which a table name comes. +const RELATION_WORDS: [&str; 5] = ["FROM", "JOIN", "INTO", "UPDATE", "TABLE"]; + +/// The first word of a parenthesised query, inside which a FROM names +/// tables again. +const QUERY_WORDS: [&str; 5] = ["SELECT", "WITH", "INSERT", "UPDATE", "DELETE"]; + +/// Words that are never an alias, beyond the completed keywords. +const CLAUSE_WORDS: [&str; 12] = [ + "FETCH", + "FINAL", + "FOR", + "LATERAL", + "LIMIT", + "NATURAL", + "OFFSET", + "ONLY", + "PREWHERE", + "RETURNING", + "TABLESAMPLE", + "WINDOW", +]; + +/// What to complete at byte `cursor` of `text`. +/// +/// `None` inside a string, a comment, a quoted name or a dollar-quoted +/// body, where what is typed is not SQL the editor can read, and inside +/// a number or after a dot that follows no name, where nothing has a +/// name to complete. +pub fn analyze(text: &str, cursor: usize, grammar: SqlGrammar, settings: LexicalSettings) -> Option { + let cursor = floor_char_boundary(text, cursor); + let plan = ScriptPlan::build(text, grammar, settings); + if plan + .tokens(cursor.saturating_sub(1)..cursor + 1) + .any(|token| encloses(&token, text, cursor)) + { + return None; + } + let Some(statement) = statement_around(&plan, cursor) else { + return Some(CompletionScope::General { + tables_in_statement: Vec::new(), + }); + }; + let lexemes: Vec> = plan + .tokens(statement) + .filter(|token| !token.kind.is_trivia()) + .filter_map(|token| Lexeme::new(text, token)) + .collect(); + let tables = TableMap::collect(&lexemes, grammar); + let mut before = lexemes.iter().take_while(|lexeme| lexeme.end <= cursor).count(); + if before + .checked_sub(1) + .and_then(|last| lexemes.get(last)) + .is_some_and(|last| last.kind == ScriptTokenKind::Word && last.end == cursor) + { + before -= 1; + } + scope_after(lexemes.get(..before).unwrap_or_default(), &tables, grammar) +} + +fn scope_after(before: &[Lexeme<'_>], tables: &TableMap, grammar: SqlGrammar) -> Option { + let Some((last, rest)) = before.split_last() else { + return Some(tables.general()); + }; + if last.is_punct(".") { + return after_dot(rest, tables); + } + let relation = relation_word(before, rest.len()).is_some(); + if relation || (last.is_punct(",") && in_from_list(rest, grammar)) { + return Some(CompletionScope::Objects { schema: None }); + } + Some(tables.general()) +} + +/// `name.` names a table's columns when the statement knows the name, +/// and a schema's objects otherwise; `a.b.` may be either, and `a.b.c.` +/// is table `b.c`. `None` when no name comes before the dot, as after a +/// string or a bracket. +fn after_dot(rest: &[Lexeme<'_>], tables: &TableMap) -> Option { + let (name, rest) = rest.split_last()?; + let name = name.identifier()?; + Some(match name_before_dot(rest) { + Some((schema, rest)) if name_before_dot(rest).is_some() => CompletionScope::Columns { + tables: vec![TableRef::new(Some(schema), name)], + }, + Some((qualifier, _)) => CompletionScope::Qualified { qualifier, name }, + None => match tables.lookup(&name) { + Some(table) => CompletionScope::Columns { + tables: vec![table.clone()], + }, + None => CompletionScope::Objects { schema: Some(name) }, + }, + }) +} + +/// The name before the dot that ends `lexemes`, and what precedes it. +fn name_before_dot<'s, 'a>(lexemes: &'s [Lexeme<'a>]) -> Option<(String, &'s [Lexeme<'a>])> { + let [rest @ .., name, dot] = lexemes else { + return None; + }; + if !dot.is_punct(".") { + return None; + } + Some((name.identifier()?, rest)) +} + +/// The relation word at `index`, when a table name follows it. +fn relation_word(lexemes: &[Lexeme<'_>], index: usize) -> Option<&'static str> { + let lexeme = lexemes.get(index)?; + let word = RELATION_WORDS.into_iter().find(|word| lexeme.is_word(word))?; + (word != "FROM" || from_names_tables(lexemes, index)).then_some(word) +} + +/// Whether the FROM at `index` starts a table list, rather than ending +/// `IS DISTINCT FROM` or sitting in a call such as +/// `EXTRACT(YEAR FROM created_at)`. +fn from_names_tables(lexemes: &[Lexeme<'_>], index: usize) -> bool { + let follows_distinct = index + .checked_sub(1) + .and_then(|before| lexemes.get(before)) + .is_some_and(|before| before.is_word("DISTINCT")); + if follows_distinct { + return false; + } + match enclosing_group(lexemes, index) { + None => true, + Some(open) => lexemes + .get(open + 1) + .is_some_and(|first| QUERY_WORDS.iter().any(|word| first.is_word(word))), + } +} + +/// The index of the `(` that opens the innermost group around `index`. +fn enclosing_group(lexemes: &[Lexeme<'_>], index: usize) -> Option { + let mut depth = 0_usize; + for (at, lexeme) in lexemes.get(..index)?.iter().enumerate().rev() { + match lexeme.kind { + ScriptTokenKind::CloseParen => depth += 1, + ScriptTokenKind::OpenParen if depth == 0 => return Some(at), + ScriptTokenKind::OpenParen => depth -= 1, + _ => {} + } + } + None +} + +/// Whether a comma continues the table list of a FROM clause. +fn in_from_list(rest: &[Lexeme<'_>], grammar: SqlGrammar) -> bool { + for (index, lexeme) in rest.iter().enumerate().rev() { + if lexeme.is_word("FROM") { + return from_names_tables(rest, index); + } + let part_of_list = lexeme.is_punct(",") + || lexeme.is_punct(".") + || lexeme.is_word("AS") + || (lexeme.identifier().is_some() && !lexeme.is_keyword(grammar)); + if !part_of_list { + return false; + } + } + false +} + +/// The tables a statement names, under their aliases and their own +/// names. +#[derive(Debug, Default)] +struct TableMap { + entries: Vec<(String, TableRef)>, +} + +impl TableMap { + /// Every relation word starts a reference, including those inside a + /// subquery, so a cursor in the subquery sees its tables too. + fn collect(lexemes: &[Lexeme<'_>], grammar: SqlGrammar) -> Self { + let mut map = Self::default(); + for index in 0..lexemes.len() { + let Some(keyword) = relation_word(lexemes, index) else { + continue; + }; + let mut at = index + 1; + loop { + if lexemes.get(at).is_some_and(|next| next.is_word("ONLY")) { + at += 1; + } + let table = if lexemes.get(at).is_some_and(Lexeme::opens_group) { + at = after_group(lexemes, at); + None + } else { + let Some((table, next)) = table_ref_at(lexemes, at, grammar) else { + break; + }; + at = next; + // A set-returning call in FROM is not a table, and + // its arguments are no alias. + let called = lexemes.get(at).is_some_and(Lexeme::opens_group); + if called && matches!(keyword, "FROM" | "JOIN") { + at = after_group(lexemes, at); + None + } else { + Some(table) + } + }; + let (alias, next) = alias_at(lexemes, at, grammar); + at = next; + if let Some(table) = table { + map.insert(table, alias); + } + if keyword == "FROM" && lexemes.get(at).is_some_and(|next| next.is_punct(",")) { + at += 1; + continue; + } + break; + } + } + map + } + + fn insert(&mut self, table: TableRef, alias: Option) { + if let Some(alias) = alias { + self.entries.push((alias, table.clone())); + } + self.entries.push((table.name.clone(), table)); + } + + /// The table `name` stands for, matched as written first and then + /// ignoring case, the way an unquoted name is read. + fn lookup(&self, name: &str) -> Option<&TableRef> { + let folded = name.to_lowercase(); + self.entries + .iter() + .find(|(key, _)| key == name) + .or_else(|| self.entries.iter().find(|(key, _)| key.to_lowercase() == folded)) + .map(|(_, table)| table) + } + + fn general(&self) -> CompletionScope { + CompletionScope::General { + tables_in_statement: self.in_order(), + } + } + + fn in_order(&self) -> Vec { + let mut tables: Vec = Vec::new(); + for (_, table) in &self.entries { + if !tables.contains(table) { + tables.push(table.clone()); + } + } + tables + } +} + +/// `name`, `schema.name` or `catalog.schema.name` at `index`, and the +/// index after it. +fn table_ref_at(lexemes: &[Lexeme<'_>], index: usize, grammar: SqlGrammar) -> Option<(TableRef, usize)> { + let mut parts = Vec::new(); + let mut at = index; + loop { + let lexeme = lexemes.get(at)?; + if lexeme.is_keyword(grammar) { + break; + } + parts.push(lexeme.identifier()?); + at += 1; + match lexemes.get(at) { + Some(dot) if dot.is_punct(".") => at += 1, + _ => break, + } + } + let name = parts.pop()?; + Some((TableRef::new(parts.pop(), name), at)) +} + +/// The index after the parenthesised group that opens at `index`. +fn after_group(lexemes: &[Lexeme<'_>], index: usize) -> usize { + let mut depth = 0_usize; + for (at, lexeme) in lexemes.iter().enumerate().skip(index) { + match lexeme.kind { + ScriptTokenKind::OpenParen => depth += 1, + ScriptTokenKind::CloseParen => { + depth = depth.saturating_sub(1); + if depth == 0 { + return at + 1; + } + } + _ => {} + } + } + lexemes.len() +} + +fn alias_at(lexemes: &[Lexeme<'_>], index: usize, grammar: SqlGrammar) -> (Option, usize) { + let Some(lexeme) = lexemes.get(index) else { + return (None, index); + }; + if lexeme.is_word("AS") { + let alias = lexemes.get(index + 1).and_then(Lexeme::identifier); + return (alias, index + 2); + } + match lexeme.identifier() { + Some(alias) if !lexeme.is_keyword(grammar) => (Some(alias), index + 1), + _ => (None, index), + } +} + +/// The statement the cursor is in or just after, unless a terminator +/// already ended it. +fn statement_around(plan: &ScriptPlan, cursor: usize) -> Option> { + let statement = plan.statement_at(cursor)?; + if cursor <= statement.range.end { + return Some(statement.range.clone()); + } + let ended = plan + .tokens(statement.range.end..cursor) + .any(|token| matches!(token.kind, ScriptTokenKind::Semicolon | ScriptTokenKind::Directive)); + (!ended).then_some(statement.range.start..cursor) +} + +/// Whether the cursor sits inside a token nothing completes in. A line +/// comment and an unclosed construct run to their end, so the end +/// counts as inside; a closed quote ends before its last character; a +/// number holds the cursor up to and just after its decimal point. +fn encloses(token: &ScriptToken, text: &str, cursor: usize) -> bool { + let inside = token.range.start < cursor && cursor < token.range.end; + let at_open_end = token.range.start < cursor && cursor == token.range.end; + match token.kind { + ScriptTokenKind::LineComment | ScriptTokenKind::Unterminated | ScriptTokenKind::Directive => { + inside || at_open_end + } + ScriptTokenKind::Number => { + inside + || (at_open_end + && text + .get(token.range.clone()) + .is_some_and(|number| number.ends_with('.'))) + } + ScriptTokenKind::StringLiteral + | ScriptTokenKind::QuotedIdentifier + | ScriptTokenKind::DollarQuoted + | ScriptTokenKind::BlockComment => inside, + _ => false, + } +} + +fn floor_char_boundary(text: &str, byte: usize) -> usize { + let mut byte = byte.min(text.len()); + while !text.is_char_boundary(byte) { + byte -= 1; + } + byte +} + +#[derive(Debug)] +struct Lexeme<'a> { + kind: ScriptTokenKind, + text: &'a str, + end: usize, +} + +impl<'a> Lexeme<'a> { + fn new(source: &'a str, token: ScriptToken) -> Option { + Some(Self { + kind: token.kind, + text: source.get(token.range.clone())?, + end: token.range.end, + }) + } + + fn is_word(&self, word: &str) -> bool { + self.kind == ScriptTokenKind::Word && self.text.eq_ignore_ascii_case(word) + } + + fn is_punct(&self, punct: &str) -> bool { + self.kind == ScriptTokenKind::Punct && self.text == punct + } + + fn opens_group(&self) -> bool { + self.kind == ScriptTokenKind::OpenParen + } + + fn is_keyword(&self, grammar: SqlGrammar) -> bool { + self.kind == ScriptTokenKind::Word + && (grammar.keywords().any(|keyword| self.is_word(keyword)) + || CLAUSE_WORDS.iter().any(|keyword| self.is_word(keyword))) + } + + /// The name a word or a quoted identifier spells. + fn identifier(&self) -> Option { + match self.kind { + ScriptTokenKind::Word => Some(self.text.to_owned()), + ScriptTokenKind::QuotedIdentifier => Some(unquote(self.text)), + _ => None, + } + } +} + +fn unquote(text: &str) -> String { + let close = match text.chars().next() { + Some('"') => '"', + Some('`') => '`', + Some('[') => ']', + _ => return text.to_owned(), + }; + let inner = text + .get(1..text.len().saturating_sub(close.len_utf8())) + .unwrap_or_default(); + inner.replace(&format!("{close}{close}"), &close.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The scope at the `|` in `marked`. + fn scope_in(grammar: SqlGrammar, marked: &str) -> Option { + let cursor = marked.find('|').expect("a cursor mark"); + let text = marked.replacen('|', "", 1); + analyze(&text, cursor, grammar, LexicalSettings::default_for(grammar)) + } + + fn scope(marked: &str) -> Option { + scope_in(SqlGrammar::PostgreSql, marked) + } + + fn table(schema: Option<&str>, name: &str) -> TableRef { + TableRef::new(schema.map(str::to_owned), name) + } + + fn columns(tables: Vec) -> Option { + Some(CompletionScope::Columns { tables }) + } + + fn general(tables: Vec) -> Option { + Some(CompletionScope::General { + tables_in_statement: tables, + }) + } + + const OBJECTS: Option = Some(CompletionScope::Objects { schema: None }); + + #[test] + fn after_from_suggests_objects() { + assert_eq!(scope("SELECT * FROM |"), OBJECTS); + assert_eq!(scope("SELECT * FROM us|"), OBJECTS); + assert_eq!(scope("select * from users u join |"), OBJECTS); + assert_eq!(scope("SELECT * FROM users u, |"), OBJECTS); + assert_eq!(scope("INSERT INTO |"), OBJECTS); + assert_eq!(scope("UPDATE |"), OBJECTS); + } + + #[test] + fn alias_dot_suggests_that_tables_columns() { + assert_eq!(scope("SELECT u.| FROM users u"), columns(vec![table(None, "users")])); + assert_eq!( + scope("SELECT u.na| FROM public.users AS u"), + columns(vec![table(Some("public"), "users")]) + ); + assert_eq!(scope("SELECT users.| FROM users"), columns(vec![table(None, "users")])); + assert_eq!(scope("SELECT t1.| FROM t1"), columns(vec![table(None, "t1")])); + assert_eq!( + scope_in(SqlGrammar::MsSql, "SELECT shop.dbo.users.| FROM x"), + columns(vec![table(Some("dbo"), "users")]) + ); + } + + #[test] + fn a_two_part_prefix_is_a_table_or_a_databases_schema() { + assert_eq!( + scope("SELECT public.users.| FROM x"), + Some(CompletionScope::Qualified { + qualifier: "public".to_owned(), + name: "users".to_owned() + }) + ); + assert_eq!( + scope_in(SqlGrammar::MsSql, "SELECT * FROM shop.dbo.|"), + Some(CompletionScope::Qualified { + qualifier: "shop".to_owned(), + name: "dbo".to_owned() + }) + ); + } + + #[test] + fn a_dot_after_no_name_proposes_nothing() { + assert_eq!(scope("SELECT * FROM items WHERE price > 0.|"), None); + assert_eq!(scope("SELECT * FROM items WHERE price > 0.|5"), None); + assert_eq!(scope("SELECT * FROM items WHERE price > .|"), None); + assert_eq!(scope("SELECT 'a'.|"), None); + assert_eq!(scope("SELECT (1).|"), None); + } + + #[test] + fn schema_dot_suggests_schema_objects() { + assert_eq!( + scope("SELECT * FROM sales.|"), + Some(CompletionScope::Objects { + schema: Some("sales".to_owned()) + }) + ); + assert_eq!( + scope("SELECT * FROM sales.ord|"), + Some(CompletionScope::Objects { + schema: Some("sales".to_owned()) + }) + ); + } + + #[test] + fn quoted_alias_is_unquoted() { + assert_eq!( + scope("SELECT \"Đơn\".| FROM \"Order \"\"Items\"\"\" AS \"Đơn\""), + columns(vec![table(None, "Order \"Items\"")]) + ); + assert_eq!( + scope_in(SqlGrammar::MySql, "SELECT o.| FROM `shop`.`order items` o"), + columns(vec![table(Some("shop"), "order items")]) + ); + assert_eq!( + scope_in(SqlGrammar::MsSql, "SELECT o.| FROM [dbo].[order]]s] o"), + columns(vec![table(Some("dbo"), "order]s")]) + ); + } + + #[test] + fn join_aliases_collected() { + let text = "SELECT | FROM users u JOIN sales.orders o ON o.user_id = u.id LEFT JOIN items ON true"; + assert_eq!( + scope(text), + general(vec![ + table(None, "users"), + table(Some("sales"), "orders"), + table(None, "items") + ]) + ); + assert_eq!( + scope("SELECT * FROM users u JOIN orders o ON o.| = u.id"), + columns(vec![table(None, "orders")]) + ); + assert_eq!( + scope("SELECT * FROM users U WHERE u.|"), + columns(vec![table(None, "users")]), + "an unquoted alias is matched without case" + ); + } + + #[test] + fn dollar_quoted_text_is_not_analyzed() { + assert_eq!(scope("SELECT $$ FROM $$ |"), general(Vec::new())); + assert_eq!(scope("SELECT $$ FROM |$$"), None); + assert_eq!(scope("SELECT $$ FROM |"), None); + assert_eq!( + scope("CREATE FUNCTION f() RETURNS int AS $body$ SELECT * FROM | $body$ LANGUAGE sql"), + None + ); + } + + #[test] + fn strings_and_comments_are_not_completed() { + assert_eq!(scope("SELECT 'FROM |'"), None); + assert_eq!(scope("SELECT 1 -- FROM |"), None); + assert_eq!(scope("SELECT /* FROM | */ 1"), None); + assert_eq!(scope("SELECT \"us|"), None); + assert_eq!(scope("SELECT 'a' |"), general(Vec::new())); + } + + #[test] + fn a_new_statement_starts_after_the_terminator() { + assert_eq!(scope("SELECT * FROM users;\n|"), general(Vec::new())); + assert_eq!(scope("SELECT * FROM users;\nsel|"), general(Vec::new())); + assert_eq!( + scope("SELECT * FROM users u WHERE |;\nSELECT 2"), + general(vec![table(None, "users")]) + ); + } + + #[test] + fn a_comma_outside_from_is_not_a_table_list() { + assert_eq!(scope("SELECT a, | FROM users"), general(vec![table(None, "users")])); + assert_eq!( + scope("SELECT * FROM users WHERE a IN (1, |)"), + general(vec![table(None, "users")]) + ); + } + + #[test] + fn a_from_after_distinct_or_inside_a_call_names_no_table() { + let orders = || general(vec![table(None, "orders")]); + assert_eq!(scope("SELECT * FROM orders WHERE a IS DISTINCT FROM |"), orders()); + assert_eq!(scope("SELECT * FROM orders WHERE a IS NOT DISTINCT FROM |"), orders()); + assert_eq!(scope("SELECT EXTRACT(YEAR FROM |) FROM orders"), orders()); + assert_eq!(scope("SELECT SUBSTRING(name FROM |) FROM orders"), orders()); + assert_eq!(scope("SELECT TRIM(BOTH ' ' FROM |) FROM orders"), orders()); + assert_eq!(scope("SELECT TRIM(FROM |) FROM orders"), orders()); + assert_eq!( + scope("SELECT EXTRACT(YEAR FROM created_at) FROM orders WHERE |"), + orders(), + "a call's argument was taken for a table" + ); + assert_eq!(scope("SELECT * FROM orders WHERE id IN (SELECT id FROM |"), OBJECTS); + assert_eq!(scope("SELECT EXISTS(SELECT 1 FROM |"), OBJECTS); + assert_eq!(scope("WITH gone AS (DELETE FROM |"), OBJECTS); + } + + #[test] + fn a_call_or_subquery_in_from_is_not_a_table() { + assert_eq!( + scope("SELECT | FROM generate_series(1, 3) g, users"), + general(vec![table(None, "users")]) + ); + assert_eq!( + scope("SELECT | FROM (SELECT * FROM orders) o, users"), + general(vec![table(None, "users"), table(None, "orders")]) + ); + assert_eq!(scope("INSERT INTO users (|"), general(vec![table(None, "users")])); + } + + #[test] + fn a_multibyte_cursor_does_not_split_a_character() { + let text = "SELECT * FROM đơn"; + let inside_a_character = text.len() - 2; + + let scope = analyze( + text, + inside_a_character, + SqlGrammar::PostgreSql, + LexicalSettings::default_for(SqlGrammar::PostgreSql), + ); + + assert_eq!(scope, OBJECTS); + } +} diff --git a/linux/crates/app/src/ui/editor/completion/fuzzy.rs b/linux/crates/app/src/ui/editor/completion/fuzzy.rs new file mode 100644 index 0000000000..1fd06c19ea --- /dev/null +++ b/linux/crates/app/src/ui/editor/completion/fuzzy.rs @@ -0,0 +1,151 @@ +//! GtkSourceView's fuzzy ranking, over characters rather than bytes. +//! +//! `gtk_source_completion_fuzzy_match` looks each needle character up +//! with `strchr`, so a needle with a character outside ASCII never +//! matches: "đơn" does not find "đơn_hàng". This keeps its scoring and +//! compares whole characters. + +use std::ops::Range; + +use gtk4::glib; +use gtk4::pango; + +/// The needle in the form the matcher compares against. +pub fn casefold(needle: &str) -> String { + glib::casefold(needle).into() +} + +/// How far `haystack` is from `casefold_needle`, lower being closer, or +/// `None` when it does not match. +pub fn fuzzy_match(haystack: &str, casefold_needle: &str) -> Option { + matched(haystack, casefold_needle).map(|found| found.score) +} + +/// Bold for the characters of `haystack` the needle matched. +pub fn highlight(haystack: &str, casefold_needle: &str) -> Option { + let found = matched(haystack, casefold_needle)?; + if found.runs.is_empty() { + return None; + } + let attrs = pango::AttrList::new(); + for run in found.runs { + let mut bold = pango::AttrInt::new_weight(pango::Weight::Bold); + bold.set_start_index(u32::try_from(run.start).unwrap_or(u32::MAX)); + bold.set_end_index(u32::try_from(run.end).unwrap_or(u32::MAX)); + attrs.insert(bold); + } + Some(attrs) +} + +struct Match { + score: u32, + /// Byte ranges of the matched characters, adjacent ones joined. + runs: Vec>, +} + +/// Each needle character at its first place after the last one. A +/// character skipped costs two, a match only by case one, and every +/// character left at the end one, so a prefix ranks first. +fn matched(haystack: &str, casefold_needle: &str) -> Option { + if haystack.is_empty() { + return None; + } + let mut chars = haystack.char_indices(); + let mut score: u32 = 0; + let mut runs: Vec> = Vec::new(); + for wanted in casefold_needle.chars() { + let mut skipped: u32 = 0; + let (at, found) = loop { + let (at, found) = chars.next()?; + if same_letter(found, wanted) { + break (at, found); + } + skipped += 1; + }; + score = score.saturating_add(skipped.saturating_mul(2)); + if found != wanted { + score = score.saturating_add(1); + } + let end = at + found.len_utf8(); + match runs.last_mut() { + Some(run) if run.end == at => run.end = end, + _ => runs.push(at..end), + } + } + let rest = u32::try_from(chars.count()).unwrap_or(u32::MAX); + Some(Match { + score: score.saturating_add(rest), + runs, + }) +} + +fn same_letter(found: char, wanted: char) -> bool { + found == wanted || found.to_lowercase().eq(wanted.to_lowercase()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn fuzzy_match_ranks_prefix_higher() { + let needle = casefold("us"); + + let prefix = fuzzy_match("users", &needle).expect("a match"); + let scattered = fuzzy_match("product_status", &needle).expect("a match"); + + assert!(prefix < scattered, "{prefix} is not closer than {scattered}"); + assert_eq!(fuzzy_match("orders", &needle), None); + assert_eq!(fuzzy_match("su", &needle), None, "the order of the letters was ignored"); + } + + #[test] + fn an_exact_case_match_ranks_above_one_by_case() { + let needle = casefold("id"); + + assert!(fuzzy_match("id", &needle) < fuzzy_match("ID", &needle)); + } + + #[test] + fn non_ascii_needle_is_casefolded() { + let needle = casefold("ĐƠN"); + + assert_eq!(needle, "đơn"); + assert!(fuzzy_match("đơn_hàng", &needle).is_some()); + assert!(fuzzy_match("ĐƠN_HÀNG", &needle).is_some()); + assert_eq!(fuzzy_match("don_hang", &needle), None); + } + + #[test] + fn an_empty_needle_matches_everything() { + assert!(fuzzy_match("users", "").is_some()); + assert_eq!(fuzzy_match("", ""), None); + } + + #[test] + fn highlight_returns_attrs() { + let attrs = highlight("users", &casefold("US")).expect("an attribute list"); + + let runs: Vec<(u32, u32)> = attrs + .attributes() + .iter() + .map(|attr| (attr.start_index(), attr.end_index())) + .collect(); + assert_eq!(runs, [(0, 2)]); + } + + #[test] + fn highlight_marks_whole_multibyte_characters() { + let attrs = highlight("bảng_đơn", &casefold("đn")).expect("an attribute list"); + + let runs: Vec<(u32, u32)> = attrs + .attributes() + .iter() + .map(|attr| (attr.start_index(), attr.end_index())) + .collect(); + let text = "bảng_đơn"; + let d = u32::try_from(text.find('đ').expect("đ")).expect("a short text"); + let n = u32::try_from(text.rfind('n').expect("n")).expect("a short text"); + assert_eq!(runs, [(d, d + 2), (n, n + 1)]); + } +} diff --git a/linux/crates/app/src/ui/editor/completion/mod.rs b/linux/crates/app/src/ui/editor/completion/mod.rs new file mode 100644 index 0000000000..d0aa9e02a2 --- /dev/null +++ b/linux/crates/app/src/ui/editor/completion/mod.rs @@ -0,0 +1,14 @@ +//! Completion in the SQL editor from the session's schema catalog. + +mod catalog_read; +mod completion_scope; +mod fuzzy; +mod proposal_kind; +mod sql_completion_provider; +mod sql_completion_provider_imp; +mod sql_proposal; +mod sql_proposal_imp; +#[cfg(test)] +mod tests; + +pub use sql_completion_provider::SqlCompletionProvider; diff --git a/linux/crates/app/src/ui/editor/completion/proposal_kind.rs b/linux/crates/app/src/ui/editor/completion/proposal_kind.rs new file mode 100644 index 0000000000..cbc4b2b4b3 --- /dev/null +++ b/linux/crates/app/src/ui/editor/completion/proposal_kind.rs @@ -0,0 +1,88 @@ +use tablepro_core::TableRef; +use tablepro_core::meta::ObjectKind; + +/// What a proposal names. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ProposalKind { + Keyword, + Schema, + Table, + View, + MaterializedView, + Column { data_type: String, table: TableRef }, + Function, +} + +impl ProposalKind { + /// The proposal for a catalog object, or `None` for one no + /// statement names by itself, such as a sequence, or a kind added + /// after this list. + pub fn for_object(kind: ObjectKind) -> Option { + match kind { + ObjectKind::Table | ObjectKind::PartitionedTable | ObjectKind::ForeignTable | ObjectKind::Dictionary => { + Some(Self::Table) + } + ObjectKind::View => Some(Self::View), + ObjectKind::MaterializedView => Some(Self::MaterializedView), + ObjectKind::Function | ObjectKind::Procedure => Some(Self::Function), + _ => None, + } + } + + /// A column shows its type instead of an icon, and a keyword is + /// plain text. + pub fn icon_name(&self) -> Option<&'static str> { + match self { + Self::Schema => Some(crate::ui::icons::FOLDER), + Self::Table => Some(crate::ui::icons::VIEW_LIST), + Self::View | Self::MaterializedView => Some(crate::ui::icons::VIEW_REVEAL), + Self::Function => Some(crate::ui::icons::SYSTEM_RUN), + Self::Keyword | Self::Column { .. } => None, + } + } + + /// The order of two proposals that match equally well: what the + /// statement already names comes before what it might. + pub fn rank(&self) -> u8 { + match self { + Self::Column { .. } => 0, + Self::Table | Self::View | Self::MaterializedView => 1, + Self::Schema => 2, + Self::Function => 3, + Self::Keyword => 4, + } + } + + pub fn is_keyword(&self) -> bool { + matches!(self, Self::Keyword) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_sequence_is_not_proposed() { + assert_eq!(ProposalKind::for_object(ObjectKind::Sequence), None); + assert_eq!( + ProposalKind::for_object(ObjectKind::PartitionedTable), + Some(ProposalKind::Table) + ); + assert_eq!( + ProposalKind::for_object(ObjectKind::Procedure), + Some(ProposalKind::Function) + ); + } + + #[test] + fn columns_come_before_tables_and_keywords_last() { + let column = ProposalKind::Column { + data_type: "integer".to_owned(), + table: TableRef::unqualified("users"), + }; + + assert!(column.rank() < ProposalKind::Table.rank()); + assert!(ProposalKind::Table.rank() < ProposalKind::Keyword.rank()); + } +} diff --git a/linux/crates/app/src/ui/editor/completion/sql_completion_provider.rs b/linux/crates/app/src/ui/editor/completion/sql_completion_provider.rs new file mode 100644 index 0000000000..f3d3386d36 --- /dev/null +++ b/linux/crates/app/src/ui/editor/completion/sql_completion_provider.rs @@ -0,0 +1,261 @@ +use std::collections::HashSet; +use std::sync::Arc; + +use gtk4::glib; +use gtk4::prelude::*; +use gtk4::subclass::prelude::*; +use sourceview5::CompletionColumn; +use tablepro_core::dialect::SqlDialect; +use tablepro_core::meta::ObjectInfo; +use tablepro_core::sql_syntax::SqlGrammar; +use tablepro_core::sql_syntax::script::{LexicalSettings, text_offsets}; +use tablepro_session::Session; +use tablepro_session::runtime::Tasks; + +use super::catalog_read::{self, CatalogAnswer}; +use super::completion_scope::{self, CompletionScope}; +use super::fuzzy; +use super::proposal_kind::ProposalKind; +use super::sql_completion_provider_imp as imp; +use super::sql_proposal::SqlProposal; +use crate::services::active_session::ActiveSession; + +glib::wrapper! { + /// Completes keywords, and the tables, views and columns of the + /// session's catalog, for what the statement at the cursor asks. + pub struct SqlCompletionProvider(ObjectSubclass) + @implements sourceview5::CompletionProvider; +} + +/// How far before and after the cursor a completion reads the buffer, in +/// characters. A file of megabytes would otherwise be copied and lexed +/// on the GTK thread every time a word starts. +pub const CONTEXT_BEFORE: i32 = 128 * 1024; +pub const CONTEXT_AFTER: i32 = 32 * 1024; + +pub struct ProviderDeps { + session: ActiveSession, + tasks: Tasks, +} + +impl SqlCompletionProvider { + pub fn new(session: ActiveSession, tasks: Tasks) -> Self { + let provider: Self = glib::Object::new(); + let _ = provider.imp().deps.set(ProviderDeps { session, tasks }); + provider + } + + /// The proposals for `word`, which starts at character `word_start` + /// of `buffer`. + pub async fn proposals(&self, buffer: >k4::TextBuffer, word_start: i32, word: &str) -> gio::ListModel { + self.set_needle(word); + let store = gio::ListStore::new::(); + let model = self.sorted(&store); + let Some(request) = self.request(buffer, word_start) else { + return model; + }; + let answer = match (&request.session, self.imp().deps.get()) { + (Some(session), Some(deps)) => deps + .tasks + .spawn_task(catalog_read::read(session.clone(), request.scope.clone())) + .await + .unwrap_or_else(|failure| { + tracing::warn!(%failure, "reading the catalog for completion failed"); + CatalogAnswer::default() + }), + _ => CatalogAnswer::default(), + }; + store.extend_from_slice(&request.proposals(&answer)); + model + } + + /// Narrow the list to the word now typed. + pub fn refilter(&self, word: &str, model: &gio::ListModel) { + self.set_needle(word); + let Some(sorted) = model.downcast_ref::() else { + return; + }; + if let Some(filter) = sorted + .model() + .and_downcast::() + .and_then(|filtered| filtered.filter()) + { + filter.changed(gtk4::FilterChange::Different); + } + if let Some(sorter) = sorted.sorter() { + sorter.changed(gtk4::SorterChange::Different); + } + } + + pub(super) fn empty(&self) -> gio::ListModel { + self.sorted(&gio::ListStore::new::()) + } + + fn set_needle(&self, word: &str) { + self.imp().needle.replace(fuzzy::casefold(word)); + } + + /// The store filtered by the needle and sorted closest match first. + fn sorted(&self, store: &gio::ListStore) -> gio::ListModel { + let needle = self.imp().needle.clone(); + let filter = gtk4::CustomFilter::new(move |item| { + let Some(proposal) = item.downcast_ref::() else { + return false; + }; + let needle = needle.borrow(); + // With nothing typed the matcher ranks by length alone, + // which would put every short keyword above the columns. + let priority = if needle.is_empty() { + Some(0) + } else { + fuzzy::fuzzy_match(proposal.label(), &needle) + }; + match priority { + Some(priority) => { + proposal.set_priority(priority); + true + } + None => false, + } + }); + let sorter = + gtk4::CustomSorter::new( + |a, b| match (a.downcast_ref::(), b.downcast_ref::()) { + (Some(a), Some(b)) => a.compare(b).into(), + _ => gtk4::Ordering::Equal, + }, + ); + let filtered = gtk4::FilterListModel::new(Some(store.clone()), Some(filter)); + gtk4::SortListModel::new(Some(filtered), Some(sorter)).upcast() + } + + fn request(&self, buffer: >k4::TextBuffer, word_start: i32) -> Option { + let session = self.imp().deps.get().and_then(|deps| deps.session.get()); + let dialect = session.as_ref().and_then(|session| session.current_dialect()); + // With no connection the keywords still complete, and + // PostgreSQL's reading of the text is the strictest. + let grammar = dialect + .as_ref() + .map_or(SqlGrammar::PostgreSql, |dialect| dialect.grammar()); + let (start, end) = context_bounds(buffer, word_start); + let text = buffer.text(&start, &end, false); + let cursor = usize::try_from(word_start - start.offset()).unwrap_or_default(); + let cursor = text_offsets::char_to_byte(&text, cursor); + let scope = completion_scope::analyze(&text, cursor, grammar, LexicalSettings::default_for(grammar))?; + Some(Request { + scope, + grammar, + session, + dialect, + }) + } +} + +/// One completion's question, read on the GTK thread. +struct Request { + scope: CompletionScope, + grammar: SqlGrammar, + session: Option>, + dialect: Option>, +} + +impl Request { + fn proposals(&self, answer: &CatalogAnswer) -> Vec { + let mut proposals = Vec::new(); + let mut columns: HashSet<&str> = HashSet::new(); + for (table, list) in &answer.columns { + for column in list.iter() { + // The same name in two joined tables is one thing to type. + if !columns.insert(&column.name) { + continue; + } + let kind = ProposalKind::Column { + data_type: column.column_type.name.clone(), + table: table.clone(), + }; + proposals.push(SqlProposal::new(kind, &column.name, &self.identifier(&column.name))); + } + } + if matches!(self.scope, CompletionScope::Objects { schema: None }) { + for schema in answer.schemas.iter().flat_map(|schemas| schemas.iter()) { + if !schema.is_system { + proposals.push(SqlProposal::new( + ProposalKind::Schema, + &schema.name, + &self.identifier(&schema.name), + )); + } + } + } + let general = matches!(self.scope, CompletionScope::General { .. }); + for object in answer.objects.iter().flat_map(|objects| objects.iter()) { + if let Some(proposal) = self.object(object, general) { + proposals.push(proposal); + } + } + if general { + proposals.extend(self.grammar.keywords().map(SqlProposal::keyword)); + } + proposals + } + + /// A relation anywhere a table goes; a function only where an + /// expression can go. + fn object(&self, object: &ObjectInfo, general: bool) -> Option { + let kind = ProposalKind::for_object(object.kind)?; + if kind == ProposalKind::Function && !general { + return None; + } + Some(SqlProposal::new(kind, &object.name, &self.identifier(&object.name))) + } + + /// `name` as the connection's dialect writes it, quoted only when + /// it would read as something else bare. + fn identifier(&self, name: &str) -> String { + match &self.dialect { + Some(dialect) if !self.grammar.is_bare_identifier(name) => dialect.quote_identifier(name), + _ => name.to_owned(), + } + } +} + +/// The part of `buffer` a completion at `word_start` reads: all of an +/// ordinary buffer, and the lines around the cursor in a large one. +pub fn context_bounds(buffer: >k4::TextBuffer, word_start: i32) -> (gtk4::TextIter, gtk4::TextIter) { + let from = word_start.saturating_sub(CONTEXT_BEFORE).max(0); + let mut start = buffer.iter_at_offset(from); + let mut line = buffer.iter_at_offset(from); + // A window that opens mid-line would cut a word in two. + if from > 0 && line.forward_line() && line.offset() <= word_start { + start = line; + } + let end = buffer.iter_at_offset(word_start.saturating_add(CONTEXT_AFTER)); + (start, end) +} + +/// What a column of the list shows for `proposal`, beside its icon and +/// name. +pub fn column_text(proposal: &SqlProposal, column: CompletionColumn) -> Option { + match (proposal.kind(), column) { + (ProposalKind::Column { data_type, .. }, CompletionColumn::After) => Some(data_type.clone()), + (ProposalKind::Column { table, .. }, CompletionColumn::Comment) => Some(match &table.schema { + Some(schema) => format!("{schema}.{}", table.name), + None => table.name.clone(), + }), + _ => None, + } +} + +/// Replace the word between `begin` and `end` with `text`, as one step +/// Ctrl+Z takes back. A cursor inside a word replaces all of it. +pub fn replace_word(buffer: >k4::TextBuffer, mut begin: gtk4::TextIter, mut end: gtk4::TextIter, text: &str) { + while end.char().is_alphanumeric() || end.char() == '_' { + if !end.forward_char() { + break; + } + } + buffer.begin_user_action(); + buffer.delete(&mut begin, &mut end); + buffer.insert(&mut begin, text); + buffer.end_user_action(); +} diff --git a/linux/crates/app/src/ui/editor/completion/sql_completion_provider_imp.rs b/linux/crates/app/src/ui/editor/completion/sql_completion_provider_imp.rs new file mode 100644 index 0000000000..0c521f4dfa --- /dev/null +++ b/linux/crates/app/src/ui/editor/completion/sql_completion_provider_imp.rs @@ -0,0 +1,98 @@ +use std::cell::{OnceCell, RefCell}; +use std::future::Future; +use std::pin::Pin; +use std::rc::Rc; + +use gtk4::glib; +use gtk4::prelude::*; +use gtk4::subclass::prelude::*; +use sourceview5::subclass::prelude::*; +use sourceview5::{CompletionCell, CompletionColumn, CompletionContext, CompletionProposal}; + +use super::fuzzy; +use super::sql_completion_provider::{ProviderDeps, column_text, replace_word}; +use super::sql_proposal::SqlProposal; + +/// Ahead of the document's own words. +const PRIORITY: i32 = 100; + +#[derive(Default)] +pub struct SqlCompletionProvider { + pub deps: OnceCell, + /// The casefolded word typed so far, which the list filters on. + pub needle: Rc>, +} + +#[glib::object_subclass] +impl ObjectSubclass for SqlCompletionProvider { + const NAME: &'static str = "TableProSqlCompletionProvider"; + type Type = super::sql_completion_provider::SqlCompletionProvider; + type Interfaces = (sourceview5::CompletionProvider,); +} + +impl ObjectImpl for SqlCompletionProvider {} + +impl CompletionProviderImpl for SqlCompletionProvider { + fn title(&self) -> Option { + Some(crate::i18n::pgettext("completion group", "Database").into()) + } + + fn priority(&self, _context: &CompletionContext) -> i32 { + PRIORITY + } + + fn is_trigger(&self, _iter: >k4::TextIter, c: char) -> bool { + c == '.' + } + + fn populate_future( + &self, + context: &CompletionContext, + ) -> Pin>>> { + let provider = self.obj().clone(); + let context = context.clone(); + Box::pin(async move { + let (Some(buffer), Some((begin, _))) = (context.buffer(), context.bounds()) else { + return Ok(provider.empty()); + }; + let word = context.word(); + Ok(provider.proposals(buffer.upcast_ref(), begin.offset(), &word).await) + }) + } + + fn refilter(&self, context: &CompletionContext, model: &gio::ListModel) { + self.obj().refilter(&context.word(), model); + } + + fn display(&self, context: &CompletionContext, proposal: &CompletionProposal, cell: &CompletionCell) { + let Some(proposal) = proposal.downcast_ref::() else { + return; + }; + let word = context.word(); + match cell.column() { + CompletionColumn::Icon => match proposal.kind().icon_name() { + Some(icon) => cell.set_icon_name(icon), + None => cell.set_text(None), + }, + CompletionColumn::TypedText => { + let text = proposal.shown_text(&word); + match fuzzy::highlight(&text, &fuzzy::casefold(&word)) { + Some(attrs) => cell.set_text_with_attributes(&text, &attrs), + None => cell.set_text(Some(&text)), + } + } + column => cell.set_text(column_text(proposal, column).as_deref()), + } + } + + fn activate(&self, context: &CompletionContext, proposal: &CompletionProposal) { + let (Some(proposal), Some(buffer), Some((begin, end))) = ( + proposal.downcast_ref::(), + context.buffer(), + context.bounds(), + ) else { + return; + }; + replace_word(buffer.upcast_ref(), begin, end, &proposal.insert_text(&context.word())); + } +} diff --git a/linux/crates/app/src/ui/editor/completion/sql_proposal.rs b/linux/crates/app/src/ui/editor/completion/sql_proposal.rs new file mode 100644 index 0000000000..2480e4c85b --- /dev/null +++ b/linux/crates/app/src/ui/editor/completion/sql_proposal.rs @@ -0,0 +1,111 @@ +use std::cmp::Ordering; + +use gtk4::glib; +use gtk4::subclass::prelude::*; + +use super::proposal_kind::ProposalKind; +use super::sql_proposal_imp as imp; + +glib::wrapper! { + /// One entry of the editor's completion list. + pub struct SqlProposal(ObjectSubclass) + @implements sourceview5::CompletionProposal; +} + +impl SqlProposal { + pub fn new(kind: ProposalKind, label: &str, insert_text: &str) -> Self { + let proposal: Self = glib::Object::new(); + let imp = proposal.imp(); + let _ = imp.kind.set(kind); + let _ = imp.label.set(label.to_owned()); + let _ = imp.insert_text.set(insert_text.to_owned()); + proposal + } + + pub fn keyword(keyword: &str) -> Self { + Self::new(ProposalKind::Keyword, keyword, keyword) + } + + pub fn kind(&self) -> &ProposalKind { + static KEYWORD: ProposalKind = ProposalKind::Keyword; + self.imp().kind.get().unwrap_or(&KEYWORD) + } + + pub fn label(&self) -> &str { + self.imp().label.get().map_or("", String::as_str) + } + + /// The text the list shows while `word` is typed: a keyword follows + /// the case the user writes keywords in. + pub fn shown_text(&self, word: &str) -> String { + self.cased(self.label(), word) + } + + /// The text that replaces `word` when the proposal is chosen. + pub fn insert_text(&self, word: &str) -> String { + let text = self.imp().insert_text.get().map_or("", String::as_str); + self.cased(text, word) + } + + pub fn priority(&self) -> u32 { + self.imp().priority.get() + } + + pub fn set_priority(&self, priority: u32) { + self.imp().priority.set(priority); + } + + /// The list order: the closest match first, then what the + /// statement names before keywords. Ties keep the catalog's order, + /// which lists a table's columns as the table defines them. + pub fn compare(&self, other: &Self) -> Ordering { + self.priority() + .cmp(&other.priority()) + .then_with(|| self.kind().rank().cmp(&other.kind().rank())) + } + + fn cased(&self, text: &str, word: &str) -> String { + let lowercase = word.chars().any(char::is_lowercase) && !word.chars().any(char::is_uppercase); + if self.kind().is_keyword() && lowercase { + text.to_lowercase() + } else { + text.to_owned() + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_keyword_follows_the_case_typed() { + let select = SqlProposal::keyword("SELECT"); + + assert_eq!(select.insert_text("sel"), "select"); + assert_eq!(select.insert_text("SEL"), "SELECT"); + assert_eq!(select.insert_text("Sel"), "SELECT"); + assert_eq!(select.insert_text(""), "SELECT"); + assert_eq!(select.shown_text("se"), "select"); + } + + #[test] + fn an_identifier_keeps_its_case() { + let table = SqlProposal::new(ProposalKind::Table, "Users", "\"Users\""); + + assert_eq!(table.insert_text("us"), "\"Users\""); + assert_eq!(table.shown_text("us"), "Users"); + } + + #[test] + fn closer_matches_sort_first_then_by_kind() { + let keyword = SqlProposal::keyword("USING"); + let table = SqlProposal::new(ProposalKind::Table, "users", "users"); + keyword.set_priority(3); + table.set_priority(3); + + assert_eq!(table.compare(&keyword), Ordering::Less); + keyword.set_priority(1); + assert_eq!(table.compare(&keyword), Ordering::Greater); + } +} diff --git a/linux/crates/app/src/ui/editor/completion/sql_proposal_imp.rs b/linux/crates/app/src/ui/editor/completion/sql_proposal_imp.rs new file mode 100644 index 0000000000..dcef181459 --- /dev/null +++ b/linux/crates/app/src/ui/editor/completion/sql_proposal_imp.rs @@ -0,0 +1,27 @@ +use std::cell::{Cell, OnceCell}; + +use gtk4::glib; +use gtk4::subclass::prelude::*; +use sourceview5::subclass::prelude::*; + +use super::proposal_kind::ProposalKind; + +#[derive(Default)] +pub struct SqlProposal { + pub kind: OnceCell, + pub label: OnceCell, + pub insert_text: OnceCell, + /// The last fuzzy score, written by the filter for the sorter. + pub priority: Cell, +} + +#[glib::object_subclass] +impl ObjectSubclass for SqlProposal { + const NAME: &'static str = "TableProSqlProposal"; + type Type = super::sql_proposal::SqlProposal; + type Interfaces = (sourceview5::CompletionProposal,); +} + +impl ObjectImpl for SqlProposal {} + +impl CompletionProposalImpl for SqlProposal {} diff --git a/linux/crates/app/src/ui/editor/completion/tests.rs b/linux/crates/app/src/ui/editor/completion/tests.rs new file mode 100644 index 0000000000..4dfa4f1107 --- /dev/null +++ b/linux/crates/app/src/ui/editor/completion/tests.rs @@ -0,0 +1,565 @@ +use std::rc::Rc; +use std::time::Duration; + +use gtk4::prelude::*; +use gtk4::subclass::prelude::*; +use sourceview5::CompletionColumn; +use sourceview5::prelude::*; +use tablepro_core::meta::{ObjectInfo, ObjectKind, ObjectScope, SchemaInfo}; +use tablepro_core::{ColumnInfo, TableRef}; +use tablepro_test_support::{CallCounters, ConnectionScript, FakeReply}; + +use super::proposal_kind::ProposalKind; +use super::sql_completion_provider::{ + CONTEXT_AFTER, CONTEXT_BEFORE, SqlCompletionProvider, column_text, context_bounds, replace_word, +}; +use super::sql_proposal::SqlProposal; +use crate::services::active_session::ActiveSession; +use crate::test_support::{FakeSession, MemorySettings, descendants, finish_on_main, test_runtime, wait_until}; +use crate::ui::editor::{SqlEditor, SqlEditorInit}; + +const WAIT: Duration = Duration::from_secs(10); + +struct Harness { + provider: SqlCompletionProvider, + session: Option, + _runtime: crate::runtime::AppRuntime, +} + +impl Harness { + fn fetched_columns(&self) -> usize { + self.session + .as_ref() + .map_or(0, |fake| CallCounters::get(&fake.connection.counters().fetch_columns)) + } + + fn listed_objects(&self) -> usize { + self.session + .as_ref() + .map_or(0, |fake| CallCounters::get(&fake.connection.counters().list_objects)) + } +} + +fn object(name: &str, kind: ObjectKind) -> ObjectInfo { + ObjectInfo { + schema: Some("public".to_owned()), + name: name.to_owned(), + kind, + } +} + +fn column(name: &str, data_type: &str) -> ColumnInfo { + ColumnInfo { + name: name.to_owned(), + column_type: tablepro_core::column::ColumnType::untyped_text(data_type), + nullable: true, + primary_key: false, + is_auto_increment: false, + is_generated: false, + default: tablepro_core::column::ColumnDefault::None, + comment: None, + } +} + +fn schema(name: &str, is_default: bool) -> SchemaInfo { + SchemaInfo { + name: name.to_owned(), + is_default, + is_system: false, + } +} + +/// A provider on a session whose default schema holds `objects`. +fn connected(script: ConnectionScript, objects: Vec) -> Harness { + connected_with(script, Vec::new(), objects) +} + +/// A provider on a session that has `schemas`, and whose default +/// schema holds `objects`. +fn connected_with(script: ConnectionScript, schemas: Vec, objects: Vec) -> Harness { + let runtime = test_runtime(); + let fake = FakeSession::start(script, [], runtime.tasks()); + fake.session.catalog().commit(schemas, objects); + let active = ActiveSession::new(); + active.replace(fake.session.clone()); + Harness { + provider: SqlCompletionProvider::new(active, runtime.tasks()), + session: Some(fake), + _runtime: runtime, + } +} + +fn unconnected() -> Harness { + let runtime = test_runtime(); + Harness { + provider: SqlCompletionProvider::new(ActiveSession::new(), runtime.tasks()), + session: None, + _runtime: runtime, + } +} + +/// The word before the `|` in `marked`, and where it starts. +fn word_before(marked: &str) -> (String, i32, String) { + let cursor = marked.find('|').expect("a cursor mark"); + let text = marked.replacen('|', "", 1); + let word: String = marked[..cursor] + .chars() + .rev() + .take_while(|c| c.is_alphanumeric() || *c == '_') + .collect::>() + .into_iter() + .rev() + .collect(); + let start = marked[..cursor].chars().count() - word.chars().count(); + (text, i32::try_from(start).expect("a short text"), word) +} + +fn proposals(harness: &Harness, marked: &str) -> Vec { + let (text, start, word) = word_before(marked); + let buffer = gtk4::TextBuffer::new(None); + buffer.set_text(&text); + let provider = harness.provider.clone(); + let model = + finish_on_main(WAIT, async move { provider.proposals(&buffer, start, &word).await }).expect("the proposals"); + (0..model.n_items()) + .filter_map(|index| model.item(index).and_downcast::()) + .collect() +} + +fn labels(proposals: &[SqlProposal]) -> Vec<&str> { + proposals.iter().map(SqlProposal::label).collect() +} + +fn find<'a>(proposals: &'a [SqlProposal], label: &str) -> &'a SqlProposal { + proposals + .iter() + .find(|proposal| proposal.label() == label) + .unwrap_or_else(|| panic!("no proposal {label} in {:?}", labels(proposals))) +} + +#[gtk4::test] +fn populate_after_from_contains_users() { + let harness = connected( + ConnectionScript::default(), + vec![ + object("users", ObjectKind::Table), + object("active_users", ObjectKind::View), + object("users_seq", ObjectKind::Sequence), + object("refresh_users", ObjectKind::Function), + object("orders", ObjectKind::Table), + ], + ); + + let found = proposals(&harness, "SELECT * FROM us|"); + + assert_eq!(labels(&found), ["users", "active_users"]); + assert_eq!(find(&found, "active_users").kind(), &ProposalKind::View); +} + +#[gtk4::test] +fn populate_alias_dot_lists_columns_with_types() { + let mut script = ConnectionScript::default(); + script.fetch_columns.when_sql_contains( + "users", + FakeReply::ok(vec![column("id", "integer"), column("email", "text")]), + ); + let harness = connected(script, vec![object("users", ObjectKind::Table)]); + + let found = proposals(&harness, "SELECT u.| FROM users u"); + + assert_eq!(labels(&found), ["id", "email"]); + let id = find(&found, "id"); + assert_eq!( + id.kind(), + &ProposalKind::Column { + data_type: "integer".to_owned(), + table: TableRef::new(Some("public".to_owned()), "users"), + }, + "the column was read under a name the catalog does not share" + ); + assert_eq!(column_text(id, CompletionColumn::After).as_deref(), Some("integer")); + assert_eq!( + column_text(id, CompletionColumn::Comment).as_deref(), + Some("public.users") + ); +} + +#[gtk4::test] +fn general_scope_offers_the_statements_columns_and_keywords() { + let mut script = ConnectionScript::default(); + script + .fetch_columns + .when_sql_contains("users", FakeReply::ok(vec![column("name", "text")])); + let harness = connected(script, vec![object("users", ObjectKind::Table)]); + + let found = proposals(&harness, "SELECT | FROM users"); + + assert!(labels(&found).contains(&"name")); + assert!(labels(&found).contains(&"users")); + assert!(labels(&found).contains(&"WHERE")); + assert_eq!(labels(&found).first(), Some(&"name"), "a column is not first"); +} + +#[gtk4::test] +fn keyword_case_follows_prefix() { + let harness = unconnected(); + + for (typed, expected) in [("sel", "select"), ("SEL", "SELECT")] { + let found = proposals(&harness, &format!("{typed}|")); + let select = find(&found, "SELECT"); + let buffer = gtk4::TextBuffer::new(None); + buffer.set_text(typed); + + replace_word( + &buffer, + buffer.start_iter(), + buffer.end_iter(), + &select.insert_text(typed), + ); + + let (start, end) = buffer.bounds(); + assert_eq!(buffer.text(&start, &end, false), expected); + assert_eq!(select.shown_text(typed), expected); + } +} + +#[gtk4::test] +fn activate_replaces_word_as_one_undo_step() { + let buffer = sourceview5::Buffer::new(None); + buffer.begin_irreversible_action(); + buffer.set_text("SELECT * FROM us WHERE true"); + buffer.end_irreversible_action(); + let text = |buffer: &sourceview5::Buffer| { + let (start, end) = buffer.bounds(); + buffer.text(&start, &end, false).to_string() + }; + + replace_word( + buffer.upcast_ref(), + buffer.iter_at_offset(14), + buffer.iter_at_offset(16), + "users", + ); + assert_eq!(text(&buffer), "SELECT * FROM users WHERE true"); + + buffer.undo(); + assert_eq!(text(&buffer), "SELECT * FROM us WHERE true"); + assert!(!buffer.can_undo(), "the completion took more than one undo step"); +} + +#[gtk4::test] +fn a_cursor_inside_a_word_replaces_all_of_it() { + let buffer = gtk4::TextBuffer::new(None); + buffer.set_text("SELECT * FROM usrs"); + + replace_word(&buffer, buffer.iter_at_offset(14), buffer.iter_at_offset(16), "users"); + + let (start, end) = buffer.bounds(); + assert_eq!(buffer.text(&start, &end, false), "SELECT * FROM users"); +} + +#[gtk4::test] +fn identifier_needing_quotes_is_quoted_by_dialect() { + let harness = connected( + ConnectionScript::default(), + vec![ + object("Order Items", ObjectKind::Table), + object("users", ObjectKind::Table), + object("to", ObjectKind::Table), + ], + ); + + let found = proposals(&harness, "SELECT * FROM |"); + + assert_eq!(find(&found, "Order Items").insert_text(""), "\"Order Items\""); + assert_eq!(find(&found, "users").insert_text(""), "users"); + assert_eq!( + find(&found, "to").insert_text(""), + "\"to\"", + "a reserved word the editor does not complete went in bare" + ); +} + +#[gtk4::test] +fn a_dot_in_a_number_proposes_nothing() { + let harness = connected(ConnectionScript::default(), vec![object("users", ObjectKind::Table)]); + + assert!(proposals(&harness, "SELECT * FROM users WHERE price > 0.|").is_empty()); +} + +#[gtk4::test] +fn a_schema_prefix_is_matched_without_case() { + let mut script = ConnectionScript::default(); + script.list_objects.always(FakeReply::ok(vec![ObjectInfo { + schema: Some("sales".to_owned()), + name: "orders".to_owned(), + kind: ObjectKind::Table, + }])); + let harness = connected_with( + script, + vec![schema("public", true), schema("sales", false)], + vec![object("users", ObjectKind::Table)], + ); + + let found = proposals(&harness, "SELECT * FROM Sales.|"); + + assert_eq!(labels(&found), ["orders"]); + let session = &harness.session.as_ref().expect("a session").session; + let listed = session.subscribe_objects(ObjectScope::Schema("sales".to_owned())); + assert_eq!( + listed.borrow().objects.len(), + 1, + "the schema was read under the name as typed" + ); +} + +#[gtk4::test] +fn a_prefix_that_names_no_schema_reads_nothing() { + let harness = connected_with( + ConnectionScript::default(), + vec![schema("public", true)], + vec![object("users", ObjectKind::Table)], + ); + + let listed = harness.listed_objects(); + + let found = proposals(&harness, "SELECT * FROM hr.|"); + + assert!(found.is_empty()); + assert_eq!(harness.listed_objects(), listed); +} + +#[gtk4::test] +fn names_the_catalog_does_not_hold_are_not_read() { + let mut script = ConnectionScript::default(); + script + .fetch_columns + .always(FakeReply::ok(vec![column("id", "integer")])); + let harness = connected(script, vec![object("users", ObjectKind::Table)]); + let fetched = harness.fetched_columns(); + + let found = proposals( + &harness, + "WITH recent AS (SELECT * FROM users) SELECT | FROM recent r, (SELECT 1) s WHERE EXTRACT(YEAR FROM r.at) > 0", + ); + + assert!(labels(&found).contains(&"id")); + assert_eq!( + harness.fetched_columns() - fetched, + 1, + "a name the catalog does not hold was read" + ); +} + +#[gtk4::test] +fn a_database_prefix_lists_the_schemas_objects() { + let mut script = ConnectionScript::default(); + script.list_objects.always(FakeReply::ok(vec![ObjectInfo { + schema: Some("sales".to_owned()), + name: "invoices".to_owned(), + kind: ObjectKind::Table, + }])); + let harness = connected_with( + script, + vec![schema("dbo", true), schema("sales", false)], + vec![object("users", ObjectKind::Table)], + ); + let fetched = harness.fetched_columns(); + + let found = proposals(&harness, "SELECT * FROM shop.sales.|"); + + assert_eq!(labels(&found), ["invoices"]); + assert_eq!(harness.fetched_columns(), fetched, "the schema was read as a table"); +} + +#[gtk4::test] +fn a_schema_and_table_prefix_lists_the_tables_columns() { + let mut script = ConnectionScript::default(); + script.list_objects.always(FakeReply::ok(vec![ObjectInfo { + schema: Some("sales".to_owned()), + name: "invoices".to_owned(), + kind: ObjectKind::Table, + }])); + script + .fetch_columns + .when_sql_contains("invoices", FakeReply::ok(vec![column("total", "numeric")])); + let harness = connected_with( + script, + vec![schema("public", true), schema("sales", false)], + vec![object("users", ObjectKind::Table)], + ); + + let found = proposals(&harness, "SELECT sales.invoices.| FROM x"); + + assert_eq!(labels(&found), ["total"]); +} + +#[gtk4::test] +fn a_large_buffer_is_read_only_near_the_cursor() { + let buffer = gtk4::TextBuffer::new(None); + buffer.set_text(&format!("{}SELECT * FROM us", "SELECT 1;\n".repeat(30_000))); + let word_start = buffer.char_count() - 2; + + let (start, end) = context_bounds(&buffer, word_start); + + assert!(start.starts_line(), "the window opened mid-line"); + assert!(word_start - start.offset() <= CONTEXT_BEFORE); + assert!(end.is_end()); + let (start, end) = context_bounds(&buffer, 5); + assert!(start.is_start()); + assert_eq!(end.offset(), 5 + CONTEXT_AFTER); + let small = gtk4::TextBuffer::new(None); + small.set_text("SELECT * FROM us"); + let (start, end) = context_bounds(&small, 14); + assert!(start.is_start() && end.is_end(), "a small buffer was not read whole"); +} + +#[gtk4::test] +fn a_completion_at_the_end_of_a_large_buffer_still_finds_tables() { + let harness = connected(ConnectionScript::default(), vec![object("users", ObjectKind::Table)]); + let marked = format!("{}SELECT * FROM us|", "SELECT 1;\n".repeat(30_000)); + + let found = proposals(&harness, &marked); + + assert_eq!(labels(&found), ["users"]); +} + +#[gtk4::test] +fn nothing_is_proposed_inside_a_string() { + let harness = connected(ConnectionScript::default(), vec![object("users", ObjectKind::Table)]); + + assert!(proposals(&harness, "SELECT 'us|").is_empty()); +} + +#[gtk4::test] +fn refilter_narrows_the_list_without_reading_again() { + let harness = connected( + ConnectionScript::default(), + vec![object("users", ObjectKind::Table), object("orders", ObjectKind::Table)], + ); + let (text, start, word) = word_before("SELECT * FROM |"); + let buffer = gtk4::TextBuffer::new(None); + buffer.set_text(&text); + let provider = harness.provider.clone(); + let model = + finish_on_main(WAIT, async move { provider.proposals(&buffer, start, &word).await }).expect("the proposals"); + assert_eq!(model.n_items(), 2); + + harness.provider.refilter("ord", &model); + + assert_eq!(model.n_items(), 1); + assert_eq!( + model + .item(0) + .and_downcast::() + .map(|proposal| proposal.label().to_owned()), + Some("orders".to_owned()) + ); + harness.provider.refilter("", &model); + assert_eq!(model.n_items(), 2); +} + +/// The texts of the completion list GtkSourceView shows for `view`. +fn shown(view: &sourceview5::View) -> Vec { + descendants(view) + .into_iter() + .filter_map(|widget| widget.downcast::().ok()) + .filter_map(|cell| cell.text()) + .map(|text| text.to_string()) + .collect() +} + +/// An editor in a window on screen, with the focus in its text. +struct OnScreen { + editor: SqlEditor, + window: gtk4::Window, + _session: FakeSession, + _settings: MemorySettings, + _runtime: crate::runtime::AppRuntime, +} + +impl OnScreen { + fn new(script: ConnectionScript, objects: Vec, text: &str) -> Self { + crate::register_test_resources(); + let runtime = test_runtime(); + let settings = MemorySettings::new(); + let fake = FakeSession::start(script, [], runtime.tasks()); + fake.session.catalog().commit(Vec::new(), objects); + let active = ActiveSession::new(); + active.replace(fake.session.clone()); + let editor = SqlEditor::new(SqlEditorInit { + initial_query: Some(text.to_owned()), + settings: settings.get().clone(), + history: None, + tasks: runtime.tasks(), + session: active, + hold: Rc::new(tablepro_session::runtime::CountingHold::new()), + close_timeout: Duration::from_secs(5), + }); + let window = gtk4::Window::new(); + window.set_child(Some(&editor)); + window.present(); + let view = editor.imp().source_view.get(); + view.grab_focus(); + // GtkSourceView starts no completion in a view that is not on + // screen with the focus. + wait_until(WAIT, || view.is_mapped() && view.has_focus()).expect("the view on screen with the focus"); + Self { + editor, + window, + _session: fake, + _settings: settings, + _runtime: runtime, + } + } + + fn view(&self) -> sourceview5::View { + self.editor.imp().source_view.get() + } + + /// Wait for the completion list to show `text`, and say what it + /// showed when it did not. + fn wait_for(&self, text: &str) -> Result<(), Vec> { + let view = self.view(); + wait_until(WAIT, || shown(&view).iter().any(|shown| shown == text)).map_err(|_| shown(&view)) + } +} + +impl Drop for OnScreen { + fn drop(&mut self) { + crate::test_support::drop_focus(&self.window); + self.window.destroy(); + } +} + +#[gtk4::test] +fn the_editor_shows_catalog_tables_in_its_completion() { + let screen = OnScreen::new( + ConnectionScript::default(), + vec![object("users", ObjectKind::Table)], + "SELECT * FROM us", + ); + let buffer = screen.view().buffer(); + buffer.place_cursor(&buffer.end_iter()); + + screen.view().completion().show(); + + screen.wait_for("users").expect("users in the list"); +} + +#[gtk4::test] +fn typing_a_dot_after_an_alias_lists_its_columns_with_types() { + let mut script = ConnectionScript::default(); + script + .fetch_columns + .when_sql_contains("users", FakeReply::ok(vec![column("id", "integer")])); + let screen = OnScreen::new(script, vec![object("users", ObjectKind::Table)], "SELECT FROM users u"); + let buffer = screen.view().buffer(); + buffer.place_cursor(&buffer.iter_at_offset(7)); + + buffer.insert_interactive_at_cursor("u", true); + buffer.insert_interactive_at_cursor(".", true); + + screen.wait_for("id").expect("the alias's column in the list"); + screen.wait_for("integer").expect("the column's type beside it"); +} diff --git a/linux/crates/app/src/ui/editor/editor_actions.rs b/linux/crates/app/src/ui/editor/editor_actions.rs new file mode 100644 index 0000000000..e7a6146ecf --- /dev/null +++ b/linux/crates/app/src/ui/editor/editor_actions.rs @@ -0,0 +1,113 @@ +use gtk4::gio; +use gtk4::prelude::*; +use gtk4::subclass::prelude::*; + +use super::find_bar::Direction; +use super::{SqlEditor, toggle_line_comment}; +use crate::application::{ShortcutOwner, local_shortcut_controller}; + +impl SqlEditor { + /// The tab's `editor` group, the source view's keys for it, and the + /// buttons' accessible keys. + pub(super) fn install_actions(&self) { + let actions = gio::SimpleActionGroup::new(); + let entry = |name: &'static str, act: fn(&SqlEditor)| { + let weak = self.downgrade(); + gio::ActionEntry::::builder(name) + .activate(move |_, _, _| { + if let Some(editor) = weak.upgrade() { + act(&editor); + } + }) + .build() + }; + actions.add_action_entries([ + entry("open-file", SqlEditor::open_file), + entry("save", SqlEditor::save), + entry("save-as", SqlEditor::save_as), + entry("run", SqlEditor::run), + entry("run-statement", SqlEditor::run_at_cursor), + entry("cancel", SqlEditor::close_popup_or_stop), + entry("format", SqlEditor::format), + entry("toggle-comment", |editor| toggle_line_comment(&editor.buffer())), + entry("find", |editor| editor.imp().find_bar.open(false)), + entry("find-replace", |editor| editor.imp().find_bar.open(true)), + entry("find-next", |editor| editor.imp().find_bar.find(Direction::Next)), + entry("find-previous", |editor| { + editor.imp().find_bar.find(Direction::Previous) + }), + ]); + set_cancel_enabled(&actions, false); + self.insert_action_group("editor", Some(&actions)); + self.connect_run_state_changed(move |_, running| set_cancel_enabled(&actions, running)); + + let imp = self.imp(); + imp.source_view + .add_controller(local_shortcut_controller(ShortcutOwner::SqlEditor)); + // On the tab, so the find keys work from the find bar's entries too. + self.add_controller(local_shortcut_controller(ShortcutOwner::SqlEditorTab)); + crate::ui::a11y::announce_key_shortcuts(&imp.run_button.get(), "editor.run"); + crate::ui::a11y::announce_key_shortcuts(&imp.stop_button.get(), "editor.cancel"); + crate::ui::a11y::announce_key_shortcuts( + imp.source_view.upcast_ref::(), + "paned.cycle-child-focus", + ); + } +} + +impl SqlEditor { + /// Escape while a query runs. The stop key's controller runs before + /// the view's own, so a popup the view has open, such as the + /// completion list, is closed here first, as the view's Escape would. + fn close_popup_or_stop(&self) { + if !close_view_popups(&self.imp().source_view.get()) { + self.stop(); + } + } +} + +/// GtkSourceView parents its completion list and other assistants to +/// the view as popovers, and its Escape hides every visible one. +fn close_view_popups(view: &impl IsA) -> bool { + let mut closed = false; + let mut child = view.as_ref().first_child(); + while let Some(widget) = child { + child = widget.next_sibling(); + if widget.is::() && widget.is_visible() { + widget.set_visible(false); + closed = true; + } + } + closed +} + +/// Off while nothing runs, so Escape goes on to the view and its +/// completion popup. +fn set_cancel_enabled(actions: &gio::SimpleActionGroup, enabled: bool) { + if let Some(cancel) = actions.lookup_action("cancel").and_downcast::() { + cancel.set_enabled(enabled); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// GtkSourceView's completion list is a popover child of the view, + /// which GtkTextView will not take from a test; any widget holds + /// one the same way. + #[gtk4::test] + fn escape_closes_a_shown_popup_and_says_so() { + let parent = gtk4::Box::new(gtk4::Orientation::Vertical, 0); + let _window = crate::test_support::OwnedWindow::presented(gtk4::Window::builder().child(&parent).build()); + let popup = gtk4::Popover::new(); + popup.set_parent(&parent); + assert!(!close_view_popups(&parent), "nothing was open"); + + popup.set_visible(true); + + assert!(close_view_popups(&parent)); + assert!(!popup.is_visible()); + popup.unparent(); + } +} diff --git a/linux/crates/app/src/ui/editor/editor_file.rs b/linux/crates/app/src/ui/editor/editor_file.rs new file mode 100644 index 0000000000..28d60b19cb --- /dev/null +++ b/linux/crates/app/src/ui/editor/editor_file.rs @@ -0,0 +1,197 @@ +//! The `.sql` file an editor tab is opened from or saved to. + +use gtk4::prelude::*; +use gtk4::subclass::prelude::*; +use gtk4::{gio, glib}; +use libadwaita as adw; +use libadwaita::prelude::*; + +use super::{SqlEditor, buffer_text, export_name_for_query}; + +impl SqlEditor { + /// The file the tab's text is saved to, once it was opened from or + /// saved to one. + pub(crate) fn file(&self) -> Option { + self.imp().file.borrow().clone() + } + + /// Whether the text differs from what the tab's file last held. + pub(crate) fn has_unsaved_file_changes(&self) -> bool { + self.imp().file.borrow().is_some() && self.buffer().is_modified() + } + + /// Point a restored tab at its file again, with its text changed + /// since the last save or not. + pub(crate) fn restore_file(&self, file: gio::File, modified: bool) { + self.imp().file.replace(Some(file)); + self.buffer().set_modified(modified); + self.emit_by_name::<()>("file-changed", &[]); + } + + /// A file or its changed state changed. + pub(crate) fn connect_file_changed(&self, f: F) -> glib::SignalHandlerId { + self.connect_closure( + "file-changed", + false, + glib::closure_local!(move |editor: &Self| f(editor)), + ) + } + + /// The buffer's own modified flag says whether the text changed + /// since the file was read or written; Undo back to that point + /// clears it again. + pub(super) fn follow_file_state(&self, buffer: &sourceview5::Buffer) { + let weak = self.downgrade(); + buffer.connect_modified_changed(move |_| { + if let Some(editor) = weak.upgrade() + && editor.imp().file.borrow().is_some() + { + editor.emit_by_name::<()>("file-changed", &[]); + } + }); + let weak = self.downgrade(); + buffer.connect_changed(move |_| { + if let Some(editor) = weak.upgrade() { + let edits = &editor.imp().edits; + edits.set(edits.get().wrapping_add(1)); + } + }); + } + + /// The text now came whole from `file`. + pub(super) fn opened_file(&self, file: gio::File) { + self.imp().file.replace(Some(file)); + self.buffer().set_modified(false); + self.emit_by_name::<()>("file-changed", &[]); + } + + /// Ctrl+S: write to the tab's file, or ask for one. + pub(super) fn save(&self) { + match self.file() { + Some(file) => self.write_reporting(file), + None => self.save_as(), + } + } + + /// Ctrl+Shift+S: ask where to write, starting from the tab's file. + pub(super) fn save_as(&self) { + let filter = sql_filter(); + let filters = gio::ListStore::new::(); + filters.append(&filter); + let dialog = gtk4::FileDialog::builder() + .title(crate::i18n::gettext("Save SQL File")) + .modal(true) + .default_filter(&filter) + .filters(&filters) + .build(); + match self.file() { + Some(file) => dialog.set_initial_file(Some(&file)), + None => dialog.set_initial_name(Some(&suggested_file_name(&buffer_text(&self.buffer())))), + } + let parent = self.root().and_downcast::(); + let weak = self.downgrade(); + glib::spawn_future_local(async move { + let Ok(file) = dialog.save_future(parent.as_ref()).await else { + return; + }; + if let Some(editor) = weak.upgrade() { + editor.write_reporting(file); + } + }); + } + + /// Write the text to the tab's file, for a close that chose Save. + /// The error is the sentence to show. + pub(crate) fn save_to_file(&self) -> impl Future> + use<> { + let editor = self.clone(); + async move { + match editor.file() { + Some(file) => editor.write_to(&file).await, + None => Ok(()), + } + } + } + + fn write_reporting(&self, file: gio::File) { + let editor = self.clone(); + glib::spawn_future_local(async move { + if let Err(message) = editor.write_to(&file).await { + editor.alert_save_failed(&message); + } + }); + } + + /// Text typed while the write runs is not in the file, so the tab + /// stays changed. + async fn write_to(&self, file: &gio::File) -> Result<(), String> { + let imp = self.imp(); + if imp.saving.replace(true) { + return Err(crate::i18n::gettext("This tab is already being saved.")); + } + let buffer = self.buffer(); + let edits = imp.edits.get(); + let written = crate::ui::file_write::replace_file(file, buffer_text(&buffer).into_bytes()).await; + imp.saving.set(false); + if let Err(error) = written { + tracing::warn!(%error, "could not save an editor tab to its file"); + return Err(crate::i18n::gettext_f( + "“{name}” could not be saved: {error}", + &[("name", &file_name(file)), ("error", error.message())], + )); + } + imp.file.replace(Some(file.clone())); + if imp.edits.get() == edits { + buffer.set_modified(false); + } + self.emit_by_name::<()>("file-changed", &[]); + Ok(()) + } + + fn alert_save_failed(&self, message: &str) { + let dialog = adw::AlertDialog::new(Some(&crate::i18n::gettext("Could Not Save File")), Some(message)); + dialog.add_response("close", &crate::i18n::gettext("_Close")); + dialog.set_default_response(Some("close")); + dialog.set_close_response("close"); + dialog.present(Some(self)); + } +} + +pub(super) fn sql_filter() -> gtk4::FileFilter { + let filter = gtk4::FileFilter::new(); + filter.set_name(Some(&crate::i18n::gettext("SQL files"))); + filter.add_suffix("sql"); + filter.add_mime_type("application/sql"); + filter +} + +/// What a tab bound to `file` is called. +pub(crate) fn file_name(file: &gio::File) -> String { + file.basename() + .map(|name| name.to_string_lossy().into_owned()) + .unwrap_or_else(|| file.parse_name().to_string()) +} + +/// A file name for a tab that has none, from its first statement. +fn suggested_file_name(text: &str) -> String { + let stem = match text.trim().is_empty() { + true => crate::i18n::gettext("query"), + false => export_name_for_query(text), + }; + format!("{stem}.sql") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_new_file_is_named_after_its_first_statement() { + assert_eq!(suggested_file_name("SELECT * FROM users;"), "select-from-users.sql"); + assert_eq!(suggested_file_name(" \n"), "query.sql"); + } + + #[gtk4::test] + fn a_file_is_named_by_its_last_part() { + assert_eq!(file_name(&gio::File::for_path("/tmp/reports/daily.sql")), "daily.sql"); + } +} diff --git a/linux/crates/app/src/ui/editor/editor_session_slot.rs b/linux/crates/app/src/ui/editor/editor_session_slot.rs new file mode 100644 index 0000000000..5e18a149e8 --- /dev/null +++ b/linux/crates/app/src/ui/editor/editor_session_slot.rs @@ -0,0 +1,325 @@ +use std::sync::{Arc, Mutex, MutexGuard, PoisonError, Weak}; +use std::time::Duration; + +use tablepro_core::{EditorSession, EditorSessionOptions, EditorSessionState, TransactionState}; +use tablepro_session::{OperationError, Session}; +use tokio_util::sync::CancellationToken; + +/// The editor tab's own connection, shared with the run on the runtime. +/// +/// The run opens it and the tab closes it, possibly while a run is +/// still opening one, so the slot remembers it was closed and a session +/// opened after that is handed back to be closed rather than kept. +/// +/// A session whose connection went with a transaction open is never +/// replaced on the same run: the next statement would commit on its +/// own, outside the transaction the user thinks is still open. Unless +/// the run that lost it already said so, that run is refused with the +/// reason, and only the one after it opens a new session. +#[derive(Default)] +pub struct EditorSessionSlot { + state: Mutex, +} + +#[derive(Default)] +struct SlotState { + open: Option, + /// A session was lost with a transaction open, and no run has been + /// told yet. + rolled_back: bool, + closed: bool, +} + +struct OpenEditor { + /// The window session it was opened from. A tab outliving a switch + /// to another database must not run on the old one. + owner: Weak, + editor: Arc, + /// Where the transaction stood after the last run that reported it. + transaction: Option, +} + +enum Lookup { + Ready(Arc), + /// None usable; `stale` is one to close before opening another. + Missing { + stale: Option>, + }, + /// The session went with a transaction open; `stale` is it, when it + /// is still to be closed. + RolledBack { + stale: Option>, + }, + Closed, +} + +/// Why a run got no editor session. +#[derive(Debug)] +pub enum AcquireError { + /// The tab closed or the run was stopped while it opened. + Cancelled, + /// The session's connection was lost with a transaction open, which + /// the server rolled back. Nothing was opened in its place. + TransactionRolledBack, + Failed(String), +} + +impl EditorSessionSlot { + pub fn new() -> Self { + Self::default() + } + + /// The tab's editor session, opening one first when the tab has + /// none, or only one that was lost or belongs to a session the + /// window has since replaced. + pub async fn acquire( + &self, + session: &Arc, + statement_timeout: Option, + close_timeout: Duration, + cancel: &CancellationToken, + ) -> Result, AcquireError> { + let stale = match self.lookup(session) { + Lookup::Ready(editor) => return Ok(editor), + Lookup::Closed => return Err(AcquireError::Cancelled), + Lookup::RolledBack { stale } => { + if let Some(stale) = stale { + close_quietly(stale, close_timeout).await; + } + return Err(AcquireError::TransactionRolledBack); + } + Lookup::Missing { stale } => stale, + }; + if let Some(stale) = stale { + close_quietly(stale, close_timeout).await; + } + let options = EditorSessionOptions { statement_timeout }; + match session.open_editor_session(options, cancel).await { + Ok(editor) => match self.adopt(session, editor.clone()) { + Ok(()) => Ok(editor), + Err(orphan) => { + close_quietly(orphan, close_timeout).await; + Err(AcquireError::Cancelled) + } + }, + Err(OperationError::Cancelled) => Err(AcquireError::Cancelled), + Err(error) => Err(AcquireError::Failed(crate::i18n::gettext_f( + "Could not open a connection for this editor: {error}", + &[("error", &crate::ui::error_text::operation_error_message(&error))], + ))), + } + } + + fn lookup(&self, owner: &Arc) -> Lookup { + let mut state = lock(&self.state); + if state.closed { + return Lookup::Closed; + } + if std::mem::take(&mut state.rolled_back) { + return Lookup::RolledBack { + stale: state.open.take().map(|open| open.editor), + }; + } + match state.open.take() { + Some(open) if open.editor.state() == EditorSessionState::Lost && open.held_transaction() => { + Lookup::RolledBack { + stale: Some(open.editor), + } + } + Some(open) + if open.owner.ptr_eq(&Arc::downgrade(owner)) && open.editor.state() != EditorSessionState::Lost => + { + let editor = open.editor.clone(); + state.open = Some(open); + Lookup::Ready(editor) + } + stale => Lookup::Missing { + stale: stale.map(|open| open.editor), + }, + } + } + + /// Where the transaction of `editor` stood after its last run. + pub fn transaction(&self, editor: &Arc) -> Option { + let state = lock(&self.state); + state + .open + .as_ref() + .filter(|open| open.editor.id() == editor.id()) + .and_then(|open| open.transaction) + } + + /// Record where a run left the transaction of `editor`. + pub fn note_transaction(&self, editor: &Arc, transaction: Option) { + let mut state = lock(&self.state); + if let Some(open) = state.open.as_mut().filter(|open| open.editor.id() == editor.id()) { + open.transaction = transaction; + } + } + + /// Keep a newly opened session, or hand it back when the tab closed + /// while it was opening. + fn adopt(&self, owner: &Arc, editor: Arc) -> Result<(), Arc> { + let mut state = lock(&self.state); + if state.closed { + return Err(editor); + } + state.open = Some(OpenEditor { + owner: Arc::downgrade(owner), + editor, + transaction: None, + }); + Ok(()) + } + + /// Let go of a session whose connection went during a run. The next + /// run opens a new session, or with `refuse_next`, when a transaction + /// went with the connection unannounced, is refused first. Returns + /// whether this slot held it. + pub fn forget(&self, editor: &Arc, refuse_next: bool) -> bool { + let mut state = lock(&self.state); + let held = state.open.as_ref().is_some_and(|open| open.editor.id() == editor.id()); + if held { + state.open = None; + state.rolled_back |= refuse_next; + } + held + } + + /// Mark the tab gone and take its session to close. Every later + /// open is closed straight away. + pub fn close(&self) -> Option> { + let mut state = lock(&self.state); + state.closed = true; + state.open.take().map(|open| open.editor) + } +} + +impl std::fmt::Debug for EditorSessionSlot { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let state = lock(&self.state); + f.debug_struct("EditorSessionSlot") + .field("open", &state.open.is_some()) + .field("closed", &state.closed) + .finish() + } +} + +impl OpenEditor { + fn held_transaction(&self) -> bool { + transaction_open(self.transaction) + } +} + +/// A transaction the server rolls back when the connection goes. +pub fn transaction_open(transaction: Option) -> bool { + matches!( + transaction, + Some(TransactionState::InTransaction | TransactionState::Failed) + ) +} + +pub async fn close_quietly(editor: Arc, close_timeout: Duration) { + if tokio::time::timeout(close_timeout, editor.close()).await.is_err() { + tracing::warn!("an editor session did not close in time"); + } +} + +fn lock(mutex: &Mutex) -> MutexGuard<'_, T> { + mutex.lock().unwrap_or_else(PoisonError::into_inner) +} + +#[cfg(test)] +pub(super) mod tests { + use tablepro_test_support::{FakeEditorSession, FakeRun, Replies}; + + use super::*; + + pub(crate) fn session_opening( + editors: Vec>, + ) -> (Arc, Arc) { + use tablepro_session::fakes::{FakeConnector, FakeCredentialSource}; + use tablepro_session::operation::DefaultDeadlines; + use tablepro_test_support::{ConnectionScript, FakeReply}; + + let mut script = ConnectionScript::default(); + for editor in editors { + script.open_editor_session.once(FakeReply::ok(editor)); + } + let (pending, connection) = FakeConnector::pending_session(FakeConnector::request().unwrap(), script); + let session = Session::start( + pending, + tablepro_session::SessionDeps { + connector: Arc::new(FakeConnector::new([])), + credentials: Arc::new(FakeCredentialSource::new()), + deadlines: Arc::new(DefaultDeadlines::new(tablepro_core::LivenessPolicy::DESKTOP)), + tasks: crate::test_support::paused_tasks(), + prompter: None, + }, + ); + (session, connection) + } + + fn answering() -> Arc { + let mut runs = Replies::default(); + runs.always(FakeRun::completed(Vec::new())); + Arc::new(FakeEditorSession::new(runs)) + } + + const CLOSE: Duration = Duration::from_secs(10); + + #[tokio::test] + async fn a_tab_opens_its_editor_session_once_and_reuses_it() { + let editor = answering(); + let (session, connection) = session_opening(vec![editor.clone()]); + let slot = EditorSessionSlot::new(); + + let first = slot + .acquire(&session, None, CLOSE, &CancellationToken::new()) + .await + .expect("an editor session"); + let second = slot + .acquire(&session, None, CLOSE, &CancellationToken::new()) + .await + .expect("the same editor session"); + + assert_eq!(first.id(), second.id()); + assert_eq!( + tablepro_test_support::CallCounters::get(&connection.counters().open_editor_session), + 1 + ); + } + + #[tokio::test] + async fn a_new_window_session_replaces_the_old_editor_session() { + let old_editor = answering(); + let new_editor = answering(); + let (old_session, _) = session_opening(vec![old_editor.clone()]); + let (new_session, _) = session_opening(vec![new_editor.clone()]); + let slot = EditorSessionSlot::new(); + + for session in [old_session, new_session] { + slot.acquire(&session, None, CLOSE, &CancellationToken::new()) + .await + .expect("an editor session"); + } + + assert_eq!(old_editor.close_count(), 1, "the old session was left open"); + assert_eq!(new_editor.close_count(), 0); + } + + #[tokio::test] + async fn a_session_opened_after_the_tab_closed_is_handed_back() { + let editor: Arc = answering(); + let (session, _) = session_opening(Vec::new()); + let slot = EditorSessionSlot::new(); + + assert!(slot.close().is_none()); + + assert!(slot.adopt(&session, editor).is_err()); + assert!(matches!( + slot.acquire(&session, None, CLOSE, &CancellationToken::new()).await, + Err(AcquireError::Cancelled) + )); + } +} diff --git a/linux/crates/app/src/ui/editor/find_bar.rs b/linux/crates/app/src/ui/editor/find_bar.rs new file mode 100644 index 0000000000..2d4b1f9c48 --- /dev/null +++ b/linux/crates/app/src/ui/editor/find_bar.rs @@ -0,0 +1,506 @@ +use std::cell::{Cell, OnceCell}; +use std::sync::OnceLock; + +use glib::subclass::Signal; +use gtk4 as gtk; +use gtk4::glib; +use gtk4::prelude::*; +use gtk4::subclass::prelude::*; +use libadwaita as adw; +use sourceview5::prelude::*; + +use super::find_state::FindStatus; + +/// Long enough for a typed word, short enough to answer the last key. +const ANNOUNCE_AFTER: std::time::Duration = std::time::Duration::from_millis(600); + +mod imp { + use libadwaita::subclass::prelude::*; + + use super::*; + + #[derive(Default, gtk::CompositeTemplate)] + #[template(resource = "/app/tablepro/TablePro/ui/find-bar.ui")] + pub struct FindBar { + #[template_child] + pub search_bar: TemplateChild, + #[template_child] + pub search_entry: TemplateChild, + #[template_child] + pub count_label: TemplateChild, + #[template_child] + pub previous_button: TemplateChild, + #[template_child] + pub next_button: TemplateChild, + #[template_child] + pub options_button: TemplateChild, + #[template_child] + pub replace_toggle: TemplateChild, + #[template_child] + pub replace_entry: TemplateChild, + + pub settings: sourceview5::SearchSettings, + pub context: OnceCell, + pub view: glib::WeakRef, + pub actions: OnceCell, + /// A move asked for while the search was still counting, made + /// once it has counted. + pub(super) pending: Cell>, + /// The count said once typing pauses, rather than at each key. + pub(super) announcing: std::cell::RefCell>, + /// What was last said or is about to be, so a refresh that changes + /// nothing says nothing. + pub(super) announcement: std::cell::RefCell>, + } + + #[glib::object_subclass] + impl ObjectSubclass for FindBar { + const NAME: &'static str = "TableProFindBar"; + type Type = super::FindBar; + type ParentType = adw::Bin; + + fn class_init(klass: &mut Self::Class) { + klass.bind_template(); + } + + fn instance_init(object: &glib::subclass::InitializingObject) { + object.init_template(); + } + } + + impl ObjectImpl for FindBar { + fn signals() -> &'static [Signal] { + static SIGNALS: OnceLock> = OnceLock::new(); + SIGNALS.get_or_init(|| vec![Signal::builder("toast").param_types([String::static_type()]).build()]) + } + + fn constructed(&self) { + self.parent_constructed(); + self.obj().setup(); + } + } + + impl WidgetImpl for FindBar {} + impl BinImpl for FindBar {} +} + +glib::wrapper! { + /// Find and replace over an editor's buffer, through a + /// GtkSourceSearchContext, as GNOME Text Editor lays it out. + pub struct FindBar(ObjectSubclass) + @extends adw::Bin, gtk::Widget, + @implements gtk::Accessible, gtk::Buildable, gtk::ConstraintTarget; +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum Direction { + Next, + Previous, + /// From the start of the selection, so a match being typed stays + /// selected. + FromSelection, +} + +impl FindBar { + fn setup(&self) { + let imp = self.imp(); + imp.settings.set_wrap_around(true); + imp.search_bar.connect_entry(&imp.search_entry.get()); + for (button, icon, label, action) in [ + ( + imp.previous_button.upcast_ref::(), + crate::ui::icons::GO_UP, + crate::i18n::gettext("Previous Match"), + Some("editor.find-previous"), + ), + ( + imp.next_button.upcast_ref(), + crate::ui::icons::GO_DOWN, + crate::i18n::gettext("Next Match"), + Some("editor.find-next"), + ), + ( + imp.options_button.upcast_ref(), + crate::ui::icons::VIEW_MORE, + crate::i18n::gettext("Search Options"), + None, + ), + ( + imp.replace_toggle.upcast_ref(), + crate::ui::icons::EDIT_FIND_REPLACE, + crate::i18n::gettext("Find and Replace"), + Some("editor.find-replace"), + ), + ] { + button.set_property("icon-name", icon); + crate::ui::a11y::describe_icon_button(button, &label, action); + } + self.install_actions(); + + let bar = self.downgrade(); + imp.search_entry.connect_search_changed(move |entry| { + if let Some(bar) = bar.upgrade() { + bar.on_search_changed(&entry.text()); + } + }); + for (signal, direction) in [ + ("activate", Direction::Next), + ("next-match", Direction::Next), + ("previous-match", Direction::Previous), + ] { + let bar = self.downgrade(); + imp.search_entry.connect_local(signal, false, move |_| { + if let Some(bar) = bar.upgrade() { + bar.find(direction); + } + None + }); + } + let bar = self.downgrade(); + imp.replace_entry.connect_activate(move |_| { + if let Some(bar) = bar.upgrade() { + bar.replace(); + } + }); + // Escape in the entry ends the search through the search bar; the + // view takes the focus back, as Text Editor's does. + let bar = self.downgrade(); + imp.search_bar.connect_search_mode_enabled_notify(move |search_bar| { + let Some(bar) = bar.upgrade() else { + return; + }; + if !search_bar.is_search_mode() { + bar.imp().pending.set(None); + if let Some(view) = bar.imp().view.upgrade() { + view.grab_focus(); + } + } + }); + self.refresh_status(); + } + + fn install_actions(&self) { + let imp = self.imp(); + let actions = gio::SimpleActionGroup::new(); + for property in ["case-sensitive", "at-word-boundaries", "regex-enabled"] { + actions.add_action(&gio::PropertyAction::new(property, &imp.settings, property)); + } + let entry = |name: &'static str, act: fn(&FindBar)| { + let bar = self.downgrade(); + gio::ActionEntry::::builder(name) + .activate(move |_, _, _| { + if let Some(bar) = bar.upgrade() { + act(&bar); + } + }) + .build() + }; + actions.add_action_entries([ + entry("next", |bar| bar.find(Direction::Next)), + entry("previous", |bar| bar.find(Direction::Previous)), + entry("replace", FindBar::replace), + entry("replace-all", FindBar::replace_all), + ]); + self.insert_action_group("find", Some(&actions)); + let _ = imp.actions.set(actions); + } + + /// Search `view`'s buffer. Called once, after the buffer is set. + pub(crate) fn attach(&self, view: &sourceview5::View) { + let imp = self.imp(); + let Ok(buffer) = view.buffer().downcast::() else { + tracing::error!("the editor's view has no source buffer to search"); + return; + }; + imp.view.set(Some(view)); + let context = sourceview5::SearchContext::new(&buffer, Some(&imp.settings)); + imp.search_bar + .bind_property("search-mode-enabled", &context, "highlight") + .sync_create() + .build(); + let bar = self.downgrade(); + context.connect_occurrences_count_notify(move |context| { + if let Some(bar) = bar.upgrade() { + bar.refresh_status(); + if context.occurrences_count() >= 0 + && let Some(direction) = bar.imp().pending.take() + { + bar.move_to_match(context, direction); + } + } + }); + let bar = self.downgrade(); + context.connect_regex_error_notify(move |context| { + if let Some(bar) = bar.upgrade() { + bar.show_regex_error(context.regex_error().as_ref()); + } + }); + let bar = self.downgrade(); + buffer.connect_mark_set(move |buffer, _, mark| { + let Some(bar) = bar.upgrade() else { + return; + }; + let moves_selection = *mark == buffer.get_insert() || *mark == buffer.selection_bound(); + if moves_selection && bar.is_open() { + bar.refresh_status(); + } + }); + let _ = imp.context.set(context); + } + + pub(crate) fn is_open(&self) -> bool { + self.imp().search_bar.is_search_mode() + } + + /// Show the bar with the focus in its entry, taking a selection on + /// one line as the text to find. `replace` shows the replace row too. + pub(crate) fn open(&self, replace: bool) { + let imp = self.imp(); + if let Some(text) = self.single_line_selection() { + imp.search_entry.set_text(&text); + } + if replace { + imp.replace_toggle.set_active(true); + } + imp.search_bar.set_search_mode(true); + imp.search_entry.grab_focus(); + imp.search_entry.select_region(0, -1); + self.refresh_status(); + } + + fn single_line_selection(&self) -> Option { + let buffer = self.imp().context.get()?.buffer(); + let (start, end) = buffer.selection_bounds()?; + let text = buffer.text(&start, &end, false); + (!text.contains('\n')).then(|| text.to_string()) + } + + fn on_search_changed(&self, text: &str) { + let imp = self.imp(); + imp.settings.set_search_text((!text.is_empty()).then_some(text)); + if text.is_empty() { + imp.pending.set(None); + self.refresh_status(); + return; + } + self.find(Direction::FromSelection); + } + + /// Select the next or previous match, wrapping at the ends. + pub(crate) fn find(&self, direction: Direction) { + let imp = self.imp(); + let Some(context) = imp.context.get() else { + return; + }; + if !self.has_search_text() { + return; + } + // The sourceview5 binding of forward_async and backward_async + // returns Ok with unset iterators when nothing matches, so the + // move runs through the synchronous search, which says so. Once + // the context has counted, the buffer is scanned and that search + // only walks its match tags. + if context.occurrences_count() < 0 { + imp.pending.set(Some(direction)); + return; + } + self.move_to_match(context, direction); + } + + fn move_to_match(&self, context: &sourceview5::SearchContext, direction: Direction) { + let buffer = context.buffer(); + let (start, end) = selection(&buffer); + let found = match direction { + Direction::Next => context.forward(&end), + Direction::Previous => context.backward(&start), + Direction::FromSelection => context.forward(&start), + }; + if let Some((match_start, match_end, _wrapped)) = found { + buffer.select_range(&match_start, &match_end); + if let Some(view) = self.imp().view.upgrade() { + view.scroll_to_mark(&buffer.get_insert(), 0.1, false, 0.0, 0.0); + } + } + self.refresh_status(); + } + + /// Replace the selected match and move to the next. A selection that + /// is not a match is only moved from. + pub(crate) fn replace(&self) { + let imp = self.imp(); + let Some(context) = imp.context.get() else { + return; + }; + if !self.status().has_matches() { + return; + } + let buffer = context.buffer(); + let (mut start, mut end) = selection(&buffer); + if context.occurrence_position(&start, &end) <= 0 { + self.find(Direction::Next); + return; + } + if let Err(error) = context.replace(&mut start, &mut end, &imp.replace_entry.text()) { + self.emit_by_name::<()>("toast", &[&error.message().to_owned()]); + return; + } + buffer.place_cursor(&end); + self.find(Direction::Next); + } + + /// Replace every match, as one step Ctrl+Z takes back. + pub(crate) fn replace_all(&self) { + let imp = self.imp(); + let Some(context) = imp.context.get() else { + return; + }; + let status = self.status(); + // The binding asserts when a replace finds nothing, so it runs + // only on a count the context has finished. + let Some(count) = status.occurrences.filter(|_| status.has_matches()) else { + return; + }; + let message = match context.replace_all(&imp.replace_entry.text()) { + Ok(()) => crate::i18n::ngettext_f( + "Replaced {count} occurrence", + "Replaced {count} occurrences", + count, + &[("count", &count.to_string())], + ), + Err(error) => error.message().to_owned(), + }; + self.emit_by_name::<()>("toast", &[&message]); + self.refresh_status(); + } + + pub(crate) fn status(&self) -> FindStatus { + let Some(context) = self.imp().context.get() else { + return FindStatus::default(); + }; + if !self.has_search_text() { + return FindStatus::default(); + } + let (start, end) = selection(&context.buffer()); + FindStatus::from_counts( + context.occurrences_count(), + context.occurrence_position(&start, &end), + context.regex_error().map(|error| error.message().to_owned()), + ) + } + + fn has_search_text(&self) -> bool { + self.imp().settings.search_text().is_some_and(|text| !text.is_empty()) + } + + fn refresh_status(&self) { + let imp = self.imp(); + let status = self.status(); + imp.count_label.set_label(&status.label()); + self.announce_when_settled(status.announcement()); + if let Some(actions) = imp.actions.get() { + for name in ["replace", "replace-all"] { + if let Some(action) = actions.lookup_action(name).and_downcast::() { + action.set_enabled(status.has_matches()); + } + } + } + } + + /// The count and the pattern's error show only in a label and on the + /// entry, away from the text the reader is on. + fn announce_when_settled(&self, announcement: Option) { + let imp = self.imp(); + if *imp.announcement.borrow() == announcement { + return; + } + imp.announcement.replace(announcement.clone()); + if let Some(pending) = imp.announcing.take() { + pending.remove(); + } + let Some(announcement) = announcement else { + return; + }; + let bar = self.downgrade(); + let pending = glib::timeout_add_local_once(ANNOUNCE_AFTER, move || { + let Some(bar) = bar.upgrade() else { + return; + }; + bar.imp().announcing.take(); + crate::ui::a11y::announce( + &bar.imp().search_entry.get(), + &announcement, + gtk::AccessibleAnnouncementPriority::Medium, + ); + }); + imp.announcing.replace(Some(pending)); + } + + fn show_regex_error(&self, error: Option<&glib::Error>) { + let entry = self.imp().search_entry.get(); + crate::ui::a11y::show_invalid(&entry, error.map(glib::Error::message)); + match error { + Some(error) => { + entry.add_css_class("error"); + entry.set_tooltip_text(Some(error.message())); + } + None => { + entry.remove_css_class("error"); + entry.set_tooltip_text(None); + } + } + self.refresh_status(); + } + + pub(crate) fn connect_toast(&self, f: F) -> glib::SignalHandlerId { + self.connect_closure( + "toast", + false, + glib::closure_local!(move |bar: &Self, text: String| f(bar, text)), + ) + } + + /// What the count label says. + #[cfg(test)] + pub(crate) fn count_text(&self) -> String { + self.imp().count_label.label().to_string() + } + + #[cfg(test)] + pub(crate) fn search_entry(&self) -> gtk::SearchEntry { + self.imp().search_entry.get() + } + + #[cfg(test)] + pub(crate) fn replace_entry(&self) -> gtk::Entry { + self.imp().replace_entry.get() + } + + #[cfg(test)] + pub(crate) fn is_replacing(&self) -> bool { + self.imp().replace_toggle.is_active() + } + + #[cfg(test)] + pub(crate) fn settings(&self) -> sourceview5::SearchSettings { + self.imp().settings.clone() + } + + #[cfg(test)] + pub(crate) fn actions(&self) -> Option { + self.imp().actions.get().cloned() + } + + #[cfg(test)] + pub(crate) fn context(&self) -> Option { + self.imp().context.get().cloned() + } +} + +/// The selection, or the cursor as an empty one. +fn selection(buffer: &impl IsA) -> (gtk::TextIter, gtk::TextIter) { + let buffer = buffer.as_ref(); + buffer.selection_bounds().unwrap_or_else(|| { + let cursor = buffer.iter_at_mark(&buffer.get_insert()); + (cursor, cursor) + }) +} diff --git a/linux/crates/app/src/ui/editor/find_state.rs b/linux/crates/app/src/ui/editor/find_state.rs new file mode 100644 index 0000000000..4148413576 --- /dev/null +++ b/linux/crates/app/src/ui/editor/find_state.rs @@ -0,0 +1,109 @@ +/// What the find bar's count label says about the search. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub(crate) struct FindStatus { + /// `None` while the search is still counting, or with nothing to + /// search for. + pub occurrences: Option, + /// The match the selection is on, counted from 1. `None` when the + /// selection is on none, or the search has not reached it yet. + pub position: Option, + pub regex_error: Option, +} + +impl FindStatus { + /// From GtkSourceSearchContext's counts, where -1 means not counted + /// yet and a position of 0 means the selection is on no match. + pub(crate) fn from_counts(occurrences: i32, position: i32, regex_error: Option) -> Self { + Self { + occurrences: u32::try_from(occurrences).ok(), + position: u32::try_from(position).ok().filter(|position| *position > 0), + regex_error, + } + } + + pub(crate) fn label(&self) -> String { + // The entry itself says what is wrong with the pattern. + if self.regex_error.is_some() { + return String::new(); + } + match self.occurrences { + None => String::new(), + Some(0) => crate::i18n::gettext("No results"), + Some(count) => crate::i18n::gettext_f( + "{position} of {count}", + &[ + ("position", &self.position.unwrap_or(0).to_string()), + ("count", &count.to_string()), + ], + ), + } + } + + /// What a screen reader hears once the search settles: the count, or + /// what is wrong with the pattern, which the label leaves out. + pub(crate) fn announcement(&self) -> Option { + if let Some(error) = &self.regex_error { + return Some(error.clone()); + } + Some(self.label()).filter(|label| !label.is_empty()) + } + + /// Whether there is a match to replace, with its count known. + pub(crate) fn has_matches(&self) -> bool { + self.regex_error.is_none() && self.occurrences.is_some_and(|count| count > 0) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn find_status_label_counting_empty_and_position() { + assert_eq!(FindStatus::from_counts(-1, -1, None).label(), "", "while counting"); + assert_eq!(FindStatus::default().label(), "", "with nothing to search for"); + assert_eq!(FindStatus::from_counts(0, 0, None).label(), "No results"); + assert_eq!(FindStatus::from_counts(3, 2, None).label(), "2 of 3"); + assert_eq!( + FindStatus::from_counts(3, 0, None).label(), + "0 of 3", + "the selection is on no match" + ); + assert_eq!( + FindStatus::from_counts(0, 0, Some("missing )".to_owned())).label(), + "", + "a bad pattern" + ); + } + + #[test] + fn the_announcement_is_the_count_or_the_pattern_error() { + assert_eq!( + FindStatus::from_counts(3, 2, None).announcement().as_deref(), + Some("2 of 3") + ); + assert_eq!( + FindStatus::from_counts(0, 0, None).announcement().as_deref(), + Some("No results") + ); + assert_eq!( + FindStatus::from_counts(-1, -1, None).announcement(), + None, + "while counting" + ); + assert_eq!( + FindStatus::from_counts(0, 0, Some("missing )".to_owned())) + .announcement() + .as_deref(), + Some("missing )") + ); + } + + #[test] + fn only_a_counted_match_can_be_replaced() { + assert!(FindStatus::from_counts(2, 1, None).has_matches()); + assert!(!FindStatus::from_counts(-1, -1, None).has_matches()); + assert!(!FindStatus::from_counts(0, 0, None).has_matches()); + assert!(!FindStatus::from_counts(2, 0, Some("bad".to_owned())).has_matches()); + } +} diff --git a/linux/crates/app/src/ui/editor/format_plan.rs b/linux/crates/app/src/ui/editor/format_plan.rs new file mode 100644 index 0000000000..d0e99e1e2f --- /dev/null +++ b/linux/crates/app/src/ui/editor/format_plan.rs @@ -0,0 +1,219 @@ +use tablepro_core::sql_syntax::SqlGrammar; +use tablepro_core::sql_syntax::script::{LexicalSettings, ScriptPlan}; + +use super::significant_tokens::significant_tokens; + +/// Reformat every statement the formatter can read, and leave the rest +/// exactly as it was. +/// +/// sqlformat has its own lexer, and it does not know every dialect the +/// app connects to: it reads `//` as an operator, ends a nested block +/// comment at the first `*/`, treats a backslash as an escape inside +/// quotes, takes `$$` for a keyword, and puts a space inside `+=`, +/// `b'01'` and `GROUP_CONCAT(`. Reformatting through it can therefore +/// change what a statement means. Each statement is checked against +/// its own tokens afterwards, and one that came back different is +/// written out as the user typed it. +pub fn format_script(text: &str, grammar: SqlGrammar, settings: LexicalSettings) -> String { + let plan = ScriptPlan::build(text, grammar, settings); + let mut out = String::with_capacity(text.len()); + let mut cursor = 0usize; + for statement in plan.statements() { + let range = statement.range.clone(); + if range.start < cursor || range.end > text.len() { + continue; + } + // Whatever sits between statements is the user's: terminators, + // GO lines, DELIMITER lines and the blank lines around them. + out.push_str(text.get(cursor..range.start).unwrap_or_default()); + let source = text.get(range.clone()).unwrap_or_default(); + out.push_str(&format_statement(source, grammar, settings)); + cursor = range.end; + } + out.push_str(text.get(cursor..).unwrap_or_default()); + out +} + +fn format_statement(source: &str, grammar: SqlGrammar, settings: LexicalSettings) -> String { + if source.trim().is_empty() { + return source.to_owned(); + } + let options = sqlformat::FormatOptions { + indent: sqlformat::Indent::Spaces(4), + // sqlformat's reserved list uppercases STATUS and LEVEL, which + // are ordinary identifiers on ClickHouse and case-sensitive + // table names on MySQL under Linux. + uppercase: None, + lines_between_queries: 1, + dialect: format_dialect(grammar), + ..sqlformat::FormatOptions::default() + }; + let formatted = sqlformat::format(source, &sqlformat::QueryParams::None, &options); + if significant_tokens(&formatted, grammar, settings) == significant_tokens(source, grammar, settings) { + formatted + } else { + source.to_owned() + } +} + +fn format_dialect(grammar: SqlGrammar) -> sqlformat::Dialect { + match grammar { + SqlGrammar::PostgreSql => sqlformat::Dialect::PostgreSql, + SqlGrammar::MsSql => sqlformat::Dialect::SQLServer, + _ => sqlformat::Dialect::Generic, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn format(text: &str, grammar: SqlGrammar) -> String { + format_script(text, grammar, LexicalSettings::default_for(grammar)) + } + + #[test] + fn plain_select_is_reflowed_case_preserved() { + let out = format("select a, b from t where a = 1", SqlGrammar::PostgreSql); + + assert!(out.contains("\n"), "the statement was not reflowed: {out}"); + assert!(out.contains("select"), "the keyword case was changed: {out}"); + assert!(!out.contains("SELECT"), "the keyword case was changed: {out}"); + } + + #[test] + fn format_is_idempotent_for_plain_statements() { + let once = format("select a, b from t where a = 1", SqlGrammar::PostgreSql); + let twice = format(&once, SqlGrammar::PostgreSql); + + assert_eq!(once, twice); + } + + #[test] + fn dollar_tag_body_is_untouched() { + let source = "CREATE FUNCTION f() RETURNS int AS $body$ SELECT 1; $body$ LANGUAGE sql"; + + let out = format(source, SqlGrammar::PostgreSql); + + assert!( + out.contains("$body$ SELECT 1; $body$"), + "the body was rewritten: {out}" + ); + } + + #[test] + fn a_comment_that_would_swallow_the_code_after_it_keeps_the_statement_verbatim() { + // sqlformat has no `//` comment, so it pulls the comma up onto + // the comment's line, where ClickHouse would never see it. + let source = "SELECT a // x\n, b FROM t"; + + assert_eq!(format(source, SqlGrammar::ClickHouse), source); + } + + #[test] + fn a_slash_comment_at_the_end_of_a_statement_still_ends_it() { + let out = format("SELECT 1 // note", SqlGrammar::ClickHouse); + + assert!(out.ends_with("// note"), "the comment stopped ending the line: {out}"); + } + + #[test] + fn nested_block_comment_kept_verbatim() { + let source = "SELECT /* outer /* inner */ still outer */ 1"; + + assert_eq!(format(source, SqlGrammar::PostgreSql), source); + } + + #[test] + fn standard_conforming_backslash_string_kept_verbatim() { + let source = r"SELECT 'C:\' AS a, 'x'"; + + assert_eq!(format(source, SqlGrammar::PostgreSql), source); + } + + #[test] + fn mysql_minus_minus_arithmetic_stays_arithmetic() { + // MySQL needs a space after `--` before it is a comment, so + // `1--1` is a subtraction. A space inserted there would turn + // the rest of the line into a comment. + let out = format("SELECT 1--1", SqlGrammar::MySql); + + assert!(out.contains("--1"), "the operator was broken up: {out}"); + assert!(!out.contains("-- 1"), "the arithmetic became a comment: {out}"); + } + + #[test] + fn go_lines_preserved() { + let source = "select 1\nGO\nselect 2\nGO\n"; + + let out = format(source, SqlGrammar::MsSql); + + assert_eq!(out.matches("GO").count(), 2, "a batch separator was lost: {out}"); + assert!(out.ends_with("GO\n"), "the trailing separator moved: {out}"); + } + + #[test] + fn delimiter_procedure_preserved() { + let source = "DELIMITER $$\nCREATE PROCEDURE p() BEGIN SELECT 1; END$$\nDELIMITER ;\n"; + + let out = format(source, SqlGrammar::MySql); + + assert!(out.contains("DELIMITER $$"), "the delimiter line was lost: {out}"); + assert!(out.contains("DELIMITER ;"), "the delimiter reset was lost: {out}"); + } + + #[test] + fn statements_sqlformat_would_respace_into_something_else_kept_verbatim() { + let cases = [ + // `+=` would become `+ =`. + ("SET @x += 1", SqlGrammar::MsSql), + // A bit literal and a charset introducer would become a + // column with a string alias. + ("select b'01', _utf8mb4'x' from t", SqlGrammar::MySql), + // `@@ version` is not a system variable. + ("select @@version", SqlGrammar::MySql), + // `U & '…'` is a bitwise and, not a Unicode escape string. + (r"select U&'d\0061t'", SqlGrammar::PostgreSql), + // A string continued on the next line is one string; on + // one line the two are a syntax error. + ("select 'a'\n'b'", SqlGrammar::PostgreSql), + // A query parameter's name, colon and type are one unit. + ("SELECT {id:UInt32}", SqlGrammar::ClickHouse), + ]; + + for (source, grammar) in cases { + assert_eq!(format(source, grammar), source, "{grammar:?}"); + } + } + + #[test] + fn a_mysql_call_keeps_its_name_against_its_paren() { + // sqlformat writes `GROUP_CONCAT (a)`, which MySQL reads as a + // stored function of that name. + let out = format("SELECT GROUP_CONCAT(a) FROM t", SqlGrammar::MySql); + + assert!( + out.contains("GROUP_CONCAT(a)"), + "the call was split from its paren: {out}" + ); + } + + #[test] + fn mysql_statements_with_untouched_calls_are_still_formatted() { + for source in [ + "select count(*), max(a) from t", + "insert into t (a, b) values (1, 2)", + "select ifnull(a, 0) from t where a in(1, 2)", + ] { + let out = format(source, SqlGrammar::MySql); + + assert!(out.contains('\n'), "the statement was not reflowed: {out}"); + } + } + + #[test] + fn an_empty_script_stays_empty() { + assert_eq!(format("", SqlGrammar::PostgreSql), ""); + assert_eq!(format(" \n\n", SqlGrammar::PostgreSql), " \n\n"); + } +} diff --git a/linux/crates/app/src/ui/editor/imp.rs b/linux/crates/app/src/ui/editor/imp.rs new file mode 100644 index 0000000000..fe943e740e --- /dev/null +++ b/linux/crates/app/src/ui/editor/imp.rs @@ -0,0 +1,125 @@ +use std::cell::{Cell, OnceCell, RefCell}; +use std::sync::{Arc, OnceLock}; + +use glib::subclass::Signal; +use gtk4::prelude::*; +use gtk4::subclass::prelude::*; +use gtk4::{gio, glib}; +use libadwaita as adw; +use libadwaita::subclass::prelude::*; +use tokio_util::sync::CancellationToken; + +use super::EditorDeps; +use super::editor_session_slot::EditorSessionSlot; +use super::find_bar::FindBar; +use super::results_pane::ResultsPane; +use super::run_log::RunLog; +use crate::ui::grid::GridSink; +use crate::ui::handler_guard::HandlerGuard; + +/// A run in flight: its cancel switch, and the loop that brings its +/// results to the GTK thread. +pub struct RunningScript { + pub cancel: CancellationToken, + pub drain: glib::JoinHandle<()>, +} + +#[derive(Default, gtk4::CompositeTemplate)] +#[template(resource = "/app/tablepro/TablePro/ui/sql-editor.ui")] +pub struct SqlEditor { + #[template_child] + pub cursor_info: TemplateChild, + #[template_child] + pub menu_button: TemplateChild, + #[template_child] + pub stop_button: TemplateChild, + #[template_child] + pub run_button: TemplateChild, + #[template_child] + pub find_bar: TemplateChild, + #[template_child] + pub source_view: TemplateChild, + #[template_child] + pub results: TemplateChild, + + pub(super) deps: OnceCell, + /// This tab's own connection, which keeps its transaction and + /// temporary tables between runs. + pub slot: Arc, + pub running: RefCell>, + pub log: RefCell>, + pub grid_sink: OnceCell, + /// Whether the last run left a transaction open, as last reported. + pub transaction_dirty: Cell, + /// Whether the run in flight, or the last one, can change what the + /// catalog lists. + pub changes_catalog: Cell, + pub shut_down: Cell, + /// The file the text was opened from or last saved to. + pub file: RefCell>, + pub saving: Cell, + /// Counts every change to the buffer, so a save knows whether the + /// text it wrote is still what the tab holds. + pub edits: Cell, + /// Theme and font handlers on the app-wide style manager and + /// settings, which would otherwise outlive the tab. + pub(super) app_handlers: RefCell>, + pub(super) completion_providers: RefCell>, +} + +#[glib::object_subclass] +impl ObjectSubclass for SqlEditor { + const NAME: &'static str = "TableProSqlEditor"; + type Type = super::SqlEditor; + type ParentType = adw::Bin; + + fn class_init(klass: &mut Self::Class) { + sourceview5::View::ensure_type(); + FindBar::ensure_type(); + ResultsPane::ensure_type(); + klass.bind_template(); + } + + fn instance_init(object: &glib::subclass::InitializingObject) { + object.init_template(); + } +} + +impl ObjectImpl for SqlEditor { + fn signals() -> &'static [Signal] { + static SIGNALS: OnceLock> = OnceLock::new(); + SIGNALS.get_or_init(|| { + vec![ + Signal::builder("run-state-changed") + .param_types([bool::static_type()]) + .build(), + Signal::builder("run-finished") + .param_types([crate::workspace::WorkOutcome::static_type()]) + .build(), + // No text: the buffer can be megabytes, and a listener + // reads only what it needs from it. + Signal::builder("content-changed").build(), + Signal::builder("toast").param_types([String::static_type()]).build(), + Signal::builder("undoable-toast") + .param_types([String::static_type()]) + .build(), + Signal::builder("export-results") + .param_types([glib::BoxedAnyObject::static_type()]) + .build(), + Signal::builder("transaction-changed") + .param_types([bool::static_type()]) + .build(), + Signal::builder("file-changed").build(), + ] + }) + } + + fn dispose(&self) { + self.obj().shutdown(); + self.app_handlers.take(); + self.obj().release_view(); + } +} + +impl WidgetImpl for SqlEditor {} +impl BinImpl for SqlEditor {} diff --git a/linux/crates/app/src/ui/editor/mod.rs b/linux/crates/app/src/ui/editor/mod.rs new file mode 100644 index 0000000000..260067461e --- /dev/null +++ b/linux/crates/app/src/ui/editor/mod.rs @@ -0,0 +1,967 @@ +//! The SQL editor tab. Each run goes through the tab's own editor +//! session, one statement or `GO` batch at a time, and every statement +//! gets a line in the results list. + +mod buffer_edit; +mod catalog_change; +mod completion; +mod editor_actions; +mod editor_file; +mod editor_session_slot; +mod find_bar; +mod find_state; +mod format_plan; +mod imp; +mod result_item; +mod result_widgets; +mod results_pane; +mod run_history; +mod run_log; +mod run_scope; +mod script_runner; +mod sidebar_policy; +mod significant_tokens; +mod sql_file; +mod statement_cursor; +#[cfg(test)] +mod tests; +mod transaction_dirty; + +use std::rc::Rc; +use std::sync::Arc; +use std::time::{Duration, SystemTime}; + +use gtk4::glib; +use gtk4::prelude::*; +use gtk4::subclass::prelude::*; +use libadwaita as adw; +use libadwaita::prelude::*; +use sourceview5::prelude::*; +use tablepro_core::dialect::SqlDialect; +use tablepro_core::sql_syntax::SqlGrammar; +use tablepro_core::{RowLimit, UserSql}; +use tablepro_session::runtime::{ProcessHold, Tasks}; +use tokio_util::sync::CancellationToken; + +use self::buffer_edit::{CursorAnchor, replace_all_undoable}; +pub(crate) use self::editor_file::file_name; +use self::imp::RunningScript; +use self::result_widgets::PageActions; +use self::run_log::{EntryKind, RunEvent, RunLog}; +use self::run_scope::ScopedText; +use self::script_runner::{RUN_ROW_LIMIT, ScriptRun, plan_batches, run_in_tab}; +use super::clipboard::{CopyRequest, InClauseRows}; +use super::export::{ExportOffer, ExportOrigin, ExportQuery, ExportSource}; +use super::grid::{GridMsg, GridSink, present_row_json}; +use super::handler_guard::HandlerGuard; +use crate::services::active_session::ActiveSession; +use crate::services::history_service::HistoryService; +use crate::workspace::WorkOutcome; + +glib::wrapper! { + pub struct SqlEditor(ObjectSubclass) + @extends adw::Bin, gtk4::Widget, + @implements gtk4::Accessible, gtk4::Buildable, gtk4::ConstraintTarget; +} + +pub struct SqlEditorInit { + pub initial_query: Option, + pub settings: Rc, + pub history: Option, + pub tasks: Tasks, + /// The window's session, which the tab opens its own connection + /// from on the first run. + pub session: ActiveSession, + /// Keeps the process alive while the tab's connection closes. + pub hold: Rc, + pub close_timeout: Duration, +} + +pub(crate) struct EditorDeps { + settings: Rc, + history: Option, + tasks: Tasks, + session: ActiveSession, + hold: Rc, + close_timeout: Duration, +} + +impl SqlEditor { + pub fn new(init: SqlEditorInit) -> Self { + let editor: Self = glib::Object::new(); + let imp = editor.imp(); + let _ = imp.deps.set(EditorDeps { + settings: init.settings.clone(), + history: init.history, + tasks: init.tasks, + session: init.session, + hold: init.hold, + close_timeout: init.close_timeout, + }); + let initial_text = init.initial_query.unwrap_or_else(|| "SELECT 1;".to_owned()); + let buffer = match sourceview5::LanguageManager::default().language("sql") { + Some(language) => sourceview5::Buffer::with_language(&language), + None => sourceview5::Buffer::new(None), + }; + // The text the tab opens with is not an edit to take back. + buffer.set_text(&initial_text); + imp.source_view.set_buffer(Some(&buffer)); + imp.find_bar.attach(&imp.source_view); + let toasts = editor.downgrade(); + imp.find_bar.connect_toast(move |_, text| { + if let Some(editor) = toasts.upgrade() { + editor.emit_by_name::<()>("toast", &[&text]); + } + }); + editor.connect_theme(&init.settings); + editor.connect_completion(&buffer); + editor.connect_buffer(&buffer); + editor.follow_file_state(&buffer); + editor.install_actions(); + editor.install_drop_target(); + editor.connect_buttons(); + editor.connect_grid(); + editor + } + + /// The text buffer, so the draft writer can read the script once per + /// write rather than carrying a copy of it per keystroke. + pub fn buffer(&self) -> gtk4::TextBuffer { + self.imp().source_view.buffer() + } + + pub fn is_running(&self) -> bool { + self.imp().running.borrow().is_some() + } + + /// Whether the tab's connection holds a transaction the user opened. + pub fn has_open_transaction(&self) -> bool { + self.imp().transaction_dirty.get() + } + + /// Whether the last run sent a statement that can change what the + /// catalog lists, so the window reads it again once the run ends. + pub(crate) fn last_run_changes_catalog(&self) -> bool { + self.imp().changes_catalog.get() + } + + /// Stop the running query. The results list says what ran before. + pub fn stop(&self) { + if let Some(running) = self.imp().running.borrow().as_ref() { + running.cancel.cancel(); + } + } + + /// Put a query from history or a saved query in the buffer, as one + /// step Ctrl+Z takes back. + pub fn replace_query(&self, text: &str) { + let buffer = self.buffer(); + if buffer_text(&buffer) == text { + return; + } + replace_all_undoable(&buffer, text); + self.emit_by_name::<()>("undoable-toast", &[&crate::i18n::gettext("Query replaced")]); + } + + /// Take back the last edit, for a toast's Undo button. + pub fn undo(&self) { + let buffer = self.buffer(); + if buffer.can_undo() { + buffer.undo(); + } + } + + /// Stop the running query and hand over the tab's connection, so a + /// teardown closes every tab's at once under one hold. + pub fn take_editor_session(&self) -> Option> { + let imp = self.imp(); + if imp.shut_down.replace(true) { + return None; + } + if let Some(running) = imp.running.take() { + running.cancel.cancel(); + running.drain.abort(); + } + imp.slot.close() + } + + /// Close the tab's connection. The process is held until the close + /// ends, so quitting right after closing a tab does not cut it off. + pub fn shutdown(&self) { + let (Some(editor), Some(deps)) = (self.take_editor_session(), self.deps()) else { + return; + }; + let hold = deps.hold.acquire(); + let closing = tablepro_session::close_editor_sessions(vec![editor], deps.close_timeout, &deps.tasks); + glib::spawn_future_local(async move { + match closing.await { + Ok(reports) => tracing::debug!(?reports, "closed an editor tab's session"), + Err(failure) => tracing::warn!(%failure, "closing an editor tab's session failed"), + } + drop(hold); + }); + } + + /// Run the selection, or the whole buffer when nothing is selected. + pub fn run(&self) { + let buffer = self.buffer(); + let text = buffer_text(&buffer); + let selection = buffer.selection_bounds().map(|(start, end)| { + let start = usize::try_from(start.offset()).unwrap_or_default(); + let end = usize::try_from(end.offset()).unwrap_or_default(); + start..end + }); + match run_scope::for_run(&text, selection) { + Some(scoped) => self.execute(scoped), + None => self.say(&crate::i18n::gettext("There is nothing to run.")), + } + } + + /// Run the statement the cursor is in, selecting it so the user sees + /// which of the buffer's queries ran. + pub fn run_at_cursor(&self) { + if self.is_running() { + return; + } + let buffer = self.buffer(); + let text = buffer_text(&buffer); + let plan = statement_cursor::plan_for(&text, self.grammar()); + let cursor = usize::try_from(buffer.iter_at_mark(&buffer.get_insert()).offset()).unwrap_or_default(); + let Some((start, end)) = statement_cursor::statement_bounds_at(&buffer, &plan, &text, cursor) else { + self.say(&crate::i18n::gettext("No statement at the cursor.")); + return; + }; + buffer.select_range(&start, &end); + self.execute(ScopedText { + text: buffer.text(&start, &end, false).to_string(), + base_char: usize::try_from(start.offset()).unwrap_or_default(), + }); + } + + /// Reformat the buffer per statement, as one undo step, keeping the + /// cursor near where it was. + pub fn format(&self) { + let buffer = self.buffer(); + let text = buffer_text(&buffer); + if text.trim().is_empty() { + return; + } + let grammar = self.grammar(); + let settings = tablepro_core::sql_syntax::script::LexicalSettings::default_for(grammar); + let formatted = format_plan::format_script(&text, grammar, settings); + if formatted == text { + return; + } + let anchor = CursorAnchor::capture(&buffer); + replace_all_undoable(&buffer, &formatted); + anchor.restore(&buffer); + self.imp().source_view.scroll_mark_onscreen(&buffer.get_insert()); + } + + /// Read a dropped or chosen file and put it in the buffer: the whole + /// buffer when it is empty, at the cursor otherwise. A file that + /// fills the buffer is the one Save writes back to. + pub async fn insert_file(&self, file: gio::File, replace: bool) { + let text = match sql_file::load_text(&file).await { + Ok(text) => text, + Err(message) => { + self.emit_by_name::<()>("toast", &[&message]); + return; + } + }; + let buffer = self.buffer(); + if replace || buffer_text(&buffer).trim().is_empty() { + replace_all_undoable(&buffer, &text); + self.opened_file(file); + } else { + buffer.begin_user_action(); + buffer.insert_at_cursor(&text); + buffer.end_user_action(); + } + } + + pub fn connect_run_state_changed(&self, f: F) -> glib::SignalHandlerId { + self.connect_closure( + "run-state-changed", + false, + glib::closure_local!(move |editor: &Self, running: bool| f(editor, running)), + ) + } + + /// A run ended on its own or was stopped. A tab closed while it runs + /// says nothing. + pub(crate) fn connect_run_finished( + &self, + f: F, + ) -> glib::SignalHandlerId { + self.connect_closure( + "run-finished", + false, + glib::closure_local!(move |editor: &Self, outcome: crate::workspace::WorkOutcome| f(editor, outcome)), + ) + } + + pub fn connect_content_changed(&self, f: F) -> glib::SignalHandlerId { + self.connect_closure( + "content-changed", + false, + glib::closure_local!(move |editor: &Self| f(editor)), + ) + } + + pub fn connect_toast(&self, f: F) -> glib::SignalHandlerId { + self.connect_closure( + "toast", + false, + glib::closure_local!(move |editor: &Self, text: String| f(editor, text)), + ) + } + + /// A toast whose Undo button calls `undo`. + pub fn connect_undoable_toast(&self, f: F) -> glib::SignalHandlerId { + self.connect_closure( + "undoable-toast", + false, + glib::closure_local!(move |editor: &Self, text: String| f(editor, text)), + ) + } + + pub(crate) fn connect_export_results(&self, f: F) -> glib::SignalHandlerId { + self.connect_closure( + "export-results", + false, + glib::closure_local!(move |editor: &Self, payload: glib::BoxedAnyObject| { + f(editor, payload.borrow::().clone()); + }), + ) + } + + pub fn connect_transaction_changed(&self, f: F) -> glib::SignalHandlerId { + self.connect_closure( + "transaction-changed", + false, + glib::closure_local!(move |editor: &Self, open: bool| f(editor, open)), + ) + } + + fn deps(&self) -> Option<&EditorDeps> { + self.imp().deps.get() + } + + /// The SQL grammar of the server the editor is pointed at, which + /// decides where statements begin and end. With no connection the + /// split still has to work, and PostgreSQL's is the strictest. + fn grammar(&self) -> SqlGrammar { + self.deps() + .and_then(|deps| deps.session.get()) + .and_then(|session| session.current_dialect()) + .map_or(SqlGrammar::PostgreSql, |dialect| dialect.grammar()) + } + + /// A one-line message where the run summary goes. + fn say(&self, message: &str) { + let results = &self.imp().results; + if !self.is_running() { + results.set_summary(message); + } + } + + fn execute(&self, scoped: ScopedText) { + let imp = self.imp(); + if self.is_running() || imp.shut_down.get() { + return; + } + let Some(deps) = self.deps() else { + return; + }; + let Some(session) = deps.session.get() else { + self.say(&crate::i18n::gettext("Connect to a database to run a query.")); + return; + }; + let buffer = self.buffer(); + let base_line = usize::try_from( + buffer + .iter_at_offset(i32::try_from(scoped.base_char).unwrap_or(i32::MAX)) + .line(), + ) + .unwrap_or_default(); + let grammar = self.grammar(); + let (batches, policy, warnings) = plan_batches(&scoped, grammar, base_line); + if batches.is_empty() { + self.say(&crate::i18n::gettext("There is nothing to run.")); + return; + } + imp.changes_catalog.set( + batches + .iter() + .any(|batch| catalog_change::changes_catalog(&batch.text, grammar)), + ); + let statements = batches.iter().map(|batch| UserSql::new(batch.text.as_str())).collect(); + let run = ScriptRun { + batches, + policy, + statement_timeout: deps.settings.query_timeout(), + row_limit: RowLimit::EDITOR_DEFAULT, + run_row_limit: RUN_ROW_LIMIT, + }; + let metadata = session.metadata().clone(); + // Read again at the end from the run's own session, never the + // window's: the link can come up during the run, and the window + // can move to another engine before it ends. + let dialect = session.current_dialect(); + let ran_on = session.clone(); + let started_at = SystemTime::now(); + let cancel = CancellationToken::new(); + let (events, mut received) = tokio::sync::mpsc::channel(8); + drop(deps.tasks.spawn_task(run_in_tab( + session, + imp.slot.clone(), + run, + deps.close_timeout, + cancel.clone(), + events, + ))); + + imp.results.clear(); + imp.results.set_running(true); + imp.results.set_summary(&crate::i18n::gettext("Running…")); + imp.run_button.set_sensitive(false); + imp.stop_button.set_visible(true); + imp.log.replace(Some(RunLog::new(statements, warnings))); + self.show_new_entries(0); + self.emit_by_name::<()>("run-state-changed", &[&true]); + + let weak = self.downgrade(); + let query = scoped.text; + let drain = glib::spawn_future_local(async move { + while let Some(event) = received.recv().await { + let Some(editor) = weak.upgrade() else { + return; + }; + let finished = matches!(event, RunEvent::Finished); + editor.apply_event(event); + if finished { + let dialect = dialect.or_else(|| ran_on.current_dialect()); + editor.finish_run(query, &metadata, started_at, dialect); + return; + } + } + // The runner ended without saying so, which only a panic + // does; the list still has to end and the tab unlock. + if let Some(editor) = weak.upgrade() { + editor.apply_event(RunEvent::Finished); + let dialect = dialect.or_else(|| ran_on.current_dialect()); + editor.finish_run(query, &metadata, started_at, dialect); + } + }); + imp.running.replace(Some(RunningScript { cancel, drain })); + } + + fn apply_event(&self, event: RunEvent) { + let before = match self.imp().log.borrow_mut().as_mut() { + Some(log) => log.apply(event), + None => return, + }; + self.show_new_entries(before); + } + + fn show_new_entries(&self, from: usize) { + let imp = self.imp(); + let log = imp.log.borrow(); + let Some(log) = log.as_ref() else { + return; + }; + imp.results.append(log.entries().get(from..).unwrap_or_default()); + } + + fn result_page(&self, index: u32) -> Option { + let imp = self.imp(); + let grid_sink = imp.grid_sink.get()?; + let log = imp.log.borrow(); + let entry = log.as_ref()?.entries().get(usize::try_from(index).ok()?)?; + let (errors, exports) = (self.downgrade(), self.downgrade()); + let actions = PageActions { + go_to_error: move |position| { + if let Some(editor) = errors.upgrade() { + editor.go_to_char(position); + } + }, + export_all: move |shown| { + if let Some(editor) = exports.upgrade() { + editor.offer_export(index, shown); + } + }, + }; + Some(result_widgets::entry_widget( + usize::try_from(index).ok()?, + entry, + grid_sink, + actions, + )) + } + + fn finish_run( + &self, + query: String, + metadata: &tablepro_session::ConnectionMetadata, + started_at: SystemTime, + dialect: Option>, + ) { + let imp = self.imp(); + drop(imp.running.take()); + imp.run_button.set_sensitive(true); + imp.stop_button.set_visible(false); + imp.results.set_running(false); + let (summary, label, dirty, first, entry, outcome) = { + let log = imp.log.borrow(); + let Some(log) = log.as_ref() else { + return; + }; + ( + log.summary(), + transaction_dirty::transaction_label(log.lost(), log.transaction()), + transaction_dirty::transaction_dirty(log.lost(), log.transaction(), log.has_run()), + log.first_to_show(), + run_history::history_entry(query, metadata, started_at, log), + log.outcome(), + ) + }; + imp.results.set_summary(&summary); + // The focus stays in the editor, where the summary does not show. + let priority = match outcome { + WorkOutcome::Failed => gtk4::AccessibleAnnouncementPriority::High, + WorkOutcome::Succeeded | WorkOutcome::Cancelled => gtk4::AccessibleAnnouncementPriority::Medium, + }; + crate::ui::a11y::announce(&*imp.results, &summary, priority); + imp.results.set_transaction(label.as_deref()); + if let Some(first) = first.and_then(|first| u32::try_from(first).ok()) { + imp.results.select(first); + } + if imp.transaction_dirty.replace(dirty) != dirty { + self.emit_by_name::<()>("transaction-changed", &[&dirty]); + } + self.record_history(entry, dialect); + self.emit_by_name::<()>("run-finished", &[&outcome]); + self.emit_by_name::<()>("run-state-changed", &[&false]); + } + + fn record_history(&self, entry: tablepro_storage::query_history::NewEntry, dialect: Option>) { + let Some(deps) = self.deps() else { + return; + }; + let Some(history) = deps.history.as_ref() else { + return; + }; + drop(deps.tasks.spawn_task(history.record(entry, dialect))); + } + + /// Put the cursor where the server says a statement went wrong. + fn go_to_char(&self, position: usize) { + let imp = self.imp(); + let buffer = self.buffer(); + let iter = buffer.iter_at_offset(i32::try_from(position).unwrap_or(i32::MAX)); + buffer.place_cursor(&iter); + imp.source_view.scroll_mark_onscreen(&buffer.get_insert()); + imp.source_view.grab_focus(); + } + + fn connect_theme(&self, settings: &Rc) { + let view = self.imp().source_view.get(); + view.set_monospace(true); + crate::ui::style_scheme_resolver::apply(&view, &settings.style_scheme()); + let style_manager = adw::StyleManager::default(); + let settings_for_theme = Rc::downgrade(settings); + let dark = style_manager.connect_dark_notify(glib::clone!( + #[weak] + view, + move |_| { + if let Some(settings) = settings_for_theme.upgrade() { + crate::ui::style_scheme_resolver::apply(&view, &settings.style_scheme()); + } + } + )); + let scheme = settings.gio().connect_changed( + Some(tablepro_storage::settings::keys::STYLE_SCHEME), + glib::clone!( + #[weak] + view, + move |settings, key| crate::ui::style_scheme_resolver::apply(&view, &settings.string(key)) + ), + ); + let mut handlers = vec![ + HandlerGuard::new(&style_manager, dark), + HandlerGuard::new(settings.gio(), scheme), + ]; + crate::ui::editor_font_provider::apply(settings); + for key in [ + tablepro_storage::settings::keys::USE_SYSTEM_FONT, + tablepro_storage::settings::keys::CUSTOM_FONT, + ] { + let settings_for_key = Rc::downgrade(settings); + let font = settings.gio().connect_changed(Some(key), move |_, _| { + if let Some(settings) = settings_for_key.upgrade() { + crate::ui::editor_font_provider::apply(&settings); + } + }); + handlers.push(HandlerGuard::new(settings.gio(), font)); + } + self.imp().app_handlers.replace(handlers); + } + + fn connect_completion(&self, buffer: &sourceview5::Buffer) { + let completion = self.imp().source_view.completion(); + let mut providers = self.imp().completion_providers.borrow_mut(); + if let Some(deps) = self.deps() { + let catalog = completion::SqlCompletionProvider::new(deps.session.clone(), deps.tasks.clone()); + completion.add_provider(&catalog); + providers.push(catalog.upcast()); + } + let words = sourceview5::CompletionWords::new(Some(&crate::i18n::pgettext("completion group", "Document"))); + words.register(buffer); + completion.add_provider(&words); + providers.push(words.upcast()); + } + + // GtkSourceView before 5.21 never frees a view that has drawn its line + // numbers, so the closing editor takes back the script and the session. + // GTK 4.24 gives the input method the view from construction until the + // view's own dispose, and IBus takes a reference on it, so the view is + // never disposed unless it drops its input method first + // (https://github.com/ibus/ibus/issues/2968). + fn release_view(&self) { + let view = self.imp().source_view.get(); + view.set_im_module(Some("gtk-im-context-none")); + let completion = view.completion(); + for provider in self.imp().completion_providers.take() { + completion.remove_provider(&provider); + } + view.set_buffer(Some(&sourceview5::Buffer::new(None))); + } + + fn connect_buffer(&self, buffer: &sourceview5::Buffer) { + let imp = self.imp(); + let cursor_info = imp.cursor_info.get(); + show_cursor_position(&cursor_info, buffer); + buffer.connect_cursor_position_notify(glib::clone!( + #[weak] + cursor_info, + move |buffer| show_cursor_position(&cursor_info, buffer) + )); + + let weak = self.downgrade(); + buffer.connect_changed(move |_| { + if let Some(editor) = weak.upgrade() { + editor.emit_by_name::<()>("content-changed", &[]); + } + }); + + let run_button = imp.run_button.get(); + buffer.connect_has_selection_notify(glib::clone!( + #[weak] + run_button, + move |buffer| label_run_button(&run_button, buffer.has_selection()) + )); + } + + fn connect_buttons(&self) { + let imp = self.imp(); + let weak = self.downgrade(); + imp.run_button.connect_clicked(move |_| { + if let Some(editor) = weak.upgrade() { + editor.run(); + } + }); + let weak = self.downgrade(); + imp.stop_button.connect_clicked(move |_| { + if let Some(editor) = weak.upgrade() { + editor.stop(); + } + }); + imp.menu_button.set_icon_name(crate::ui::icons::VIEW_MORE); + } + + /// Result pages, and the context-menu actions from their grids. + fn connect_grid(&self) { + let weak = self.downgrade(); + self.imp() + .results + .set_page_builder(move |index| weak.upgrade().and_then(|editor| editor.result_page(index))); + let weak = self.downgrade(); + let sink: GridSink = std::rc::Rc::new(move |message| { + let Some(editor) = weak.upgrade() else { + return; + }; + match message { + GridMsg::Copy(request) => editor.copy(request), + GridMsg::CopyInClause(values) => editor.copy_in_clause(values), + GridMsg::ShowRowJson(request) => { + if let Some(tasks) = editor.deps().map(|deps| deps.tasks.clone()) { + let parent = editor.clone().upcast(); + glib::spawn_future_local(async move { + present_row_json(parent, tasks, request).await; + }); + } + } + GridMsg::ShowToast(text) => editor.emit_by_name::<()>("toast", &[&text]), + GridMsg::ExportResults(shown) => { + if let Some(index) = editor.imp().results.selected() { + editor.offer_export(index, shown); + } + } + _ => {} + } + }); + let _ = self.imp().grid_sink.set(sink); + } + + /// Render a copy from a result grid off the GTK thread, and toast + /// how it went. + fn copy(&self, request: CopyRequest) { + let Some(tasks) = self.deps().map(|deps| deps.tasks.clone()) else { + return; + }; + let editor = self.downgrade(); + crate::ui::clipboard::copy_rows(self, &tasks, request, move |text| { + if let Some(editor) = editor.upgrade() { + editor.emit_by_name::<()>("toast", &[&text]); + } + }); + } + + /// An IN list in the spelling of the server the editor runs on. + fn copy_in_clause(&self, values: InClauseRows) { + let dialect = self + .deps() + .and_then(|deps| deps.session.get()) + .and_then(|session| session.current_dialect()); + match dialect { + Some(dialect) => self.copy(CopyRequest::InClause { dialect, values }), + None => self.emit_by_name::<()>("toast", &[&crate::i18n::gettext("Connect to a database first.")]), + } + } + + /// Ask the window to export the result on line `index`: the rows + /// its grid shows, or every row its statement returns when run + /// again. + fn offer_export(&self, index: u32, shown: ExportSource) { + let Some(offer) = self.export_offer(index, shown) else { + return; + }; + self.emit_by_name::<()>("export-results", &[&glib::BoxedAnyObject::new(offer)]); + } + + fn export_offer(&self, index: u32, shown: ExportSource) -> Option { + let log = self.imp().log.borrow(); + let entry = log.as_ref()?.entries().get(usize::try_from(index).ok()?)?; + let EntryKind::ResultSet(result) = &entry.kind else { + return None; + }; + Some(ExportOffer { + origin: ExportOrigin::Editor { + shown, + query: ExportQuery::Editor { + sql: result.statement.clone(), + }, + shown_rows: result.row_count() as u64, + truncated: result.summary.is_truncated(), + }, + session: self.deps().and_then(|deps| deps.session.get()), + name: export_name_for_query(&entry.preview), + target: None, + }) + } + + fn install_drop_target(&self) { + let target = gtk4::DropTarget::new(gio::File::static_type(), gtk4::gdk::DragAction::COPY); + let weak = self.downgrade(); + target.connect_drop(move |_, value, _, _| { + let (Ok(file), Some(editor)) = (value.get::(), weak.upgrade()) else { + return false; + }; + // Adding to what is there keeps the user's work; Ctrl+Z + // takes the drop back. + glib::spawn_future_local(async move { editor.insert_file(file, false).await }); + true + }); + self.imp().source_view.add_controller(target); + } + + fn open_file(&self) { + if buffer_text(&self.buffer()).trim().is_empty() { + self.choose_file(); + return; + } + // Loading replaces what is in the tab, which is where the user's + // unrun work lives, so it asks first. + let dialog = adw::AlertDialog::new( + Some(&crate::i18n::gettext("Replace the Editor Contents?")), + Some(&crate::i18n::gettext( + "The file is loaded into this tab, and what is written here now is replaced. Undo brings it back.", + )), + ); + dialog.add_response("cancel", &crate::i18n::gettext("Cancel")); + dialog.add_response("replace", &crate::i18n::gettext("Replace")); + dialog.set_default_response(Some("cancel")); + dialog.set_close_response("cancel"); + let weak = self.downgrade(); + dialog.connect_response(Some("replace"), move |_, _| { + if let Some(editor) = weak.upgrade() { + editor.choose_file(); + } + }); + dialog.present(Some(self)); + } + + fn choose_file(&self) { + let filter = editor_file::sql_filter(); + let filters = gio::ListStore::new::(); + filters.append(&filter); + let dialog = gtk4::FileDialog::builder() + .title(crate::i18n::gettext("Open SQL File")) + .modal(true) + .default_filter(&filter) + .filters(&filters) + .build(); + let parent = self.root().and_downcast::(); + let weak = self.downgrade(); + glib::spawn_future_local(async move { + let Ok(file) = dialog.open_future(parent.as_ref()).await else { + return; + }; + if let Some(editor) = weak.upgrade() { + editor.insert_file(file, true).await; + } + }); + } +} + +fn show_cursor_position(label: >k4::Label, buffer: &sourceview5::Buffer) { + let iter = buffer.iter_at_mark(&buffer.get_insert()); + label.set_label(&crate::i18n::gettext_f( + "Ln {line}, Col {column}", + &[ + ("line", &(iter.line() + 1).to_string()), + ("column", &(iter.line_offset() + 1).to_string()), + ], + )); +} + +fn label_run_button(button: >k4::Button, has_selection: bool) { + if has_selection { + button.set_label(&crate::i18n::gettext("Run Selection")); + button.set_tooltip_text(Some(&crate::i18n::gettext("Run the Selected Text"))); + } else { + button.set_label(&crate::i18n::gettext("Run")); + button.set_tooltip_text(Some(&crate::i18n::gettext("Run the Script"))); + } +} + +fn buffer_text(buffer: &impl IsA) -> String { + let buffer = buffer.as_ref(); + let (start, end) = buffer.bounds(); + buffer.text(&start, &end, false).to_string() +} + +/// Toggle SQL line-comment (`-- `) for the lines in the buffer's +/// current selection (or the cursor's line when nothing is selected). +/// If every non-blank line in the range is already commented, strip +/// the prefix; otherwise prepend `-- ` after each line's leading +/// whitespace. Blank lines are skipped in both directions so toggling +/// twice returns the original text, and the whole edit is one undo step. +fn toggle_line_comment(buffer: >k4::TextBuffer) { + let (sel_start, sel_end) = buffer.selection_bounds().unwrap_or_else(|| { + let i = buffer.iter_at_mark(&buffer.get_insert()); + (i, i) + }); + let start_line = sel_start.line(); + let mut end_line = sel_end.line(); + // A selection that ends at column 0 of the next line does not take + // that line in, as in other editors. + if sel_end.line_offset() == 0 && end_line > start_line { + end_line -= 1; + } + + let lines: Vec = (start_line..=end_line) + .map(|l| { + let Some(s) = buffer.iter_at_line(l) else { + return String::new(); + }; + let mut e = s; + e.forward_to_line_end(); + buffer.text(&s, &e, false).to_string() + }) + .collect(); + + let all_commented = lines + .iter() + .filter(|l| !l.trim().is_empty()) + .all(|l| l.trim_start().starts_with("--")); + + buffer.begin_user_action(); + for (offset, original) in lines.iter().enumerate() { + if original.trim().is_empty() { + continue; + } + let line_n = start_line + offset as i32; + let leading_chars: i32 = original.chars().take_while(|c| c.is_whitespace()).count() as i32; + let Some(mut iter) = buffer.iter_at_line(line_n) else { + continue; + }; + iter.forward_chars(leading_chars); + + if all_commented { + let trimmed = original.trim_start(); + let strip_chars: i32 = if trimmed.starts_with("-- ") { + 3 + } else if trimmed.starts_with("--") { + 2 + } else { + 0 + }; + if strip_chars > 0 { + let mut end = iter; + end.forward_chars(strip_chars); + buffer.delete(&mut iter, &mut end); + } + } else { + buffer.insert(&mut iter, "-- "); + } + } + buffer.end_user_action(); +} + +/// File-name stem for an editor export, taken from the statement that +/// produced the results: exporting two queries in a row proposes two +/// different files instead of offering to overwrite the first. +pub fn export_name_for_query(query: &str) -> String { + if query.trim().is_empty() { + return crate::i18n::gettext("query-results"); + } + let mut stem = String::new(); + for c in derive_tab_label(query).chars() { + if c.is_alphanumeric() { + stem.extend(c.to_lowercase()); + } else if !stem.ends_with('-') { + stem.push('-'); + } + } + match stem.trim_matches('-') { + "" => crate::i18n::gettext("query-results"), + trimmed => trimmed.to_string(), + } +} + +pub fn derive_tab_label(query: &str) -> String { + for line in query.lines() { + let trimmed = line.trim(); + if trimmed.is_empty() || trimmed.starts_with("--") { + continue; + } + let cleaned: String = trimmed.chars().take(30).collect(); + if cleaned.chars().count() < trimmed.chars().count() { + return format!("{cleaned}…"); + } + return cleaned; + } + crate::i18n::gettext("Empty Query") +} diff --git a/linux/crates/app/src/ui/editor/result_item.rs b/linux/crates/app/src/ui/editor/result_item.rs new file mode 100644 index 0000000000..ce1427e5dd --- /dev/null +++ b/linux/crates/app/src/ui/editor/result_item.rs @@ -0,0 +1,51 @@ +use std::cell::RefCell; + +use gtk4::glib; +use gtk4::subclass::prelude::*; + +mod imp { + use super::*; + + #[derive(Default)] + pub struct ResultItem { + pub title: RefCell, + pub subtitle: RefCell, + pub icon_name: RefCell, + } + + #[glib::object_subclass] + impl ObjectSubclass for ResultItem { + const NAME: &'static str = "TableProResultItem"; + type Type = super::ResultItem; + } + + impl ObjectImpl for ResultItem {} +} + +glib::wrapper! { + /// One line of the results list. + pub struct ResultItem(ObjectSubclass); +} + +impl ResultItem { + pub fn new(title: &str, subtitle: &str, icon_name: &str) -> Self { + let item: Self = glib::Object::new(); + let imp = item.imp(); + imp.title.replace(title.to_owned()); + imp.subtitle.replace(subtitle.to_owned()); + imp.icon_name.replace(icon_name.to_owned()); + item + } + + pub fn title(&self) -> String { + self.imp().title.borrow().clone() + } + + pub fn subtitle(&self) -> String { + self.imp().subtitle.borrow().clone() + } + + pub fn icon_name(&self) -> String { + self.imp().icon_name.borrow().clone() + } +} diff --git a/linux/crates/app/src/ui/editor/result_widgets.rs b/linux/crates/app/src/ui/editor/result_widgets.rs new file mode 100644 index 0000000000..82783c34ea --- /dev/null +++ b/linux/crates/app/src/ui/editor/result_widgets.rs @@ -0,0 +1,197 @@ +use gtk4::prelude::*; +use libadwaita as adw; + +use super::run_log::{EntryKind, RunLogEntry}; +use crate::ui::export::ExportSource; +use crate::ui::grid::GridSink; +use crate::ui::icons; +use crate::ui::result_pane::result_set_page; + +/// What a page's buttons reach in the editor. +pub struct PageActions { + /// Put the editor cursor where the server says a statement went + /// wrong. + pub go_to_error: G, + /// Offer every row of a result the row limit cut short, with the + /// rows its grid shows. + pub export_all: E, +} + +/// The widget the results list's `index`th line shows. +pub fn entry_widget( + index: usize, + entry: &RunLogEntry, + grid_sink: &GridSink, + actions: PageActions, +) -> gtk4::Widget +where + G: Fn(usize) + 'static, + E: Fn(ExportSource) + 'static, +{ + let PageActions { + go_to_error, + export_all, + } = actions; + match &entry.kind { + EntryKind::ResultSet(result) => { + result_set_page(result, &result.summary.tab_label(index), grid_sink.clone(), export_all) + } + EntryKind::Command { rows_affected } => { + let title = match rows_affected { + Some(rows) => crate::i18n::ngettext_f( + "{n} Row Affected", + "{n} Rows Affected", + u32::try_from(*rows).unwrap_or(u32::MAX), + &[("n", &rows.to_string())], + ), + None => crate::i18n::gettext("Command Completed"), + }; + status_page(&title, &entry.preview, icons::SUCCESS).upcast() + } + EntryKind::Notice { message } => { + status_page(&crate::i18n::gettext("Notice"), message, icons::TEXT_X_GENERIC).upcast() + } + EntryKind::Failed { message, error_char } => { + let page = status_page(&crate::i18n::gettext("Statement Failed"), message, icons::DIALOG_ERROR); + if let Some(position) = *error_char { + let button = gtk4::Button::builder() + .label(crate::i18n::gettext("Go to Error")) + .halign(gtk4::Align::Center) + .build(); + button.add_css_class("pill"); + button.connect_clicked(move |_| go_to_error(position)); + page.set_child(Some(&button)); + } + page.upcast() + } + EntryKind::ClientWarning { message } => status_page( + &crate::i18n::gettext("Check the Script"), + message, + icons::DIALOG_WARNING, + ) + .upcast(), + EntryKind::Cancelled => status_page( + &crate::i18n::gettext("Query Stopped"), + &crate::i18n::gettext("The running query was stopped."), + icons::PROCESS_STOP, + ) + .upcast(), + EntryKind::TimedOut { server_cancelled } => { + let description = if *server_cancelled { + crate::i18n::gettext( + "The statement ran past the time limit set in Preferences, so the server stopped it.", + ) + } else { + crate::i18n::gettext( + "The statement ran past the time limit set in Preferences. The server may still be running it.", + ) + }; + status_page( + &crate::i18n::gettext("Query Timed Out"), + &description, + icons::DIALOG_WARNING, + ) + .upcast() + } + EntryKind::NotRun => status_page( + &crate::i18n::gettext("Not Run"), + &crate::i18n::gettext("Skipped because an earlier statement failed or the run was stopped."), + icons::STATEMENT_PENDING, + ) + .upcast(), + } +} + +/// A status page whose description is shown as written. The property +/// takes Pango markup, so a server message with a `<` in it would be +/// read as markup otherwise. +fn status_page(title: &str, description: &str, icon: &str) -> adw::StatusPage { + adw::StatusPage::builder() + .title(title) + .description(glib::markup_escape_text(description).as_str()) + .icon_name(icon) + .vexpand(true) + .build() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sink() -> GridSink { + std::rc::Rc::new(|_| {}) + } + + fn actions(go_to_error: G) -> PageActions { + PageActions { + go_to_error, + export_all: |_| {}, + } + } + + #[gtk4::test] + fn a_result_grid_is_named_as_its_line_in_the_list() { + let columns: std::sync::Arc<[tablepro_core::column::ResultColumn]> = + std::sync::Arc::from([tablepro_core::column::ResultColumn::new( + "a", + tablepro_core::column::ColumnType::untyped_text("text"), + )]); + let view = crate::ui::result_pane::ResultSetView::new(columns, tablepro_core::UserSql::new("SELECT a FROM t")); + let name = view.summary.tab_label(1); + let entry = RunLogEntry { + kind: EntryKind::ResultSet(view), + batch: Some(0), + preview: String::new(), + }; + + let widget = entry_widget(1, &entry, &sink(), actions(|_| {})); + + let grid = crate::test_support::first_descendant_of_type::(&widget).expect("the grid"); + assert_eq!(name, "Result 2 (0)"); + assert!(gtk4::test_accessible_has_property( + &grid, + gtk4::AccessibleProperty::Label + )); + } + + #[gtk4::test] + fn markup_in_error_is_rendered_literally() { + let entry = RunLogEntry { + kind: EntryKind::Failed { + message: "column name & more".to_owned(), + error_char: None, + }, + batch: Some(0), + preview: String::new(), + }; + + let widget = entry_widget(0, &entry, &sink(), actions(|_| {})); + + let page = widget.downcast::().expect("a status page"); + assert_eq!( + page.description().as_deref(), + Some("column <b>name</b> & more") + ); + } + + #[gtk4::test] + fn go_to_error_reports_the_buffer_position() { + let entry = RunLogEntry { + kind: EntryKind::Failed { + message: "syntax error".to_owned(), + error_char: Some(42), + }, + batch: Some(0), + preview: String::new(), + }; + let reached = std::rc::Rc::new(std::cell::Cell::new(None)); + let seen = reached.clone(); + + let widget = entry_widget(0, &entry, &sink(), actions(move |position| seen.set(Some(position)))); + let page = widget.downcast::().expect("a status page"); + let button = page.child().and_downcast::().expect("Go to Error"); + button.emit_clicked(); + + assert_eq!(reached.get(), Some(42)); + } +} diff --git a/linux/crates/app/src/ui/editor/results_pane.rs b/linux/crates/app/src/ui/editor/results_pane.rs new file mode 100644 index 0000000000..4124bda55f --- /dev/null +++ b/linux/crates/app/src/ui/editor/results_pane.rs @@ -0,0 +1,496 @@ +use gtk4::glib; +use gtk4::prelude::*; +use gtk4::subclass::prelude::*; +use libadwaita as adw; + +use super::result_item::ResultItem; +use super::run_log::{EntryKind, RunLogEntry}; +use super::sidebar_policy::{self, SidebarState}; + +/// How many results keep their page once shown, so going back to one +/// keeps its scroll position without every line holding a page. +const PAGES_KEPT: usize = 8; + +type PageBuilder = Box Option>; + +mod imp { + use std::cell::{Cell, RefCell}; + use std::collections::VecDeque; + + use libadwaita::subclass::prelude::*; + + use super::*; + + #[derive(Default, gtk4::CompositeTemplate)] + #[template(resource = "/app/tablepro/TablePro/ui/results-pane.ui")] + pub struct ResultsPane { + #[template_child] + pub sidebar_toggle: TemplateChild, + #[template_child] + pub spinner: TemplateChild, + #[template_child] + pub summary_label: TemplateChild, + #[template_child] + pub transaction_label: TemplateChild, + #[template_child] + pub split_view: TemplateChild, + #[template_child] + pub list_view: TemplateChild, + #[template_child] + pub stack: TemplateChild, + pub items: std::cell::OnceCell, + pub selection: std::cell::OnceCell, + pub sidebar: Cell, + pub page_builder: RefCell>, + /// The lines whose page is built, most recently shown first. + pub built: RefCell>, + } + + #[glib::object_subclass] + impl ObjectSubclass for ResultsPane { + const NAME: &'static str = "TableProResultsPane"; + type Type = super::ResultsPane; + type ParentType = adw::Bin; + + fn class_init(klass: &mut Self::Class) { + klass.bind_template(); + } + + fn instance_init(object: &glib::subclass::InitializingObject) { + object.init_template(); + } + } + + impl ObjectImpl for ResultsPane { + fn constructed(&self) { + self.parent_constructed(); + let pane = self.obj(); + self.sidebar_toggle.set_icon_name(crate::ui::icons::SIDEBAR_SHOW); + // The toggle is the way back to a collapsed list, so the two + // can never disagree about whether it shows. + self.sidebar_toggle + .bind_property("active", &*self.split_view, "show-sidebar") + .bidirectional() + .sync_create() + .build(); + + let items = gio::ListStore::new::(); + let selection = gtk4::SingleSelection::new(Some(items.clone())); + selection.set_autoselect(false); + selection.set_can_unselect(false); + let factory = gtk4::SignalListItemFactory::new(); + factory.connect_setup(|_, item| { + if let Some(item) = item.downcast_ref::() { + item.set_child(Some(&row_widget())); + } + }); + factory.connect_bind(|_, item| { + if let Some(item) = item.downcast_ref::() { + bind_row(item); + } + }); + self.list_view.set_factory(Some(&factory)); + self.list_view.set_model(Some(&selection)); + let weak = pane.downgrade(); + selection.connect_selected_notify(move |selection| { + if let Some(pane) = weak.upgrade() { + pane.show_page(selection.selected()); + } + }); + let _ = self.items.set(items); + let _ = self.selection.set(selection); + } + } + + impl WidgetImpl for ResultsPane {} + impl BinImpl for ResultsPane {} +} + +glib::wrapper! { + /// What a run returned: one line per statement in a list beside the + /// result it is showing. The list folds over the result when the + /// pane is narrow, and a toggle brings it back. + pub struct ResultsPane(ObjectSubclass) + @extends adw::Bin, gtk4::Widget, + @implements gtk4::Accessible, gtk4::Buildable, gtk4::ConstraintTarget; +} + +impl Default for ResultsPane { + fn default() -> Self { + glib::Object::new() + } +} + +impl ResultsPane { + /// Forget the last run's results. + pub fn clear(&self) { + let imp = self.imp(); + if let Some(items) = imp.items.get() { + items.remove_all(); + } + for index in imp.built.take() { + if let Some(page) = imp.stack.child_by_name(&page_name(index)) { + imp.stack.remove(&page); + } + } + imp.stack.set_visible_child_name("empty"); + imp.sidebar.set(SidebarState::default()); + imp.sidebar_toggle.set_visible(false); + imp.split_view.set_show_sidebar(false); + imp.transaction_label.set_visible(false); + } + + /// A page is built when its line is first shown, so a script with + /// thousands of statements builds one, not thousands. + pub fn set_page_builder(&self, builder: impl Fn(u32) -> Option + 'static) { + self.imp().page_builder.replace(Some(Box::new(builder))); + } + + pub fn set_running(&self, running: bool) { + self.imp().spinner.set_visible(running); + } + + pub fn set_summary(&self, text: &str) { + self.imp().summary_label.set_label(text); + } + + pub fn set_transaction(&self, label: Option<&str>) { + let imp = self.imp(); + imp.transaction_label.set_label(label.unwrap_or_default()); + imp.transaction_label.set_visible(label.is_some()); + } + + pub fn append(&self, entries: &[RunLogEntry]) { + let imp = self.imp(); + let Some(items) = imp.items.get() else { + return; + }; + if entries.is_empty() { + return; + } + let first = items.n_items(); + let added: Vec = entries + .iter() + .enumerate() + .map(|(offset, entry)| { + let (title, icon) = entry_title(first as usize + offset, entry); + ResultItem::new(&title, &entry.preview, icon) + }) + .collect(); + items.splice(first, 0, &added); + + let previous = SidebarState { + show_sidebar: imp.split_view.shows_sidebar(), + ..imp.sidebar.get() + }; + let state = sidebar_policy::decide(items.n_items() as usize, imp.split_view.is_collapsed(), previous); + imp.sidebar.set(state); + imp.sidebar_toggle.set_visible(state.toggle_visible); + imp.split_view.set_show_sidebar(state.show_sidebar); + if first == 0 { + self.select(0); + } + } + + pub fn select(&self, index: u32) { + let Some(selection) = self.imp().selection.get() else { + return; + }; + if selection.selected() == index { + self.show_page(index); + } else { + selection.set_selected(index); + } + } + + pub fn n_entries(&self) -> u32 { + self.imp().items.get().map_or(0, gio::prelude::ListModelExt::n_items) + } + + pub fn selected(&self) -> Option { + let selected = self.imp().selection.get()?.selected(); + (selected != gtk4::INVALID_LIST_POSITION).then_some(selected) + } + + fn show_page(&self, index: u32) { + let imp = self.imp(); + if index == gtk4::INVALID_LIST_POSITION || !self.keep_page(index) { + return; + } + imp.stack.set_visible_child_name(&page_name(index)); + // A collapsed list covers the result it just picked. + if imp.split_view.is_collapsed() { + imp.split_view.set_show_sidebar(false); + } + } + + fn keep_page(&self, index: u32) -> bool { + let imp = self.imp(); + { + let mut built = imp.built.borrow_mut(); + if let Some(position) = built.iter().position(|kept| *kept == index) { + built.remove(position); + built.push_front(index); + return true; + } + } + let page = imp.page_builder.borrow().as_ref().and_then(|build| build(index)); + let Some(page) = page else { + return false; + }; + imp.stack.add_named(&page, Some(&page_name(index))); + let evicted: Vec = { + let mut built = imp.built.borrow_mut(); + built.push_front(index); + let keep = built.len().min(PAGES_KEPT); + built.drain(keep..).collect() + }; + for oldest in evicted { + if let Some(page) = imp.stack.child_by_name(&page_name(oldest)) { + imp.stack.remove(&page); + } + } + true + } +} + +fn page_name(index: u32) -> String { + format!("entry-{index}") +} + +/// The line's title and icon. +pub fn entry_title(index: usize, entry: &RunLogEntry) -> (String, &'static str) { + use crate::ui::icons; + match &entry.kind { + EntryKind::ResultSet(result) => (result.summary.tab_label(index), icons::VIEW_GRID), + EntryKind::Command { + rows_affected: Some(rows), + } => ( + crate::i18n::ngettext_f( + "{n} Row Affected", + "{n} Rows Affected", + u32::try_from(*rows).unwrap_or(u32::MAX), + &[("n", &rows.to_string())], + ), + icons::SUCCESS, + ), + EntryKind::Command { rows_affected: None } => (crate::i18n::gettext("Command Completed"), icons::SUCCESS), + EntryKind::Notice { .. } => (crate::i18n::gettext("Notice"), icons::TEXT_X_GENERIC), + EntryKind::Failed { .. } => (crate::i18n::gettext("Error"), icons::DIALOG_ERROR), + EntryKind::ClientWarning { .. } => (crate::i18n::gettext("Warning"), icons::DIALOG_WARNING), + EntryKind::Cancelled => (crate::i18n::gettext("Stopped"), icons::PROCESS_STOP), + EntryKind::TimedOut { .. } => (crate::i18n::gettext("Timed Out"), icons::DIALOG_WARNING), + EntryKind::NotRun => (crate::i18n::gettext("Not Run"), icons::STATEMENT_PENDING), + } +} + +fn row_widget() -> gtk4::Box { + let row = gtk4::Box::builder() + .orientation(gtk4::Orientation::Horizontal) + .spacing(8) + .margin_top(4) + .margin_bottom(4) + .build(); + let icon = gtk4::Image::new(); + let labels = gtk4::Box::builder().orientation(gtk4::Orientation::Vertical).build(); + let title = gtk4::Label::builder() + .xalign(0.0) + .ellipsize(gtk4::pango::EllipsizeMode::End) + .build(); + let subtitle = gtk4::Label::builder() + .xalign(0.0) + .ellipsize(gtk4::pango::EllipsizeMode::End) + .build(); + subtitle.add_css_class("dim-label"); + subtitle.add_css_class("caption"); + labels.append(&title); + labels.append(&subtitle); + row.append(&icon); + row.append(&labels); + row +} + +fn bind_row(item: >k4::ListItem) { + let Some(result) = item.item().and_downcast::() else { + return; + }; + let Some(row) = item.child() else { + return; + }; + if let Some(icon) = row.first_child().and_downcast::() { + icon.set_icon_name(Some(&result.icon_name())); + } + let Some(labels) = row.last_child() else { + return; + }; + if let Some(title) = labels.first_child().and_downcast::() { + title.set_label(&result.title()); + } + if let Some(subtitle) = labels.last_child().and_downcast::() { + let preview = result.subtitle(); + subtitle.set_visible(!preview.is_empty()); + subtitle.set_label(&preview); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn pane() -> ResultsPane { + crate::register_test_resources(); + let pane = ResultsPane::default(); + pane.set_page_builder(|index| Some(gtk4::Label::new(Some(&index.to_string())).upcast())); + pane + } + + fn built_pages(pane: &ResultsPane) -> u32 { + // The stack always holds the empty state as well. + pane.imp().stack.pages().n_items() - 1 + } + + fn entry(kind: EntryKind) -> RunLogEntry { + RunLogEntry { + kind, + batch: Some(0), + preview: "SELECT 1".to_owned(), + } + } + + fn command() -> RunLogEntry { + entry(EntryKind::Command { rows_affected: Some(1) }) + } + + #[gtk4::test] + fn twenty_results_use_list_not_switcher() { + let pane = pane(); + + for _ in 0..20 { + pane.append(&[command()]); + } + + let imp = pane.imp(); + assert_eq!(pane.n_entries(), 20); + assert!(imp.sidebar_toggle.is_visible()); + assert!(imp.split_view.shows_sidebar(), "the list stayed closed on a wide pane"); + assert_eq!(imp.stack.visible_child_name().as_deref(), Some("entry-0")); + } + + #[gtk4::test] + fn a_single_result_has_no_list() { + let pane = pane(); + + pane.append(&[command()]); + + assert!(!pane.imp().sidebar_toggle.is_visible()); + assert!(!pane.imp().split_view.shows_sidebar()); + } + + #[gtk4::test] + fn first_failure_is_selected() { + let pane = pane(); + pane.append(&[command()]); + pane.append(&[ + entry(EntryKind::Failed { + message: "boom".to_owned(), + error_char: None, + }), + entry(EntryKind::NotRun), + ]); + + pane.select(1); + + assert_eq!(pane.selected(), Some(1)); + assert_eq!(pane.imp().stack.visible_child_name().as_deref(), Some("entry-1")); + } + + #[gtk4::test] + fn collapsed_selection_hides_sidebar_and_toggle_reopens_list() { + let pane = pane(); + let imp = pane.imp(); + imp.split_view.set_collapsed(true); + for _ in 0..3 { + pane.append(&[command()]); + } + imp.split_view.set_show_sidebar(true); + + pane.select(2); + assert!( + !imp.split_view.shows_sidebar(), + "the list stayed over the picked result" + ); + assert!(!imp.sidebar_toggle.is_active()); + + imp.sidebar_toggle.set_active(true); + assert!(imp.split_view.shows_sidebar(), "the toggle did not bring the list back"); + } + + #[gtk4::test] + fn clearing_forgets_the_last_run() { + let pane = pane(); + pane.append(&[command(), command()]); + pane.select(1); + + pane.clear(); + + assert_eq!(pane.n_entries(), 0); + assert!(!pane.imp().sidebar_toggle.is_visible()); + assert_eq!(pane.imp().stack.visible_child_name().as_deref(), Some("empty")); + assert_eq!(built_pages(&pane), 0, "a page outlived its run"); + pane.append(&[command()]); + assert_eq!(pane.imp().stack.visible_child_name().as_deref(), Some("entry-0")); + } + + #[gtk4::test] + fn the_list_toggle_has_an_accessible_name() { + let pane = pane(); + + assert!(gtk4::test_accessible_has_property( + &pane.imp().sidebar_toggle.get(), + gtk4::AccessibleProperty::Label + )); + } + + #[gtk4::test] + fn a_long_list_builds_only_the_page_it_shows() { + let pane = pane(); + let not_run = entry(EntryKind::NotRun); + let entries = vec![not_run; 100_000]; + + pane.append(&entries); + + assert_eq!(pane.n_entries(), 100_000); + assert_eq!(built_pages(&pane), 1); + assert_eq!(pane.imp().stack.visible_child_name().as_deref(), Some("entry-0")); + } + + #[gtk4::test] + fn paging_through_the_list_keeps_a_bounded_number_of_pages() { + let pane = pane(); + pane.append(&vec![command(); 50]); + + for index in 0..50 { + pane.select(index); + } + + assert_eq!(built_pages(&pane), PAGES_KEPT as u32); + assert_eq!(pane.imp().stack.visible_child_name().as_deref(), Some("entry-49")); + assert!( + pane.imp().stack.child_by_name("entry-0").is_none(), + "the oldest page was kept" + ); + } + + #[gtk4::test] + fn going_back_to_a_recent_result_keeps_its_page() { + let pane = pane(); + pane.append(&[command(), command()]); + pane.select(0); + let first = pane.imp().stack.visible_child().expect("the first page"); + + pane.select(1); + pane.select(0); + + assert_eq!(pane.imp().stack.visible_child().as_ref(), Some(&first)); + } +} diff --git a/linux/crates/app/src/ui/editor/run_history.rs b/linux/crates/app/src/ui/editor/run_history.rs new file mode 100644 index 0000000000..b0a5d73adf --- /dev/null +++ b/linux/crates/app/src/ui/editor/run_history.rs @@ -0,0 +1,108 @@ +use std::time::SystemTime; + +use tablepro_session::ConnectionMetadata; +use tablepro_storage::query_history::{NewEntry, Outcome}; + +use super::run_log::RunLog; + +/// The history line for one press of Run: the SQL that was sent, which +/// is the selection when there was one, and how the run ended. +pub fn history_entry(query: String, metadata: &ConnectionMetadata, executed_at: SystemTime, log: &RunLog) -> NewEntry { + let outcome = if let Some(message) = log.first_failure() { + Outcome::Error(message.to_owned()) + } else if log.timed_out() { + Outcome::Error(crate::i18n::gettext( + "The statement ran past the time limit set in Preferences.", + )) + } else if log.was_stopped() { + Outcome::Cancelled + } else { + Outcome::Success + }; + let duration_ms = SystemTime::now() + .duration_since(executed_at) + .ok() + .and_then(|elapsed| i64::try_from(elapsed.as_millis()).ok()); + NewEntry { + query, + driver_id: metadata.driver_id.clone(), + connection_id: metadata.id, + connection_name: metadata.name.clone(), + executed_at, + duration_ms, + rows_affected: log.rows_for_history(), + outcome, + } +} + +#[cfg(test)] +mod tests { + use uuid::Uuid; + + use super::super::run_log::{BatchReport, EntryKind, RunEvent, Verdict}; + use super::*; + + fn metadata() -> ConnectionMetadata { + ConnectionMetadata { + id: Uuid::new_v4(), + name: "Prod".to_owned(), + driver_id: "postgres".to_owned(), + } + } + + fn log_of(entries: Vec, verdict: Verdict) -> RunLog { + let mut log = RunLog::new(vec![tablepro_core::UserSql::new("SELECT 2")], Vec::new()); + log.apply(RunEvent::Batch(BatchReport { + batch: 0, + entries, + verdict, + transaction: None, + lost: false, + elapsed_ms: 1, + })); + log.apply(RunEvent::Finished); + log + } + + #[test] + fn history_records_executed_selection() { + let log = log_of(vec![EntryKind::Command { rows_affected: Some(3) }], Verdict::Completed); + let metadata = metadata(); + + let entry = history_entry("SELECT 2".to_owned(), &metadata, SystemTime::now(), &log); + + assert_eq!(entry.query, "SELECT 2"); + assert_eq!(entry.connection_id, metadata.id); + assert_eq!(entry.rows_affected, Some(3)); + assert_eq!(entry.outcome, Outcome::Success); + } + + #[test] + fn a_failure_or_a_stop_is_what_history_remembers() { + let failed = log_of( + vec![EntryKind::Failed { + message: "boom".to_owned(), + error_char: None, + }], + Verdict::Failed, + ); + let stopped = log_of(vec![EntryKind::Cancelled], Verdict::Stopped); + + let metadata = metadata(); + assert_eq!( + history_entry(String::new(), &metadata, SystemTime::now(), &failed).outcome, + Outcome::Error("boom".to_owned()) + ); + assert_eq!( + history_entry(String::new(), &metadata, SystemTime::now(), &stopped).outcome, + Outcome::Cancelled + ); + let mut never_sent = RunLog::new(vec![tablepro_core::UserSql::new("SELECT 2")], Vec::new()); + never_sent.apply(RunEvent::Finished); + assert_eq!( + history_entry(String::new(), &metadata, SystemTime::now(), &never_sent).outcome, + Outcome::Cancelled, + "a run stopped before it began is remembered as a success" + ); + } +} diff --git a/linux/crates/app/src/ui/editor/run_log.rs b/linux/crates/app/src/ui/editor/run_log.rs new file mode 100644 index 0000000000..f9e6901ea4 --- /dev/null +++ b/linux/crates/app/src/ui/editor/run_log.rs @@ -0,0 +1,807 @@ +use tablepro_core::sql_syntax::script::BatchErrorPolicy; +use tablepro_core::{ + DriverError, EditorSessionState, ErrorCategory, ResultEvent, RunEnd, RunSummary, TransactionState, Truncation, + UserSql, +}; + +use super::script_runner::sql_preview; +use crate::ui::result_pane::{ResultSetView, ResultSummary}; +use crate::workspace::WorkOutcome; + +/// One line of the results list. +#[derive(Debug, Clone)] +pub enum EntryKind { + ResultSet(ResultSetView), + /// A statement that returned no rows, with what it changed when the + /// server reports that. + Command { + rows_affected: Option, + }, + Notice { + message: String, + }, + Failed { + message: String, + /// Where in the buffer the server says the error is, in + /// characters, when it says. + error_char: Option, + }, + /// Something the editor noticed before sending, such as a string + /// that never ends. The text is still sent, so the server has the + /// last word. + ClientWarning { + message: String, + }, + Cancelled, + TimedOut { + server_cancelled: bool, + }, + /// Never sent: a statement before it failed, or the run was + /// stopped first. + NotRun, +} + +impl EntryKind { + pub fn is_failure(&self) -> bool { + matches!(self, Self::Failed { .. } | Self::TimedOut { .. }) + } +} + +#[derive(Debug, Clone)] +pub struct RunLogEntry { + pub kind: EntryKind, + /// The batch it came from, `None` for a warning about the script. + pub batch: Option, + pub preview: String, +} + +/// How one run of a batch ended, for deciding whether the next is sent. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Verdict { + Completed, + Failed, + /// Cancelled, timed out, or the session itself failed: nothing more + /// can run, whatever the engine's batch rules say. + Stopped, +} + +/// Whether the script goes on after a batch ended this way. A failed +/// statement stops it, because the statements after it were written +/// assuming it worked, unless the engine runs batches independently. +pub fn continues(verdict: Verdict, policy: BatchErrorPolicy) -> bool { + match verdict { + Verdict::Completed => true, + Verdict::Failed => policy == BatchErrorPolicy::ContinueNextBatch, + Verdict::Stopped => false, + } +} + +/// One batch's run, as the runner hands it to the results list. +#[derive(Debug, Clone)] +pub struct BatchReport { + pub batch: usize, + pub entries: Vec, + pub verdict: Verdict, + pub transaction: Option, + /// The editor session's connection is gone. + pub lost: bool, + pub elapsed_ms: u128, +} + +#[derive(Debug, Clone)] +pub enum RunEvent { + Batch(BatchReport), + Finished, +} + +/// The rows a batch may still keep under its run's limit. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct RowRoom { + /// The run's limit, which a result it cuts short names. + pub run_limit: u64, + pub left: u64, + /// The statement was sent with `left` as its row limit, below its + /// own, so a result the driver cut short was cut by the run's limit. + pub limits_statement: bool, +} + +impl RowRoom { + pub const UNLIMITED: Self = Self { + run_limit: u64::MAX, + left: u64::MAX, + limits_statement: false, + }; +} + +/// Turns the events of one run into results list lines, on the runtime, +/// so only finished lines cross to the GTK thread. +#[derive(Debug)] +pub struct BatchCollector { + /// The batch's text, which each of its result sets keeps. + statement: UserSql, + entries: Vec, + /// Where each result set of this run sits in `entries`. + sets: Vec, + room: RowRoom, +} + +impl BatchCollector { + pub fn new(statement: UserSql) -> Self { + Self { + statement, + entries: Vec::new(), + sets: Vec::new(), + room: RowRoom::UNLIMITED, + } + } + + /// Rows past `room` are dropped, and the result says why. + pub fn within(self, room: RowRoom) -> Self { + Self { room, ..self } + } + + pub fn record(&mut self, event: ResultEvent) { + match event { + ResultEvent::ResultSetStarted { columns } => { + self.sets.push(self.entries.len()); + self.entries.push(EntryKind::ResultSet(ResultSetView::new( + columns, + self.statement.clone(), + ))); + } + ResultEvent::ColumnsResolved { set_index, columns } => { + if let Some(result) = self.set(set_index) { + result.columns = columns; + } + } + ResultEvent::Rows(rows) => { + if self.sets.is_empty() { + // Rows with no columns would draw a grid that cannot + // say what any cell is. + tracing::warn!(rows = rows.len(), "rows arrived before their result set"); + return; + } + let kept = usize::try_from(self.room.left).unwrap_or(usize::MAX).min(rows.len()); + self.room.left -= kept as u64; + let cut_by = (kept < rows.len()).then_some(self.room.run_limit); + if let Some(result) = self.last_set() { + result.rows.extend(rows.into_iter().take(kept).map(Into::into)); + result.summary.retained = result.rows.len() as u64; + result.summary.run_limit = result.summary.run_limit.or(cut_by); + } + } + // Until the set ends and says how far the read went, all + // that is known is that more rows were left behind. + ResultEvent::RowLimitReached { .. } => { + let cut_by = self.room.limits_statement.then_some(self.room.run_limit); + if let Some(result) = self.last_set() { + result.summary = ResultSummary { + retained: result.rows.len() as u64, + total_rows: None, + truncation: Truncation::ServerStopped, + run_limit: result.summary.run_limit.or(cut_by), + }; + } + } + ResultEvent::ResultSetFinished { + total_rows, truncation, .. + } => { + let cut_by = (self.room.limits_statement && truncation == Truncation::ReadPastLimit) + .then_some(self.room.run_limit); + if let Some(result) = self.last_set() { + result.summary = ResultSummary { + retained: result.rows.len() as u64, + total_rows, + truncation, + run_limit: result.summary.run_limit.or(cut_by), + }; + } + } + ResultEvent::CommandCompleted { rows_affected } => { + self.entries.push(EntryKind::Command { rows_affected }); + } + ResultEvent::Notice(notice) => self.entries.push(EntryKind::Notice { + message: format!("{}: {}", notice.severity, notice.message), + }), + } + } + + /// Close the run with how it ended. `in_transaction` says a + /// transaction was open when the batch was sent. `error_base` is + /// where the batch starts in the buffer, so a server's error position + /// becomes a buffer position. + pub fn finish( + mut self, + outcome: Result, + state: EditorSessionState, + in_transaction: bool, + error_base: usize, + ) -> (Vec, Verdict, Option) { + let lost = SessionLoss { + lost: state == EditorSessionState::Lost, + in_transaction, + }; + let (verdict, transaction) = match outcome { + Ok(summary) => { + let verdict = match summary.end { + RunEnd::Completed => { + if self.entries.is_empty() { + // Every statement shows up, even one that + // neither returned rows nor counted any. + self.entries.push(EntryKind::Command { rows_affected: None }); + } + Verdict::Completed + } + RunEnd::Failed(error) => { + self.entries.push(failed(&error, lost, error_base)); + Verdict::Failed + } + RunEnd::Cancelled => { + self.entries.push(EntryKind::Cancelled); + Verdict::Stopped + } + RunEnd::TimedOut { server_cancelled } => { + self.entries.push(EntryKind::TimedOut { server_cancelled }); + Verdict::Stopped + } + }; + (verdict, Some(summary.transaction_state)) + } + Err(error) if error.category() == ErrorCategory::Interrupted => { + self.entries.push(EntryKind::Cancelled); + (Verdict::Stopped, None) + } + Err(error) => { + self.entries.push(failed(&error, lost, error_base)); + (Verdict::Stopped, None) + } + }; + (self.entries, verdict, transaction) + } + + fn set(&mut self, set_index: usize) -> Option<&mut ResultSetView> { + let index = *self.sets.get(set_index)?; + match self.entries.get_mut(index) { + Some(EntryKind::ResultSet(result)) => Some(result), + _ => None, + } + } + + fn last_set(&mut self) -> Option<&mut ResultSetView> { + let last = self.sets.len().checked_sub(1)?; + self.set(last) + } +} + +/// Whether the session's connection went with this batch, and whether +/// a transaction was open on it. +#[derive(Debug, Clone, Copy)] +struct SessionLoss { + lost: bool, + in_transaction: bool, +} + +fn failed(error: &DriverError, lost: SessionLoss, error_base: usize) -> EntryKind { + let mut message = crate::ui::error_text::driver_message(error); + // An editor session is never reopened behind the user's back: its + // open transaction and temporary tables went with it. With a + // transaction open, the next run is refused rather than reopening. + if lost.lost && lost.in_transaction { + message = crate::i18n::gettext_f( + "{error} This tab's connection was lost, so the server rolled back its open transaction.", + &[("error", &message)], + ); + } else if lost.lost { + message = crate::i18n::gettext_f( + "{error} This tab's connection was lost; the next run opens a new one.", + &[("error", &message)], + ); + } + let error_char = match error { + DriverError::Server(diagnostics) => diagnostics + .position + .and_then(|position| usize::try_from(position).ok()) + .filter(|position| *position > 0) + .map(|position| error_base + position - 1), + _ => None, + }; + EntryKind::Failed { message, error_char } +} + +/// The results list of one run, built as the batches report. +#[derive(Debug, Default)] +pub struct RunLog { + previews: Vec, + entries: Vec, + /// The first batch no report has arrived for yet. + next_batch: usize, + transaction: Option, + lost: bool, + elapsed_ms: u128, +} + +impl RunLog { + /// The log of a run of `statements`, one per batch. + pub fn new(statements: Vec, warnings: Vec) -> Self { + let entries = warnings + .into_iter() + .map(|message| RunLogEntry { + kind: EntryKind::ClientWarning { message }, + batch: None, + preview: String::new(), + }) + .collect(); + Self { + previews: statements.iter().map(|sql| sql_preview(sql.as_str())).collect(), + entries, + ..Self::default() + } + } + + /// Take one event; returns how many lines the log had before it, so + /// the caller adds only the new ones. + pub fn apply(&mut self, event: RunEvent) -> usize { + let before = self.entries.len(); + match event { + RunEvent::Batch(report) => { + let preview = self.previews.get(report.batch).cloned().unwrap_or_default(); + self.entries.extend(report.entries.into_iter().map(|kind| RunLogEntry { + kind, + batch: Some(report.batch), + preview: preview.clone(), + })); + self.next_batch = self.next_batch.max(report.batch + 1); + if report.transaction.is_some() { + self.transaction = report.transaction; + } + self.lost |= report.lost; + self.elapsed_ms += report.elapsed_ms; + } + RunEvent::Finished => { + for batch in self.next_batch..self.previews.len() { + self.entries.push(RunLogEntry { + kind: EntryKind::NotRun, + batch: Some(batch), + preview: self.previews[batch].clone(), + }); + } + self.next_batch = self.previews.len(); + } + } + before + } + + pub fn entries(&self) -> &[RunLogEntry] { + &self.entries + } + + pub fn transaction(&self) -> Option { + self.transaction + } + + pub fn lost(&self) -> bool { + self.lost + } + + pub fn elapsed_ms(&self) -> u128 { + self.elapsed_ms + } + + pub fn has_run(&self) -> bool { + self.next_batch > 0 + } + + /// The line to show first: the first failure, else the first line. + pub fn first_to_show(&self) -> Option { + self.entries + .iter() + .position(|entry| entry.kind.is_failure()) + .or_else(|| (!self.entries.is_empty()).then_some(0)) + } + + /// The first failure's sentence, for history. + pub fn first_failure(&self) -> Option<&str> { + self.entries.iter().find_map(|entry| match &entry.kind { + EntryKind::Failed { message, .. } => Some(message.as_str()), + _ => None, + }) + } + + /// Whether the run was stopped: a statement says so, or one was + /// never sent with no failure ahead of it, as when Stop comes before + /// the first statement goes. + pub fn was_stopped(&self) -> bool { + let kinds = || self.entries.iter().map(|entry| &entry.kind); + kinds().any(|kind| matches!(kind, EntryKind::Cancelled)) + || (kinds().any(|kind| matches!(kind, EntryKind::NotRun)) && !kinds().any(EntryKind::is_failure)) + } + + pub fn timed_out(&self) -> bool { + self.entries + .iter() + .any(|entry| matches!(entry.kind, EntryKind::TimedOut { .. })) + } + + /// How the run went as a whole: one failed or timed out statement + /// fails it, as it does in history. + pub fn outcome(&self) -> WorkOutcome { + if self.entries.iter().any(|entry| entry.kind.is_failure()) { + WorkOutcome::Failed + } else if self.was_stopped() { + WorkOutcome::Cancelled + } else { + WorkOutcome::Succeeded + } + } + + /// What the run changed or returned, for the history entry: the + /// rows statements reported changing, else every row the results + /// had. A result the row limit cut short with no total makes that + /// unknown, rather than the rows the grid happens to hold. + pub fn rows_for_history(&self) -> Option { + let mut affected: Option = None; + let mut returned: Option = Some(0); + for entry in &self.entries { + match &entry.kind { + EntryKind::Command { + rows_affected: Some(rows), + } => { + let rows = i64::try_from(*rows).unwrap_or(i64::MAX); + affected = Some(affected.unwrap_or(0).saturating_add(rows)); + } + EntryKind::ResultSet(result) => { + returned = returned + .zip(result.summary.history_row_count()) + .map(|(sum, rows)| sum.saturating_add(rows)); + } + _ => {} + } + } + affected.or(returned.filter(|rows| *rows > 0)) + } + + /// The line above the results: how many there are and how long the + /// run took, and how many failed. + pub fn summary(&self) -> String { + let shown = self + .entries + .iter() + .filter(|entry| !matches!(entry.kind, EntryKind::NotRun | EntryKind::ClientWarning { .. })) + .count(); + let failures = self.entries.iter().filter(|entry| entry.kind.is_failure()).count(); + let ms = self.elapsed_ms.to_string(); + let results = crate::i18n::ngettext_f( + "{n} result in {ms} ms", + "{n} results in {ms} ms", + u32::try_from(shown).unwrap_or(u32::MAX), + &[("n", &shown.to_string()), ("ms", &ms)], + ); + if failures == 0 { + return results; + } + let failed = crate::i18n::ngettext_f( + "{n} failed", + "{n} failed", + u32::try_from(failures).unwrap_or(u32::MAX), + &[("n", &failures.to_string())], + ); + format!("{results} · {failed}") + } +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + use std::time::Duration; + + use tablepro_core::column::{ColumnType, ResultColumn}; + use tablepro_core::{ServerCode, ServerDiagnostics, ServerNotice, Value}; + + use super::*; + + fn columns(names: &[&str]) -> Arc<[ResultColumn]> { + names + .iter() + .map(|name| ResultColumn::new(*name, ColumnType::untyped_text("text"))) + .collect() + } + + fn summary(end: RunEnd) -> Result { + Ok(RunSummary { + elapsed: Duration::from_millis(3), + end, + transaction_state: TransactionState::Idle, + }) + } + + fn collect(events: Vec, end: RunEnd) -> (Vec, Verdict) { + let mut collector = BatchCollector::new(UserSql::new("SELECT a FROM t")); + for event in events { + collector.record(event); + } + let (entries, verdict, _) = collector.finish(summary(end), EditorSessionState::Ready, false, 0); + (entries, verdict) + } + + fn report(batch: usize, entries: Vec, verdict: Verdict) -> RunEvent { + RunEvent::Batch(BatchReport { + batch, + entries, + verdict, + transaction: Some(TransactionState::Idle), + lost: false, + elapsed_ms: 2, + }) + } + + #[test] + fn run_log_maps_events() { + let (entries, verdict) = collect( + vec![ + ResultEvent::ResultSetStarted { + columns: columns(&["a"]), + }, + ResultEvent::Rows(vec![vec![Value::Int(1)], vec![Value::Int(2)]]), + ResultEvent::RowLimitReached { retained: 2 }, + ResultEvent::ResultSetFinished { + retained: 2, + total_rows: Some(5), + truncation: Truncation::ServerStopped, + }, + ResultEvent::Notice(ServerNotice { + severity: "NOTICE".to_owned(), + message: "vacuumed".to_owned(), + code: None, + }), + ResultEvent::CommandCompleted { rows_affected: Some(4) }, + ], + RunEnd::Completed, + ); + + assert_eq!(verdict, Verdict::Completed); + assert_eq!(entries.len(), 3, "{entries:?}"); + let EntryKind::ResultSet(result) = &entries[0] else { + panic!("expected rows: {entries:?}"); + }; + assert_eq!(result.row_count(), 2); + assert_eq!( + result.summary, + ResultSummary { + retained: 2, + total_rows: Some(5), + truncation: Truncation::ServerStopped, + run_limit: None, + } + ); + assert_eq!(result.statement.as_str(), "SELECT a FROM t"); + assert!(matches!(&entries[1], EntryKind::Notice { message } if message.contains("vacuumed"))); + assert!(matches!(entries[2], EntryKind::Command { rows_affected: Some(4) })); + } + + #[test] + fn a_set_the_driver_cut_at_the_runs_room_names_the_run_limit() { + let room = RowRoom { + run_limit: 100, + left: 1, + limits_statement: true, + }; + let mut collector = BatchCollector::new(UserSql::new("SELECT a FROM t")).within(room); + for event in [ + ResultEvent::ResultSetStarted { + columns: columns(&["a"]), + }, + ResultEvent::Rows(vec![vec![Value::Int(1)]]), + ResultEvent::RowLimitReached { retained: 1 }, + ResultEvent::ResultSetFinished { + retained: 1, + total_rows: None, + truncation: Truncation::ReadPastLimit, + }, + ] { + collector.record(event); + } + let (entries, _, _) = collector.finish(summary(RunEnd::Completed), EditorSessionState::Ready, false, 0); + + let EntryKind::ResultSet(result) = &entries[0] else { + panic!("expected rows: {entries:?}"); + }; + assert_eq!(result.row_count(), 1); + assert_eq!(result.summary.run_limit, Some(100)); + } + + #[test] + fn a_set_cut_by_the_limit_and_then_stopped_stays_truncated() { + let (entries, _) = collect( + vec![ + ResultEvent::ResultSetStarted { + columns: columns(&["a"]), + }, + ResultEvent::Rows(vec![vec![Value::Int(1)]]), + ResultEvent::RowLimitReached { retained: 1 }, + ], + RunEnd::Cancelled, + ); + + let EntryKind::ResultSet(result) = &entries[0] else { + panic!("expected rows: {entries:?}"); + }; + assert!(result.summary.is_truncated()); + assert_eq!(result.summary.total_rows, None); + } + + #[test] + fn history_counts_every_row_a_result_had() { + let set = |summary: ResultSummary| { + let mut view = ResultSetView::new(columns(&["a"]), UserSql::new("SELECT a FROM t")); + view.summary = summary; + EntryKind::ResultSet(view) + }; + let capped = ResultSummary { + retained: 100, + total_rows: None, + truncation: Truncation::ServerStopped, + run_limit: None, + }; + let counted = ResultSummary { + retained: 100, + total_rows: Some(250), + truncation: Truncation::ReadPastLimit, + run_limit: None, + }; + let history = |entries: Vec| { + let mut log = RunLog::new(vec![UserSql::new("SELECT a FROM t")], Vec::new()); + log.apply(report(0, entries, Verdict::Completed)); + log.rows_for_history() + }; + + assert_eq!(history(vec![set(counted), set(ResultSummary::complete(3))]), Some(253)); + assert_eq!(history(vec![set(counted), set(capped)]), None); + assert_eq!( + history(vec![set(capped), EntryKind::Command { rows_affected: Some(4) }]), + Some(4), + "rows a statement changed are what history records" + ); + } + + #[test] + fn a_failure_anywhere_fails_the_run_and_a_stop_alone_cancels_it() { + let outcome = |batches: Vec<(Vec, Verdict)>| { + let mut log = RunLog::new( + (0..batches.len()).map(|_| UserSql::new("SELECT 1")).collect(), + Vec::new(), + ); + for (batch, (entries, verdict)) in batches.into_iter().enumerate() { + log.apply(report(batch, entries, verdict)); + } + log.apply(RunEvent::Finished); + log.outcome() + }; + let done = || (vec![EntryKind::Command { rows_affected: None }], Verdict::Completed); + let failed = || { + ( + vec![EntryKind::Failed { + message: "boom".to_owned(), + error_char: None, + }], + Verdict::Failed, + ) + }; + + assert_eq!(outcome(vec![done(), done()]), WorkOutcome::Succeeded); + assert_eq!(outcome(vec![done(), failed(), done()]), WorkOutcome::Failed); + assert_eq!( + outcome(vec![( + vec![EntryKind::TimedOut { server_cancelled: true }], + Verdict::Stopped + )]), + WorkOutcome::Failed + ); + assert_eq!( + outcome(vec![done(), (vec![EntryKind::Cancelled], Verdict::Stopped)]), + WorkOutcome::Cancelled + ); + } + + #[test] + fn a_run_stopped_before_its_first_statement_was_stopped() { + let mut log = RunLog::new(vec![UserSql::new("SELECT 1"), UserSql::new("SELECT 2")], Vec::new()); + + log.apply(RunEvent::Finished); + + assert!(log.was_stopped()); + assert_eq!(log.outcome(), WorkOutcome::Cancelled); + } + + #[test] + fn a_statement_with_nothing_to_say_still_gets_a_line() { + let (entries, _) = collect(Vec::new(), RunEnd::Completed); + + assert!(matches!( + entries.as_slice(), + [EntryKind::Command { rows_affected: None }] + )); + } + + #[test] + fn a_server_error_position_becomes_a_buffer_position() { + let diagnostics = ServerDiagnostics { + position: Some(8), + ..ServerDiagnostics::new(Some(ServerCode::SqlState("42601".to_owned())), "syntax error") + }; + let collector = BatchCollector::new(UserSql::new("SELECT")); + + let (entries, verdict, _) = collector.finish( + summary(RunEnd::Failed(DriverError::reported(diagnostics))), + EditorSessionState::Ready, + false, + 100, + ); + + assert_eq!(verdict, Verdict::Failed); + assert!( + matches!( + entries.as_slice(), + [EntryKind::Failed { + error_char: Some(107), + .. + }] + ), + "{entries:?}" + ); + } + + #[test] + fn run_log_stop_policy_marks_rest_not_run() { + let failed = vec![EntryKind::Failed { + message: "boom".to_owned(), + error_char: None, + }]; + assert!(!continues(Verdict::Failed, BatchErrorPolicy::StopScript)); + let mut log = RunLog::new( + ["SELECT 1", "SELECT boom", "SELECT 3", "SELECT 4"] + .into_iter() + .map(UserSql::new) + .collect(), + Vec::new(), + ); + + log.apply(report( + 0, + vec![EntryKind::Command { rows_affected: None }], + Verdict::Completed, + )); + log.apply(report(1, failed, Verdict::Failed)); + log.apply(RunEvent::Finished); + + let kinds: Vec<&EntryKind> = log.entries().iter().map(|entry| &entry.kind).collect(); + assert!(matches!(kinds[0], EntryKind::Command { .. })); + assert!(matches!(kinds[1], EntryKind::Failed { .. })); + assert!(matches!(kinds[2], EntryKind::NotRun)); + assert!(matches!(kinds[3], EntryKind::NotRun)); + assert_eq!(log.entries()[3].preview, "SELECT 4"); + assert_eq!(log.first_to_show(), Some(1), "the failure is not what shows first"); + } + + #[test] + fn run_log_continue_policy_runs_next_batch() { + assert!(continues(Verdict::Failed, BatchErrorPolicy::ContinueNextBatch)); + assert!(!continues(Verdict::Stopped, BatchErrorPolicy::ContinueNextBatch)); + assert!(continues(Verdict::Completed, BatchErrorPolicy::StopScript)); + } + + #[test] + fn a_warning_comes_first_and_counts_as_no_result() { + let mut log = RunLog::new(vec![UserSql::new("SELECT 1")], vec!["Unterminated string".into()]); + + log.apply(report( + 0, + vec![EntryKind::Command { rows_affected: Some(1) }], + Verdict::Completed, + )); + log.apply(RunEvent::Finished); + + assert!(matches!(log.entries()[0].kind, EntryKind::ClientWarning { .. })); + assert!(log.summary().starts_with("1 result"), "{}", log.summary()); + assert_eq!(log.rows_for_history(), Some(1)); + } +} diff --git a/linux/crates/app/src/ui/editor/run_scope.rs b/linux/crates/app/src/ui/editor/run_scope.rs new file mode 100644 index 0000000000..658a00bb63 --- /dev/null +++ b/linux/crates/app/src/ui/editor/run_scope.rs @@ -0,0 +1,63 @@ +use std::ops::Range; + +use tablepro_core::sql_syntax::script::text_offsets; + +/// The SQL one run sends, and where it starts in the buffer. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ScopedText { + pub text: String, + /// The buffer offset, in characters, of `text`'s first character, + /// so a server's error position can be found in the buffer. + pub base_char: usize, +} + +/// Run sends the selection when there is one, and the whole buffer +/// when there is not, so a highlighted statement is the only one sent. +pub fn for_run(buffer_text: &str, selection: Option>) -> Option { + let scoped = match selection.filter(|range| range.start < range.end) { + Some(range) => ScopedText { + text: char_slice(buffer_text, range.clone()).to_owned(), + base_char: range.start, + }, + None => ScopedText { + text: buffer_text.to_owned(), + base_char: 0, + }, + }; + (!scoped.text.trim().is_empty()).then_some(scoped) +} + +fn char_slice(text: &str, range: Range) -> &str { + let start = text_offsets::char_to_byte(text, range.start); + let end = text_offsets::char_to_byte(text, range.end); + text.get(start..end).unwrap_or_default() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn run_scope_prefers_selection() { + let buffer = "SELECT 1;\nSELECT 2;\nSELECT 3;"; + + let selected = for_run(buffer, Some(10..18)).expect("a selection"); + assert_eq!(selected.text, "SELECT 2"); + assert_eq!(selected.base_char, 10); + + let whole = for_run(buffer, Some(4..4)).expect("an empty selection runs everything"); + assert_eq!(whole.text, buffer); + assert_eq!(whole.base_char, 0); + + assert_eq!(for_run(" \n ", None), None); + } + + #[test] + fn a_selection_is_cut_by_characters_not_bytes() { + let buffer = "SELECT 'tên'; SELECT 2"; + + let selected = for_run(buffer, Some(14..22)).expect("a selection"); + + assert_eq!(selected.text, "SELECT 2"); + } +} diff --git a/linux/crates/app/src/ui/editor/script_runner.rs b/linux/crates/app/src/ui/editor/script_runner.rs new file mode 100644 index 0000000000..3907b39f52 --- /dev/null +++ b/linux/crates/app/src/ui/editor/script_runner.rs @@ -0,0 +1,797 @@ +use std::future::Future; +use std::pin::Pin; +use std::sync::{Arc, Mutex, MutexGuard, PoisonError}; +use std::time::{Duration, Instant}; + +use tablepro_core::sql_syntax::SqlGrammar; +use tablepro_core::sql_syntax::script::text_offsets::CharCounter; +use tablepro_core::sql_syntax::script::{BatchErrorPolicy, LexicalSettings, ScriptDiagnostic, ScriptPlan}; +use tablepro_core::{ + EditorSession, EditorSessionState, ResultEvent, ResultSink, RowLimit, RunOptions, SinkFlow, TransactionState, + UserSql, +}; +use tablepro_session::{MonitorSignal, Session}; +use tokio::sync::mpsc; +use tokio_util::sync::CancellationToken; + +use super::editor_session_slot::{AcquireError, EditorSessionSlot, close_quietly, transaction_open}; +use super::run_log::{BatchCollector, BatchReport, EntryKind, RowRoom, RunEvent, Verdict, continues}; +use super::run_scope::ScopedText; + +/// One unit the server is sent: a statement on most engines, a whole +/// `GO` batch on SQL Server. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RunBatch { + pub text: String, + /// How many times `GO n` asks for it. + pub repeat: u32, + /// Where the batch starts in the buffer, in characters. + pub error_base: usize, +} + +/// Rows one run keeps across all its results. `row_limit` bounds each +/// result, and a script of many SELECTs would otherwise hold all of them +/// until the next run. +pub const RUN_ROW_LIMIT: u64 = 100_000; + +/// What one press of Run sends, and how. +#[derive(Debug, Clone)] +pub struct ScriptRun { + pub batches: Vec, + pub policy: BatchErrorPolicy, + pub statement_timeout: Option, + pub row_limit: RowLimit, + pub run_row_limit: u64, +} + +/// What a statement may keep once the run has kept `kept` rows, and the +/// row limit it goes out with. With no room left it still runs, for +/// what it changes, and reads one row, which is dropped. +fn room_after(run: &ScriptRun, kept: u64) -> (RowRoom, RowLimit) { + let left = run.run_row_limit.saturating_sub(kept); + let own = run.row_limit.get(); + let room = RowRoom { + run_limit: run.run_row_limit, + left, + limits_statement: left < own, + }; + (room, RowLimit::new(left.clamp(1, own)).unwrap_or(run.row_limit)) +} + +/// The batches of the scoped text and what the plan warns about. +/// +/// Each statement goes on its own, as psql, mysql and sqlite3 send it, +/// so no implicit transaction can roll back statements that already +/// reported success. On SQL Server the unit is the `GO` batch, sent +/// whole as sqlcmd sends it. +pub fn plan_batches( + scoped: &ScopedText, + grammar: SqlGrammar, + base_line: usize, +) -> (Vec, BatchErrorPolicy, Vec) { + let plan = ScriptPlan::build(&scoped.text, grammar, LexicalSettings::default_for(grammar)); + let mut chars = CharCounter::new(&scoped.text); + let batches = plan + .batches() + .iter() + .filter_map(|batch| { + let text = scoped.text.get(batch.range.clone())?.trim(); + (!text.is_empty()).then(|| RunBatch { + text: text.to_owned(), + repeat: batch.repeat.get(), + error_base: scoped.base_char + chars.char_at(batch.range.start), + }) + }) + .collect(); + let warnings = plan + .diagnostics() + .iter() + .map(|diagnostic| match diagnostic { + ScriptDiagnostic::Unterminated { start, .. } => { + let line = base_line + scoped.text.get(..*start).unwrap_or_default().matches('\n').count() + 1; + crate::ui::error_text::script_diagnostic_message(diagnostic, line) + } + }) + .collect(); + (batches, plan.batch_error_policy(), warnings) +} + +/// The first ~60 characters of a statement on one line, to tell the +/// results list's lines apart. +pub fn sql_preview(sql: &str) -> String { + let single_line = sql.split_whitespace().collect::>().join(" "); + if single_line.chars().count() > 60 { + let prefix: String = single_line.chars().take(60).collect(); + format!("{prefix}…") + } else { + single_line + } +} + +/// Where a run left the session. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct AfterRun { + /// Where the transaction stands, as far as a connection still there + /// said. + pub transaction: Option, + /// The run lost the connection with a transaction open, and its + /// results already say the transaction was rolled back. + pub rollback_told: bool, +} + +/// Send each batch in turn, reporting each one as it ends. `transaction` +/// is where the session's transaction stood before the run. +pub async fn run_batches( + editor: &Arc, + run: &ScriptRun, + cancel: &CancellationToken, + events: &mpsc::Sender, + transaction: Option, +) -> AfterRun { + let mut after = AfterRun { + transaction, + rollback_told: false, + }; + let mut kept: u64 = 0; + 'batches: for (index, batch) in run.batches.iter().enumerate() { + for _ in 0..batch.repeat.max(1) { + if cancel.is_cancelled() { + break 'batches; + } + let sql = UserSql::new(batch.text.as_str()); + let (room, row_limit) = room_after(run, kept); + let collector = Arc::new(Mutex::new(BatchCollector::new(sql.clone()).within(room))); + let options = RunOptions::new(cancel.child_token()) + .with_deadline(run.statement_timeout) + .with_row_limit(row_limit); + let sink = Box::new(CollectingSink { + collector: collector.clone(), + }); + let started = Instant::now(); + let outcome = editor.run(sql.clone(), options, sink).await; + let elapsed_ms = started.elapsed().as_millis(); + let collected = std::mem::replace(&mut *lock(&collector), BatchCollector::new(sql)); + let state = editor.state(); + let lost = state == EditorSessionState::Lost; + let in_transaction = transaction_open(after.transaction); + let (entries, verdict, reported) = collected.finish(outcome, state, in_transaction, batch.error_base); + // A lost session's last word describes a connection that is + // gone, not the transaction the user had. + if !lost && reported.is_some() { + after.transaction = reported; + } + after.rollback_told |= + lost && in_transaction && entries.iter().any(|entry| matches!(entry, EntryKind::Failed { .. })); + kept += entries + .iter() + .map(|entry| match entry { + EntryKind::ResultSet(result) => result.row_count() as u64, + _ => 0, + }) + .sum::(); + let report = BatchReport { + batch: index, + entries, + verdict, + transaction: reported, + lost, + elapsed_ms, + }; + if events.send(RunEvent::Batch(report)).await.is_err() { + // The tab went away; nobody is left to show the rest. + return after; + } + if !continues(verdict, run.policy) { + break 'batches; + } + } + } + let _ = events.send(RunEvent::Finished).await; + after +} + +/// Run a script on the tab's editor session, opening one first when +/// needed. A session that lost its connection is let go, and the +/// window's own connection is checked, since it may have gone too. +pub async fn run_in_tab( + session: Arc, + slot: Arc, + run: ScriptRun, + close_timeout: Duration, + cancel: CancellationToken, + events: mpsc::Sender, +) { + let editor = match slot + .acquire(&session, run.statement_timeout, close_timeout, &cancel) + .await + { + Ok(editor) => editor, + Err(AcquireError::Cancelled) => { + let _ = events.send(RunEvent::Finished).await; + return; + } + Err(AcquireError::TransactionRolledBack) => { + let message = crate::i18n::gettext( + "This tab's connection was lost, so the server rolled back its open transaction. Nothing was run; run again to open a new connection.", + ); + refuse(&events, message, true).await; + return; + } + Err(AcquireError::Failed(message)) => { + refuse(&events, message, false).await; + return; + } + }; + let before = slot.transaction(&editor); + let after = run_batches(&editor, &run, &cancel, &events, before).await; + if editor.state() != EditorSessionState::Lost { + slot.note_transaction(&editor, after.transaction); + return; + } + let generation = *session.link_changes().borrow(); + session.signal(MonitorSignal::ProbeRequested { generation }); + let refuse_next = transaction_open(after.transaction) && !after.rollback_told; + if slot.forget(&editor, refuse_next) { + close_quietly(editor, close_timeout).await; + } +} + +/// End a run that sent nothing, with `message` as its one line. +async fn refuse(events: &mpsc::Sender, message: String, lost: bool) { + let report = BatchReport { + batch: 0, + entries: vec![EntryKind::Failed { + message, + error_char: None, + }], + verdict: Verdict::Stopped, + transaction: None, + lost, + elapsed_ms: 0, + }; + let _ = events.send(RunEvent::Batch(report)).await; + let _ = events.send(RunEvent::Finished).await; +} + +struct CollectingSink { + collector: Arc>, +} + +impl ResultSink for CollectingSink { + fn accept<'life0, 'async_trait>( + &'life0 mut self, + event: ResultEvent, + ) -> Pin + Send + 'async_trait>> + where + 'life0: 'async_trait, + Self: 'async_trait, + { + lock(&self.collector).record(event); + Box::pin(std::future::ready(SinkFlow::Continue)) + } +} + +fn lock(mutex: &Mutex) -> MutexGuard<'_, T> { + mutex.lock().unwrap_or_else(PoisonError::into_inner) +} + +#[cfg(test)] +mod tests { + use tablepro_core::{DriverError, LossPhase, RunEnd, TransactionState}; + use tablepro_test_support::{FakeEditorSession, FakeRun, Replies}; + + use super::super::editor_session_slot::tests::session_opening; + use super::super::run_log::RunLog; + use super::*; + + fn scoped(text: &str) -> ScopedText { + ScopedText { + text: text.to_owned(), + base_char: 0, + } + } + + fn script(text: &str, grammar: SqlGrammar) -> (ScriptRun, Vec, Vec) { + let (batches, policy, warnings) = plan_batches(&scoped(text), grammar, 0); + let statements = batches.iter().map(|batch| UserSql::new(batch.text.as_str())).collect(); + ( + ScriptRun { + batches, + policy, + statement_timeout: None, + row_limit: RowLimit::EDITOR_DEFAULT, + run_row_limit: RUN_ROW_LIMIT, + }, + statements, + warnings, + ) + } + + fn answering() -> Replies { + let mut runs = Replies::default(); + runs.always(FakeRun::completed(Vec::new())); + runs + } + + /// Run the script and fold every event into a results list. + async fn run_with(editor: &Arc, text: &str, grammar: SqlGrammar) -> RunLog { + run_cancellable(editor, text, grammar, &CancellationToken::new()).await + } + + async fn run_cancellable( + editor: &Arc, + text: &str, + grammar: SqlGrammar, + cancel: &CancellationToken, + ) -> RunLog { + let (run, statements, warnings) = script(text, grammar); + play(editor, &run, statements, warnings, cancel).await + } + + async fn play( + editor: &Arc, + run: &ScriptRun, + statements: Vec, + warnings: Vec, + cancel: &CancellationToken, + ) -> RunLog { + let (events, mut received) = mpsc::channel(8); + let session: Arc = editor.clone(); + let collect = async move { + let mut log = RunLog::new(statements, warnings); + while let Some(event) = received.recv().await { + let finished = matches!(event, RunEvent::Finished); + log.apply(event); + if finished { + break; + } + } + log + }; + let (_, log) = tokio::join!(run_batches(&session, run, cancel, &events, None), collect); + log + } + + fn texts(editor: &FakeEditorSession) -> Vec { + editor.texts().iter().map(|sql| sql.as_str().to_owned()).collect() + } + + #[tokio::test] + async fn pg_script_sends_one_statement_per_run() { + let editor = Arc::new(FakeEditorSession::new(answering())); + + let log = run_with(&editor, "SELECT 1;\nSELECT 2;\n\nSELECT 3", SqlGrammar::PostgreSql).await; + + assert_eq!(texts(&editor), ["SELECT 1", "SELECT 2", "SELECT 3"]); + assert_eq!(log.entries().len(), 3); + } + + #[tokio::test] + async fn vacuum_then_select_sends_two_runs() { + let editor = Arc::new(FakeEditorSession::new(answering())); + + run_with(&editor, "VACUUM;\nSELECT 1", SqlGrammar::PostgreSql).await; + + assert_eq!(texts(&editor), ["VACUUM", "SELECT 1"]); + } + + #[tokio::test] + async fn failing_third_statement_keeps_first_two_and_marks_rest_not_run() { + let mut runs = Replies::default(); + runs.when_sql_contains( + "boom", + FakeRun::failed(DriverError::server("relation \"boom\" does not exist")), + ); + runs.always(FakeRun::completed(Vec::new())); + let editor = Arc::new(FakeEditorSession::new(runs)); + + let log = run_with( + &editor, + "SELECT 1; SELECT 2; SELECT * FROM boom; SELECT 4; SELECT 5", + SqlGrammar::PostgreSql, + ) + .await; + + assert_eq!(texts(&editor).len(), 3, "statements after the failure were sent"); + let kinds: Vec<&EntryKind> = log.entries().iter().map(|entry| &entry.kind).collect(); + assert!(matches!(kinds[0], EntryKind::Command { .. })); + assert!(matches!(kinds[1], EntryKind::Command { .. })); + assert!(matches!(kinds[2], EntryKind::Failed { message, .. } if message.contains("boom"))); + assert!(matches!(kinds[3], EntryKind::NotRun)); + assert!(matches!(kinds[4], EntryKind::NotRun)); + } + + #[tokio::test] + async fn mssql_go_script_sends_two_batches_second_twice() { + let editor = Arc::new(FakeEditorSession::new(answering())); + + run_with( + &editor, + "SELECT 1\nGO\nINSERT INTO t VALUES (1)\nSELECT 2\nGO 2\n", + SqlGrammar::MsSql, + ) + .await; + + let sent = texts(&editor); + assert_eq!(sent.len(), 3, "{sent:?}"); + assert_eq!(sent[0], "SELECT 1"); + assert!( + sent[1].starts_with("INSERT") && sent[1].ends_with("SELECT 2"), + "{sent:?}" + ); + assert_eq!(sent[1], sent[2]); + } + + #[tokio::test] + async fn a_failed_go_batch_does_not_stop_the_next_one() { + let mut runs = Replies::default(); + runs.when_sql_contains( + "boom", + FakeRun::failed(DriverError::server("Invalid object name 'boom'.")), + ); + runs.always(FakeRun::completed(Vec::new())); + let editor = Arc::new(FakeEditorSession::new(runs)); + + let log = run_with(&editor, "SELECT * FROM boom\nGO\nSELECT 2\nGO\n", SqlGrammar::MsSql).await; + + assert_eq!(texts(&editor).len(), 2, "SQL Server batches run on their own"); + assert!(matches!(log.entries()[1].kind, EntryKind::Command { .. })); + } + + #[tokio::test] + async fn editor_session_busy_shows_failed_entry() { + let mut runs = Replies::default(); + runs.always(FakeRun::session_error(DriverError::EditorSessionBusy)); + let editor = Arc::new(FakeEditorSession::new(runs)); + + let log = run_with(&editor, "SELECT 1; SELECT 2", SqlGrammar::PostgreSql).await; + + let EntryKind::Failed { message, .. } = &log.entries()[0].kind else { + panic!("expected a failure: {:?}", log.entries()); + }; + assert!(message.contains("still running"), "{message}"); + assert_eq!(texts(&editor).len(), 1, "the busy batch was retried"); + assert!(matches!(log.entries()[1].kind, EntryKind::NotRun)); + } + + #[tokio::test] + async fn unterminated_diagnostic_warns_and_still_sends() { + let editor = Arc::new(FakeEditorSession::new(answering())); + + let log = run_with(&editor, "SELECT 1;\nSELECT $$never closed", SqlGrammar::PostgreSql).await; + + let EntryKind::ClientWarning { message } = &log.entries()[0].kind else { + panic!("expected a warning first: {:?}", log.entries()); + }; + assert!(message.contains("line 2"), "{message}"); + assert_eq!(texts(&editor).len(), 2, "the unterminated text was not sent"); + } + + #[tokio::test] + async fn stop_cancels_run_token() { + let mut runs = Replies::default(); + runs.always(FakeRun::until_stopped()); + let editor = Arc::new(FakeEditorSession::new(runs)); + let cancel = CancellationToken::new(); + let stopping = { + let cancel = cancel.clone(); + async move { + tokio::task::yield_now().await; + cancel.cancel(); + } + }; + + let (log, ()) = tokio::join!( + run_cancellable( + &editor, + "SELECT pg_sleep(60); SELECT 2", + SqlGrammar::PostgreSql, + &cancel + ), + stopping + ); + + assert!( + matches!(log.entries()[0].kind, EntryKind::Cancelled), + "{:?}", + log.entries() + ); + assert!(matches!(log.entries()[1].kind, EntryKind::NotRun)); + assert_eq!(editor.stopped_runs(), 1); + assert!(log.was_stopped()); + } + + #[tokio::test] + async fn the_last_run_says_where_the_transaction_stands() { + let mut runs = Replies::default(); + runs.always(FakeRun::completed(Vec::new()).leaving(TransactionState::InTransaction)); + let editor = Arc::new(FakeEditorSession::new(runs)); + + let log = run_with(&editor, "BEGIN; UPDATE t SET a = 1", SqlGrammar::PostgreSql).await; + + assert_eq!(log.transaction(), Some(TransactionState::InTransaction)); + } + + #[tokio::test] + async fn a_lost_session_says_the_next_run_reconnects() { + let mut runs = Replies::default(); + runs.always(FakeRun::session_error(DriverError::ConnectionLost { + during: LossPhase::Statement, + })); + let editor = Arc::new(FakeEditorSession::new(runs)); + + let log = run_with(&editor, "SELECT 1", SqlGrammar::PostgreSql).await; + + let EntryKind::Failed { message, .. } = &log.entries()[0].kind else { + panic!("expected a failure: {:?}", log.entries()); + }; + assert!(message.contains("next run"), "{message}"); + assert!(log.lost()); + } + + /// Run the script the way a tab does, through its slot, and fold + /// every event into a results list. + async fn run_in_slot(session: &Arc, slot: &Arc, text: &str) -> RunLog { + let (run, statements, warnings) = script(text, SqlGrammar::PostgreSql); + let (events, mut received) = mpsc::channel(8); + let running = run_in_tab( + session.clone(), + slot.clone(), + run, + Duration::from_secs(10), + CancellationToken::new(), + events, + ); + let collect = async move { + let mut log = RunLog::new(statements, warnings); + while let Some(event) = received.recv().await { + let finished = matches!(event, RunEvent::Finished); + log.apply(event); + if finished { + break; + } + } + log + }; + let ((), log) = tokio::join!(running, collect); + log + } + + fn first_failure(log: &RunLog) -> String { + match &log.entries()[0].kind { + EntryKind::Failed { message, .. } => message.clone(), + other => panic!("expected a failure: {other:?}"), + } + } + + fn opening(transaction: TransactionState) -> Arc { + let mut runs = Replies::default(); + runs.always(FakeRun::completed(Vec::new()).leaving(transaction)); + Arc::new(FakeEditorSession::new(runs)) + } + + #[tokio::test] + async fn a_session_lost_idle_in_a_transaction_refuses_the_next_run() { + let first = opening(TransactionState::InTransaction); + let second = Arc::new(FakeEditorSession::new(answering())); + let (session, _) = session_opening(vec![first.clone(), second.clone()]); + let slot = Arc::new(EditorSessionSlot::new()); + + run_in_slot(&session, &slot, "BEGIN").await; + first.lose(); + let refused = run_in_slot(&session, &slot, "UPDATE a SET x = 0 WHERE id = 2").await; + let reopened = run_in_slot(&session, &slot, "UPDATE a SET x = 0 WHERE id = 2").await; + + assert!( + first_failure(&refused).contains("rolled back"), + "{:?}", + refused.entries() + ); + assert!(refused.lost()); + assert_eq!(refused.transaction(), None); + assert_eq!( + first.texts(), + [UserSql::new("BEGIN")], + "the lost session ran the statement" + ); + assert_eq!(first.close_count(), 1, "the lost session was left open"); + assert!(matches!(reopened.entries()[0].kind, EntryKind::Command { .. })); + assert_eq!(second.texts(), [UserSql::new("UPDATE a SET x = 0 WHERE id = 2")]); + } + + #[tokio::test] + async fn a_session_lost_mid_run_in_a_transaction_says_so_and_the_next_run_reopens() { + let mut runs = Replies::default(); + runs.once(FakeRun::completed(Vec::new()).leaving(TransactionState::Failed)); + runs.always(FakeRun::session_error(DriverError::ConnectionLost { + during: LossPhase::Statement, + })); + let first = Arc::new(FakeEditorSession::new(runs)); + let second = Arc::new(FakeEditorSession::new(answering())); + let (session, _) = session_opening(vec![first.clone(), second.clone()]); + let slot = Arc::new(EditorSessionSlot::new()); + + run_in_slot(&session, &slot, "BEGIN").await; + let lost = run_in_slot(&session, &slot, "ROLLBACK").await; + let reopened = run_in_slot(&session, &slot, "SELECT 1").await; + + let message = first_failure(&lost); + assert!( + message.contains("rolled back") && !message.contains("next run"), + "{message}" + ); + assert!(lost.lost()); + assert!(matches!(reopened.entries()[0].kind, EntryKind::Command { .. })); + assert_eq!(second.texts(), [UserSql::new("SELECT 1")]); + } + + /// A stop the server never acknowledged gives the connection up, and + /// the run's results say only that it timed out. + #[tokio::test] + async fn a_stop_that_gives_up_a_session_in_a_transaction_refuses_the_next_run() { + let mut runs = Replies::default(); + runs.once(FakeRun::completed(Vec::new()).leaving(TransactionState::InTransaction)); + runs.always( + FakeRun { + end: Ok(RunEnd::TimedOut { + server_cancelled: false, + }), + ..FakeRun::completed(Vec::new()) + } + .losing_the_session(), + ); + let first = Arc::new(FakeEditorSession::new(runs)); + let second = Arc::new(FakeEditorSession::new(answering())); + let (session, _) = session_opening(vec![first.clone(), second.clone()]); + let slot = Arc::new(EditorSessionSlot::new()); + + run_in_slot(&session, &slot, "BEGIN").await; + let stopped = run_in_slot(&session, &slot, "UPDATE a SET x = 0").await; + let refused = run_in_slot(&session, &slot, "UPDATE a SET x = 1").await; + let reopened = run_in_slot(&session, &slot, "UPDATE a SET x = 1").await; + + assert!(matches!(stopped.entries()[0].kind, EntryKind::TimedOut { .. })); + assert!( + first_failure(&refused).contains("rolled back"), + "{:?}", + refused.entries() + ); + assert!(matches!(reopened.entries()[0].kind, EntryKind::Command { .. })); + assert_eq!(second.texts(), [UserSql::new("UPDATE a SET x = 1")]); + } + + #[tokio::test] + async fn a_session_lost_with_no_transaction_reopens_on_the_next_run() { + let first = opening(TransactionState::Idle); + let second = Arc::new(FakeEditorSession::new(answering())); + let (session, _) = session_opening(vec![first.clone(), second.clone()]); + let slot = Arc::new(EditorSessionSlot::new()); + + run_in_slot(&session, &slot, "SELECT 1").await; + first.lose(); + let reopened = run_in_slot(&session, &slot, "SELECT 2").await; + + assert!(matches!(reopened.entries()[0].kind, EntryKind::Command { .. })); + assert_eq!(second.texts(), [UserSql::new("SELECT 2")]); + } + + fn sixty_rows() -> FakeRun { + let columns: Arc<[tablepro_core::column::ResultColumn]> = vec![tablepro_core::column::ResultColumn::new( + "n", + tablepro_core::column::ColumnType::untyped_text("int4"), + )] + .into(); + FakeRun::completed(vec![ + ResultEvent::ResultSetStarted { columns }, + ResultEvent::Rows((0..60).map(|n| vec![tablepro_core::Value::Int(n)]).collect()), + ResultEvent::ResultSetFinished { + retained: 60, + total_rows: None, + truncation: tablepro_core::Truncation::Complete, + }, + ]) + } + + #[tokio::test] + async fn a_run_keeps_rows_up_to_its_limit_across_all_results() { + let mut runs = Replies::default(); + runs.always(sixty_rows()); + let editor = Arc::new(FakeEditorSession::new(runs)); + let (mut run, statements, warnings) = script("SELECT 1; SELECT 2; SELECT 3", SqlGrammar::PostgreSql); + run.run_row_limit = 100; + + let log = play(&editor, &run, statements, warnings, &CancellationToken::new()).await; + + assert_eq!(texts(&editor).len(), 3, "a statement past the limit was not sent"); + let kept: Vec<(usize, Option)> = log + .entries() + .iter() + .filter_map(|entry| match &entry.kind { + EntryKind::ResultSet(result) => Some((result.row_count(), result.summary.run_limit)), + _ => None, + }) + .collect(); + assert_eq!(kept, [(60, None), (40, Some(100)), (0, Some(100))]); + } + + #[test] + fn a_statement_reads_only_the_rows_its_run_has_room_for() { + let (mut run, _, _) = script("SELECT 1", SqlGrammar::PostgreSql); + run.run_row_limit = 100_000; + let limit = |kept| { + let (room, limit) = room_after(&run, kept); + (room.left, room.limits_statement, limit.get()) + }; + + assert_eq!(limit(0), (100_000, false, 10_000)); + assert_eq!(limit(95_000), (5_000, true, 5_000)); + assert_eq!(limit(100_000), (0, true, 1)); + assert_eq!(limit(250_000), (0, true, 1)); + } + + #[test] + fn batches_know_where_they_start_in_the_buffer() { + let scoped = ScopedText { + text: "SELECT 'tên';\nSELECT oops".to_owned(), + base_char: 50, + }; + + let (batches, _, _) = plan_batches(&scoped, SqlGrammar::PostgreSql, 3); + + assert_eq!(batches[1].text, "SELECT oops"); + assert_eq!(batches[1].error_base, 50 + 14); + } + + #[test] + fn a_long_script_finds_every_start_in_one_pass() { + // Counting each start from the top of the text is about 10^11 + // steps here, which no run finishes; one pass is about 10^6. + const STATEMENTS: usize = 100_000; + let statement = "SELECT 'é';\n"; + let scoped = ScopedText { + text: statement.repeat(STATEMENTS), + base_char: 7, + }; + + let (batches, _, _) = plan_batches(&scoped, SqlGrammar::PostgreSql, 0); + + assert_eq!(batches.len(), STATEMENTS); + let width = statement.chars().count(); + for (index, batch) in batches.iter().enumerate() { + assert_eq!(batch.error_base, 7 + index * width, "batch {index}"); + } + } + + #[test] + fn a_timed_out_run_is_a_stop() { + assert!(!continues(Verdict::Stopped, BatchErrorPolicy::ContinueNextBatch)); + let collector = BatchCollector::new(UserSql::new("SELECT pg_sleep(60)")); + let (entries, verdict, _) = collector.finish( + Ok(tablepro_core::RunSummary { + elapsed: Duration::from_secs(30), + end: RunEnd::TimedOut { server_cancelled: true }, + transaction_state: TransactionState::Idle, + }), + EditorSessionState::Ready, + false, + 0, + ); + assert_eq!(verdict, Verdict::Stopped); + assert!(matches!( + entries.as_slice(), + [EntryKind::TimedOut { server_cancelled: true }] + )); + } + + #[test] + fn sql_preview_collapses_whitespace_and_truncates() { + assert_eq!( + sql_preview("SELECT *\n FROM users\n WHERE id = 1"), + "SELECT * FROM users WHERE id = 1" + ); + let long = "SELECT col1, col2, col3, col4, col5, col6, col7, col8, col9 FROM users WHERE id = 1"; + let preview = sql_preview(long); + assert!(preview.ends_with('…')); + assert!(preview.chars().count() <= 61); + } +} diff --git a/linux/crates/app/src/ui/editor/sidebar_policy.rs b/linux/crates/app/src/ui/editor/sidebar_policy.rs new file mode 100644 index 0000000000..31a787264a --- /dev/null +++ b/linux/crates/app/src/ui/editor/sidebar_policy.rs @@ -0,0 +1,74 @@ +/// Whether the results list and its toggle show. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct SidebarState { + pub toggle_visible: bool, + pub show_sidebar: bool, +} + +/// One result needs no list, so the list and its toggle appear with +/// the second. While the pane is wide the list opens by itself the +/// moment it has something to choose between; while it is collapsed +/// over the result, opening it is left to the user. +pub fn decide(entries: usize, collapsed: bool, previous: SidebarState) -> SidebarState { + if entries < 2 { + return SidebarState::default(); + } + let show_sidebar = if previous.toggle_visible || collapsed { + previous.show_sidebar + } else { + true + }; + SidebarState { + toggle_visible: true, + show_sidebar, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn sidebar_policy_single_entry_hides_toggle() { + for previous in [ + SidebarState::default(), + SidebarState { + toggle_visible: true, + show_sidebar: true, + }, + ] { + assert_eq!(decide(1, false, previous), SidebarState::default()); + assert_eq!(decide(0, true, previous), SidebarState::default()); + } + } + + #[test] + fn sidebar_policy_two_entries_expanded_shows_list() { + let shown = decide(2, false, SidebarState::default()); + assert_eq!( + shown, + SidebarState { + toggle_visible: true, + show_sidebar: true, + } + ); + + let closed_by_user = SidebarState { + toggle_visible: true, + show_sidebar: false, + }; + assert_eq!( + decide(3, false, closed_by_user), + closed_by_user, + "the list reopened itself" + ); + } + + #[test] + fn a_collapsed_pane_leaves_the_list_to_the_user() { + let state = decide(2, true, SidebarState::default()); + + assert!(state.toggle_visible); + assert!(!state.show_sidebar, "the list covered the result by itself"); + } +} diff --git a/linux/crates/app/src/ui/editor/significant_tokens.rs b/linux/crates/app/src/ui/editor/significant_tokens.rs new file mode 100644 index 0000000000..6d4586bfd0 --- /dev/null +++ b/linux/crates/app/src/ui/editor/significant_tokens.rs @@ -0,0 +1,194 @@ +use tablepro_core::sql_syntax::SqlGrammar; +use tablepro_core::sql_syntax::script::{LexicalSettings, ScriptPlan, ScriptTokenKind}; + +/// What a statement says, with the spacing taken out where spacing +/// cannot change it. +/// +/// Two texts with the same list are the same statement written +/// differently, which is the only thing a formatter is allowed to do. +pub fn significant_tokens(text: &str, grammar: SqlGrammar, settings: LexicalSettings) -> Vec { + let plan = ScriptPlan::build(text, grammar, settings); + let mut tokens: Vec = Vec::new(); + let mut spacing = Spacing::default(); + for token in plan.tokens(0..text.len()) { + let slice = text.get(token.range.clone()).unwrap_or_default(); + if token.kind == ScriptTokenKind::Whitespace { + spacing.spaced = true; + spacing.line_break |= slice.contains('\n'); + continue; + } + let body = match token.kind { + // A formatter may move the spaces at the end of a + // comment without changing what the comment says. + ScriptTokenKind::LineComment | ScriptTokenKind::BlockComment => slice.trim_end(), + _ => slice, + }; + let gap = tokens.last().map_or(Gap::Free, |previous| { + gap_between(grammar, previous, token.kind, body, spacing) + }); + tokens.push(SignificantToken { + kind: token.kind, + text: body.to_owned(), + gap, + }); + spacing = Spacing::default(); + } + tokens +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SignificantToken { + kind: ScriptTokenKind, + text: String, + gap: Gap, +} + +/// The space between two tokens, kept only where an engine reads the +/// two differently with and without it. +/// +/// The script lexer reads every operator character on its own, where +/// an engine reads `+=` or `->>` as one operator; and it reads `b'01'` +/// as a word and a string, where MySQL reads one bit literal. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Gap { + Free, + Touching, + Spaced, + /// Two string literals with a line break between are one string in + /// PostgreSQL; on one line they are a syntax error. + LineBreak, +} + +/// Words sqlformat spaces from their paren that MySQL 8.4 and MariaDB +/// 11.4 were checked to read the same either way. +const MYSQL_SPACED_CALLS: [&str; 7] = ["EXISTS", "IF", "IFNULL", "IN", "REPLACE", "USING", "VALUES"]; + +#[derive(Debug, Clone, Copy, Default)] +struct Spacing { + spaced: bool, + line_break: bool, +} + +fn gap_between( + grammar: SqlGrammar, + previous: &SignificantToken, + kind: ScriptTokenKind, + text: &str, + spacing: Spacing, +) -> Gap { + let strings = previous.kind == ScriptTokenKind::StringLiteral && kind == ScriptTokenKind::StringLiteral; + if strings && spacing.line_break { + return Gap::LineBreak; + } + let operator = previous.kind == ScriptTokenKind::Punct + && kind == ScriptTokenKind::Punct + && previous.text != "," + && text != ","; + // Without IGNORE_SPACE, MySQL reads `count(` as the built-in and + // `count (` as a stored function named count. + let call = grammar == SqlGrammar::MySql + && previous.kind == ScriptTokenKind::Word + && kind == ScriptTokenKind::OpenParen + && !MYSQL_SPACED_CALLS + .iter() + .any(|word| word.eq_ignore_ascii_case(&previous.text)); + let free = spacing_is_free(previous.kind, &previous.text) || spacing_is_free(kind, text); + if !strings && !operator && !call && free { + return Gap::Free; + } + if spacing.spaced { Gap::Spaced } else { Gap::Touching } +} + +/// Whether spacing next to this token changes nothing an engine reads, +/// outside the pairs `gap_between` checks first: a bracket, a +/// separator, a comment, or an operator character that is never part +/// of a name, a literal or a parameter. +fn spacing_is_free(kind: ScriptTokenKind, text: &str) -> bool { + match kind { + ScriptTokenKind::OpenParen + | ScriptTokenKind::CloseParen + | ScriptTokenKind::Semicolon + | ScriptTokenKind::LineComment + | ScriptTokenKind::BlockComment => true, + ScriptTokenKind::Punct => matches!( + text, + "," | "." | "=" | "<" | ">" | "+" | "-" | "*" | "/" | "%" | "^" | "|" | "!" | "~" | "[" | "]" + ), + _ => false, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn tokens_for(text: &str, grammar: SqlGrammar) -> Vec { + significant_tokens(text, grammar, LexicalSettings::default_for(grammar)) + } + + fn tokens(text: &str) -> Vec { + tokens_for(text, SqlGrammar::PostgreSql) + } + + #[test] + fn spacing_alone_does_not_change_a_statement() { + assert_eq!(tokens("select a ,b from t"), tokens("select a, b\nfrom t")); + assert_eq!(tokens("select a=-1,(1,-1)"), tokens("select a =- 1, (1, -1)")); + } + + #[test] + fn a_changed_word_changes_the_statement() { + assert_ne!(tokens("select a from t"), tokens("SELECT a from t")); + } + + #[test] + fn a_comment_keeps_its_text_but_not_its_trailing_spaces() { + assert_eq!(tokens("select 1 -- note "), tokens("select 1 -- note")); + assert_ne!(tokens("select 1 -- note"), tokens("select 1 -- other")); + } + + #[test] + fn an_operator_split_by_a_space_changes_the_statement() { + assert_ne!( + tokens_for("SET @x += 1", SqlGrammar::MsSql), + tokens_for("SET @x + = 1", SqlGrammar::MsSql) + ); + assert_ne!(tokens("select a->>'k'"), tokens("select a- >>'k'")); + assert_eq!(tokens("select a->>'k'"), tokens("select a ->> 'k'")); + } + + #[test] + fn a_prefix_split_from_its_string_changes_the_statement() { + assert_ne!( + tokens_for("select b'01'", SqlGrammar::MySql), + tokens_for("select b '01'", SqlGrammar::MySql) + ); + assert_ne!( + tokens_for("select @@version", SqlGrammar::MySql), + tokens_for("select @@ version", SqlGrammar::MySql) + ); + } + + #[test] + fn a_space_before_a_mysql_call_changes_the_statement() { + assert_ne!( + tokens_for("select count(a) from t", SqlGrammar::MySql), + tokens_for("select count (a) from t", SqlGrammar::MySql) + ); + assert_eq!( + tokens_for("select count(a) from t", SqlGrammar::MySql), + tokens_for("select\n count(a)\nfrom t", SqlGrammar::MySql) + ); + assert_eq!(tokens("select count(a) from t"), tokens("select count (a) from t")); + assert_eq!( + tokens_for("select ifnull(a, 0) from t where a in(1)", SqlGrammar::MySql), + tokens_for("select ifnull (a, 0) from t where a in (1)", SqlGrammar::MySql) + ); + } + + #[test] + fn strings_joined_onto_one_line_change_the_statement() { + assert_ne!(tokens("select 'a'\n'b'"), tokens("select 'a' 'b'")); + assert_eq!(tokens("select 'a'\n'b'"), tokens("select\n 'a'\n 'b'")); + } +} diff --git a/linux/crates/app/src/ui/editor/sql_file.rs b/linux/crates/app/src/ui/editor/sql_file.rs new file mode 100644 index 0000000000..dbb243e3a1 --- /dev/null +++ b/linux/crates/app/src/ui/editor/sql_file.rs @@ -0,0 +1,85 @@ +use gtk4::glib; + +use crate::ui::file_read::{FileReadError, read_capped_file}; + +/// The most a file may be before the editor refuses it. +/// +/// A dump can be gigabytes, and a text buffer holding one stops being +/// an editor. Refusing is better than a window that will not paint. +const MAX_SQL_FILE_BYTES: usize = 8 * 1024 * 1024; + +/// Read a file as text off the main thread, or say why it cannot be. +pub async fn load_text(file: &gio::File) -> Result { + let bytes = match read_capped_file(file, MAX_SQL_FILE_BYTES).await { + Ok(bytes) => bytes, + Err(FileReadError::NotRegular) => { + return Err(crate::i18n::gettext("Only regular files can be opened in the editor.")); + } + Err(FileReadError::TooLarge) => { + return Err(crate::i18n::gettext("That file is too large to open in the editor.")); + } + Err(FileReadError::Io(error)) => return Err(unreadable(error)), + }; + String::from_utf8(bytes).map_err(|_| crate::i18n::gettext("That file is not UTF-8 text.")) +} + +fn unreadable(error: glib::Error) -> String { + tracing::warn!(%error, "could not read a file for the editor"); + crate::i18n::gettext("That file could not be read.") +} + +#[cfg(test)] +mod tests { + use std::time::Duration; + + use super::*; + use crate::test_support::finish_on_main; + + const WAIT: Duration = Duration::from_secs(10); + + fn load(file: gio::File) -> Result { + finish_on_main(WAIT, async move { load_text(&file).await }).expect("the load to end") + } + + #[gtk4::test] + fn a_device_is_refused_before_it_is_read() { + let refused = load(gio::File::for_path("/dev/zero")).expect_err("a refusal"); + + assert!(refused.contains("regular files"), "{refused}"); + } + + #[gtk4::test] + fn a_pipe_is_refused_without_waiting_for_a_writer() { + let root = tempfile::tempdir().expect("tempdir"); + let path = root.path().join("script.sql"); + let made = std::process::Command::new("mkfifo") + .arg(&path) + .status() + .expect("mkfifo"); + assert!(made.success()); + + let refused = load(gio::File::for_path(&path)).expect_err("a refusal"); + + assert!(refused.contains("regular files"), "{refused}"); + } + + #[gtk4::test] + fn a_file_over_the_limit_is_refused() { + let root = tempfile::tempdir().expect("tempdir"); + let path = root.path().join("dump.sql"); + std::fs::write(&path, vec![b' '; MAX_SQL_FILE_BYTES + 1]).expect("write"); + + let refused = load(gio::File::for_path(&path)).expect_err("a refusal"); + + assert!(refused.contains("too large"), "{refused}"); + } + + #[gtk4::test] + fn a_small_file_is_read_as_text() { + let root = tempfile::tempdir().expect("tempdir"); + let path = root.path().join("query.sql"); + std::fs::write(&path, "SELECT 'tên';").expect("write"); + + assert_eq!(load(gio::File::for_path(&path)).as_deref(), Ok("SELECT 'tên';")); + } +} diff --git a/linux/crates/app/src/ui/editor/statement_cursor.rs b/linux/crates/app/src/ui/editor/statement_cursor.rs new file mode 100644 index 0000000000..7d97bd7255 --- /dev/null +++ b/linux/crates/app/src/ui/editor/statement_cursor.rs @@ -0,0 +1,125 @@ +use gtk4::prelude::*; +use tablepro_core::sql_syntax::SqlGrammar; +use tablepro_core::sql_syntax::script::{LexicalSettings, ScriptPlan, text_offsets}; + +/// The script the buffer holds, read with the engine's own rules: a +/// dollar-quoted body, a `GO` batch or a procedure between `DELIMITER` +/// lines is one statement, where a split on semicolons would cut it. +pub fn plan_for(text: &str, grammar: SqlGrammar) -> ScriptPlan { + ScriptPlan::build(text, grammar, LexicalSettings::default_for(grammar)) +} + +/// Where the statement under the cursor starts and ends in the buffer. +/// +/// The buffer counts characters and the plan counts bytes, so every +/// mapping between the two goes through here: a cursor in a Vietnamese +/// identifier lands mid-character otherwise. +pub fn statement_bounds_at( + buffer: >k4::TextBuffer, + plan: &ScriptPlan, + text: &str, + cursor_char: usize, +) -> Option<(gtk4::TextIter, gtk4::TextIter)> { + let byte = text_offsets::char_to_byte(text, cursor_char); + let statement = plan.statement_at(byte)?; + Some(( + iter_for_byte(buffer, text, statement.range.start), + iter_for_byte(buffer, text, statement.range.end), + )) +} + +/// The buffer position of a byte offset into `text`, which must be the +/// buffer's text. Past the end is the end. +pub fn iter_for_byte(buffer: >k4::TextBuffer, text: &str, byte: usize) -> gtk4::TextIter { + let chars = i32::try_from(text_offsets::byte_to_char(text, byte)).unwrap_or(i32::MAX); + buffer.iter_at_offset(chars) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn buffer(text: &str) -> gtk4::TextBuffer { + let buffer = gtk4::TextBuffer::new(None); + buffer.set_text(text); + buffer + } + + fn bounds_at(text: &str, cursor_char: usize) -> Option<(i32, i32, String)> { + let buffer = buffer(text); + let plan = plan_for(text, SqlGrammar::PostgreSql); + let (start, end) = statement_bounds_at(&buffer, &plan, text, cursor_char)?; + Some(( + start.offset(), + end.offset(), + buffer.text(&start, &end, false).to_string(), + )) + } + + #[gtk4::test] + fn statement_bounds_multibyte_text() { + let text = "SELECT 'tên'; SELECT \"bảng\" FROM t"; + + let (start, end, statement) = bounds_at(text, 30).expect("a statement"); + + assert_eq!(statement, "SELECT \"bảng\" FROM t"); + assert_eq!((start, end), (14, 34), "the bounds were not mapped to characters"); + } + + #[gtk4::test] + fn a_cursor_in_a_multibyte_identifier_stays_in_its_statement() { + // Character 9 is inside the quoted identifier, which is three + // bytes further along than it is characters. + let (_, _, statement) = bounds_at("SELECT \"tên\" FROM t; SELECT 2", 9).expect("a statement"); + + assert_eq!(statement, "SELECT \"tên\" FROM t"); + } + + #[gtk4::test] + fn the_cursor_picks_the_statement_it_sits_in() { + let text = "SELECT 1; SELECT 2"; + + assert_eq!(bounds_at(text, 4).map(|(_, _, sql)| sql).as_deref(), Some("SELECT 1")); + assert_eq!(bounds_at(text, 17).map(|(_, _, sql)| sql).as_deref(), Some("SELECT 2")); + } + + #[gtk4::test] + fn a_cursor_past_the_end_picks_the_last_statement() { + let statement = bounds_at("SELECT 1; SELECT 2", 9999).map(|(_, _, sql)| sql); + + assert_eq!(statement.as_deref(), Some("SELECT 2")); + } + + #[gtk4::test] + fn a_semicolon_inside_a_string_is_not_a_boundary() { + let (_, _, statement) = bounds_at("INSERT INTO t VALUES ('a;b'); SELECT 2", 24).expect("a statement"); + + assert_eq!(statement, "INSERT INTO t VALUES ('a;b')"); + } + + #[gtk4::test] + fn a_dollar_quoted_body_is_one_statement() { + let text = "CREATE FUNCTION f() RETURNS int AS $$ SELECT 1; $$ LANGUAGE sql;\nSELECT 2;"; + + let (start, _, statement) = bounds_at(text, 40).expect("a statement"); + + assert_eq!(start, 0); + assert!(statement.ends_with("LANGUAGE sql"), "{statement}"); + } + + #[gtk4::test] + fn an_empty_buffer_has_no_statement() { + assert_eq!(bounds_at("", 0), None); + assert_eq!(bounds_at(" \n -- only a comment\n", 3), None); + } + + #[gtk4::test] + fn iter_for_byte_at_end() { + let text = "SELECT 'tên'"; + let buffer = buffer(text); + + assert!(iter_for_byte(&buffer, text, text.len()).is_end()); + assert!(iter_for_byte(&buffer, text, text.len() + 10).is_end()); + assert_eq!(iter_for_byte(&buffer, text, text.len() - 1).offset(), 11); + } +} diff --git a/linux/crates/app/src/ui/editor/tests.rs b/linux/crates/app/src/ui/editor/tests.rs new file mode 100644 index 0000000000..3e9aa66539 --- /dev/null +++ b/linux/crates/app/src/ui/editor/tests.rs @@ -0,0 +1,1352 @@ +use std::sync::Arc; +use std::time::Duration; + +use tablepro_core::column::{ColumnType, ResultColumn}; +use tablepro_core::{DriverError, EditorSession, ResultEvent, TransactionState, Truncation, Value}; +use tablepro_session::Session; +use tablepro_session::fakes::{FakeConnector, FakeCredentialSource}; +use tablepro_session::operation::DefaultDeadlines; +use tablepro_test_support::{ConnectionScript, FakeEditorSession, FakeReply, FakeRun, Replies}; + +use super::*; +use crate::services::history_preferences::HistoryPreferences; +use crate::test_support::{MemorySettings, SignalLog, wait_until}; + +const WAIT: Duration = Duration::from_secs(10); + +struct Harness { + editor: SqlEditor, + fake: Arc, + history: Option, + active: ActiveSession, + _settings: MemorySettings, + runtime: crate::runtime::AppRuntime, +} + +fn session_with(fake: Arc, tasks: Tasks) -> Arc { + session_on(fake, tasks, ConnectionScript::default()) +} + +fn session_on(fake: Arc, tasks: Tasks, mut script: ConnectionScript) -> Arc { + let editor: Arc = fake; + script.open_editor_session.always(FakeReply::ok(editor)); + let request = FakeConnector::request().expect("a request"); + let (pending, _) = FakeConnector::pending_session(request, script); + Session::start( + pending, + tablepro_session::SessionDeps { + connector: Arc::new(FakeConnector::new([])), + credentials: Arc::new(FakeCredentialSource::new()), + deadlines: Arc::new(DefaultDeadlines::new(tablepro_core::LivenessPolicy::DESKTOP)), + tasks, + prompter: None, + }, + ) +} + +fn editor_with(runs: Replies, text: &str) -> Harness { + build_editor(runs, text, |_| None) +} + +fn build_editor(runs: Replies, text: &str, history: impl FnOnce(&Tasks) -> Option) -> Harness { + crate::register_test_resources(); + let runtime = crate::test_support::test_runtime(); + let settings = MemorySettings::new(); + let fake = Arc::new(FakeEditorSession::new(runs)); + let active = ActiveSession::new(); + active.replace(session_with(fake.clone(), runtime.tasks())); + let history = history(&runtime.tasks()); + let editor = SqlEditor::new(SqlEditorInit { + initial_query: Some(text.to_owned()), + settings: settings.get().clone(), + history: history.clone(), + tasks: runtime.tasks(), + session: active.clone(), + hold: Rc::new(tablepro_session::runtime::CountingHold::new()), + close_timeout: Duration::from_secs(5), + }); + Harness { + editor, + fake, + history, + active, + _settings: settings, + runtime, + } +} + +/// An editor whose runs go into a real history under `root`. +fn editor_with_history(root: &tempfile::TempDir, text: &str) -> Harness { + editor_with_history_runs(root, answering(), text) +} + +fn editor_with_history_runs(root: &tempfile::TempDir, runs: Replies, text: &str) -> Harness { + let paths = tablepro_storage::StoragePaths::under(root.path(), "tablepro-test", "app.tablepro.TablePro.Devel"); + build_editor(runs, text, |tasks| { + let preferences = HistoryPreferences { + retention_days: tokio::sync::watch::channel(30).1, + save: tokio::sync::watch::channel(true).1, + }; + Some(HistoryService::start(paths, preferences, tasks)) + }) +} + +fn answering() -> Replies { + let mut runs = Replies::default(); + runs.always(FakeRun::completed(Vec::new())); + runs +} + +fn sent(fake: &FakeEditorSession) -> Vec { + fake.texts().iter().map(|sql| sql.as_str().to_owned()).collect() +} + +fn run_and_wait(harness: &Harness) { + harness.editor.run(); + assert!(harness.editor.is_running(), "the run did not start"); + wait_until(WAIT, || !harness.editor.is_running()).expect("the run to end"); +} + +#[gtk4::test] +fn selected_text_is_the_only_sql_sent() { + let harness = editor_with(answering(), "SELECT 1;\nSELECT 2;\nSELECT 3;"); + let buffer = harness.editor.buffer(); + let start = buffer.iter_at_offset(10); + let end = buffer.iter_at_offset(18); + buffer.select_range(&start, &end); + assert_eq!( + harness.editor.imp().run_button.label().as_deref(), + Some("Run Selection") + ); + + run_and_wait(&harness); + + assert_eq!(sent(&harness.fake), ["SELECT 2"]); +} + +#[gtk4::test] +fn only_a_run_that_sent_ddl_says_the_catalog_changed() { + let harness = editor_with(answering(), "SELECT 1;"); + run_and_wait(&harness); + assert!( + !harness.editor.last_run_changes_catalog(), + "a SELECT changed the catalog" + ); + + harness + .editor + .buffer() + .set_text("SELECT 1;\nCREATE TABLE orders (id int);"); + run_and_wait(&harness); + assert!(harness.editor.last_run_changes_catalog()); + + harness.editor.buffer().set_text("UPDATE orders SET id = 2;"); + run_and_wait(&harness); + assert!( + !harness.editor.last_run_changes_catalog(), + "the last run's answer outlived it" + ); +} + +#[gtk4::test] +fn run_at_cursor_selects_executed_statement() { + let harness = editor_with(answering(), "SELECT 'tên';\nSELECT 2 FROM t;\nSELECT 3;"); + let buffer = harness.editor.buffer(); + buffer.place_cursor(&buffer.iter_at_offset(20)); + + harness.editor.run_at_cursor(); + assert!(harness.editor.is_running(), "the run did not start"); + wait_until(WAIT, || !harness.editor.is_running()).expect("the run to end"); + + assert_eq!(sent(&harness.fake), ["SELECT 2 FROM t"]); + let (start, end) = buffer.selection_bounds().expect("the statement is selected"); + assert_eq!((start.offset(), end.offset()), (14, 29)); +} + +#[gtk4::test] +fn run_at_cursor_during_a_run_leaves_the_buffer_alone() { + let mut runs = Replies::default(); + runs.always(FakeRun::until_stopped()); + let harness = editor_with(runs, "SELECT pg_sleep(60);\nSELECT 2;"); + let buffer = harness.editor.buffer(); + harness.editor.run(); + wait_until(WAIT, || !harness.fake.texts().is_empty()).expect("the first statement to start"); + let sent_before = sent(&harness.fake); + buffer.place_cursor(&buffer.iter_at_offset(23)); + + harness.editor.run_at_cursor(); + + assert!( + buffer.selection_bounds().is_none(), + "a statement that did not run was selected" + ); + assert_eq!(sent(&harness.fake), sent_before); + harness.editor.stop(); + wait_until(WAIT, || !harness.editor.is_running()).expect("the run to end"); +} + +#[gtk4::test] +fn a_script_fills_the_results_list_one_line_per_statement() { + let mut runs = Replies::default(); + runs.when_sql_contains( + "boom", + FakeRun::failed(DriverError::server("relation \"boom\" does not exist")), + ); + runs.always(FakeRun::completed(Vec::new())); + let harness = editor_with(runs, "SELECT 1; SELECT * FROM boom; SELECT 3"); + let states = SignalLog::connect(&harness.editor, "run-state-changed", |values| { + values[1].get::().expect("a flag") + }); + + run_and_wait(&harness); + + let results = &harness.editor.imp().results; + assert_eq!(results.n_entries(), 3); + assert_eq!(results.selected(), Some(1), "the failure is not what shows"); + assert_eq!(sent(&harness.fake), ["SELECT 1", "SELECT * FROM boom"]); + assert_eq!(states.take(), [true, false]); + assert!(harness.editor.imp().run_button.is_sensitive()); +} + +/// Ctrl+Return leaves the focus in the editor, so the summary is said. +#[gtk4::test] +fn a_finished_run_says_how_it_went() { + let mut runs = Replies::default(); + runs.when_sql_contains( + "boom", + FakeRun::failed(DriverError::server("relation \"boom\" does not exist")), + ); + runs.always(FakeRun::completed(Vec::new())); + let harness = editor_with(runs, "SELECT 1"); + + run_and_wait(&harness); + harness.editor.buffer().set_text("SELECT * FROM boom"); + run_and_wait(&harness); + + let said: Vec<(bool, gtk4::AccessibleAnnouncementPriority)> = crate::ui::a11y::announced_in(&harness.editor) + .into_iter() + .map(|(message, priority)| (message.contains("failed"), priority)) + .collect(); + assert_eq!( + said, + [ + (false, gtk4::AccessibleAnnouncementPriority::Medium), + (true, gtk4::AccessibleAnnouncementPriority::High), + ] + ); +} + +#[gtk4::test] +fn a_long_script_that_fails_early_builds_one_result_page() { + let mut runs = Replies::default(); + runs.when_sql_contains( + "boom", + FakeRun::failed(DriverError::server("relation \"boom\" does not exist")), + ); + runs.always(FakeRun::completed(Vec::new())); + let script: Vec = std::iter::once("SELECT * FROM boom;".to_owned()) + .chain((1..2_000).map(|n| format!("SELECT {n};"))) + .collect(); + let harness = editor_with(runs, &script.join("\n")); + + run_and_wait(&harness); + + let results = &harness.editor.imp().results; + assert_eq!(results.n_entries(), 2_000); + assert_eq!( + results.imp().stack.pages().n_items(), + 2, + "pages were built for lines nobody opened" + ); +} + +#[gtk4::test] +fn an_open_transaction_marks_the_tab() { + let mut runs = Replies::default(); + runs.always(FakeRun::completed(Vec::new()).leaving(TransactionState::InTransaction)); + let harness = editor_with(runs, "BEGIN; UPDATE t SET a = 1"); + let changes = SignalLog::connect(&harness.editor, "transaction-changed", |values| { + values[1].get::().expect("a flag") + }); + + run_and_wait(&harness); + + assert!(harness.editor.has_open_transaction()); + assert_eq!(changes.take(), [true]); +} + +#[gtk4::test] +fn stop_cancels_the_running_statement() { + let mut runs = Replies::default(); + runs.always(FakeRun::until_stopped()); + let harness = editor_with(runs, "SELECT pg_sleep(60); SELECT 2"); + + harness.editor.run(); + wait_until(WAIT, || !harness.fake.texts().is_empty()).expect("the first statement to start"); + harness.editor.stop(); + wait_until(WAIT, || !harness.editor.is_running()).expect("the run to end"); + + assert_eq!(harness.fake.stopped_runs(), 1); + assert_eq!( + harness.editor.imp().results.n_entries(), + 2, + "the second statement has no line" + ); +} + +#[gtk4::test] +fn replace_query_can_be_undone() { + let harness = editor_with(answering(), "SELECT 1"); + let toasts = SignalLog::connect(&harness.editor, "undoable-toast", |values| { + values[1].get::().expect("a title") + }); + + harness.editor.replace_query("SELECT 2"); + assert_eq!(buffer_text(&harness.editor.buffer()), "SELECT 2"); + assert_eq!(toasts.take(), ["Query replaced"]); + + harness.editor.undo(); + assert_eq!(buffer_text(&harness.editor.buffer()), "SELECT 1"); + + harness.editor.replace_query("SELECT 1"); + assert_eq!(toasts.count(), 0, "replacing a query with itself said something"); +} + +#[gtk4::test] +fn format_keeps_the_old_text_one_undo_away() { + let harness = editor_with(answering(), "select a,b from t where x=1"); + + harness.editor.format(); + let formatted = buffer_text(&harness.editor.buffer()); + assert_ne!(formatted, "select a,b from t where x=1"); + + harness.editor.undo(); + assert_eq!(buffer_text(&harness.editor.buffer()), "select a,b from t where x=1"); +} + +#[gtk4::test] +fn typing_emits_content_changed_without_text() { + let harness = editor_with(answering(), "SELECT 1"); + let changes = SignalLog::connect(&harness.editor, "content-changed", |values| values.len()); + let buffer = harness.editor.buffer(); + + buffer.insert(&mut buffer.end_iter(), " "); + buffer.insert(&mut buffer.end_iter(), "2"); + + // One emission per change, carrying the editor and nothing else. + assert_eq!(changes.take(), [1, 1]); +} + +#[gtk4::test] +fn dropped_non_utf8_file_is_rejected() { + let harness = editor_with(answering(), "SELECT 1"); + let root = tempfile::tempdir().expect("tempdir"); + let path = root.path().join("dump.sql"); + std::fs::write(&path, [0xff, 0xfe]).expect("write"); + let toasts = SignalLog::connect(&harness.editor, "toast", |values| { + values[1].get::().expect("a message") + }); + + let editor = harness.editor.clone(); + glib::spawn_future_local(async move { editor.insert_file(gio::File::for_path(path), false).await }); + wait_until(WAIT, || toasts.count() == 1).expect("the refusal"); + + assert_eq!(buffer_text(&harness.editor.buffer()), "SELECT 1"); + assert!(toasts.take()[0].contains("UTF-8")); +} + +#[gtk4::test] +fn a_dropped_file_goes_in_at_the_cursor_as_one_undo_step() { + let harness = editor_with(answering(), "SELECT 1;\n"); + let root = tempfile::tempdir().expect("tempdir"); + let path = root.path().join("more.sql"); + std::fs::write(&path, "SELECT 2;").expect("write"); + let buffer = harness.editor.buffer(); + buffer.place_cursor(&buffer.end_iter()); + + let editor = harness.editor.clone(); + glib::spawn_future_local(async move { editor.insert_file(gio::File::for_path(path), false).await }); + wait_until(WAIT, || buffer_text(&buffer).contains("SELECT 2")).expect("the file"); + + assert_eq!(buffer_text(&buffer), "SELECT 1;\nSELECT 2;"); + harness.editor.undo(); + assert_eq!(buffer_text(&buffer), "SELECT 1;\n"); +} + +#[gtk4::test] +fn a_copy_from_a_result_grid_reaches_the_clipboard() { + use tablepro_core::column::{ColumnType, ResultColumn}; + + use tablepro_core::Value; + + use crate::ui::clipboard::{CopyKind, CopyRequest}; + use crate::ui::grid::RowSnapshot; + + let harness = editor_with(answering(), "SELECT 1"); + let toasts = Rc::new(std::cell::RefCell::new(Vec::new())); + let sink = toasts.clone(); + harness + .editor + .connect_toast(move |_, text| sink.borrow_mut().push(text)); + let grid = harness.editor.imp().grid_sink.get().expect("the grid's sink").clone(); + let clipboard = harness.editor.clipboard(); + clipboard.set_text("before"); + let columns: std::sync::Arc<[ResultColumn]> = ["a", "b"] + .iter() + .map(|name| ResultColumn::new(*name, ColumnType::untyped_text("text"))) + .collect(); + let rows = vec![ + RowSnapshot::new(vec![Value::Int(1), Value::Text("x".into())].into(), Vec::new()), + RowSnapshot::new(vec![Value::Int(2), Value::Text("y".into())].into(), Vec::new()), + ]; + + grid(GridMsg::Copy(CopyRequest::Rows { + columns, + rows, + kind: CopyKind::Rows, + })); + wait_until(WAIT, || !toasts.borrow().is_empty()).expect("the copy to land"); + + assert_eq!(*toasts.borrow(), ["Copied 2 rows".to_owned()]); + assert!(clipboard.formats().contain_mime_type("text/tab-separated-values")); + let text: Option = clipboard + .content() + .and_then(|content| content.value(String::static_type()).ok()) + .and_then(|value| value.get().ok()); + assert_eq!(text.as_deref(), Some("1\tx\n2\ty\n")); +} + +#[gtk4::test] +fn closing_the_tab_closes_its_connection() { + let harness = editor_with(answering(), "SELECT 1"); + run_and_wait(&harness); + + harness.editor.shutdown(); + + wait_until(WAIT, || harness.fake.is_closed()).expect("the editor session to close"); +} + +#[gtk4::test] +fn the_icon_buttons_have_accessible_names() { + let harness = editor_with(answering(), "SELECT 1"); + + assert!(gtk4::test_accessible_has_property( + &harness.editor.imp().menu_button.get(), + gtk4::AccessibleProperty::Label + )); + assert!(gtk4::test_accessible_has_property( + harness.editor.imp().source_view.upcast_ref::(), + gtk4::AccessibleProperty::Label + )); + assert_eq!( + crate::test_support::unlabelled_icon_buttons(&harness.editor), + Vec::::new() + ); + assert_eq!( + crate::test_support::tooltips_with_keys(&harness.editor), + Vec::::new() + ); +} + +/// Tab indents and Ctrl+Tab switches tabs, so the editor names F6, the +/// key its pane binds to move on to the results. +#[gtk4::test] +fn the_editor_names_its_way_out() { + use crate::test_support::shortcut_keys::{class_binding_keys, keystroke}; + let harness = editor_with(answering(), "SELECT 1"); + let view = harness.editor.imp().source_view.get(); + let paned = view.ancestor(gtk4::Paned::static_type()).expect("the editor's pane"); + + assert!(class_binding_keys(&paned).contains(&keystroke("F6"))); + assert!(gtk4::test_accessible_has_property( + view.upcast_ref::(), + gtk4::AccessibleProperty::KeyShortcuts + )); +} + +#[gtk4::test] +fn a_run_is_recorded_unless_it_carries_a_credential() { + let root = tempfile::tempdir().expect("tempdir"); + let harness = editor_with_history(&root, "CREATE USER u PASSWORD 'p';"); + run_and_wait(&harness); + + harness.editor.buffer().set_text("SELECT 2;"); + run_and_wait(&harness); + + wait_until(WAIT, || { + recorded(&harness).iter().any(|query| query.contains("SELECT 2")) + }) + .expect("the plain run was never recorded"); + let recorded = recorded(&harness); + assert!( + recorded.iter().all(|query| !query.contains("CREATE USER")), + "a statement with a password was recorded: {recorded:?}" + ); +} + +#[gtk4::test] +fn a_run_is_judged_by_the_engine_it_ran_on() { + let root = tempfile::tempdir().expect("tempdir"); + let harness = editor_with_history(&root, "CREATE USER u PASSWORD 'p';"); + let ran_on = harness.active.get().expect("the window's session"); + drop(ran_on.begin_shutdown()); + assert!(ran_on.current_dialect().is_none(), "the run's engine is still known"); + + harness.editor.run(); + assert!(harness.editor.is_running(), "the run did not start"); + // SQLite has no logins, so its rules let CREATE USER into the history. + let sqlite = ConnectionScript { + dialect: Arc::new(tablepro_driver_sqlite::SqliteDialect::new( + tablepro_driver_sqlite::SQLITE_MIN_VERSION, + )), + ..ConnectionScript::default() + }; + let moved_to = Arc::new(FakeEditorSession::new(answering())); + harness + .active + .replace(session_on(moved_to, harness.runtime.tasks(), sqlite)); + wait_until(WAIT, || !harness.editor.is_running()).expect("the run to end"); + + harness.editor.buffer().set_text("SELECT 2;"); + run_and_wait(&harness); + + wait_until(WAIT, || { + recorded(&harness).iter().any(|query| query.contains("SELECT 2")) + }) + .expect("the run on the new engine was never recorded"); + let recorded = recorded(&harness); + assert!( + recorded.iter().all(|query| !query.contains("CREATE USER")), + "a statement with a password was recorded: {recorded:?}" + ); +} + +/// What the harness's history holds, newest first. +fn recorded(harness: &Harness) -> Vec { + recorded_entries(harness).into_iter().map(|entry| entry.query).collect() +} + +fn recorded_entries(harness: &Harness) -> Vec { + let Some(history) = harness.history.as_ref().and_then(HistoryService::store) else { + return Vec::new(); + }; + let everything = tablepro_storage::query_history::HistoryQuery::default(); + let search = history.search_page( + &everything, + tablepro_storage::query_history::HistoryPageRequest::first(100), + ); + block_on(&harness.runtime, search).expect("search").entries +} + +/// A result set of `rows` one-column rows, ended the way `truncation` +/// says. +fn result_set(rows: u64, total_rows: Option, truncation: Truncation) -> FakeRun { + let columns: Arc<[ResultColumn]> = vec![ResultColumn::new("n", ColumnType::untyped_text("text"))].into(); + let mut events = vec![ + ResultEvent::ResultSetStarted { columns }, + ResultEvent::Rows((0..rows).map(|row| vec![Value::Int(row as i64)]).collect()), + ]; + if truncation != Truncation::Complete { + events.push(ResultEvent::RowLimitReached { retained: rows }); + } + events.push(ResultEvent::ResultSetFinished { + retained: rows, + total_rows, + truncation, + }); + FakeRun::completed(events) +} + +/// The banner over the result page on show. +fn shown_banner(harness: &Harness) -> adw::Banner { + let page = harness + .editor + .imp() + .results + .imp() + .stack + .visible_child() + .expect("a result page"); + crate::test_support::first_descendant_of_type::(&page).expect("the page's banner") +} + +#[gtk4::test] +fn truncated_result_reveals_banner_and_complete_result_hides_it() { + let mut runs = Replies::default(); + runs.when_sql_contains("capped", result_set(3, None, Truncation::ServerStopped)); + runs.always(result_set(2, Some(2), Truncation::Complete)); + let harness = editor_with(runs, "SELECT n FROM capped; SELECT n FROM small"); + + run_and_wait(&harness); + let results = &harness.editor.imp().results; + results.select(0); + let capped = shown_banner(&harness); + results.select(1); + let complete = shown_banner(&harness); + + assert!(capped.is_revealed(), "the capped result has no banner"); + assert_eq!( + capped.title(), + "Stopped after the first 3 rows: the statement did not run past them" + ); + assert_eq!(capped.button_label().as_deref(), Some("Export All…")); + assert!(!complete.is_revealed(), "a complete result shows a banner"); +} + +#[gtk4::test] +fn export_all_uses_originating_statement_of_second_result() { + let mut runs = Replies::default(); + runs.always(result_set(3, Some(40), Truncation::ReadPastLimit)); + let harness = editor_with(runs, "SELECT n FROM first;\nSELECT n FROM second WHERE n > 2;"); + let offers = SignalLog::connect(&harness.editor, "export-results", |values| { + values[1] + .get::() + .expect("an offer") + .borrow::() + .clone() + }); + run_and_wait(&harness); + + harness.editor.imp().results.select(1); + shown_banner(&harness).emit_by_name::<()>("button-clicked", &[]); + + let offers = offers.take(); + let [offer] = offers.as_slice() else { + panic!("one offer, not {}", offers.len()); + }; + let ExportOrigin::Editor { + shown, + query: ExportQuery::Editor { sql }, + shown_rows, + truncated, + } = &offer.origin + else { + panic!("not an editor result: {:?}", offer.origin); + }; + assert_eq!(sql.as_str(), "SELECT n FROM second WHERE n > 2"); + assert!(*truncated); + assert_eq!(*shown_rows, 3); + assert_eq!(shown.row_count(), Some(3)); + assert!(offer.session.is_some(), "Export All has no session to read through"); + assert_eq!(offer.origin.preselected(), crate::ui::export::ScopeSource::Query); +} + +#[gtk4::test] +fn history_records_none_for_capped_result() { + let root = tempfile::tempdir().expect("tempdir"); + let mut runs = Replies::default(); + runs.when_sql_contains("capped", result_set(3, None, Truncation::ServerStopped)); + runs.always(result_set(2, Some(9), Truncation::ReadPastLimit)); + let harness = editor_with_history_runs(&root, runs, "SELECT n FROM capped;"); + run_and_wait(&harness); + harness.editor.buffer().set_text("SELECT n FROM counted;"); + run_and_wait(&harness); + + wait_until(WAIT, || recorded_entries(&harness).len() == 2).expect("both runs recorded"); + let entries = recorded_entries(&harness); + let rows = |query: &str| { + entries + .iter() + .find(|entry| entry.query.contains(query)) + .map(|entry| entry.rows_affected) + .expect("the run's entry") + }; + assert_eq!(rows("capped"), None, "a capped result recorded the rows it kept"); + assert_eq!(rows("counted"), Some(9)); +} + +/// The test drives the runtime from outside it, which is the one place +/// blocking on it cannot deadlock. +#[expect( + clippy::disallowed_methods, + reason = "a GTK test has no runtime of its own, so it drives the app's from outside" +)] +fn block_on(runtime: &crate::runtime::AppRuntime, future: F) -> F::Output { + runtime.tasks().handle().block_on(future) +} + +fn unconnected_editor(settings: &MemorySettings, tasks: Tasks) -> SqlEditor { + crate::register_test_resources(); + SqlEditor::new(SqlEditorInit { + initial_query: None, + settings: settings.get().clone(), + history: None, + tasks, + session: ActiveSession::new(), + hold: Rc::new(tablepro_session::runtime::CountingHold::new()), + close_timeout: Duration::from_secs(5), + }) +} + +fn scheme_id(view: &sourceview5::View) -> Option { + let buffer = view.buffer().downcast::().ok()?; + buffer.style_scheme().map(|scheme| scheme.id().to_string()) +} + +#[gtk4::test] +fn closed_editor_frees_source_view() { + let runtime = crate::test_support::test_runtime(); + let settings = MemorySettings::new(); + let editor = unconnected_editor(&settings, runtime.tasks()); + let view = editor.imp().source_view.get().downgrade(); + + drop(editor); + crate::test_support::drain_main_context(); + + assert!( + view.upgrade().is_none(), + "the closed editor's source view is still alive" + ); +} + +#[gtk4::test] +fn closed_editor_drops_its_theme_and_font_handlers() { + let _ = adw::init(); + let runtime = crate::test_support::test_runtime(); + let settings = MemorySettings::new(); + let counts = |settings: &MemorySettings| (Rc::strong_count(settings.get()), Rc::weak_count(settings.get())); + let before = counts(&settings); + let style = adw::StyleManager::default(); + style.set_color_scheme(adw::ColorScheme::ForceLight); + let editor = unconnected_editor(&settings, runtime.tasks()); + // Held past the editor, so a handler left behind has a view to repaint. + let view = editor.imp().source_view.get(); + + drop(editor); + crate::test_support::drain_main_context(); + let scheme = scheme_id(&view); + settings.get().set_style_scheme("cobalt").expect("a style scheme"); + style.set_color_scheme(adw::ColorScheme::ForceDark); + let after = scheme_id(&view); + style.set_color_scheme(adw::ColorScheme::Default); + + assert_eq!(after, scheme, "a closed editor still follows the theme"); + assert_eq!( + counts(&settings), + before, + "the settings still carry a closed editor's handlers" + ); +} + +#[test] +fn export_name_slugs_the_statement() { + assert_eq!(export_name_for_query("SELECT * FROM users"), "select-from-users"); + assert_eq!(export_name_for_query(" select id\nfrom t"), "select-id"); +} + +#[test] +fn export_name_falls_back_when_there_is_no_statement() { + assert_eq!(export_name_for_query(" \n "), crate::i18n::gettext("query-results")); +} + +/// The editor in a window. A widget finds its ancestors' action groups +/// once it is rooted, which a key press always is. +struct InWindow(gtk4::Window); + +impl InWindow { + fn new(editor: &SqlEditor) -> Self { + let window = gtk4::Window::new(); + window.set_child(Some(editor)); + Self(window) + } + + /// Shown, and holding the keyboard focus. + fn presented(editor: &SqlEditor) -> Self { + let shown = Self::new(editor); + shown.0.set_default_size(480, 320); + crate::test_support::KeyInjector::focus_only(&shown.0); + wait_until(Duration::from_secs(2), || shown.0.is_active()).expect("the window to take the focus"); + shown + } +} + +impl Drop for InWindow { + fn drop(&mut self) { + crate::test_support::drop_focus(&self.0); + self.0.set_child(None::<>k4::Widget>); + self.0.destroy(); + } +} + +#[gtk4::test] +fn a_closed_editor_lets_go_of_its_script_and_completion() { + let harness = editor_with(answering(), "SELECT 1"); + let view = harness.editor.imp().source_view.get(); + let script = harness.editor.buffer().downgrade(); + let providers: Vec> = harness + .editor + .imp() + .completion_providers + .borrow() + .iter() + .map(ObjectExt::downgrade) + .collect(); + assert_eq!(providers.len(), 2, "the catalog and the document words"); + { + let shown = InWindow::new(&harness.editor); + shown.0.present(); + wait_until(WAIT, || view.is_mapped()).expect("the editor on screen"); + crate::test_support::after_next_frame(&view); + } + + drop(view); + drop(harness); + crate::test_support::drain_main_context(); + + assert!(script.upgrade().is_none(), "the script outlived its editor"); + assert!( + providers.iter().all(|provider| provider.upgrade().is_none()), + "the completion, which holds the session, outlived its editor" + ); +} + +fn editor_keys(harness: &Harness) -> gtk4::ShortcutController { + crate::test_support::shortcut_keys::owner_controller( + &harness.editor.imp().source_view.get(), + crate::application::ShortcutOwner::SqlEditor, + ) + .expect("the editor's shortcuts") +} + +/// What `accel` does on the source view, as a key reaching the editor's +/// controller would. `false` when the key goes on. +fn press(harness: &Harness, accel: &str) -> bool { + use crate::test_support::shortcut_keys::{keystroke, shortcuts, trigger_keys}; + let key = keystroke(accel); + let action = shortcuts(&editor_keys(harness)) + .into_iter() + .find(|shortcut| { + shortcut + .trigger() + .is_some_and(|trigger| trigger_keys(&trigger).contains(&key)) + }) + .and_then(|shortcut| shortcut.action()) + .expect("the editor binds the key"); + action.activate( + gtk4::ShortcutActionFlags::empty(), + &harness.editor.imp().source_view.get(), + None, + ) +} + +/// GtkTextView binds Ctrl+/ to select-all, and a bubble controller on +/// the view runs before that binding only while it was added last, so +/// every editor key that a class binds too runs in the capture phase. +#[gtk4::test] +fn editor_keys_that_the_view_binds_run_in_capture() { + use crate::application::{ACCELERATORS, AcceleratorScope, ShortcutOwner}; + use crate::test_support::shortcut_keys::{class_binding_keys, keystroke}; + let harness = editor_with(answering(), "SELECT 1"); + let view = harness.editor.imp().source_view.get(); + let class_keys = class_binding_keys(&view); + let controller = editor_keys(&harness); + + assert!( + class_keys.contains(&keystroke("slash")), + "the view's own bindings were not found, so the check reads nothing" + ); + assert_eq!(controller.propagation_phase(), gtk4::PropagationPhase::Capture); + for entry in ACCELERATORS { + let AcceleratorScope::Local { + owner: ShortcutOwner::SqlEditor, + phase, + } = entry.scope + else { + continue; + }; + if entry + .triggers + .iter() + .any(|accel| class_keys.contains(&keystroke(accel))) + { + assert_eq!(phase, gtk4::PropagationPhase::Capture, "{}", entry.action); + } + } +} + +#[gtk4::test] +fn ctrl_slash_toggles_the_comment() { + let harness = editor_with(answering(), "select 1"); + let _window = InWindow::new(&harness.editor); + + assert!(press(&harness, "slash")); + + assert_eq!(buffer_text(&harness.editor.buffer()), "-- select 1"); + assert!(!harness.editor.buffer().has_selection()); +} + +#[gtk4::test] +fn escape_cancels_only_while_a_query_runs() { + let mut runs = Replies::default(); + runs.always(FakeRun::until_stopped()); + let harness = editor_with(runs, "SELECT pg_sleep(60)"); + let _window = InWindow::new(&harness.editor); + + assert!(!press(&harness, "Escape"), "Escape was taken with nothing running"); + + assert!(press(&harness, "Return")); + assert!(harness.editor.is_running(), "Ctrl+Return did not run"); + wait_until(WAIT, || !harness.fake.texts().is_empty()).expect("the statement to start"); + assert!(press(&harness, "Escape"), "Escape did not cancel the run"); + wait_until(WAIT, || !harness.editor.is_running()).expect("the run to end"); + assert_eq!(harness.fake.stopped_runs(), 1); + assert!(!press(&harness, "Escape"), "Escape stayed taken after the run"); +} + +/// GtkPopoverMenu shows only application accelerators by itself. +#[gtk4::test] +fn the_editor_menu_shows_the_tables_local_keys() { + use crate::application::{ACCELERATORS, AcceleratorScope}; + let harness = editor_with(answering(), "SELECT 1"); + let menu = harness + .editor + .imp() + .menu_button + .menu_model() + .expect("the editor's menu"); + let mut checked = 0; + for section in 0..menu.n_items() { + let Some(items) = menu.item_link(section, "section") else { + continue; + }; + for item in 0..items.n_items() { + let Some(action) = items + .item_attribute_value(item, "action", None) + .and_then(|value| value.get::()) + else { + continue; + }; + let accel = items + .item_attribute_value(item, "accel", None) + .and_then(|value| value.get::()); + let entry = ACCELERATORS.iter().find(|entry| entry.action == action); + let expected = entry + .filter(|entry| matches!(entry.scope, AcceleratorScope::Local { .. })) + .and_then(|entry| entry.triggers.first()) + .map(|accel| (*accel).to_owned()); + assert_eq!(accel, expected, "{action}"); + checked += usize::from(expected.is_some()); + } + } + assert!(checked > 0, "no menu item has a local key, so the check reads nothing"); +} + +/// Load `path` into the whole buffer, as Open SQL File does. +fn open_path(harness: &Harness, path: &std::path::Path) { + let (editor, file) = (harness.editor.clone(), gio::File::for_path(path)); + crate::test_support::finish_on_main(WAIT, async move { editor.insert_file(file, true).await }) + .expect("the file to load"); +} + +#[gtk4::test] +fn ctrl_s_writes_the_opened_file_and_marks_the_text_saved() { + let root = tempfile::tempdir().expect("tempdir"); + let path = root.path().join("daily.sql"); + std::fs::write(&path, "SELECT 1;").expect("seed"); + let harness = editor_with(answering(), ""); + let changes = SignalLog::connect(&harness.editor, "file-changed", |_| ()); + open_path(&harness, &path); + assert_eq!(harness.editor.file().and_then(|file| file.path()), Some(path.clone())); + assert!( + !harness.editor.has_unsaved_file_changes(), + "the text just read counts as changed" + ); + + let buffer = harness.editor.buffer(); + buffer.insert(&mut buffer.end_iter(), "\nSELECT 2;"); + assert!(harness.editor.has_unsaved_file_changes()); + + assert!(press_on_tab(&harness, "s"), "Ctrl+S did nothing"); + wait_until(WAIT, || !harness.editor.has_unsaved_file_changes()).expect("the save to land"); + + assert_eq!( + std::fs::read_to_string(&path).expect("read back"), + "SELECT 1;\nSELECT 2;" + ); + assert!(changes.count() >= 3, "the workspace was not told: {}", changes.count()); +} + +#[gtk4::test] +fn undo_back_to_the_saved_text_is_saved_again() { + let root = tempfile::tempdir().expect("tempdir"); + let path = root.path().join("daily.sql"); + std::fs::write(&path, "SELECT 1;").expect("seed"); + let harness = editor_with(answering(), ""); + open_path(&harness, &path); + let buffer = harness.editor.buffer(); + + buffer.begin_user_action(); + buffer.insert(&mut buffer.end_iter(), " -- note"); + buffer.end_user_action(); + assert!(harness.editor.has_unsaved_file_changes()); + buffer.undo(); + + assert!(!harness.editor.has_unsaved_file_changes()); +} + +#[gtk4::test] +fn a_file_that_cannot_be_written_keeps_the_text_changed_and_says_why() { + let root = tempfile::tempdir().expect("tempdir"); + let harness = editor_with(answering(), "SELECT 1;"); + harness + .editor + .restore_file(gio::File::for_path(root.path().join("gone").join("daily.sql")), true); + + let saving = harness.editor.save_to_file(); + let failed = crate::test_support::finish_on_main(WAIT, saving).expect("the save to end"); + + let message = failed.expect_err("a folder that is not there cannot be written to"); + assert!(message.starts_with("“daily.sql” could not be saved: "), "{message}"); + assert!(harness.editor.has_unsaved_file_changes()); +} + +#[gtk4::test] +fn a_tab_with_no_file_has_nothing_unsaved_to_ask_about() { + let harness = editor_with(answering(), "SELECT 1;"); + let buffer = harness.editor.buffer(); + buffer.insert(&mut buffer.end_iter(), "\nSELECT 2;"); + + assert!(harness.editor.file().is_none()); + assert!(!harness.editor.has_unsaved_file_changes()); +} + +fn find_bar(harness: &Harness) -> find_bar::FindBar { + harness.editor.imp().find_bar.get() +} + +/// What `accel` does on the editor tab, as a key reaching the tab's own +/// controller would. +fn press_on_tab(harness: &Harness, accel: &str) -> bool { + use crate::test_support::shortcut_keys::{keystroke, owner_controller, shortcuts, trigger_keys}; + let key = keystroke(accel); + let controller = owner_controller(&harness.editor, crate::application::ShortcutOwner::SqlEditorTab) + .expect("the tab's shortcuts"); + let action = shortcuts(&controller) + .into_iter() + .find(|shortcut| { + shortcut + .trigger() + .is_some_and(|trigger| trigger_keys(&trigger).contains(&key)) + }) + .and_then(|shortcut| shortcut.action()) + .expect("the tab binds the key"); + action.activate(gtk4::ShortcutActionFlags::empty(), &harness.editor, None) +} + +fn select(buffer: >k4::TextBuffer, start: i32, end: i32) { + buffer.select_range(&buffer.iter_at_offset(start), &buffer.iter_at_offset(end)); +} + +fn selected(buffer: >k4::TextBuffer) -> Option<(i32, i32)> { + buffer + .selection_bounds() + .map(|(start, end)| (start.offset(), end.offset())) +} + +/// Open the bar and search for `text`, waiting until the bar shows +/// what the search counted. The count can be read a moment before the +/// context says it changed. +fn search(harness: &Harness, text: &str) -> find_bar::FindBar { + assert!(press_on_tab(harness, "f")); + let bar = find_bar(harness); + bar.search_entry().set_text(text); + wait_until(WAIT, || !bar.count_text().is_empty()).expect("the search to count"); + bar +} + +#[gtk4::test] +fn ctrl_f_reveals_bar_and_prefills_selection() { + let harness = editor_with(answering(), "SELECT * FROM orders JOIN orders_lines"); + let _window = InWindow::new(&harness.editor); + let buffer = harness.editor.buffer(); + select(&buffer, 14, 20); + + assert!(press_on_tab(&harness, "f")); + + let bar = find_bar(&harness); + assert!(bar.is_open()); + assert!(!bar.is_replacing()); + assert_eq!(bar.search_entry().text(), "orders"); + wait_until(WAIT, || bar.count_text() == "1 of 2").expect("the search to count"); + assert_eq!( + bar.context().map(|context| context.is_highlight()), + Some(true), + "matches are not highlighted" + ); +} + +#[gtk4::test] +fn a_selection_over_lines_is_not_searched_for() { + let harness = editor_with(answering(), "SELECT 1;\nSELECT 2;"); + let _window = InWindow::new(&harness.editor); + select(&harness.editor.buffer(), 0, 15); + + assert!(press_on_tab(&harness, "f")); + + assert_eq!(find_bar(&harness).search_entry().text(), ""); +} + +#[gtk4::test] +fn next_selects_second_occurrence() { + let harness = editor_with(answering(), "orders; orders; orders"); + let _window = InWindow::new(&harness.editor); + let buffer = harness.editor.buffer(); + select(&buffer, 0, 6); + let bar = search(&harness, "orders"); + wait_until(WAIT, || selected(&buffer) == Some((0, 6))).expect("the first match selected"); + + assert!(press_on_tab(&harness, "g")); + assert_eq!(selected(&buffer), Some((8, 14))); + assert_eq!(bar.count_text(), "2 of 3"); + + assert!(press_on_tab(&harness, "g")); + assert_eq!(selected(&buffer), Some((0, 6))); + assert!(press_on_tab(&harness, "g")); + assert_eq!(selected(&buffer), Some((16, 22)), "the search did not wrap around"); +} + +/// GtkSearchEntry binds Ctrl+G itself, so in the entry the key reaches +/// the bar as the entry's next-match signal. +#[gtk4::test] +fn the_search_entrys_next_match_moves_to_the_next() { + let harness = editor_with(answering(), "a; b; a"); + let _window = InWindow::new(&harness.editor); + let buffer = harness.editor.buffer(); + buffer.place_cursor(&buffer.start_iter()); + let bar = search(&harness, "a"); + wait_until(WAIT, || selected(&buffer) == Some((0, 1))).expect("the first match selected"); + + bar.search_entry().emit_by_name::<()>("next-match", &[]); + + assert_eq!(selected(&buffer), Some((6, 7))); +} + +#[gtk4::test] +fn replace_all_is_single_undo_step() { + let harness = editor_with(answering(), "x = 1 AND x = 2"); + let _window = InWindow::new(&harness.editor); + let toasts = SignalLog::connect(&harness.editor, "toast", |values| { + values[1].get::().expect("a message") + }); + assert!(press_on_tab(&harness, "h")); + let bar = find_bar(&harness); + assert!(bar.is_replacing(), "Ctrl+H did not show the replace row"); + bar.replace_entry().set_text("y"); + bar.search_entry().set_text("x"); + wait_until(WAIT, || bar.count_text().ends_with("of 2")).expect("the search to count"); + + WidgetExt::activate_action(&bar, "find.replace-all", None).expect("find.replace-all"); + + let buffer = harness.editor.buffer(); + assert_eq!(toasts.take(), ["Replaced 2 occurrences"]); + assert_eq!(buffer_text(&buffer), "y = 1 AND y = 2"); + buffer.undo(); + assert_eq!(buffer_text(&buffer), "x = 1 AND x = 2"); +} + +#[gtk4::test] +fn replace_changes_the_selected_match_and_moves_on() { + let harness = editor_with(answering(), "x; x; x"); + let _window = InWindow::new(&harness.editor); + let buffer = harness.editor.buffer(); + select(&buffer, 3, 4); + let bar = search(&harness, "x"); + bar.replace_entry().set_text("yy"); + + WidgetExt::activate_action(&bar, "find.replace", None).expect("find.replace"); + + assert_eq!(buffer_text(&buffer), "x; yy; x"); + wait_until(WAIT, || selected(&buffer) == Some((7, 8))).expect("the next match selected"); +} + +#[gtk4::test] +fn nothing_to_replace_leaves_the_buffer_alone() { + let harness = editor_with(answering(), "SELECT 1"); + let _window = InWindow::new(&harness.editor); + let bar = search(&harness, "orders"); + assert_eq!(bar.count_text(), "No results"); + + let actions = bar.actions().expect("the find actions"); + assert!( + !actions.is_action_enabled("replace-all"), + "Replace All is on with nothing to replace" + ); + assert!(!actions.is_action_enabled("replace")); + bar.replace_all(); + + assert_eq!(buffer_text(&harness.editor.buffer()), "SELECT 1"); +} + +#[gtk4::test] +fn invalid_regex_marks_entry_error() { + let harness = editor_with(answering(), "SELECT 1"); + let _window = InWindow::new(&harness.editor); + assert!(press_on_tab(&harness, "f")); + let bar = find_bar(&harness); + WidgetExt::activate_action(&bar, "find.regex-enabled", None).expect("find.regex-enabled"); + let entry = bar.search_entry(); + + entry.set_text("("); + wait_until(WAIT, || entry.has_css_class("error")).expect("the entry to show the error"); + assert!(entry.tooltip_text().is_some_and(|text| !text.is_empty())); + assert_eq!(bar.count_text(), ""); + + entry.set_text("SEL+"); + wait_until(WAIT, || !entry.has_css_class("error")).expect("the error to clear"); + assert_eq!(entry.tooltip_text(), None); +} + +/// The count and a pattern's error show away from the text the reader +/// is on, so they are said, and the error marks the field. +#[gtk4::test] +fn the_match_count_and_a_bad_pattern_are_said() { + let harness = editor_with(answering(), "SELECT 1; SELECT 2"); + let _window = InWindow::new(&harness.editor); + let bar = search(&harness, "SELECT"); + let said = |text: &str| { + crate::ui::a11y::announced_in(&bar) + .iter() + .any(|(message, _)| message == text) + }; + wait_until(WAIT, || said(&bar.count_text())).expect("the count to be said"); + + WidgetExt::activate_action(&bar, "find.regex-enabled", None).expect("find.regex-enabled"); + let entry = bar.search_entry(); + entry.set_text("("); + let text = entry.delegate().expect("the entry's text field"); + wait_until(WAIT, || { + gtk4::test_accessible_has_state(&text, gtk4::AccessibleState::Invalid) + && entry.tooltip_text().is_some_and(|error| said(&error)) + }) + .expect("the pattern's error to be said and marked"); + + entry.set_text("SEL+"); + wait_until(WAIT, || { + !gtk4::test_accessible_has_state(&text, gtk4::AccessibleState::Invalid) + }) + .expect("the mark to clear"); +} + +#[gtk4::test] +fn case_sensitive_action_toggles_settings() { + let harness = editor_with(answering(), "SELECT 1"); + let _window = InWindow::new(&harness.editor); + let bar = find_bar(&harness); + assert!(!bar.settings().is_case_sensitive()); + + WidgetExt::activate_action(&bar, "find.case-sensitive", None).expect("find.case-sensitive"); + assert!(bar.settings().is_case_sensitive()); + + WidgetExt::activate_action(&bar, "find.at-word-boundaries", None).expect("find.at-word-boundaries"); + assert!(bar.settings().is_at_word_boundaries()); +} + +#[gtk4::test] +fn escape_hides_bar_and_disables_highlight() { + let harness = editor_with(answering(), "SELECT 1"); + let _window = InWindow::new(&harness.editor); + let bar = search(&harness, "SEL"); + let context = bar.context().expect("the search context"); + assert!(context.is_highlight()); + + bar.search_entry().emit_by_name::<()>("stop-search", &[]); + + assert!(!bar.is_open()); + assert!(!context.is_highlight()); +} + +/// A bubble key on the tab reaches it only when no class on the way up +/// from the focus binds it first. The search entry binds Ctrl+G, which +/// the bar follows through the entry's own signals. +#[gtk4::test] +fn tab_keys_are_not_class_bindings_inside_the_tab() { + use crate::application::{ACCELERATORS, AcceleratorScope, ShortcutOwner}; + use crate::test_support::shortcut_keys::{class_keys_on_focus_paths, keystroke}; + let harness = editor_with(answering(), "SELECT 1"); + let _window = InWindow::new(&harness.editor); + + let class_keys = class_keys_on_focus_paths(&harness.editor, |widget| { + widget.is::() || widget.is::() + }); + + assert!( + class_keys.contains(&keystroke("slash")), + "the view's own bindings were not found, so the check reads nothing" + ); + let mut checked = 0; + for entry in ACCELERATORS { + let AcceleratorScope::Local { + owner: ShortcutOwner::SqlEditorTab, + phase, + } = entry.scope + else { + continue; + }; + assert_eq!(phase, gtk4::PropagationPhase::Bubble, "{}", entry.action); + for accel in entry.triggers { + assert!( + !class_keys.contains(&keystroke(accel)), + "{accel} of {} never reaches the tab", + entry.action + ); + checked += 1; + } + } + assert_eq!(checked, 7); +} + +/// These press real keys on the X display every test shares; nextest runs +/// them with no other test beside them. +mod real_keys { + use super::*; + + /// A real key press, so the editor's controller meets GtkTextView's own + /// Ctrl+/ binding in the order GTK runs them. + #[gtk4::test] + fn ctrl_slash_toggles_comment_instead_of_selecting_all() { + let harness = editor_with(answering(), "select 1"); + let _window = InWindow::presented(&harness.editor); + let buffer = harness.editor.buffer(); + buffer.place_cursor(&buffer.start_iter()); + harness.editor.imp().source_view.grab_focus(); + + crate::test_support::KeyInjector::connect() + .expect("an X11 display") + .press(gtk4::gdk::Key::slash, gtk4::gdk::ModifierType::CONTROL_MASK) + .expect("the key press"); + + wait_until(Duration::from_secs(2), || buffer_text(&buffer) == "-- select 1") + .expect("Ctrl+/ to comment the line"); + assert!(!buffer.has_selection(), "Ctrl+/ selected the text"); + } + + /// Real keys, so Ctrl+F meets every handler on its way up from the + /// view, and Escape the search entry's own. + #[gtk4::test] + fn ctrl_f_in_the_view_opens_find_and_escape_returns_to_it() { + // The bar slides in, and its entry takes no key until the first + // frame of the slide shows it. + if let Some(settings) = gtk4::Settings::default() { + settings.set_gtk_enable_animations(false); + } + let harness = editor_with(answering(), "select 1"); + let _window = InWindow::presented(&harness.editor); + let view = harness.editor.imp().source_view.get(); + view.grab_focus(); + let keys = crate::test_support::KeyInjector::connect().expect("an X11 display"); + let bar = find_bar(&harness); + let entry = bar.search_entry(); + + keys.press(gtk4::gdk::Key::f, gtk4::gdk::ModifierType::CONTROL_MASK) + .expect("the key press"); + wait_until(Duration::from_secs(2), || { + bar.is_open() && entry.state_flags().contains(gtk4::StateFlags::FOCUS_WITHIN) + }) + .expect("Ctrl+F to open the find bar with the focus in its entry"); + + keys.press(gtk4::gdk::Key::Escape, gtk4::gdk::ModifierType::empty()) + .expect("the key press"); + wait_until(Duration::from_secs(2), || !bar.is_open() && view.has_focus()) + .expect("Escape to close the bar and return to the view"); + } +} diff --git a/linux/crates/app/src/ui/editor/transaction_dirty.rs b/linux/crates/app/src/ui/editor/transaction_dirty.rs new file mode 100644 index 0000000000..8e8cca1af4 --- /dev/null +++ b/linux/crates/app/src/ui/editor/transaction_dirty.rs @@ -0,0 +1,65 @@ +use tablepro_core::TransactionState; + +/// Whether closing the tab would throw away work the server holds for +/// it: an open transaction, or one that failed and still needs a +/// rollback. A lost session holds nothing any more. `Unknown` counts +/// once something ran, because the engine cannot say what that left. +pub fn transaction_dirty(lost: bool, state: Option, has_run: bool) -> bool { + if lost { + return false; + } + match state { + Some(TransactionState::InTransaction | TransactionState::Failed) => true, + Some(TransactionState::Unknown) => has_run, + Some(TransactionState::Idle) | None => false, + } +} + +/// The line above the results that says a transaction is still open. +/// A lost session has none, whatever its last run said before it went. +pub fn transaction_label(lost: bool, state: Option) -> Option { + if lost { + return None; + } + match state { + Some(TransactionState::InTransaction) => Some(crate::i18n::gettext("Transaction open")), + Some(TransactionState::Failed) => Some(crate::i18n::gettext("Transaction failed, roll back to continue")), + Some(TransactionState::Idle | TransactionState::Unknown) | None => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn transaction_dirty_cleared_when_lost() { + for state in [TransactionState::InTransaction, TransactionState::Failed] { + assert!(transaction_dirty(false, Some(state), true)); + assert!(!transaction_dirty(true, Some(state), true), "{state:?}"); + } + } + + #[test] + fn transaction_dirty_unknown_needs_a_run() { + assert!(!transaction_dirty(false, Some(TransactionState::Unknown), false)); + assert!(transaction_dirty(false, Some(TransactionState::Unknown), true)); + assert!(!transaction_dirty(false, Some(TransactionState::Idle), true)); + assert!(!transaction_dirty(false, None, true)); + } + + #[test] + fn only_an_open_or_failed_transaction_is_labelled() { + assert!(transaction_label(false, Some(TransactionState::InTransaction)).is_some()); + assert!(transaction_label(false, Some(TransactionState::Failed)).is_some()); + assert_eq!(transaction_label(false, Some(TransactionState::Idle)), None); + assert_eq!(transaction_label(false, Some(TransactionState::Unknown)), None); + } + + #[test] + fn a_lost_session_shows_no_open_transaction() { + for state in [TransactionState::InTransaction, TransactionState::Failed] { + assert_eq!(transaction_label(true, Some(state)), None, "{state:?}"); + } + } +} diff --git a/linux/crates/app/src/ui/editor_font_provider.rs b/linux/crates/app/src/ui/editor_font_provider.rs new file mode 100644 index 0000000000..933362adc1 --- /dev/null +++ b/linux/crates/app/src/ui/editor_font_provider.rs @@ -0,0 +1,146 @@ +use std::cell::RefCell; + +use gtk4::glib::translate::IntoGlib; +use gtk4::pango; +use tablepro_storage::{AppSettings, EditorFont}; + +thread_local! { + static PROVIDER: RefCell> = const { RefCell::new(None) }; +} + +/// The CSS the display currently carries for the editor, or `None` when +/// the system font is selected and no rule is installed. +#[cfg(test)] +pub(crate) fn installed_css() -> Option { + PROVIDER.with(|cell| cell.borrow().as_ref().map(|(_, css)| css.clone())) +} + +/// GTK 4.10 removed the per-widget CSS provider, so the editor font is a +/// display-scoped rule on `textview.sql-editor`. With the system font +/// selected there is no rule at all: `style.css` already points the class +/// at libadwaita's monospace variables. +pub(crate) fn apply(settings: &AppSettings) { + let Some(display) = gtk4::gdk::Display::default() else { + return; + }; + PROVIDER.with(|cell| { + if let Some((previous, _)) = cell.borrow_mut().take() { + gtk4::style_context_remove_provider_for_display(&display, &previous); + } + let EditorFont::Custom(description) = settings.editor_font() else { + return; + }; + let css = editor_font_css(&pango::FontDescription::from_string(&description)); + let provider = gtk4::CssProvider::new(); + provider.load_from_string(&css); + gtk4::style_context_add_provider_for_display(&display, &provider, gtk4::STYLE_PROVIDER_PRIORITY_APPLICATION); + *cell.borrow_mut() = Some((provider, css)); + }); +} + +fn editor_font_css(font: &pango::FontDescription) -> String { + let mut declarations = Vec::new(); + if let Some(family) = font.family() { + declarations.push(format!("font-family: \"{}\";", escape_css_string(&family))); + } + let size = font.size(); + if size > 0 { + let unit = if font.is_size_absolute() { "px" } else { "pt" }; + declarations.push(format!("font-size: {}{unit};", size / pango::SCALE)); + } + if font.weight() != pango::Weight::Normal { + declarations.push(format!("font-weight: {};", font.weight().into_glib())); + } + match font.style() { + pango::Style::Italic => declarations.push("font-style: italic;".to_owned()), + pango::Style::Oblique => declarations.push("font-style: oblique;".to_owned()), + _ => {} + } + format!( + "textview.sql-editor, textview.sql-editor text {{ {} }}", + declarations.join(" ") + ) +} + +/// A family name reaches CSS inside double quotes, so a quote or a +/// backslash in it would end the string early. +fn escape_css_string(value: &str) -> String { + value.replace('\\', "\\\\").replace('"', "\\\"") +} + +#[cfg(test)] +mod tests { + use tablepro_storage::EditorFont; + + use crate::test_support::MemorySettings; + + use super::*; + + #[gtk4::test] + fn system_font_installs_no_provider() { + let settings = MemorySettings::new(); + + apply(settings.get()); + + assert_eq!(installed_css(), None); + } + + #[gtk4::test] + fn custom_font_change_updates_css() { + let settings = MemorySettings::new(); + settings + .get() + .set_editor_font(&EditorFont::Custom("Fira Code 13".to_owned())) + .expect("store the custom font"); + + apply(settings.get()); + let first = installed_css().expect("a provider for the custom font"); + + settings + .get() + .set_editor_font(&EditorFont::Custom("Monospace 9".to_owned())) + .expect("store the second font"); + apply(settings.get()); + let second = installed_css().expect("a provider for the second font"); + + assert!(first.contains("Fira Code") && first.contains("13pt"), "{first}"); + assert!(second.contains("Monospace") && second.contains("9pt"), "{second}"); + } + + #[test] + fn editor_font_css_quotes_and_escapes_the_family() { + let css = editor_font_css(&pango::FontDescription::from_string("Fira Code 13")); + + assert!(css.contains("font-family: \"Fira Code\";"), "{css}"); + assert!(css.contains("font-size: 13pt;"), "{css}"); + assert!(css.starts_with("textview.sql-editor, textview.sql-editor text {")); + } + + #[test] + fn editor_font_css_escapes_quotes_and_backslashes() { + let mut font = pango::FontDescription::new(); + font.set_family("Ev\"il\\Font"); + + let css = editor_font_css(&font); + + assert!(css.contains("font-family: \"Ev\\\"il\\\\Font\";"), "{css}"); + } + + #[test] + fn editor_font_css_uses_px_for_an_absolute_size() { + let mut font = pango::FontDescription::from_string("Monospace"); + font.set_absolute_size(f64::from(16 * pango::SCALE)); + + let css = editor_font_css(&font); + + assert!(css.contains("font-size: 16px;"), "{css}"); + } + + #[test] + fn editor_font_css_carries_weight_and_style() { + let css = editor_font_css(&pango::FontDescription::from_string("Monospace Bold Italic 11")); + + assert!(css.contains("font-weight: 700;"), "{css}"); + assert!(css.contains("font-style: italic;"), "{css}"); + } +} diff --git a/linux/crates/app/src/ui/error_text/ddl.rs b/linux/crates/app/src/ui/error_text/ddl.rs new file mode 100644 index 0000000000..15091002c8 --- /dev/null +++ b/linux/crates/app/src/ui/error_text/ddl.rs @@ -0,0 +1,128 @@ +//! DDL refusals and problems in the user's words, for the Structure tab +//! and for a save refused before it starts. + +use tablepro_core::ddl::{BuildDdlError, DdlOperation, DraftProblem, RefusalReason}; + +use crate::services::pending_structure::SaveBlocker; + +/// Why a control is off, for its tooltip. +pub fn refusal_text(reason: &RefusalReason) -> String { + match reason { + RefusalReason::EngineUnsupported => crate::i18n::gettext("This database can't make this change."), + RefusalReason::KeyColumn => crate::i18n::gettext( + "This column is part of the table's sorting or partition key, which only rebuilding the table can change.", + ), + RefusalReason::GeneratedColumn => crate::i18n::gettext("The database computes this column."), + RefusalReason::IdentityColumn => { + crate::i18n::gettext("The database numbers this column, so it has no default.") + } + RefusalReason::NotAnInteger => crate::i18n::gettext("Only an integer column can number itself."), + RefusalReason::AlwaysNumbered => crate::i18n::gettext( + "The database numbers this column while it is the table's integer primary key. Change its type or the primary key to stop it.", + ), + RefusalReason::UnmodeledAttribute(attribute) => crate::i18n::gettext_f( + "This column has an attribute TablePro can't restate: {attribute}", + &[("attribute", attribute)], + ), + RefusalReason::ColumnInUse(users) => { + crate::i18n::gettext_f("Still used by {users}.", &[("users", &users.join(", "))]) + } + RefusalReason::ReferencedByForeignKeys(keys) => crate::i18n::gettext_f( + "Foreign keys on other tables depend on it: {keys}", + &[("keys", &keys.join(", "))], + ), + RefusalReason::UnreadableDefinition => { + crate::i18n::gettext("The database didn't report this column's full definition.") + } + RefusalReason::ConflictingAttribute(attribute) => crate::i18n::gettext_f( + "This column's {attribute} doesn't allow this change.", + &[("attribute", attribute)], + ), + RefusalReason::NamesRenamedColumn(column) => crate::i18n::gettext_f( + "Its definition names column {column}, which this save renames. Save the rename on its own first.", + &[("column", column)], + ), + RefusalReason::InsertedByPosition(triggers) => crate::i18n::ngettext_f( + "Trigger {triggers} inserts into this table without naming its columns, so adding or removing a column would break it.", + "Triggers {triggers} insert into this table without naming its columns, so adding or removing a column would break them.", + u32::try_from(triggers.len()).unwrap_or(u32::MAX), + &[("triggers", &triggers.join(", "))], + ), + } +} + +/// The first thing to fix before Save. +fn draft_problem_text(problem: &DraftProblem) -> String { + match problem { + DraftProblem::EmptyTableName => crate::i18n::gettext("Table name is required."), + DraftProblem::NoColumns => crate::i18n::gettext("At least one column is required."), + DraftProblem::EmptyColumnName => crate::i18n::gettext("Every column needs a name."), + DraftProblem::DuplicateColumnName(name) => { + crate::i18n::gettext_f("Duplicate column name: {name}", &[("name", name)]) + } + DraftProblem::EmptyColumnType(name) => crate::i18n::gettext_f("Column {name} needs a type.", &[("name", name)]), + DraftProblem::NullablePrimaryKey(name) => { + crate::i18n::gettext_f("Primary key columns must be NOT NULL: {name}", &[("name", name)]) + } + DraftProblem::ColumnInUse { column, used_by } => crate::i18n::gettext_f( + "Column {column} is removed but {users} still use it. Remove them first.", + &[("column", column), ("users", &used_by.join(", "))], + ), + DraftProblem::EmptyIndexName => crate::i18n::gettext("Index name is required."), + DraftProblem::IndexWithoutKeys(name) => { + crate::i18n::gettext_f("Index {name} has no columns.", &[("name", name)]) + } + DraftProblem::EmptyForeignKeyName => crate::i18n::gettext("Foreign key name is required."), + DraftProblem::ForeignKeyColumnMismatch(name) => crate::i18n::gettext_f( + "Foreign key {name} needs as many referenced columns as source columns.", + &[("name", name)], + ), + } +} + +/// Why Save, from the tab or from a close prompt, refused. +pub fn save_blocker_text(blocker: &SaveBlocker) -> String { + match blocker { + SaveBlocker::Draft(problem) => draft_problem_text(problem), + SaveBlocker::UnparsedDefault(_) => crate::i18n::gettext("Fix the highlighted fields first."), + } +} + +/// Why the change could not be written as SQL. +pub fn build_error_text(error: &BuildDdlError) -> String { + match error { + BuildDdlError::Draft(problem) => draft_problem_text(problem), + BuildDdlError::Refused { column, reason, .. } => crate::i18n::gettext_f( + "Column {column} can't be changed this way. {reason}", + &[("column", column), ("reason", &refusal_text(reason))], + ), + BuildDdlError::Unsupported(DdlOperation::AddRequiredColumnWithoutDefault) => crate::i18n::gettext( + "A NOT NULL column needs a default here, since the rows already in the table need a value.", + ), + BuildDdlError::Unsupported(_) => crate::i18n::gettext("This database can't make this change."), + BuildDdlError::InvalidType { column, error } => crate::i18n::gettext_f( + "Column {column} has a type this database doesn't accept: {error}", + &[("column", column), ("error", &error.to_string())], + ), + BuildDdlError::Literal { column, error } => crate::i18n::gettext_f( + "The default of column {column} can't be written as SQL: {error}", + &[("column", column), ("error", &error.to_string())], + ), + BuildDdlError::MissingCatalogDeclaration(column) => crate::i18n::gettext_f( + "The database didn't report the type of column {column}.", + &[("column", column)], + ), + BuildDdlError::ReferentialAction(action) => crate::i18n::gettext_f( + "This database doesn't take {action} on a foreign key.", + &[("action", action.as_sql())], + ), + BuildDdlError::NoTableSource => crate::i18n::gettext("The database gave no CREATE statement for this table."), + BuildDdlError::UnreplayableDependent(name) => crate::i18n::gettext_f( + "The table can't be rebuilt: the stored statement of {name}, which uses it, can't be run again.", + &[("name", name)], + ), + BuildDdlError::NotAsReviewed => crate::i18n::gettext( + "The changes were edited or the connection changed during the review, so nothing was saved. Save again to review them.", + ), + } +} diff --git a/linux/crates/app/src/ui/error_text/driver.rs b/linux/crates/app/src/ui/error_text/driver.rs new file mode 100644 index 0000000000..30b150275d --- /dev/null +++ b/linux/crates/app/src/ui/error_text/driver.rs @@ -0,0 +1,279 @@ +use tablepro_core::{ + ConfigError, DriverError, ErrorCategory, ReadOnlyRefusal, ServerCode, ServerDiagnostics, TimeoutPhase, + TransportError, +}; + +use super::ssh::ssh_failure_message; +use super::tls::tls_message; + +pub fn driver_message(error: &DriverError) -> String { + // An SSH failure carries its own category and its own next step. + if let DriverError::Transport(TransportError::Ssh(failure)) = error { + return ssh_failure_message(failure); + } + match error.category() { + ErrorCategory::Configuration => configuration_message(error), + ErrorCategory::Network => network_message(error), + ErrorCategory::Tls => match error { + DriverError::Tls { failure, detail } => tls_message(*failure, detail), + other => detail_sentence(crate::i18n::gettext("The secure connection failed."), other), + }, + ErrorCategory::Authentication => authentication_message(error), + ErrorCategory::ReadOnly => read_only_message(error), + ErrorCategory::Server => match error { + DriverError::Server(diagnostics) => server_message(diagnostics), + other => detail_sentence(crate::i18n::gettext("The server refused the request."), other), + }, + ErrorCategory::Interrupted => crate::i18n::gettext("Stopped."), + ErrorCategory::Timeout => timeout_message(error), + ErrorCategory::ConnectionLost => crate::i18n::gettext("The connection was closed. Try reconnecting."), + ErrorCategory::WriteOutcome => write_outcome_message(error), + ErrorCategory::File => file_message(error), + ErrorCategory::Busy => match error { + DriverError::EditorSessionBusy => { + crate::i18n::gettext("This tab is still running a statement. Stop it or wait for it to finish.") + } + _ => crate::i18n::gettext("The server has no room for another connection right now. Try again shortly."), + }, + ErrorCategory::Internal => internal_message(error), + } +} + +/// A category sentence followed by what the error itself says, for a +/// variant this build has no sentence of its own for. +fn detail_sentence(sentence: String, error: &DriverError) -> String { + format!("{sentence} {error}") +} + +fn configuration_message(error: &DriverError) -> String { + match error { + DriverError::Config(config) => config_message(config), + DriverError::Unsupported { feature } => { + crate::i18n::gettext_f("This database has no {feature}.", &[("feature", feature)]) + } + other => detail_sentence(crate::i18n::gettext("The connection settings cannot be used."), other), + } +} + +pub fn config_message(error: &ConfigError) -> String { + match error { + ConfigError::EmptyHost => crate::i18n::gettext("Enter the server's host name."), + ConfigError::InvalidHost => crate::i18n::gettext("The host name has spaces or control characters in it."), + ConfigError::InvalidPort => crate::i18n::gettext("The port must be a number from 1 to 65535."), + ConfigError::RelativePath => crate::i18n::gettext("Use a full path, starting with /."), + ConfigError::UnsupportedTlsMode => crate::i18n::gettext("This database does not offer that TLS mode."), + ConfigError::CaFileUnsupported => { + crate::i18n::gettext("A CA certificate needs Verify full and a database that accepts one.") + } + ConfigError::ClientIdentityUnsupported => { + crate::i18n::gettext("A client certificate needs TLS turned on and a database that accepts one.") + } + ConfigError::ClientIdentityIncomplete => { + crate::i18n::gettext("A client certificate needs both its certificate file and its key file.") + } + ConfigError::CaFile { path, problem } => crate::i18n::gettext_f( + "The CA certificate {path} cannot be used: {problem}", + &[("path", &path.display().to_string()), ("problem", &problem.to_string())], + ), + ConfigError::ClientIdentityFile { path, problem } => crate::i18n::gettext_f( + "The client certificate {path} cannot be used: {problem}", + &[("path", &path.display().to_string()), ("problem", &problem.to_string())], + ), + ConfigError::InvalidServerName => { + crate::i18n::gettext("The TLS server name is not a valid host name or IP address.") + } + ConfigError::ServerNameOverrideUnsupported { .. } => { + crate::i18n::gettext("This database cannot check the certificate against another server name here.") + } + ConfigError::AuthModeUnsupported => { + crate::i18n::gettext("This database does not support that way of logging in.") + } + ConfigError::DatabaseRequired => crate::i18n::gettext("Enter the name of the database to open."), + } +} + +fn network_message(error: &DriverError) -> String { + match error { + DriverError::Transport(TransportError::Refused { endpoint }) => crate::i18n::gettext_f( + "{host}:{port} refused the connection. Is the database running?", + &[("host", endpoint.host()), ("port", &endpoint.port().to_string())], + ), + DriverError::Transport(TransportError::NameResolution { host, .. }) => { + crate::i18n::gettext_f("Could not look up {host}.", &[("host", host)]) + } + DriverError::Transport(TransportError::Unreachable { endpoint, .. }) => crate::i18n::gettext_f( + "Could not reach {host}:{port}.", + &[("host", endpoint.host()), ("port", &endpoint.port().to_string())], + ), + other => detail_sentence(crate::i18n::gettext("The server could not be reached."), other), + } +} + +/// Never echoes what was typed: the server's own explanation is safe to +/// show, the secret is not part of any variant. +fn authentication_message(error: &DriverError) -> String { + match error { + DriverError::Auth { + diagnostics: Some(diagnostics), + } => crate::i18n::gettext_f( + "Username or password is wrong. The server said: {detail}", + &[("detail", &diagnostics.message)], + ), + DriverError::LoginRefused(diagnostics) => crate::i18n::gettext_f( + "The server refused this login. The server said: {detail}", + &[("detail", &diagnostics.message)], + ), + DriverError::IntegratedAuth(detail) => crate::i18n::gettext_f( + "Kerberos login failed: {detail}. Check that klist shows a valid ticket, run kinit if it does not, and make sure the server's SPN matches the host you typed.", + &[("detail", detail)], + ), + _ => crate::i18n::gettext("Username or password is wrong."), + } +} + +fn read_only_message(error: &DriverError) -> String { + match error { + DriverError::ReadOnly(ReadOnlyRefusal::ServerRejected(diagnostics)) => crate::i18n::gettext_f( + "The server refused the write: {message}", + &[("message", &diagnostics.message)], + ), + _ => crate::i18n::gettext("This connection is read-only. Reopen it without read-only mode to make changes."), + } +} + +/// The server's words with whatever it added: its code, the detail line +/// and its hint. +pub fn server_message(diagnostics: &ServerDiagnostics) -> String { + let mut message = match &diagnostics.code { + Some(code) => crate::i18n::gettext_f( + "Query failed ({code}): {message}", + &[("code", &server_code_text(code)), ("message", &diagnostics.message)], + ), + None => crate::i18n::gettext_f("Query failed: {message}", &[("message", &diagnostics.message)]), + }; + if let Some(detail) = diagnostics.detail.as_deref().filter(|detail| !detail.is_empty()) { + message.push('\n'); + message.push_str(detail); + } + if let Some(hint) = diagnostics.hint.as_deref().filter(|hint| !hint.is_empty()) { + message.push('\n'); + message.push_str(&crate::i18n::gettext_f("Hint: {hint}", &[("hint", hint)])); + } + message +} + +/// The code in the words each engine's own documentation uses, so it +/// can be looked up there. +fn server_code_text(code: &ServerCode) -> String { + let number = code.to_string(); + match code { + ServerCode::SqlState(_) => crate::i18n::gettext_f("SQLSTATE {code}", &[("code", &number)]), + ServerCode::MySql { .. } => crate::i18n::gettext_f("MySQL error {code}", &[("code", &number)]), + ServerCode::Tds { .. } => crate::i18n::gettext_f("SQL Server error {code}", &[("code", &number)]), + ServerCode::ClickHouse { .. } => crate::i18n::gettext_f("ClickHouse code {code}", &[("code", &number)]), + ServerCode::Sqlite { .. } => crate::i18n::gettext_f("SQLite error {code}", &[("code", &number)]), + } +} + +fn timeout_message(error: &DriverError) -> String { + let (phase, server_cancelled) = match error { + DriverError::Timeout { + phase, + server_cancelled, + } => (*phase, *server_cancelled), + DriverError::Transport(TransportError::Timeout { phase }) => (*phase, false), + other => return detail_sentence(crate::i18n::gettext("The server took too long to answer."), other), + }; + match phase { + TimeoutPhase::Connect => crate::i18n::gettext("The server did not answer the connection in time."), + TimeoutPhase::Login => crate::i18n::gettext("The server did not finish the login in time."), + TimeoutPhase::SshHandshake => crate::i18n::gettext("The SSH server did not finish the handshake in time."), + TimeoutPhase::SshChannelOpen => { + crate::i18n::gettext("The SSH server did not open the tunnel to the database in time.") + } + TimeoutPhase::LockWait => { + crate::i18n::gettext("Another session holds a lock this needs. Try again when it has finished.") + } + TimeoutPhase::Statement if server_cancelled => { + crate::i18n::gettext("The statement ran past its time limit, so the server stopped it.") + } + TimeoutPhase::Statement => { + crate::i18n::gettext("The statement ran past its time limit. The server may still be running it.") + } + TimeoutPhase::Probe => crate::i18n::gettext("The server did not answer a check in time."), + } +} + +/// After a failed write the question is what is in the database now, +/// and each variant answers it differently. Only `RolledBack` and a +/// failed guard may say nothing changed. +fn write_outcome_message(error: &DriverError) -> String { + match error { + DriverError::RolledBack { + statement_index, + source, + } => crate::i18n::gettext_f( + "Save failed at statement {n}: {error}. The transaction was rolled back; no rows were changed.", + &[ + ("n", &(statement_index + 1).to_string()), + ("error", &driver_message(source)), + ], + ), + DriverError::RowGuardFailed { expected, actual, .. } => crate::i18n::gettext_f( + "The save matched {actual} rows where it expected {expected}, so the row is not the one that was loaded. Nothing was changed; reload and try again.", + &[("actual", &actual.to_string()), ("expected", &expected.to_string())], + ), + DriverError::PartiallyApplied { + applied, + statement_index, + .. + } => crate::i18n::gettext_f( + "{applied} of the save's statements were applied before statement {n} failed, and this database has no transaction to undo them. Reload to see what the server has.", + &[ + ("applied", &applied.to_string()), + ("n", &(statement_index + 1).to_string()), + ], + ), + _ => crate::i18n::gettext( + "The connection was lost after the save was sent, so whether it was applied is not known. Reload to see what the server has.", + ), + } +} + +fn file_message(error: &DriverError) -> String { + match error { + DriverError::FileNotFound { path } => { + crate::i18n::gettext_f("No database file at {path}.", &[("path", &path.display().to_string())]) + } + DriverError::FileAccessDenied { path } => crate::i18n::gettext_f( + "No permission to open {path}.", + &[("path", &path.display().to_string())], + ), + DriverError::NotADatabase { path } => crate::i18n::gettext_f( + "{path} is not a database file.", + &[("path", &path.display().to_string())], + ), + DriverError::FileIo { path, kind } => crate::i18n::gettext_f( + "Could not write {path}: {reason}", + &[("path", &path.display().to_string()), ("reason", &kind.to_string())], + ), + other => detail_sentence(crate::i18n::gettext("The database file cannot be used."), other), + } +} + +fn internal_message(error: &DriverError) -> String { + match error { + DriverError::GuardUnsupported { .. } => crate::i18n::gettext( + "This database does not report how many rows a statement changed, so the save cannot be checked. Use a table with a primary key.", + ), + DriverError::Decode { column, detail } => crate::i18n::gettext_f( + "Could not read column {column}: {detail}", + &[("column", column), ("detail", detail)], + ), + DriverError::Protocol(detail) => crate::i18n::gettext_f( + "The driver could not read the server's reply: {detail}", + &[("detail", detail)], + ), + other => detail_sentence(crate::i18n::gettext("The driver hit an internal error."), other), + } +} diff --git a/linux/crates/app/src/ui/error_text/edit.rs b/linux/crates/app/src/ui/error_text/edit.rs new file mode 100644 index 0000000000..542cebbc40 --- /dev/null +++ b/linux/crates/app/src/ui/error_text/edit.rs @@ -0,0 +1,162 @@ +use tablepro_core::column::IntegerKind; +use tablepro_core::edit::{EditParseError, ReadOnlyReason}; + +use crate::i18n::{gettext, gettext_f, ngettext_f}; + +/// What to tell the user about text a cell would not take. Every +/// variant has its own branch, so a new one fails to compile here +/// rather than reaching a widget as its log text. +pub fn edit_parse_message(error: &EditParseError) -> String { + match error { + EditParseError::Required => gettext("This column needs a value"), + EditParseError::InvalidInteger => gettext("Enter a whole number"), + EditParseError::IntegerOutOfRange { kind } => { + let (min, max) = integer_range(*kind); + gettext_f( + "Enter a whole number from {min} to {max}", + &[("min", &min), ("max", &max)], + ) + } + EditParseError::InvalidFloat => gettext("Enter a number"), + EditParseError::InvalidDecimal(_) => gettext("Enter a decimal number, such as 12.50"), + EditParseError::PrecisionExceeded { precision } => ngettext_f( + "This column keeps at most {n} digit", + "This column keeps at most {n} digits", + *precision, + &[("n", &precision.to_string())], + ), + EditParseError::ScaleExceeded { scale } => ngettext_f( + "This column keeps at most {n} decimal place", + "This column keeps at most {n} decimal places", + *scale, + &[("n", &scale.to_string())], + ), + EditParseError::InvalidBoolean => gettext("Enter true or false"), + EditParseError::InvalidUuid => gettext("Enter a UUID"), + EditParseError::InvalidJson(detail) => gettext_f("Enter valid JSON: {detail}", &[("detail", detail)]), + EditParseError::InvalidDate => gettext("Enter a date as YYYY-MM-DD"), + EditParseError::InvalidTime => gettext("Enter a time as HH:MM:SS"), + EditParseError::TimeOutOfRange => gettext("This time is outside the range the column can hold"), + EditParseError::InvalidTimestamp => gettext("Enter a date and time as YYYY-MM-DD HH:MM:SS"), + EditParseError::OffsetRequired => gettext("Add a time zone offset, such as +02:00"), + EditParseError::InvalidInterval => gettext("Enter an interval, such as 1 day 02:00:00"), + EditParseError::InvalidBits => gettext("Enter bits as 0 and 1"), + EditParseError::BitLengthMismatch { expected } => ngettext_f( + "Enter exactly {n} bit", + "Enter exactly {n} bits", + *expected, + &[("n", &expected.to_string())], + ), + EditParseError::UnknownEnumLabel => gettext("Enter one of the values this column allows"), + EditParseError::NotEditable(reason) => not_editable_message(*reason), + } +} + +fn not_editable_message(reason: ReadOnlyReason) -> String { + match reason { + ReadOnlyReason::Bytes => gettext("Binary values can't be edited here"), + ReadOnlyReason::Spatial => gettext("Spatial values can't be edited here"), + ReadOnlyReason::Undecodable => gettext("This value couldn't be read, so it can't be edited"), + ReadOnlyReason::Composite => gettext("Arrays and composite values can't be edited here"), + ReadOnlyReason::Variant => gettext("Values whose type changes by row can't be edited here"), + } +} + +/// The smallest and largest value of an integer column, written out. +fn integer_range(kind: IntegerKind) -> (String, String) { + let bits = kind.width.bits(); + match (kind.signed, bits) { + (true, 256) => ("-2^255".to_owned(), "2^255 - 1".to_owned()), + (false, 256) => ("0".to_owned(), "2^256 - 1".to_owned()), + (true, 128) => (i128::MIN.to_string(), i128::MAX.to_string()), + (false, 128) => ("0".to_owned(), u128::MAX.to_string()), + (true, bits) => { + let half = 1i128 << (bits - 1); + ((-half).to_string(), (half - 1).to_string()) + } + (false, bits) => ("0".to_owned(), ((1u128 << bits) - 1).to_string()), + } +} + +#[cfg(test)] +mod tests { + use std::collections::HashSet; + + use tablepro_core::column::{IntegerKind, IntegerWidth}; + use tablepro_core::edit::{EditParseError, ReadOnlyReason}; + use tablepro_core::value::DecimalParseError; + + use super::{edit_parse_message, integer_range}; + + #[test] + fn every_edit_parse_error_variant_has_a_distinct_message() { + let kind = IntegerKind { + width: IntegerWidth::W16, + signed: true, + }; + let errors = [ + EditParseError::Required, + EditParseError::InvalidInteger, + EditParseError::IntegerOutOfRange { kind }, + EditParseError::InvalidFloat, + EditParseError::InvalidDecimal(DecimalParseError::InvalidSyntax), + EditParseError::PrecisionExceeded { precision: 5 }, + EditParseError::ScaleExceeded { scale: 2 }, + EditParseError::InvalidBoolean, + EditParseError::InvalidUuid, + EditParseError::InvalidJson("expected value at line 1 column 1".to_owned()), + EditParseError::InvalidDate, + EditParseError::InvalidTime, + EditParseError::TimeOutOfRange, + EditParseError::InvalidTimestamp, + EditParseError::OffsetRequired, + EditParseError::InvalidInterval, + EditParseError::InvalidBits, + EditParseError::BitLengthMismatch { expected: 8 }, + EditParseError::UnknownEnumLabel, + EditParseError::NotEditable(ReadOnlyReason::Bytes), + ]; + + let messages: Vec = errors.iter().map(edit_parse_message).collect(); + + assert!(messages.iter().all(|message| !message.is_empty()), "{messages:?}"); + let distinct: HashSet<&String> = messages.iter().collect(); + assert_eq!(distinct.len(), messages.len(), "{messages:?}"); + assert_eq!(messages[2], "Enter a whole number from -32768 to 32767"); + } + + #[test] + fn every_read_only_reason_says_its_own_thing() { + let reasons = [ + ReadOnlyReason::Bytes, + ReadOnlyReason::Spatial, + ReadOnlyReason::Undecodable, + ReadOnlyReason::Composite, + ReadOnlyReason::Variant, + ]; + + let messages: HashSet = reasons + .iter() + .map(|reason| edit_parse_message(&EditParseError::NotEditable(*reason))) + .collect(); + + assert_eq!(messages.len(), reasons.len()); + } + + #[test] + fn integer_ranges_follow_width_and_sign() { + let range = |width, signed| integer_range(IntegerKind { width, signed }); + + assert_eq!(range(IntegerWidth::W8, false), ("0".to_owned(), "255".to_owned())); + assert_eq!( + range(IntegerWidth::W24, true), + ("-8388608".to_owned(), "8388607".to_owned()) + ); + assert_eq!( + range(IntegerWidth::W64, true), + (i64::MIN.to_string(), i64::MAX.to_string()) + ); + assert_eq!(range(IntegerWidth::W128, false).1, u128::MAX.to_string()); + assert_eq!(range(IntegerWidth::W256, true).0, "-2^255"); + } +} diff --git a/linux/crates/app/src/ui/error_text/mod.rs b/linux/crates/app/src/ui/error_text/mod.rs new file mode 100644 index 0000000000..5fa70446de --- /dev/null +++ b/linux/crates/app/src/ui/error_text/mod.rs @@ -0,0 +1,27 @@ +//! The sentence the user sees for each error, chosen by what kind of +//! problem it is rather than by which driver raised it. +//! +//! `DriverError::category` decides the branch, so an error a driver +//! adds later still lands in a branch that says what to do next; the +//! variant only adds detail. + +mod ddl; +mod driver; +mod edit; +mod operation; +mod saved_connection; +mod script; +mod secret; +mod ssh; +#[cfg(test)] +mod tests; +mod tls; +mod tls_remedy; + +pub use ddl::{build_error_text, refusal_text, save_blocker_text}; +pub use driver::{config_message, driver_message}; +pub use edit::edit_parse_message; +pub use operation::{connect_error_message, operation_error_message, task_failure_message}; +pub use saved_connection::saved_connection_error_message; +pub use script::script_diagnostic_message; +pub use tls_remedy::{TlsRemedy, connect_remedy}; diff --git a/linux/crates/app/src/ui/error_text/operation.rs b/linux/crates/app/src/ui/error_text/operation.rs new file mode 100644 index 0000000000..906e5bad70 --- /dev/null +++ b/linux/crates/app/src/ui/error_text/operation.rs @@ -0,0 +1,102 @@ +use tablepro_core::browse::BrowseSqlError; +use tablepro_core::dml::BuildSqlError; +use tablepro_core::statement::SqlBuildError; +use tablepro_session::runtime::TaskFailure; +use tablepro_session::{ConnectError, OperationError}; +use tablepro_storage::KeyringError; + +use super::driver::driver_message; +use super::secret::secret_message; + +/// What to tell the user about an operation that produced no value. +pub fn operation_error_message(error: &OperationError) -> String { + match error { + OperationError::Driver(error) => driver_message(error), + OperationError::Sql(error) => sql_build_message(error), + OperationError::Browse(error) => browse_message(error), + OperationError::Build(error) => build_message(error), + OperationError::Panicked(panic) => crate::i18n::gettext_f( + "TablePro hit an internal error: {detail}", + &[("detail", panic.message())], + ), + OperationError::Cancelled => crate::i18n::gettext("Stopped."), + OperationError::Closed => crate::i18n::gettext("The connection is closed."), + } +} + +/// What to tell the user about a connect that produced no session. +pub fn connect_error_message(error: &ConnectError) -> String { + match error { + ConnectError::Driver(error) => driver_message(error), + ConnectError::Keyring(KeyringError::SecretNotStored { .. }) => crate::i18n::gettext( + "No password is saved for this connection, and none was entered. Connect again to type it.", + ), + ConnectError::Keyring(KeyringError::Secret { error, .. }) => secret_message(error), + ConnectError::UnknownDriver(driver) => { + crate::i18n::gettext_f("This build of TablePro has no {driver} driver.", &[("driver", driver)]) + } + ConnectError::Cancelled => crate::i18n::gettext("The connection was cancelled."), + ConnectError::Panicked(panic) => crate::i18n::gettext_f( + "TablePro hit an internal error: {detail}", + &[("detail", panic.message())], + ), + } +} + +/// What to tell the user when the work itself died rather than failed. +pub fn task_failure_message(failure: &TaskFailure) -> String { + match failure { + TaskFailure::Panicked(panic) => crate::i18n::gettext_f( + "TablePro hit an internal error: {detail}", + &[("detail", panic.message())], + ), + TaskFailure::Aborted => crate::i18n::gettext("Stopped."), + } +} + +fn sql_build_message(error: &SqlBuildError) -> String { + match error { + SqlBuildError::Dml(bind) => crate::i18n::gettext_f( + "This change cannot be saved: {reason}", + &[("reason", &bind.to_string())], + ), + SqlBuildError::Browse(bind) => crate::i18n::gettext_f( + "This filter cannot be applied: {reason}", + &[("reason", &bind.to_string())], + ), + SqlBuildError::Ddl(reason) => crate::i18n::gettext_f( + "This structure change cannot be written: {reason}", + &[("reason", reason)], + ), + } +} + +fn browse_message(error: &BrowseSqlError) -> String { + match error { + BrowseSqlError::Filter(filter) => crate::i18n::gettext_f( + "This filter cannot be applied: {reason}", + &[("reason", &filter.to_string())], + ), + BrowseSqlError::Build(build) => build_message(build), + other => crate::i18n::gettext_f( + "This page of the table cannot be read: {reason}", + &[("reason", &other.to_string())], + ), + } +} + +fn build_message(error: &BuildSqlError) -> String { + match error { + BuildSqlError::NoRowIdentity | BuildSqlError::ReadOnlyRows => crate::i18n::gettext( + "Rows in this table cannot be edited, because nothing identifies a single row. Add a primary key to edit them.", + ), + BuildSqlError::NothingToUpdate | BuildSqlError::NoInsertValues => crate::i18n::gettext("No changes to save."), + BuildSqlError::UndecodableKey => crate::i18n::gettext( + "Part of this row's key could not be read from the server, so the row cannot be changed.", + ), + other => crate::i18n::gettext_f( + "This change cannot be saved: {reason}", + &[("reason", &other.to_string())], + ), + } +} diff --git a/linux/crates/app/src/ui/error_text/saved_connection.rs b/linux/crates/app/src/ui/error_text/saved_connection.rs new file mode 100644 index 0000000000..b454af2380 --- /dev/null +++ b/linux/crates/app/src/ui/error_text/saved_connection.rs @@ -0,0 +1,47 @@ +use tablepro_storage::StorageError; + +use super::operation::task_failure_message; +use super::secret::secret_message; +use crate::services::saved_connection_error::SavedConnectionError; + +/// What to tell the user about an edit, a copy or a move of a saved +/// connection that did not happen. Nothing was changed in each case, so +/// the sentence says what to fix before trying again. +pub fn saved_connection_error_message(error: &SavedConnectionError) -> String { + match error { + SavedConnectionError::Secret(error) => secret_message(error), + SavedConnectionError::NotFound(_) => crate::i18n::gettext("The connection was deleted."), + SavedConnectionError::Storage(StorageError::DocumentUnavailable(_)) => { + crate::i18n::gettext("The saved connections could not be read, so nothing was changed.") + } + SavedConnectionError::Storage(error) => crate::i18n::gettext_f( + "The saved connections could not be written: {reason}", + &[("reason", &error.to_string())], + ), + SavedConnectionError::Task(failure) => task_failure_message(failure), + } +} + +#[cfg(test)] +mod tests { + use tablepro_core::credentials::SecretError; + use uuid::Uuid; + + use super::*; + + #[test] + fn each_failure_says_what_happened() { + assert_eq!( + saved_connection_error_message(&SavedConnectionError::Secret(SecretError::Locked)), + "The keyring is locked. Unlock it and try again." + ); + assert_eq!( + saved_connection_error_message(&SavedConnectionError::NotFound(Uuid::nil())), + "The connection was deleted." + ); + let written = saved_connection_error_message(&SavedConnectionError::Storage(StorageError::Schema( + "the disk is full".to_owned(), + ))); + assert!(written.contains("the disk is full"), "{written}"); + } +} diff --git a/linux/crates/app/src/ui/error_text/script.rs b/linux/crates/app/src/ui/error_text/script.rs new file mode 100644 index 0000000000..1b1185d98a --- /dev/null +++ b/linux/crates/app/src/ui/error_text/script.rs @@ -0,0 +1,28 @@ +use tablepro_core::sql_syntax::script::{OpenConstruct, ScriptDiagnostic}; + +/// The warning for something the script planner found, with `line` +/// counted from 1 in the buffer. +pub fn script_diagnostic_message(diagnostic: &ScriptDiagnostic, line: usize) -> String { + let line = line.to_string(); + let args = [("line", line.as_str())]; + match diagnostic { + ScriptDiagnostic::Unterminated { construct, .. } => match construct { + OpenConstruct::StringLiteral => crate::i18n::gettext_f( + "The string starting on line {line} is never closed. It is sent as written.", + &args, + ), + OpenConstruct::QuotedIdentifier => crate::i18n::gettext_f( + "The quoted name starting on line {line} is never closed. It is sent as written.", + &args, + ), + OpenConstruct::BlockComment => crate::i18n::gettext_f( + "The comment starting on line {line} is never closed. It is sent as written.", + &args, + ), + OpenConstruct::DollarQuoted => crate::i18n::gettext_f( + "The dollar-quoted string starting on line {line} is never closed. It is sent as written.", + &args, + ), + }, + } +} diff --git a/linux/crates/app/src/ui/error_text/secret.rs b/linux/crates/app/src/ui/error_text/secret.rs new file mode 100644 index 0000000000..416528a433 --- /dev/null +++ b/linux/crates/app/src/ui/error_text/secret.rs @@ -0,0 +1,24 @@ +/// What to tell the user about a keyring failure. Each variant maps to +/// a different next step, so they are not collapsed into one sentence. +pub(super) fn secret_message(error: &tablepro_core::credentials::SecretError) -> String { + use tablepro_core::credentials::SecretError; + + match error { + SecretError::ServiceUnavailable { .. } => crate::i18n::gettext( + "No keyring is running, so saved passwords cannot be read. Start a keyring, or enter the password by hand.", + ), + SecretError::PortalUnavailable { .. } => crate::i18n::gettext( + "The secret portal is unavailable, so saved passwords cannot be read inside the sandbox. Check that xdg-desktop-portal and a keyring are running, or enter the password by hand.", + ), + SecretError::Locked => crate::i18n::gettext("The keyring is locked. Unlock it and try again."), + SecretError::UnlockDismissed => { + crate::i18n::gettext("The keyring stayed locked because the unlock prompt was dismissed.") + } + SecretError::InvalidEncoding => { + crate::i18n::gettext("The stored password is not readable text. Enter it again to replace it.") + } + SecretError::Backend { detail } => { + crate::i18n::gettext_f("The keyring reported: {detail}", &[("detail", detail)]) + } + } +} diff --git a/linux/crates/app/src/ui/error_text/ssh.rs b/linux/crates/app/src/ui/error_text/ssh.rs new file mode 100644 index 0000000000..cef3751258 --- /dev/null +++ b/linux/crates/app/src/ui/error_text/ssh.rs @@ -0,0 +1,97 @@ +use tablepro_core::SshFailure; + +/// One sentence per way OpenSSH can fail, each naming what to change. +pub fn ssh_failure_message(failure: &SshFailure) -> String { + match failure { + SshFailure::OpenSshClientMissing { program } => crate::i18n::gettext_f( + "The OpenSSH client {program} is not installed. Install openssh-client to connect over SSH.", + &[("program", &program.display().to_string())], + ), + SshFailure::AskpassHelperMissing { program } => crate::i18n::gettext_f( + "TablePro's SSH helper is missing at {program}. Reinstall TablePro to connect over SSH.", + &[("program", &program.display().to_string())], + ), + SshFailure::InvalidDestination { detail } => crate::i18n::gettext_f( + "The SSH host or jump host is not valid: {detail}", + &[("detail", detail)], + ), + SshFailure::ConfigRejected { detail } => crate::i18n::gettext_f( + "ssh refused the configuration. Check ~/.ssh/config for this host: {detail}", + &[("detail", detail)], + ), + SshFailure::ControlPathTooLong { .. } => crate::i18n::gettext( + "The runtime directory path is too long for an SSH control socket. Use a shorter XDG_RUNTIME_DIR.", + ), + SshFailure::RuntimeDirUnsafe { path, detail } => crate::i18n::gettext_f( + "The SSH runtime directory {path} cannot be used safely: {detail}", + &[("path", &path.display().to_string()), ("detail", detail)], + ), + SshFailure::HostKeyUnknown { host, declined, .. } if *declined => crate::i18n::gettext_f( + "The host key of {host} was not trusted, so the connection was not made.", + &[("host", host)], + ), + // ssh refuses under StrictHostKeyChecking yes before it shows the + // key, so there is no fingerprint and nothing to review. + SshFailure::HostKeyUnknown { + host, + trust_allowed: false, + .. + } => crate::i18n::gettext_f( + "The SSH configuration for {host} accepts only keys already in known_hosts. Add the server's key to known_hosts outside TablePro, then retry.", + &[("host", host)], + ), + SshFailure::HostKeyUnknown { + host, + sha256_fingerprint, + .. + } if sha256_fingerprint.is_empty() => crate::i18n::gettext_f( + "{host} is not in known_hosts. Connect once to review and trust its key.", + &[("host", host)], + ), + SshFailure::HostKeyUnknown { + host, + sha256_fingerprint, + .. + } => crate::i18n::gettext_f( + "{host} is not in known_hosts (key {fingerprint}). Connect once to review and trust its key.", + &[("host", host), ("fingerprint", sha256_fingerprint)], + ), + SshFailure::HostKeyChanged { + host, + known_hosts, + line, + .. + } => crate::i18n::gettext_f( + "The host key of {host} changed since it was trusted ({file}, line {line}). This can mean someone is intercepting the connection; check with the server's administrator before removing the old key.", + &[ + ("host", host), + ("file", &known_hosts.display().to_string()), + ("line", &line.to_string()), + ], + ), + SshFailure::HostKeyRevoked { host, .. } => crate::i18n::gettext_f( + "The host key of {host} is revoked, so the connection was refused.", + &[("host", host)], + ), + SshFailure::AuthenticationRejected { user, host, .. } => crate::i18n::gettext_f( + "The SSH server {host} refused the login for {user}. Check the key, the agent or the password.", + &[("host", host), ("user", user)], + ), + SshFailure::InteractionCancelled => crate::i18n::gettext("The SSH login was cancelled."), + SshFailure::ForwardRejected { target, detail } => crate::i18n::gettext_f( + "The SSH server would not forward to {target}: {detail}", + &[("target", target), ("detail", detail)], + ), + SshFailure::ChannelOpenFailed { target, detail } => crate::i18n::gettext_f( + "The SSH server could not reach {target}: {detail}", + &[("target", target), ("detail", detail)], + ), + SshFailure::MasterExited { detail, .. } => { + crate::i18n::gettext_f("The SSH connection ended: {detail}", &[("detail", detail)]) + } + SshFailure::Protocol { detail } => { + crate::i18n::gettext_f("ssh printed something unexpected: {detail}", &[("detail", detail)]) + } + other => crate::i18n::gettext_f("SSH failed: {detail}", &[("detail", &other.to_string())]), + } +} diff --git a/linux/crates/app/src/ui/error_text/tests.rs b/linux/crates/app/src/ui/error_text/tests.rs new file mode 100644 index 0000000000..dc504c4503 --- /dev/null +++ b/linux/crates/app/src/ui/error_text/tests.rs @@ -0,0 +1,454 @@ +use std::path::PathBuf; + +use tablepro_core::credentials::{SecretError, SecretKind}; +use tablepro_core::sql_syntax::script::{OpenConstruct, ScriptDiagnostic}; +use tablepro_core::{ + ConfigError, DriverError, ErrorCategory, LossPhase, NetworkEndpoint, ReadOnlyRefusal, ServerCode, + ServerDiagnostics, SshFailure, StatementOutcome, TimeoutPhase, TlsFailure, TransportError, +}; +use tablepro_session::runtime::TaskPanic; +use tablepro_session::{ConnectError, OperationError}; +use tablepro_storage::KeyringError; + +use super::secret::secret_message; +use super::ssh::ssh_failure_message; +use super::*; + +/// One error per category, so a category without a branch shows up as +/// an empty or shared sentence. +fn one_per_category() -> Vec<(ErrorCategory, DriverError)> { + let endpoint = NetworkEndpoint::new("db.internal", 5432).expect("an endpoint"); + vec![ + ( + ErrorCategory::Configuration, + DriverError::Config(ConfigError::DatabaseRequired), + ), + ( + ErrorCategory::Network, + DriverError::Transport(TransportError::Refused { endpoint }), + ), + ( + ErrorCategory::Tls, + DriverError::Tls { + failure: TlsFailure::Expired, + detail: "expired".to_owned(), + }, + ), + (ErrorCategory::Authentication, DriverError::auth(None)), + ( + ErrorCategory::ReadOnly, + DriverError::ReadOnly(ReadOnlyRefusal::ClientGuard), + ), + (ErrorCategory::Server, DriverError::server("syntax error")), + (ErrorCategory::Interrupted, DriverError::Cancelled), + ( + ErrorCategory::Timeout, + DriverError::Timeout { + phase: TimeoutPhase::Statement, + server_cancelled: true, + }, + ), + ( + ErrorCategory::ConnectionLost, + DriverError::ConnectionLost { + during: LossPhase::Statement, + }, + ), + ( + ErrorCategory::WriteOutcome, + DriverError::RowGuardFailed { + statement_index: 0, + expected: 1, + actual: 0, + }, + ), + ( + ErrorCategory::File, + DriverError::FileNotFound { + path: PathBuf::from("/srv/app.db"), + }, + ), + (ErrorCategory::Busy, DriverError::Busy), + (ErrorCategory::Internal, DriverError::Protocol("bad frame".to_owned())), + ] +} + +#[test] +fn every_category_has_a_message() { + let cases = one_per_category(); + let messages: std::collections::HashSet = cases + .iter() + .map(|(category, error)| { + assert_eq!(error.category(), *category, "{error:?}"); + let message = driver_message(error); + assert!(!message.trim().is_empty(), "{category:?} has no message"); + message + }) + .collect(); + + assert_eq!(messages.len(), cases.len(), "two categories share a message"); +} + +#[test] +fn server_message_includes_code_detail_hint() { + let diagnostics = ServerDiagnostics { + detail: Some("Key (email)=(a@b.c) already exists.".to_owned()), + hint: Some("Pick another email.".to_owned()), + ..ServerDiagnostics::new(Some(ServerCode::SqlState("23505".to_owned())), "duplicate key") + }; + + let message = driver_message(&DriverError::reported(diagnostics)); + + assert!(message.contains("23505"), "{message}"); + assert!(message.contains("duplicate key"), "{message}"); + assert!(message.contains("already exists"), "{message}"); + assert!(message.contains("Pick another email."), "{message}"); + + let numbered = driver_message(&DriverError::reported(ServerDiagnostics::new( + Some(ServerCode::Tds { + number: 208, + state: 1, + class: 16, + }), + "Invalid object name 'users'.", + ))); + assert!(numbered.contains("208"), "{numbered}"); + + let bare = driver_message(&DriverError::server("syntax error")); + assert!(!bare.contains("SQLSTATE"), "{bare}"); + assert!(bare.contains("syntax error"), "{bare}"); +} + +#[test] +fn diagnostics_codes_rendered_per_engine() { + let cases = [ + (ServerCode::SqlState("23505".to_owned()), "SQLSTATE 23505"), + ( + ServerCode::MySql { + number: 1062, + sqlstate: Some("23000".to_owned()), + }, + "MySQL error 1062", + ), + ( + ServerCode::Tds { + number: 2627, + state: 1, + class: 14, + }, + "SQL Server error 2627", + ), + (ServerCode::ClickHouse { code: 60 }, "ClickHouse code 60"), + ( + ServerCode::Sqlite { + primary: 19, + extended: 2067, + }, + "SQLite error 2067", + ), + ]; + for (code, expected) in cases { + let message = driver_message(&DriverError::reported(ServerDiagnostics::new(Some(code), "refused"))); + assert!(message.contains(expected), "{message}"); + } +} + +#[test] +fn tls_unknown_issuer_suggests_ca() { + let unknown = driver_message(&DriverError::Tls { + failure: TlsFailure::UnknownIssuer, + detail: "unknown issuer".to_owned(), + }); + assert!(unknown.contains("CA certificate"), "{unknown}"); + + let mismatch = driver_message(&DriverError::Tls { + failure: TlsFailure::NameMismatch, + detail: "not valid for name".to_owned(), + }); + assert!(mismatch.contains("server name"), "{mismatch}"); +} + +#[test] +fn auth_message_has_no_secret() { + let message = driver_message(&DriverError::auth(Some(ServerDiagnostics::new( + None, + "password authentication failed for user \"app\"", + )))); + + assert!(message.contains("wrong"), "{message}"); + assert!(message.contains("password authentication failed"), "{message}"); + assert!(!message.contains("hunter2")); +} + +#[test] +fn a_refused_login_is_not_called_a_wrong_password() { + let message = driver_message(&DriverError::LoginRefused(Box::new(ServerDiagnostics::new( + Some(ServerCode::SqlState("28000".to_owned())), + "no pg_hba.conf entry for host \"10.0.0.5\", user \"app\", database \"shop\", no encryption", + )))); + + assert!(!message.contains("wrong"), "{message}"); + assert!(message.contains("no pg_hba.conf entry"), "{message}"); +} + +#[test] +fn busy_and_editor_busy_messages_differ() { + assert_ne!( + driver_message(&DriverError::Busy), + driver_message(&DriverError::EditorSessionBusy) + ); +} + +#[test] +fn file_not_found_names_path() { + let message = driver_message(&DriverError::FileNotFound { + path: PathBuf::from("/srv/app.db"), + }); + + assert!(message.contains("/srv/app.db"), "{message}"); +} + +#[test] +fn timeout_phase_login_vs_statement_differ() { + let timeout = |phase| { + driver_message(&DriverError::Timeout { + phase, + server_cancelled: false, + }) + }; + + assert_ne!(timeout(TimeoutPhase::Login), timeout(TimeoutPhase::Statement)); + assert_eq!( + driver_message(&DriverError::Transport(TransportError::Timeout { + phase: TimeoutPhase::Connect + })), + timeout(TimeoutPhase::Connect), + "a transport timeout reads like the driver's own" + ); +} + +#[test] +fn a_timeout_says_the_server_stopped_only_when_it_confirmed() { + let message = |server_cancelled| { + driver_message(&DriverError::Timeout { + phase: TimeoutPhase::Statement, + server_cancelled, + }) + }; + + assert!(message(true).contains("the server stopped it"), "{}", message(true)); + assert!(!message(false).contains("stopped it"), "{}", message(false)); + assert!(message(false).contains("may still be running"), "{}", message(false)); +} + +#[test] +fn partially_applied_and_commit_unknown_never_claim_rollback() { + let lost = Box::new(DriverError::ConnectionLost { + during: LossPhase::Commit, + }); + let unknown = driver_message(&DriverError::CommitOutcomeUnknown { + token: None, + source: lost.clone(), + }); + let partial = driver_message(&DriverError::PartiallyApplied { + applied: 2, + statement_index: 2, + failed_outcome: StatementOutcome::Failed, + source: lost, + }); + + for message in [&unknown, &partial] { + assert!(!message.contains("rolled back"), "{message}"); + assert!(!message.contains("no rows were changed"), "{message}"); + assert!(message.contains("Reload"), "{message}"); + } +} + +#[test] +fn a_refused_connection_names_the_server_it_tried() { + let endpoint = NetworkEndpoint::new("db.internal", 5432).expect("an endpoint"); + + let message = driver_message(&DriverError::Transport(TransportError::Refused { endpoint })); + + assert!(message.contains("db.internal:5432"), "{message}"); +} + +#[test] +fn integrated_auth_names_the_remedy_and_keeps_the_gssapi_detail() { + let message = driver_message(&DriverError::IntegratedAuth("No Kerberos credentials available".into())); + assert!(message.contains("No Kerberos credentials available")); + assert!(message.contains("kinit")); + assert!(message.contains("SPN")); +} + +#[test] +fn ssh_failures_say_what_to_change() { + let changed = driver_message(&DriverError::Transport(TransportError::Ssh( + SshFailure::HostKeyChanged { + host: "bastion".to_owned(), + algorithm: "ED25519".to_owned(), + sha256_fingerprint: "SHA256:new".to_owned(), + known_hosts: PathBuf::from("/home/u/.ssh/known_hosts"), + line: 7, + trust_allowed: true, + }, + ))); + assert!(changed.contains("known_hosts"), "{changed}"); + assert!(changed.contains('7'), "{changed}"); + + let missing = ssh_failure_message(&SshFailure::OpenSshClientMissing { + program: PathBuf::from("ssh"), + }); + assert!(missing.contains("openssh-client"), "{missing}"); + + let declined = ssh_failure_message(&SshFailure::HostKeyUnknown { + host: "bastion".to_owned(), + algorithm: "ED25519".to_owned(), + sha256_fingerprint: "SHA256:abc".to_owned(), + trust_allowed: true, + declined: true, + }); + assert!(declined.contains("not trusted"), "{declined}"); +} + +/// Under StrictHostKeyChecking yes ssh refuses before it shows the key, +/// so there is no fingerprint to print and no review the user can do. +#[test] +fn an_unknown_key_says_what_the_configuration_allows() { + let unknown = |sha256_fingerprint: &str, trust_allowed: bool| { + ssh_failure_message(&SshFailure::HostKeyUnknown { + host: "[bastion]:2222".to_owned(), + algorithm: "ED25519".to_owned(), + sha256_fingerprint: sha256_fingerprint.to_owned(), + trust_allowed, + declined: false, + }) + }; + let strict = unknown("", false); + let unseen = unknown("", true); + let reviewable = unknown("SHA256:abc", true); + + for message in [&strict, &unseen] { + assert!(message.contains("[bastion]:2222"), "{message}"); + assert!(!message.contains("key )") && !message.contains("(key"), "{message}"); + } + assert!(strict.contains("known_hosts outside TablePro"), "{strict}"); + assert!(!strict.contains("review"), "{strict}"); + assert!(unseen.contains("review"), "{unseen}"); + assert!(reviewable.contains("(key SHA256:abc)"), "{reviewable}"); + assert!(reviewable.contains("review"), "{reviewable}"); +} + +#[test] +fn connect_error_text_covers_every_variant() { + let errors = [ + ConnectError::Driver(DriverError::auth(None)), + ConnectError::Keyring(KeyringError::SecretNotStored { + kind: SecretKind::DatabasePassword, + }), + ConnectError::Keyring(KeyringError::Secret { + kind: SecretKind::DatabasePassword, + error: SecretError::Locked, + }), + ConnectError::UnknownDriver("oracle".to_owned()), + ConnectError::Cancelled, + ConnectError::Panicked(TaskPanic::from_payload(Box::new("boom"))), + ]; + + let messages: std::collections::HashSet = errors.iter().map(connect_error_message).collect(); + + assert_eq!(messages.len(), errors.len(), "two connect errors share a message"); + assert!(connect_error_message(&errors[3]).contains("oracle")); +} + +#[test] +fn operation_errors_cancelled_and_closed_differ() { + assert_ne!( + operation_error_message(&OperationError::Cancelled), + operation_error_message(&OperationError::Closed) + ); + assert_eq!( + operation_error_message(&OperationError::Driver(DriverError::Busy)), + driver_message(&DriverError::Busy) + ); +} + +#[test] +fn every_secret_error_has_its_own_message() { + let errors = [ + SecretError::ServiceUnavailable { detail: "x".to_owned() }, + SecretError::PortalUnavailable { detail: "x".to_owned() }, + SecretError::Locked, + SecretError::UnlockDismissed, + SecretError::InvalidEncoding, + SecretError::Backend { + detail: "boom".to_owned(), + }, + ]; + + let messages: std::collections::HashSet = errors.iter().map(secret_message).collect(); + + assert_eq!(messages.len(), errors.len(), "two errors share a message"); +} + +#[test] +fn the_portal_message_says_what_to_check() { + let message = secret_message(&SecretError::PortalUnavailable { + detail: "org.freedesktop.portal.Secret not found".to_owned(), + }); + + assert!(message.contains("xdg-desktop-portal"), "{message}"); +} + +#[test] +fn a_backend_error_keeps_its_detail() { + let message = secret_message(&SecretError::Backend { + detail: "org.freedesktop.Secret failed".to_owned(), + }); + + assert!(message.contains("org.freedesktop.Secret failed"), "{message}"); +} + +#[test] +fn a_retryable_error_says_to_try_again() { + assert!(secret_message(&SecretError::Locked).contains("try again")); +} + +#[test] +fn unterminated_dollar_quote_diagnostic_names_line() { + let diagnostic = ScriptDiagnostic::Unterminated { + construct: OpenConstruct::DollarQuoted, + start: 10, + }; + + let message = script_diagnostic_message(&diagnostic, 7); + + assert!(message.contains("dollar-quoted"), "{message}"); + assert!(message.contains("line 7"), "{message}"); +} + +#[test] +fn a_change_a_positional_insert_would_break_names_its_triggers() { + let one = refusal_text(&tablepro_core::ddl::RefusalReason::InsertedByPosition(vec![ + "audit_copy".to_owned(), + ])); + let two = refusal_text(&tablepro_core::ddl::RefusalReason::InsertedByPosition(vec![ + "audit_copy".to_owned(), + "log_copy".to_owned(), + ])); + + assert!(one.starts_with("Trigger audit_copy inserts"), "{one}"); + assert!(two.starts_with("Triggers audit_copy, log_copy insert"), "{two}"); +} + +#[test] +fn a_rebuild_refusal_names_the_dependent_rather_than_a_column() { + let unreplayable = build_error_text(&tablepro_core::ddl::BuildDdlError::UnreplayableDependent( + "big_orders".to_owned(), + )); + let numbered = refusal_text(&tablepro_core::ddl::RefusalReason::AlwaysNumbered); + + assert!(unreplayable.contains("big_orders"), "{unreplayable}"); + assert!(!unreplayable.contains("Column"), "{unreplayable}"); + assert!(numbered.contains("primary key"), "{numbered}"); +} diff --git a/linux/crates/app/src/ui/error_text/tls.rs b/linux/crates/app/src/ui/error_text/tls.rs new file mode 100644 index 0000000000..547e7b2b7a --- /dev/null +++ b/linux/crates/app/src/ui/error_text/tls.rs @@ -0,0 +1,27 @@ +use tablepro_core::TlsFailure; + +/// Each TLS failure has a different next step, which is the whole point +/// of telling them apart. +pub(super) fn tls_message(failure: TlsFailure, detail: &str) -> String { + match failure { + TlsFailure::UnknownIssuer => crate::i18n::gettext( + "The server's certificate was signed by an authority this machine does not know. Choose a CA certificate for this connection.", + ), + TlsFailure::NameMismatch => crate::i18n::gettext( + "The server's certificate is for a different name. Set the TLS server name to the one on the certificate.", + ), + TlsFailure::Expired => crate::i18n::gettext("The server's certificate has expired."), + TlsFailure::NotYetValid => crate::i18n::gettext("The server's certificate is not valid yet."), + TlsFailure::Revoked => crate::i18n::gettext("The server's certificate was revoked."), + TlsFailure::ServerRefusedTls => crate::i18n::gettext("The server does not accept TLS on this port."), + TlsFailure::ServerRequiresTls => { + crate::i18n::gettext("The server requires TLS. Turn TLS on for this connection.") + } + TlsFailure::HandshakeRejected => { + crate::i18n::gettext_f("The server ended the secure handshake: {detail}", &[("detail", detail)]) + } + TlsFailure::CaFileInvalid => crate::i18n::gettext("The chosen CA certificate file could not be read."), + TlsFailure::ClientIdentityInvalid => crate::i18n::gettext("The client certificate or key could not be read."), + TlsFailure::Other => crate::i18n::gettext_f("TLS failed: {detail}", &[("detail", detail)]), + } +} diff --git a/linux/crates/app/src/ui/error_text/tls_remedy.rs b/linux/crates/app/src/ui/error_text/tls_remedy.rs new file mode 100644 index 0000000000..606cbd844a --- /dev/null +++ b/linux/crates/app/src/ui/error_text/tls_remedy.rs @@ -0,0 +1,94 @@ +use tablepro_core::{DriverError, TlsFailure}; +use tablepro_session::ConnectError; + +/// The one setting that fixes a TLS failure, when there is one the +/// user can change in the connect dialog. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum TlsRemedy { + ChooseCaCertificate, + SetServerName, +} + +impl TlsRemedy { + /// The toast button that takes the user to the setting. + pub fn label(self) -> String { + match self { + Self::ChooseCaCertificate => crate::i18n::gettext("Choose CA Certificate…"), + Self::SetServerName => crate::i18n::gettext("Set TLS Server Name"), + } + } +} + +/// Only an unknown issuer and a name mismatch are fixed from this side; +/// an expired or revoked certificate is the server's to replace. +pub fn remedy(failure: TlsFailure) -> Option { + match failure { + TlsFailure::UnknownIssuer => Some(TlsRemedy::ChooseCaCertificate), + TlsFailure::NameMismatch => Some(TlsRemedy::SetServerName), + TlsFailure::Expired + | TlsFailure::NotYetValid + | TlsFailure::Revoked + | TlsFailure::ServerRefusedTls + | TlsFailure::ServerRequiresTls + | TlsFailure::HandshakeRejected + | TlsFailure::CaFileInvalid + | TlsFailure::ClientIdentityInvalid + | TlsFailure::Other => None, + } +} + +/// The remedy for a connect that failed on TLS. Setting a server name +/// is offered only where the connect dialog shows that row: MySQL over +/// a direct route checks the host it dials and no other name. +pub fn connect_remedy(error: &ConnectError, server_name_shown: bool) -> Option { + match error { + ConnectError::Driver(DriverError::Tls { failure, .. }) => { + remedy(*failure).filter(|remedy| *remedy != TlsRemedy::SetServerName || server_name_shown) + } + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn unknown_issuer_and_name_mismatch_have_actions() { + assert_eq!(remedy(TlsFailure::UnknownIssuer), Some(TlsRemedy::ChooseCaCertificate)); + assert_eq!(remedy(TlsFailure::NameMismatch), Some(TlsRemedy::SetServerName)); + for failure in [TlsFailure::Expired, TlsFailure::Revoked, TlsFailure::Other] { + assert_eq!(remedy(failure), None, "{failure:?}"); + } + assert_ne!(TlsRemedy::ChooseCaCertificate.label(), TlsRemedy::SetServerName.label()); + } + + #[test] + fn a_connect_error_carries_the_remedy_of_its_tls_failure() { + let error = ConnectError::Driver(DriverError::Tls { + failure: TlsFailure::NameMismatch, + detail: "certificate is for db.internal".to_owned(), + }); + + assert_eq!(connect_remedy(&error, true), Some(TlsRemedy::SetServerName)); + assert_eq!(connect_remedy(&ConnectError::Cancelled, true), None); + } + + #[test] + fn a_server_name_is_not_offered_where_the_dialog_hides_its_row() { + let mismatch = ConnectError::Driver(DriverError::Tls { + failure: TlsFailure::NameMismatch, + detail: "certificate is for db.internal".to_owned(), + }); + let unknown_issuer = ConnectError::Driver(DriverError::Tls { + failure: TlsFailure::UnknownIssuer, + detail: "UnknownIssuer".to_owned(), + }); + + assert_eq!(connect_remedy(&mismatch, false), None); + assert_eq!( + connect_remedy(&unknown_issuer, false), + Some(TlsRemedy::ChooseCaCertificate) + ); + } +} diff --git a/linux/crates/app/src/ui/export/dialog.rs b/linux/crates/app/src/ui/export/dialog.rs new file mode 100644 index 0000000000..9d00e78c19 --- /dev/null +++ b/linux/crates/app/src/ui/export/dialog.rs @@ -0,0 +1,705 @@ +use std::cell::RefCell; +use std::rc::Rc; +use std::sync::Arc; + +use relm4::adw::prelude::*; +use relm4::gtk::{gio, glib}; +use relm4::{adw, gtk}; + +use tablepro_core::dialect::SqlDialect; +use tablepro_core::export::{CsvDecimal, CsvDelimiter, CsvLineBreak, CsvOptions, CsvQuote, XLSX_MAX_DATA_ROWS}; +use tablepro_core::meta::TableRef; +use tablepro_session::Session; +use uuid::Uuid; + +use super::{ + DocumentFormat, ExpectedRows, ExportFormat, ExportJob, ExportLedger, ExportOffer, ExportOrigin, ExportSource, + InsertTarget, RowFormat, ScopeChoice, ScopeSource, scope_choices, +}; +use crate::i18n::gettext_f; +use crate::ui::format::format_thousands; + +/// The table a result can be written back to as `INSERT` statements. +/// +/// A browse tab knows it; a query result does not, because it may join +/// several tables or none, so SQL is not offered for one. +#[derive(Debug, Clone)] +pub struct SqlTarget { + pub table: TableRef, + /// The columns the server computes, which the statements leave out. + pub generated: Arc<[String]>, +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum Format { + Csv, + Xlsx, + Json, + Markdown, + Html, + Xml, + Sql, +} + +impl Format { + /// Every format a query result can take. A browse tab adds SQL, + /// which needs a table to insert into. + const ALL: [Format; 6] = [ + Format::Csv, + Format::Xlsx, + Format::Json, + Format::Markdown, + Format::Html, + Format::Xml, + ]; + const ALL_WITH_SQL: [Format; 7] = [ + Format::Csv, + Format::Xlsx, + Format::Json, + Format::Markdown, + Format::Html, + Format::Xml, + Format::Sql, + ]; + + fn offered(target: Option<&InsertTarget>) -> &'static [Format] { + match target { + Some(_) => &Self::ALL_WITH_SQL, + None => &Self::ALL, + } + } + + fn label(self) -> &'static str { + match self { + Format::Csv => "CSV", + Format::Xlsx => "Excel", + Format::Json => "JSON", + Format::Markdown => "Markdown", + Format::Html => "HTML", + Format::Xml => "XML", + Format::Sql => "SQL", + } + } + + fn extension(self) -> &'static str { + match self { + Format::Csv => "csv", + Format::Xlsx => "xlsx", + Format::Json => "json", + Format::Markdown => "md", + Format::Html => "html", + Format::Xml => "xml", + Format::Sql => "sql", + } + } + + /// What a job writes in this format. `None` for SQL without a table + /// to insert into, which the dialog never offers. + fn job_format(self, csv: CsvOptions, name: &str, target: Option<&InsertTarget>) -> Option { + Some(match self { + Format::Csv => ExportFormat::Rows(RowFormat::Csv(csv)), + Format::Json => ExportFormat::Rows(RowFormat::Json), + Format::Markdown => ExportFormat::Rows(RowFormat::Markdown), + Format::Xlsx => ExportFormat::Document(DocumentFormat::Xlsx { sheet: name.to_owned() }), + Format::Html => ExportFormat::Rows(RowFormat::Html { title: name.to_owned() }), + Format::Xml => ExportFormat::Rows(RowFormat::Xml), + Format::Sql => ExportFormat::Rows(RowFormat::Sql(target?.clone())), + }) + } + + /// Why rows numbering `expected` can't be written in this format, + /// told before the export reads them all: a table known to hold + /// more rows than an Excel sheet does. + fn refusal(self, expected: Option) -> Option { + let limit = XLSX_MAX_DATA_ROWS as u64; + match (self, expected) { + (Format::Xlsx, Some(ExpectedRows::Exact(rows))) if rows > limit => Some(gettext_f( + "An Excel sheet holds {limit} rows, and this export has {rows}. Export it as CSV instead.", + &[("limit", &format_thousands(limit)), ("rows", &format_thousands(rows))], + )), + _ => None, + } + } + + fn mime_type(self) -> &'static str { + match self { + Format::Csv => "text/csv", + Format::Xlsx => "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + Format::Json => "application/json", + Format::Markdown => "text/markdown", + Format::Html => "text/html", + Format::Xml => "application/xml", + Format::Sql => "application/sql", + } + } +} + +struct CsvRows { + null_to_empty: adw::SwitchRow, + line_break_to_space: adw::SwitchRow, + header_row: adw::SwitchRow, + sanitize_formulas: adw::SwitchRow, + delimiter: adw::ComboRow, + quote: adw::ComboRow, + line_break: adw::ComboRow, + decimal: adw::ComboRow, +} + +impl CsvRows { + fn new() -> Self { + Self { + null_to_empty: switch_row(&crate::i18n::gettext("Convert NULL to Empty"), None), + line_break_to_space: switch_row(&crate::i18n::gettext("Convert Line Breaks to Spaces"), None), + header_row: switch_row(&crate::i18n::gettext("Put Field Names in the First Row"), None), + sanitize_formulas: switch_row( + &crate::i18n::gettext("Sanitize Formula-Like Values"), + Some(&crate::i18n::gettext( + "Prefix values starting with =, +, - or @ so spreadsheets do not run them", + )), + ), + delimiter: combo_row( + &crate::i18n::gettext("Delimiter"), + &[ + &crate::i18n::gettext("Comma (,)"), + &crate::i18n::gettext("Semicolon (;)"), + &crate::i18n::gettext("Tab"), + &crate::i18n::gettext("Pipe (|)"), + ], + ), + quote: combo_row( + &crate::i18n::gettext("Quote"), + &[ + &crate::i18n::gettext("Always"), + &crate::i18n::gettext("Quote if needed"), + &crate::i18n::gettext("Never"), + ], + ), + line_break: combo_row( + &crate::i18n::gettext("Line break"), + &["LF (\\n)", "CRLF (\\r\\n)", "CR (\\r)"], + ), + decimal: combo_row( + &crate::i18n::gettext("Decimal separator"), + &[&crate::i18n::gettext("Period (.)"), &crate::i18n::gettext("Comma (,)")], + ), + } + } + + fn switches(&self) -> [&adw::SwitchRow; 4] { + [ + &self.null_to_empty, + &self.line_break_to_space, + &self.header_row, + &self.sanitize_formulas, + ] + } + + fn combos(&self) -> [&adw::ComboRow; 4] { + [&self.delimiter, &self.quote, &self.line_break, &self.decimal] + } + + fn group(&self) -> adw::PreferencesGroup { + let group = adw::PreferencesGroup::builder() + .title(crate::i18n::gettext("CSV Options")) + .build(); + for row in self.switches() { + group.add(row); + } + for row in self.combos() { + group.add(row); + } + group + } + + /// GSettings is the single copy, so two open dialogs converge instead + /// of overwriting each other. + fn save_changes_to(self: &Rc, settings: &Rc) { + let persist = { + let rows = self.clone(); + let settings = settings.clone(); + Rc::new(move || { + if let Err(error) = settings.set_csv_options(&rows.read()) { + tracing::warn!(%error, "could not save the CSV export options"); + } + }) + }; + for row in self.switches() { + let persist = persist.clone(); + row.connect_active_notify(move |_| persist()); + } + for row in self.combos() { + let persist = persist.clone(); + row.connect_selected_notify(move |_| persist()); + } + } + + fn show(&self, opts: &CsvOptions) { + self.null_to_empty.set_active(opts.null_to_empty); + self.line_break_to_space.set_active(opts.line_break_to_space); + self.header_row.set_active(opts.header_row); + self.sanitize_formulas.set_active(opts.sanitize_formulas); + self.delimiter + .set_selected(index_of(&CsvDelimiter::ALL, opts.delimiter)); + self.quote.set_selected(index_of(&CsvQuote::ALL, opts.quote)); + self.line_break + .set_selected(index_of(&CsvLineBreak::ALL, opts.line_break)); + self.decimal.set_selected(index_of(&CsvDecimal::ALL, opts.decimal)); + } + + fn read(&self) -> CsvOptions { + CsvOptions { + null_to_empty: self.null_to_empty.is_active(), + line_break_to_space: self.line_break_to_space.is_active(), + header_row: self.header_row.is_active(), + sanitize_formulas: self.sanitize_formulas.is_active(), + delimiter: pick(&CsvDelimiter::ALL, self.delimiter.selected()), + quote: pick(&CsvQuote::ALL, self.quote.selected()), + line_break: pick(&CsvLineBreak::ALL, self.line_break.selected()), + decimal: pick(&CsvDecimal::ALL, self.decimal.selected()), + } + } +} + +fn index_of(all: &[T], value: T) -> u32 { + all.iter().position(|v| *v == value).unwrap_or(0) as u32 +} + +fn pick(all: &[T], index: u32) -> T { + all[(index as usize).min(all.len() - 1)] +} + +fn switch_row(title: &str, subtitle: Option<&str>) -> adw::SwitchRow { + let row = adw::SwitchRow::builder().title(title).build(); + if let Some(subtitle) = subtitle { + row.set_subtitle(subtitle); + } + row +} + +fn combo_row(title: &str, choices: &[&str]) -> adw::ComboRow { + let row = adw::ComboRow::builder() + .title(title) + .model(>k::StringList::new(choices)) + .build(); + crate::ui::combo_value::show_whole_value(&row); + row +} + +/// What the dialog exports, and where it hands the job. +pub(crate) struct ExportDialogInit<'a> { + pub parent: &'a adw::ApplicationWindow, + pub exports: &'a Rc, + pub offer: ExportOffer, + pub dialect: Option>, + pub settings: &'a Rc, + /// The tab that offered the rows. + pub tab: Uuid, +} + +/// The Rows list: which rows the export writes, one entry per scope +/// the origin offers. +struct ScopeRow { + row: adw::ComboRow, + choices: Vec, +} + +impl ScopeRow { + fn new(origin: &ExportOrigin, can_read: bool) -> Self { + let mut choices = scope_choices(origin); + // A read of every row goes through the tab's session, and a + // window that disconnected has none. + choices.retain(|choice| can_read || choice.source == ScopeSource::Shown); + let titles: Vec<&str> = choices.iter().map(|choice| choice.title.as_str()).collect(); + let row = combo_row(&crate::i18n::gettext("Rows"), &titles); + let preselected = origin.preselected(); + let index = choices + .iter() + .position(|choice| choice.source == preselected) + .unwrap_or_default(); + row.set_selected(u32::try_from(index).unwrap_or_default()); + row.set_sensitive(choices.len() > 1); + let subtitles: Vec = choices.iter().map(|choice| choice.subtitle.clone()).collect(); + let show_subtitle = move |row: &adw::ComboRow| { + let subtitle = subtitles.get(row.selected() as usize).map_or("", String::as_str); + row.set_subtitle(subtitle); + }; + show_subtitle(&row); + row.connect_selected_notify(show_subtitle); + Self { row, choices } + } + + fn selected(&self) -> Option<&ScopeChoice> { + self.choices.get(self.row.selected() as usize) + } +} + +pub(crate) fn present(init: ExportDialogInit<'_>) { + let ExportDialogInit { + parent, + exports, + offer, + dialect, + settings, + tab, + } = init; + let ExportOffer { + origin, + session, + name, + target, + } = offer; + let target = target.zip(dialect).map(|(target, dialect)| InsertTarget { + table: target.table, + generated: target.generated, + dialect, + }); + let formats = Format::offered(target.as_ref()); + let page = adw::PreferencesPage::new(); + + let format_group = adw::PreferencesGroup::new(); + let scope = ScopeRow::new(&origin, session.is_some()); + format_group.add(&scope.row); + let format_labels: Vec<&str> = formats.iter().map(|f| f.label()).collect(); + let format_row = combo_row(&crate::i18n::gettext("Format"), &format_labels); + format_group.add(&format_row); + page.add(&format_group); + + let rows = Rc::new(CsvRows::new()); + rows.show(&settings.csv_options()); + let csv_group = rows.group(); + page.add(&csv_group); + rows.save_changes_to(settings); + format_row.connect_selected_notify(move |row| { + csv_group.set_visible(pick(formats, row.selected()) == Format::Csv); + }); + + let export_button = gtk::Button::builder().label(crate::i18n::gettext("Export…")).build(); + export_button.add_css_class("suggested-action"); + let footer = footer(&reset_button(&rows, settings), &export_button); + + let expected = scope.choices.iter().map(|choice| choice.expected).collect(); + refuse_what_cannot_fit(&format_row, formats, &scope.row, expected, &export_button); + + let dialog = dialog(&page, &footer, &export_button); + let form = ExportForm { + formats, + format_row, + rows, + scope, + name, + target, + tab, + unexported: RefCell::new(Some(Unexported { origin, session })), + }; + let window = parent.clone(); + let exports = exports.clone(); + let dialog_for_export = dialog.clone(); + export_button.connect_clicked(move |_| { + let Some(chosen) = form.take_chosen() else { + return; + }; + dialog_for_export.close(); + glib::spawn_future_local(save_with_file_dialog(window.clone(), exports.clone(), chosen)); + }); + + dialog.present(Some(parent)); +} + +fn reset_button(rows: &Rc, settings: &Rc) -> gtk::Button { + let button = gtk::Button::builder() + .label(crate::i18n::gettext("Reset to Defaults")) + .build(); + button.add_css_class("flat"); + let rows = rows.clone(); + let settings = settings.clone(); + button.connect_clicked(move |_| { + settings.reset_csv_options(); + rows.show(&settings.csv_options()); + }); + button +} + +fn footer(reset_button: >k::Button, export_button: >k::Button) -> gtk::Box { + let footer = gtk::Box::builder() + .orientation(gtk::Orientation::Horizontal) + .margin_top(6) + .margin_bottom(12) + .margin_start(12) + .margin_end(12) + .build(); + footer.append(reset_button); + footer.append(>k::Box::builder().hexpand(true).build()); + footer.append(export_button); + footer +} + +fn dialog(page: &adw::PreferencesPage, footer: >k::Box, export_button: >k::Button) -> adw::Dialog { + let toolbar = adw::ToolbarView::new(); + toolbar.add_top_bar(&adw::HeaderBar::new()); + toolbar.set_content(Some(page)); + toolbar.add_bottom_bar(footer); + + let dialog = adw::Dialog::builder() + .title(crate::i18n::gettext("Export Rows")) + .content_width(480) + .child(&toolbar) + .build(); + dialog.set_default_widget(Some(export_button)); + dialog +} + +/// What Export reads from the dialog when it is clicked. +struct ExportForm { + formats: &'static [Format], + format_row: adw::ComboRow, + rows: Rc, + scope: ScopeRow, + name: String, + target: Option, + tab: Uuid, + /// One dialog, one export: the rows go to the job rather than being + /// copied for each click. + unexported: RefCell>, +} + +impl ExportForm { + /// The export the dialog holds, taken once, or `None` while the + /// chosen rows can't be written in the chosen format. + fn take_chosen(&self) -> Option { + let format = pick(self.formats, self.format_row.selected()); + let job_format = format.job_format(self.rows.read(), &self.name, self.target.as_ref())?; + let choice = self.scope.selected().cloned()?; + if format.refusal(choice.expected).is_some() { + return None; + } + let Unexported { origin, session } = self.unexported.borrow_mut().take()?; + let source = origin.into_source(choice.source, session)?; + Some(Chosen { + format, + job_format, + name: self.name.clone(), + source, + expected_rows: choice.expected, + tab: self.tab, + }) + } +} + +/// Keeps Export off, with the reason under the format, while the chosen +/// rows can't be written in the chosen format. `expected` is each scope's +/// count, in the Rows list's order. +fn refuse_what_cannot_fit( + format_row: &adw::ComboRow, + formats: &'static [Format], + scope_row: &adw::ComboRow, + expected: Vec>, + export_button: >k::Button, +) { + let refuse = { + let format_row = format_row.downgrade(); + let scope_row = scope_row.downgrade(); + let export_button = export_button.downgrade(); + Rc::new(move || { + let (Some(format_row), Some(scope_row), Some(export_button)) = + (format_row.upgrade(), scope_row.upgrade(), export_button.upgrade()) + else { + return; + }; + let expected = expected.get(scope_row.selected() as usize).copied().flatten(); + let refusal = pick(formats, format_row.selected()).refusal(expected); + format_row.set_subtitle(refusal.as_deref().unwrap_or_default()); + export_button.set_sensitive(refusal.is_none()); + }) + }; + for row in [format_row, scope_row] { + let refuse = refuse.clone(); + row.connect_selected_notify(move |_| refuse()); + } + refuse(); +} + +/// What the dialog holds until its one export takes it. +struct Unexported { + origin: ExportOrigin, + session: Option>, +} + +/// One export, as the dialog left it. +struct Chosen { + format: Format, + job_format: ExportFormat, + name: String, + source: ExportSource, + expected_rows: Option, + tab: Uuid, +} + +async fn save_with_file_dialog(parent: adw::ApplicationWindow, exports: Rc, chosen: Chosen) { + let Chosen { + format, + job_format, + name, + source, + expected_rows, + tab, + } = chosen; + let filter = gtk::FileFilter::new(); + filter.set_name(Some(&crate::i18n::gettext_f( + "{format} files", + &[("format", format.label())], + ))); + filter.add_mime_type(format.mime_type()); + filter.add_suffix(format.extension()); + let filters = gio::ListStore::new::(); + filters.append(&filter); + let file_dialog = gtk::FileDialog::builder() + .title(crate::i18n::gettext("Export Rows")) + .modal(true) + .initial_name(format!("{name}.{}", format.extension())) + .default_filter(&filter) + .filters(&filters) + .build(); + // Cancelling the chooser is the only way it fails without a file. + let Ok(destination) = file_dialog.save_future(Some(&parent)).await else { + return; + }; + // The ledger reports how the job ends, or logs why it never began. + let _ = exports.begin(ExportJob { + destination, + format: job_format, + source, + expected_rows, + tab: Some(tab), + }); +} + +#[cfg(test)] +mod tests { + use std::time::Duration; + + use tablepro_core::column::{ColumnType, ResultColumn}; + use tablepro_core::{EditorSession, ResultEvent, Truncation, Value}; + use tablepro_test_support::{FakeEditorSession, FakeReply, FakeRun, Replies}; + + use super::*; + use crate::test_support::{descendants, first_descendant_of_type, wait_until}; + use crate::ui::session_window::tests::{TestWindow, editors}; + + const WAIT: Duration = Duration::from_secs(20); + + /// A run whose one result the row limit cut short. + fn capped_run() -> Replies { + let columns: Arc<[ResultColumn]> = vec![ResultColumn::new("n", ColumnType::untyped_text("text"))].into(); + let mut runs = Replies::default(); + runs.always(FakeRun::completed(vec![ + ResultEvent::ResultSetStarted { columns }, + ResultEvent::Rows(vec![vec![Value::Int(1)], vec![Value::Int(2)]]), + ResultEvent::RowLimitReached { retained: 2 }, + ResultEvent::ResultSetFinished { + retained: 2, + total_rows: None, + truncation: Truncation::ServerStopped, + }, + ])); + runs + } + + #[gtk4::test] + fn export_all_opens_dialog_with_all_rows_scope_selected() { + let fake = Arc::new(FakeEditorSession::new(capped_run())); + let test = TestWindow::connected_with("ExportAll", |script| { + let editor: Arc = fake.clone(); + script.open_editor_session.always(FakeReply::ok(editor)); + }); + test.run_in_new_editor_tab(); + let editor = editors(&test.window).pop().expect("the editor tab"); + let banner = first_descendant_of_type::(&editor).expect("the result's banner"); + assert!(banner.is_revealed(), "the capped result has no banner"); + + banner.emit_by_name::<()>("button-clicked", &[]); + + wait_until(WAIT, || test.window.visible_dialog().is_some()).expect("the export dialog"); + let dialog = test.window.visible_dialog().expect("the export dialog"); + assert_eq!(dialog.title(), "Export Rows"); + let rows = descendants(&dialog) + .into_iter() + .filter_map(|widget| widget.downcast::().ok()) + .find(|row| row.title() == "Rows") + .expect("the Rows list"); + let selected = rows + .selected_item() + .and_downcast::() + .expect("a selected scope"); + assert_eq!(selected.string(), "All Rows"); + assert_eq!( + rows.subtitle().as_deref(), + Some( + "Runs the statement again on a separate read-only connection, without this editor's USE or SET changes" + ) + ); + } + + #[test] + fn every_format_has_a_label_extension_and_type_of_its_own() { + let mut labels: Vec<&str> = Format::ALL_WITH_SQL.iter().map(|f| f.label()).collect(); + let mut extensions: Vec<&str> = Format::ALL_WITH_SQL.iter().map(|f| f.extension()).collect(); + let mut types: Vec<&str> = Format::ALL_WITH_SQL.iter().map(|f| f.mime_type()).collect(); + + for list in [&mut labels, &mut extensions, &mut types] { + let before = list.len(); + list.sort_unstable(); + list.dedup(); + assert_eq!(list.len(), before, "two formats share an entry: {list:?}"); + } + } + + #[test] + fn excel_is_refused_for_more_rows_than_a_sheet_holds() { + let limit = XLSX_MAX_DATA_ROWS as u64; + + assert!(Format::Xlsx.refusal(Some(ExpectedRows::Exact(limit + 1))).is_some()); + assert_eq!(Format::Xlsx.refusal(Some(ExpectedRows::Exact(limit))), None); + assert_eq!( + Format::Xlsx.refusal(Some(ExpectedRows::About(limit * 2))), + None, + "an estimate can be wrong, so the sheet's own check decides" + ); + assert_eq!(Format::Xlsx.refusal(None), None); + assert_eq!(Format::Csv.refusal(Some(ExpectedRows::Exact(limit * 2))), None); + } + + #[gtk4::test] + fn export_is_off_while_the_rows_cannot_fit_an_excel_sheet() { + adw::init().expect("libadwaita initialises under the test backend"); + let formats = Format::offered(None); + let at = |format| u32::try_from(formats.iter().position(|f| *f == format).expect("offered")).expect("an index"); + let labels: Vec<&str> = formats.iter().map(|f| f.label()).collect(); + let format_row = combo_row("Format", &labels); + let scope_row = combo_row("Rows", &["Current Page", "Entire Table"]); + let export = gtk::Button::new(); + let past_a_sheet = XLSX_MAX_DATA_ROWS as u64 + 1; + refuse_what_cannot_fit( + &format_row, + formats, + &scope_row, + vec![Some(ExpectedRows::Exact(3)), Some(ExpectedRows::Exact(past_a_sheet))], + &export, + ); + + format_row.set_selected(at(Format::Xlsx)); + assert!(export.is_sensitive(), "a page fits a sheet"); + scope_row.set_selected(1); + assert!(!export.is_sensitive(), "the whole table was let through"); + assert!(format_row.subtitle().is_some_and(|text| text.contains("Excel"))); + format_row.set_selected(at(Format::Csv)); + assert!(export.is_sensitive()); + assert!(format_row.subtitle().unwrap_or_default().is_empty()); + } + + #[test] + fn sql_is_the_only_format_that_needs_a_table() { + assert!(!Format::offered(None).contains(&Format::Sql)); + assert!(Format::offered(None).contains(&Format::Xlsx)); + assert_eq!(Format::offered(None).len() + 1, Format::ALL_WITH_SQL.len()); + } +} diff --git a/linux/crates/app/src/ui/export/document_sink.rs b/linux/crates/app/src/ui/export/document_sink.rs new file mode 100644 index 0000000000..3cd005f209 --- /dev/null +++ b/linux/crates/app/src/ui/export/document_sink.rs @@ -0,0 +1,233 @@ +use std::sync::Arc; + +use async_trait::async_trait; +use tablepro_core::column::ResultColumn; +use tablepro_core::export::RenderError; +use tablepro_core::{ResultEvent, ResultSink, SinkFlow, Value}; +use tablepro_session::runtime::Tasks; +use tokio::sync::mpsc; + +use super::export_format::DocumentWriter; +use super::{DocumentFormat, ExportMessage, ExportStreamFailure}; + +/// Rows a document takes in between progress reports. +const ROWS_PER_PROGRESS: u64 = 4_096; + +/// Writes a result's rows into its document on the blocking pool as +/// they arrive, and hands the finished document on as one chunk. A +/// workbook is a zip archive, which is written whole, but its sheet +/// spills the rows to a temporary file meanwhile, so the result is +/// never held. +pub(crate) struct DocumentSink { + messages: mpsc::Sender, + format: DocumentFormat, + /// The most rows the document can hold. + limit: u64, + tasks: Tasks, + /// Out on the blocking pool while a batch is written, and `None` + /// before the result set starts. + writer: Option, + rows: u64, + result_sets: u32, +} + +impl DocumentSink { + pub(crate) fn new(messages: mpsc::Sender, format: DocumentFormat, tasks: Tasks) -> Self { + Self { + messages, + limit: format.row_limit() as u64, + format, + tasks, + writer: None, + rows: 0, + result_sets: 0, + } + } + + async fn send(&mut self, message: ExportMessage) -> SinkFlow { + match self.messages.send(message).await { + Ok(()) => SinkFlow::Continue, + Err(_) => SinkFlow::ConsumerGone, + } + } + + async fn fail(&mut self, failure: ExportStreamFailure) -> SinkFlow { + self.send(ExportMessage::Failed(failure)).await; + SinkFlow::ConsumerGone + } + + async fn in_pool( + &mut self, + work: impl FnOnce() -> Result + Send + 'static, + ) -> Result { + match self.tasks.spawn_blocking_task(work).await { + Ok(Ok(done)) => Ok(done), + Ok(Err(error)) => Err(self.fail(error.into()).await), + Err(failure) => Err(self.fail(failure.into()).await), + } + } + + async fn start(&mut self, columns: Arc<[ResultColumn]>) -> SinkFlow { + let format = self.format.clone(); + match self.in_pool(move || format.start(columns)).await { + Ok(writer) => { + self.writer = Some(writer); + SinkFlow::Continue + } + Err(flow) => flow, + } + } + + async fn take(&mut self, rows: Vec>) -> SinkFlow { + let Some(mut writer) = self.writer.take() else { + tracing::warn!("rows arrived before their result set started"); + return SinkFlow::ConsumerGone; + }; + let before = self.rows; + let now = before + rows.len() as u64; + // The sheet refuses a row past its last, so the read stops here + // rather than reading the rest. + if now > self.limit { + let limit = self.limit as usize; + return self.fail(ExportStreamFailure::SheetFull { limit }).await; + } + let written = self.in_pool(move || writer.write_rows(&rows).map(|()| writer)).await; + match written { + Ok(writer) => self.writer = Some(writer), + Err(flow) => return flow, + } + self.rows = now; + if before / ROWS_PER_PROGRESS != now / ROWS_PER_PROGRESS { + return self.send(ExportMessage::Progress { rows: now }).await; + } + SinkFlow::Continue + } + + async fn finish(&mut self) -> SinkFlow { + let Some(writer) = self.writer.take() else { + return SinkFlow::Continue; + }; + let rows = self.rows; + let bytes = match self.in_pool(move || writer.finish()).await { + Ok(bytes) => bytes, + Err(flow) => return flow, + }; + if self.send(ExportMessage::Chunk { bytes, rows }).await == SinkFlow::ConsumerGone { + return SinkFlow::ConsumerGone; + } + self.send(ExportMessage::Finished { rows }).await + } +} + +#[async_trait] +impl ResultSink for DocumentSink { + async fn accept(&mut self, event: ResultEvent) -> SinkFlow { + match event { + ResultEvent::ResultSetStarted { columns } => { + self.result_sets += 1; + if self.result_sets > 1 { + return self.fail(ExportStreamFailure::MultipleResultSets).await; + } + self.start(columns).await + } + ResultEvent::Rows(rows) => self.take(rows).await, + ResultEvent::ResultSetFinished { .. } => self.finish().await, + _ => SinkFlow::Continue, + } + } +} + +#[cfg(test)] +mod tests { + use tablepro_core::Truncation; + use tablepro_core::column::ColumnType; + + use super::*; + use crate::test_support::paused_tasks; + + fn started() -> ResultEvent { + ResultEvent::ResultSetStarted { + columns: vec![ResultColumn::new("a", ColumnType::untyped_text("text"))].into(), + } + } + + fn finished() -> ResultEvent { + ResultEvent::ResultSetFinished { + retained: 0, + total_rows: None, + truncation: Truncation::Complete, + } + } + + fn rows(count: usize) -> Vec> { + (0..count).map(|row| vec![Value::Int(row as i64)]).collect() + } + + fn sheet() -> DocumentFormat { + DocumentFormat::Xlsx { + sheet: "rows".to_owned(), + } + } + + #[tokio::test] + async fn a_document_is_rendered_once_its_last_row_is_in() { + let (messages, mut received) = mpsc::channel(16); + let mut sink = DocumentSink::new(messages, sheet(), paused_tasks()); + + sink.accept(started()).await; + sink.accept(ResultEvent::Rows(rows(5_000))).await; + assert_eq!(sink.accept(finished()).await, SinkFlow::Continue); + drop(sink); + + let mut seen = Vec::new(); + while let Some(message) = received.recv().await { + seen.push(message); + } + let [ + ExportMessage::Progress { rows: 5_000 }, + ExportMessage::Chunk { bytes, rows: 5_000 }, + ExportMessage::Finished { rows: 5_000 }, + ] = seen.as_slice() + else { + panic!("{seen:?}"); + }; + assert!(bytes.starts_with(b"PK"), "the workbook is not a zip archive"); + } + + /// Each batch goes into the sheet as it arrives, so the sink holds + /// no rows between batches. + #[tokio::test] + async fn rows_are_written_into_the_sheet_as_they_arrive() { + let (messages, _received) = mpsc::channel(16); + let mut sink = DocumentSink::new(messages, sheet(), paused_tasks()); + + sink.accept(started()).await; + sink.accept(ResultEvent::Rows(rows(300))).await; + sink.accept(ResultEvent::Rows(rows(200))).await; + + let Some(DocumentWriter::Xlsx(writer)) = sink.writer.as_ref() else { + panic!("no sheet is being written"); + }; + assert_eq!(writer.rows(), 500); + } + + #[tokio::test] + async fn a_sheet_stops_the_read_once_it_is_full() { + let (messages, mut received) = mpsc::channel(16); + let mut sink = DocumentSink::new(messages, sheet(), paused_tasks()); + let limit = 10; + sink.limit = limit as u64; + + sink.accept(started()).await; + let flow = sink.accept(ResultEvent::Rows(rows(limit + 1))).await; + + assert_eq!(flow, SinkFlow::ConsumerGone); + drop(sink); + let mut full = false; + while let Some(message) = received.recv().await { + full |= + matches!(message, ExportMessage::Failed(ExportStreamFailure::SheetFull { limit: got }) if got == limit); + } + assert!(full, "the full sheet was not reported"); + } +} diff --git a/linux/crates/app/src/ui/export/encoding_sink.rs b/linux/crates/app/src/ui/export/encoding_sink.rs new file mode 100644 index 0000000000..24d1ca804b --- /dev/null +++ b/linux/crates/app/src/ui/export/encoding_sink.rs @@ -0,0 +1,261 @@ +use async_trait::async_trait; +use tablepro_core::export::RowEncoder; +use tablepro_core::{ResultEvent, ResultSink, SinkFlow, Value}; +use tokio::sync::mpsc; + +use super::{ExportMessage, ExportStreamFailure, RowFormat}; + +/// How much encoded output a sink holds before it hands it to the +/// writer. +pub(crate) const CHUNK_BYTES: usize = 64 * 1024; + +/// Encodes a result's rows as they arrive, and hands the bytes on in +/// chunks. A send waits while the writer is behind, which stops the +/// read until the disk catches up. +pub(crate) struct EncodingSink { + messages: mpsc::Sender, + format: RowFormat, + /// Built from the columns the stream announces, which are the ones + /// its rows carry. + encoder: Option>, + buffer: Vec, + rows: u64, + result_sets: u32, +} + +impl EncodingSink { + pub(crate) fn new(messages: mpsc::Sender, format: RowFormat) -> Self { + Self { + messages, + format, + encoder: None, + buffer: Vec::with_capacity(CHUNK_BYTES), + rows: 0, + result_sets: 0, + } + } + + // `&mut self` throughout: the encoder is not Sync, so a shared + // borrow held across an await would make the future not Send. + async fn send(&mut self, message: ExportMessage) -> SinkFlow { + match self.messages.send(message).await { + Ok(()) => SinkFlow::Continue, + Err(_) => SinkFlow::ConsumerGone, + } + } + + async fn flush(&mut self) -> SinkFlow { + if self.buffer.is_empty() { + return SinkFlow::Continue; + } + let bytes = std::mem::replace(&mut self.buffer, Vec::with_capacity(CHUNK_BYTES)); + self.send(ExportMessage::Chunk { bytes, rows: self.rows }).await + } + + /// The export cannot go on, so the run is stopped too. + async fn fail(&mut self, failure: ExportStreamFailure) -> SinkFlow { + self.send(ExportMessage::Failed(failure)).await; + SinkFlow::ConsumerGone + } + + async fn rows(&mut self, rows: Vec>) -> SinkFlow { + for row in &rows { + let Some(encoder) = self.encoder.as_mut() else { + tracing::warn!("rows arrived before their result set started"); + return SinkFlow::ConsumerGone; + }; + if let Err(error) = encoder.row(&mut self.buffer, &mut row.iter()) { + return self.fail(error.into()).await; + } + self.rows += 1; + if self.buffer.len() >= CHUNK_BYTES && self.flush().await == SinkFlow::ConsumerGone { + return SinkFlow::ConsumerGone; + } + } + SinkFlow::Continue + } +} + +#[async_trait] +impl ResultSink for EncodingSink { + async fn accept(&mut self, event: ResultEvent) -> SinkFlow { + match event { + ResultEvent::ResultSetStarted { columns } => { + self.result_sets += 1; + // A file holds one result, and a second set would come + // out as a second document glued to the first. + if self.result_sets > 1 { + return self.fail(ExportStreamFailure::MultipleResultSets).await; + } + let mut encoder = self.format.encoder(columns); + encoder.begin(&mut self.buffer); + self.encoder = Some(encoder); + SinkFlow::Continue + } + ResultEvent::Rows(rows) => self.rows(rows).await, + ResultEvent::ResultSetFinished { .. } => { + if let Some(encoder) = self.encoder.as_mut() { + encoder.finish(&mut self.buffer); + } + if self.flush().await == SinkFlow::ConsumerGone { + return SinkFlow::ConsumerGone; + } + self.send(ExportMessage::Finished { rows: self.rows }).await + } + _ => SinkFlow::Continue, + } + } +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + use std::sync::atomic::{AtomicBool, Ordering}; + use std::time::Duration; + + use tablepro_core::Truncation; + use tablepro_core::column::{ColumnType, ResultColumn}; + use tablepro_core::export::CsvOptions; + + use super::*; + use crate::test_support::paused_tasks; + + fn columns(names: &[&str]) -> Arc<[ResultColumn]> { + names + .iter() + .map(|name| ResultColumn::new(*name, ColumnType::untyped_text("text"))) + .collect() + } + + fn plain_csv() -> RowFormat { + RowFormat::Csv(CsvOptions { + header_row: false, + sanitize_formulas: false, + ..CsvOptions::default() + }) + } + + fn sink(format: RowFormat, capacity: usize) -> (EncodingSink, mpsc::Receiver) { + let (messages, received) = mpsc::channel(capacity); + (EncodingSink::new(messages, format), received) + } + + /// One-cell rows that each encode to exactly 1 KiB of plain CSV. + fn kib_rows(count: usize) -> Vec> { + (0..count) + .map(|row| vec![Value::Text(format!("{row:01023}"))]) + .collect() + } + + fn started(names: &[&str]) -> ResultEvent { + ResultEvent::ResultSetStarted { + columns: columns(names), + } + } + + fn finished() -> ResultEvent { + ResultEvent::ResultSetFinished { + retained: 0, + total_rows: None, + truncation: Truncation::Complete, + } + } + + async fn drain(received: &mut mpsc::Receiver) -> (Vec<(Vec, u64)>, Option) { + let mut chunks = Vec::new(); + let mut finished = None; + while let Some(message) = received.recv().await { + match message { + ExportMessage::Chunk { bytes, rows } => chunks.push((bytes, rows)), + ExportMessage::Finished { rows } => finished = Some(rows), + ExportMessage::Progress { .. } => {} + ExportMessage::Failed(failure) => panic!("the export failed: {failure}"), + } + } + (chunks, finished) + } + + #[tokio::test(start_paused = true)] + async fn encoding_sink_chunks_at_64_kib() { + let (mut sink, mut received) = sink(plain_csv(), 64); + + assert_eq!(sink.accept(started(&["a"])).await, SinkFlow::Continue); + assert_eq!( + sink.accept(ResultEvent::Rows(kib_rows(1_000))).await, + SinkFlow::Continue + ); + assert_eq!(sink.accept(finished()).await, SinkFlow::Continue); + drop(sink); + let (chunks, finished) = drain(&mut received).await; + + assert_eq!(finished, Some(1_000)); + let (last, full) = chunks.split_last().expect("chunks"); + assert_eq!(full.len(), 15); + for (index, (bytes, rows)) in full.iter().enumerate() { + assert_eq!(bytes.len(), CHUNK_BYTES, "chunk {index}"); + assert_eq!(*rows, 64 * (index as u64 + 1), "chunk {index}"); + } + assert_eq!(last.0.len(), 40 * 1024); + assert_eq!(last.1, 1_000); + } + + #[tokio::test(start_paused = true)] + async fn encoding_sink_applies_backpressure() { + let (mut sink, mut received) = sink(plain_csv(), 4); + let done = Arc::new(AtomicBool::new(false)); + let finished = done.clone(); + let running = paused_tasks().spawn_task(async move { + sink.accept(started(&["a"])).await; + sink.accept(ResultEvent::Rows(kib_rows(1_000))).await; + finished.store(true, Ordering::SeqCst); + }); + + tokio::time::sleep(Duration::from_secs(60)).await; + assert_eq!(received.len(), 4, "the sink did not fill the channel"); + assert!(!done.load(Ordering::SeqCst), "the sink ran ahead of the writer"); + + received.recv().await.expect("a chunk"); + tokio::time::sleep(Duration::from_secs(60)).await; + assert_eq!(received.len(), 4, "a freed slot was not filled"); + assert!(!done.load(Ordering::SeqCst)); + + while !done.load(Ordering::SeqCst) { + received.recv().await.expect("a chunk"); + tokio::time::sleep(Duration::from_millis(1)).await; + } + running.await.expect("the sink's task"); + } + + #[tokio::test(start_paused = true)] + async fn encoding_sink_builds_encoder_from_stream_columns() { + let (mut sink, mut received) = sink(RowFormat::Csv(CsvOptions::default()), 4); + + sink.accept(started(&["x", "y"])).await; + sink.accept(ResultEvent::Rows(vec![vec![Value::Int(1), Value::Int(2)]])) + .await; + sink.accept(finished()).await; + drop(sink); + let (chunks, finished) = drain(&mut received).await; + + assert_eq!(finished, Some(1)); + let text: Vec = chunks.into_iter().flat_map(|(bytes, _)| bytes).collect(); + assert_eq!(String::from_utf8(text).expect("utf-8"), "\"x\",\"y\"\n\"1\",\"2\"\n"); + } + + #[tokio::test(start_paused = true)] + async fn encoding_sink_rejects_second_result_set() { + let (mut sink, mut received) = sink(RowFormat::Json, 4); + + sink.accept(started(&["a"])).await; + sink.accept(finished()).await; + let flow = sink.accept(started(&["b"])).await; + + assert_eq!(flow, SinkFlow::ConsumerGone); + drop(sink); + let mut failed = false; + while let Some(message) = received.recv().await { + failed |= matches!(message, ExportMessage::Failed(ExportStreamFailure::MultipleResultSets)); + } + assert!(failed, "no failure was sent"); + } +} diff --git a/linux/crates/app/src/ui/export/export_failure_text.rs b/linux/crates/app/src/ui/export/export_failure_text.rs new file mode 100644 index 0000000000..82deeec549 --- /dev/null +++ b/linux/crates/app/src/ui/export/export_failure_text.rs @@ -0,0 +1,382 @@ +use std::rc::Rc; + +use gtk4::prelude::*; +use gtk4::{self as gtk, gio, glib}; +use libadwaita as adw; +use libadwaita::prelude::*; +use tablepro_core::ErrorCategory; +use tablepro_core::export::{EncodeError, RenderError}; +use tablepro_session::OperationError; +use tablepro_session::runtime::TaskFailure; + +use super::{AbortOutcome, ExpectedRows, ExportFailure, ExportOutcome, ExportStreamFailure}; +use crate::i18n::{gettext, gettext_f, ngettext_f}; +use crate::ui::error_text::operation_error_message; +use crate::ui::file_reveal::{FileRevealer, reveal_failure_toast}; +use crate::ui::format::{format_thousands, plural_count}; + +pub(crate) fn exported_text(rows: u64, name: &str) -> String { + ngettext_f( + "Exported {rows} row to {name}", + "Exported {rows} rows to {name}", + plural_count(rows), + &[("rows", &format_thousands(rows)), ("name", name)], + ) +} + +pub(crate) fn stopped_text(outcome: AbortOutcome, name: &str) -> String { + match outcome { + AbortOutcome::Removed | AbortOutcome::Untouched => gettext("Export stopped"), + AbortOutcome::Incomplete => gettext_f("Export stopped. {name} is incomplete.", &[("name", name)]), + } +} + +/// How far a running export has got, for its row in the operations +/// popover. +pub(crate) fn progress_text(rows: u64, expected: Option) -> String { + let count = format_thousands(rows); + match expected { + Some(ExpectedRows::Exact(total)) => ngettext_f( + "{rows} of {total} row", + "{rows} of {total} rows", + plural_count(total), + &[("rows", &count), ("total", &format_thousands(total))], + ), + Some(ExpectedRows::About(total)) => ngettext_f( + "{rows} of about {total} row", + "{rows} of about {total} rows", + plural_count(total), + &[("rows", &count), ("total", &format_thousands(total))], + ), + None => ngettext_f("{rows} row", "{rows} rows", plural_count(rows), &[("rows", &count)]), + } +} + +/// Why an export failed, as the alert says it, and what it left of the +/// file. +pub(crate) fn failure_text(failure: &ExportFailure, file: Option, name: &str) -> String { + let reason = match failure { + ExportFailure::Stream(ExportStreamFailure::Encode(error)) => encode_text(error), + ExportFailure::Stream(ExportStreamFailure::MultipleResultSets) => { + gettext("This statement returned more than one result, so it can't be exported as one file.") + } + ExportFailure::Stream(ExportStreamFailure::NoResultSet) => { + gettext("This statement returned no rows to export.") + } + ExportFailure::Stream(ExportStreamFailure::Document(error)) => document_text(error), + ExportFailure::Stream(ExportStreamFailure::SheetFull { limit }) => gettext_f( + "An Excel sheet holds {limit} rows, and this result has more. Export it as CSV instead.", + &[("limit", &format_thousands(*limit as u64))], + ), + ExportFailure::Table(error) => operation_error_message(error), + ExportFailure::Statement(error) => rerun_text(error), + ExportFailure::File(error) => gettext_f( + "Writing {name} failed: {error}", + &[("name", name), ("error", error.message())], + ), + ExportFailure::InUse => gettext_f( + "An export to {name} is already running. Wait for it to finish, or export to another file.", + &[("name", name)], + ), + ExportFailure::Task(TaskFailure::Panicked(_) | TaskFailure::Aborted) + | ExportFailure::Stream(ExportStreamFailure::Task(_)) => gettext("The export stopped unexpectedly."), + }; + match file { + Some(AbortOutcome::Incomplete) => { + format!("{reason}\n\n{}", gettext_f("{name} is incomplete.", &[("name", name)])) + } + _ => reason, + } +} + +/// Why Export All's run of the statement failed. It ran on a connection +/// of its own, read-only, which is behind most of the ways it can fail +/// where the editor's own run did not. +fn rerun_text(error: &OperationError) -> String { + match error.category() { + Some(ErrorCategory::ReadOnly) => { + gettext("Export All only runs statements that read data. This statement changes data.") + } + Some(ErrorCategory::Server) => format!( + "{}\n\n{}", + gettext( + "Export All runs the statement again on a separate connection, so temporary tables, session settings and uncommitted changes from this editor aren't available." + ), + operation_error_message(error) + ), + _ => operation_error_message(error), + } +} + +/// Why a value could not go into the file, whichever format refused it. +fn encode_text(error: &EncodeError) -> String { + match error { + EncodeError::Undecodable { column } => gettext_f( + "The column “{column}” holds a value TablePro couldn't read, so it can't be written to the file.", + &[("column", column)], + ), + EncodeError::NotXmlText { column } => gettext_f( + "The column “{column}” holds a control character XML can't carry. Export it as CSV or JSON instead.", + &[("column", column)], + ), + } +} + +fn document_text(error: &RenderError) -> String { + match error { + RenderError::Encode(error) => encode_text(error), + RenderError::TextTooLong { column, limit } => gettext_f( + "The column “{column}” holds a value longer than the {limit} characters an Excel cell holds. Export it as CSV instead.", + &[("column", column), ("limit", &format_thousands(*limit as u64))], + ), + RenderError::TooManyRows { got, limit } => gettext_f( + "An Excel sheet holds {limit} rows, and this result has {got}.", + &[ + ("limit", &format_thousands(*limit as u64)), + ("got", &format_thousands(*got as u64)), + ], + ), + RenderError::TooManyColumns { got, limit } => gettext_f( + "An Excel sheet holds {limit} columns, and this result has {got}.", + &[ + ("limit", &format_thousands(*limit as u64)), + ("got", &format_thousands(*got as u64)), + ], + ), + RenderError::Xlsx(_) | RenderError::Csv(_) | RenderError::Flush(_) | RenderError::Utf8(_) => { + gettext_f("The file couldn't be built: {error}", &[("error", &error.to_string())]) + } + } +} + +/// Where an export's outcome is told: a toast on the workspace, or an +/// alert over its window for a failure. +#[derive(Clone)] +pub(crate) struct ExportReporter { + parent: glib::WeakRef, + toasts: glib::WeakRef, + revealer: Rc, +} + +impl std::fmt::Debug for ExportReporter { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ExportReporter").finish_non_exhaustive() + } +} + +impl ExportReporter { + pub(crate) fn new( + parent: &impl IsA, + toasts: &adw::ToastOverlay, + revealer: Rc, + ) -> Self { + Self { + parent: parent.as_ref().downgrade(), + toasts: toasts.downgrade(), + revealer, + } + } + + pub(crate) fn report(&self, file: &gio::File, name: &str, outcome: &ExportOutcome) { + match outcome { + ExportOutcome::Exported { rows } => { + let toast = crate::ui::plain_toast(&exported_text(*rows, name)); + toast.set_button_label(Some(&gettext("Show in Files"))); + let (file, reporter) = (file.clone(), self.clone()); + toast.connect_button_clicked(move |_| reporter.show_in_files(&file)); + self.toast(toast); + } + ExportOutcome::Stopped(left) => self.toast(crate::ui::plain_toast(&stopped_text(*left, name))), + ExportOutcome::Failed { failure, file: left } => { + tracing::warn!(%failure, "an export failed"); + let Some(parent) = self.parent.upgrade() else { + return; + }; + let alert = adw::AlertDialog::new( + Some(&gettext("Couldn't Export")), + Some(&failure_text(failure, *left, name)), + ); + alert.add_response("close", &gettext("_Close")); + alert.set_default_response(Some("close")); + alert.set_close_response("close"); + alert.present(Some(&parent)); + } + } + } + + fn toast(&self, toast: adw::Toast) { + if let Some(toasts) = self.toasts.upgrade() { + toasts.add_toast(toast); + } + } + + /// A file manager that could not show the file is said in a toast, + /// as Preferences and History do. + pub(crate) fn show_in_files(&self, file: &gio::File) { + let window = self + .parent + .upgrade() + .and_then(|parent| parent.root()) + .and_downcast::(); + let revealing = self.revealer.reveal(file.clone(), window); + let reporter = self.clone(); + glib::spawn_future_local(async move { + if let Some(toast) = reveal_failure_toast(&revealing.await) { + reporter.toast(toast); + } + }); + } +} + +#[cfg(test)] +mod tests { + use tablepro_core::{DriverError, LossPhase, ReadOnlyRefusal}; + + use super::*; + + fn statement(error: DriverError) -> ExportFailure { + ExportFailure::Statement(OperationError::Driver(error)) + } + + #[test] + fn editor_rerun_failure_text_per_category() { + let missing = || DriverError::server("relation \"scratch\" does not exist"); + let read_only = failure_text( + &statement(DriverError::ReadOnly(ReadOnlyRefusal::ClientGuard)), + None, + "rows.csv", + ); + let server = failure_text(&statement(missing()), None, "rows.csv"); + let lost = failure_text( + &statement(DriverError::ConnectionLost { + during: LossPhase::Statement, + }), + None, + "rows.csv", + ); + let table = failure_text( + &ExportFailure::Table(OperationError::Driver(missing())), + None, + "rows.csv", + ); + let multiple = failure_text( + &ExportFailure::Stream(ExportStreamFailure::MultipleResultSets), + None, + "rows.csv", + ); + + assert_eq!( + read_only, + "Export All only runs statements that read data. This statement changes data." + ); + let (why, said) = server + .split_once("\n\n") + .expect("a blank line before the server's words"); + assert_eq!( + why, + "Export All runs the statement again on a separate connection, so temporary tables, session settings and uncommitted changes from this editor aren't available." + ); + assert_eq!(said, "Query failed: relation \"scratch\" does not exist"); + assert_eq!(lost, "The connection was closed. Try reconnecting."); + assert_eq!(table, said, "a table read has no statement to explain"); + assert_eq!( + multiple, + "This statement returned more than one result, so it can't be exported as one file." + ); + } + + #[gtk4::test] + fn a_failed_show_in_files_is_said_in_a_toast() { + adw::init().expect("libadwaita initialises under the test backend"); + let toasts = adw::ToastOverlay::new(); + let window = adw::Window::new(); + window.set_content(Some(&toasts)); + let window = crate::test_support::OwnedWindow::presented(window); + let reporter = ExportReporter::new( + &*window, + &toasts, + Rc::new(crate::ui::file_reveal::RecordingRevealer::failing()), + ); + + reporter.show_in_files(&gio::File::for_path("/tmp/rows.csv")); + + crate::test_support::wait_until(std::time::Duration::from_secs(10), || { + crate::test_support::descendants(&toasts) + .iter() + .filter_map(|widget| widget.downcast_ref::()) + .any(|label| label.label() == "Could not show the file in Files") + }) + .expect("a toast said the file could not be shown"); + } + + #[test] + fn progress_against_an_estimate_says_about() { + assert_eq!( + progress_text(12, Some(ExpectedRows::About(500))), + "12 of about 500 rows" + ); + assert_eq!(progress_text(12, Some(ExpectedRows::Exact(500))), "12 of 500 rows"); + assert_eq!(progress_text(1, None), "1 row"); + } + + #[test] + fn a_full_sheet_says_how_many_rows_it_holds() { + let text = failure_text( + &ExportFailure::Stream(ExportStreamFailure::SheetFull { limit: 100 }), + Some(AbortOutcome::Removed), + "rows.xlsx", + ); + + assert_eq!( + text, + "An Excel sheet holds 100 rows, and this result has more. Export it as CSV instead." + ); + } + + fn document(error: RenderError) -> String { + failure_text( + &ExportFailure::Stream(ExportStreamFailure::Document(error)), + None, + "rows.xlsx", + ) + } + + #[test] + fn a_sheet_names_the_column_it_could_not_write() { + let unreadable = document(RenderError::Encode(EncodeError::Undecodable { + column: "body".to_owned(), + })); + let long = document(RenderError::TextTooLong { + column: "body".to_owned(), + limit: 32_767, + }); + + assert_eq!( + unreadable, + "The column “body” holds a value TablePro couldn't read, so it can't be written to the file." + ); + assert_eq!( + long, + format!( + "The column “body” holds a value longer than the {} characters an Excel cell holds. Export it as CSV instead.", + format_thousands(32_767) + ) + ); + } + + #[test] + fn xml_names_the_column_with_a_control_character() { + let text = failure_text( + &ExportFailure::Stream(ExportStreamFailure::Encode(EncodeError::NotXmlText { + column: "note".to_owned(), + })), + None, + "rows.xml", + ); + + assert_eq!( + text, + "The column “note” holds a control character XML can't carry. Export it as CSV or JSON instead." + ); + } +} diff --git a/linux/crates/app/src/ui/export/export_format.rs b/linux/crates/app/src/ui/export/export_format.rs new file mode 100644 index 0000000000..2696261d8a --- /dev/null +++ b/linux/crates/app/src/ui/export/export_format.rs @@ -0,0 +1,101 @@ +use std::sync::Arc; + +use tablepro_core::Value; +use tablepro_core::column::ResultColumn; +use tablepro_core::dialect::SqlDialect; +use tablepro_core::export::{ + self, CsvEncoder, CsvOptions, HtmlEncoder, JsonArrayEncoder, MarkdownEncoder, RenderError, RowEncoder, + SqlInsertEncoder, XlsxSheetWriter, XmlEncoder, +}; +use tablepro_core::meta::TableRef; + +/// What an export writes. +#[derive(Debug, Clone)] +pub(crate) enum ExportFormat { + /// Encoded a row at a time, as the rows arrive. + Rows(RowFormat), + /// Written on the blocking pool as the rows arrive, and handed on + /// whole once the last is in: a workbook is a zip archive. + Document(DocumentFormat), +} + +#[derive(Debug, Clone)] +pub(crate) enum RowFormat { + Csv(CsvOptions), + Json, + Markdown, + Html { title: String }, + Xml, + Sql(InsertTarget), +} + +impl RowFormat { + /// An encoder for rows under `columns`, the ones the stream itself + /// announced. + pub(crate) fn encoder(&self, columns: Arc<[ResultColumn]>) -> Box { + match self { + Self::Csv(options) => Box::new(CsvEncoder::new(columns, options.clone())), + Self::Json => Box::new(JsonArrayEncoder::new(columns)), + Self::Markdown => Box::new(MarkdownEncoder::new(columns)), + Self::Html { title } => Box::new(HtmlEncoder::new(columns, title.clone())), + Self::Xml => Box::new(XmlEncoder::new(columns)), + Self::Sql(target) => Box::new(SqlInsertEncoder::new( + target.dialect.clone(), + &target.table, + columns, + &target.generated, + )), + } + } +} + +#[derive(Debug, Clone)] +pub(crate) enum DocumentFormat { + Xlsx { sheet: String }, +} + +impl DocumentFormat { + /// The most rows the document can hold. + pub(crate) fn row_limit(&self) -> usize { + match self { + Self::Xlsx { .. } => export::XLSX_MAX_DATA_ROWS, + } + } + + /// A document for rows under `columns`, written on the blocking + /// pool. + pub(crate) fn start(&self, columns: Arc<[ResultColumn]>) -> Result { + match self { + Self::Xlsx { sheet } => XlsxSheetWriter::new(columns, sheet).map(DocumentWriter::Xlsx), + } + } +} + +/// A document part way through, which takes rows a batch at a time. +pub(crate) enum DocumentWriter { + Xlsx(XlsxSheetWriter), +} + +impl DocumentWriter { + pub(crate) fn write_rows(&mut self, rows: &[Vec]) -> Result<(), RenderError> { + match self { + Self::Xlsx(writer) => writer.write_rows(rows), + } + } + + pub(crate) fn finish(self) -> Result, RenderError> { + match self { + Self::Xlsx(writer) => writer.finish(), + } + } +} + +/// A table to insert into and the spelling of the server the rows came +/// from. SQL is offered only with both. +#[derive(Debug, Clone)] +pub(crate) struct InsertTarget { + pub table: TableRef, + /// The columns the server computes, which the statements leave out. + pub generated: Arc<[String]>, + pub dialect: Arc, +} diff --git a/linux/crates/app/src/ui/export/export_job.rs b/linux/crates/app/src/ui/export/export_job.rs new file mode 100644 index 0000000000..dc6e8a64c7 --- /dev/null +++ b/linux/crates/app/src/ui/export/export_job.rs @@ -0,0 +1,42 @@ +use gtk4::gio; +use uuid::Uuid; + +use super::{ExportFormat, ExportSource}; + +/// One export, as the dialog hands it to the ledger. +#[derive(Debug)] +pub(crate) struct ExportJob { + pub destination: gio::File, + pub format: ExportFormat, + pub source: ExportSource, + pub expected_rows: Option, + /// The tab the rows came from, which hears how the export ended. + pub tab: Option, +} + +/// Names a running export in its ledger. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub(crate) struct ExportJobId(u64); + +impl ExportJobId { + pub(crate) fn new(id: u64) -> Self { + Self(id) + } +} + +/// How many rows an export will write, as far as anyone knows before it +/// does. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ExpectedRows { + Exact(u64), + /// The server's estimate, which the export may pass. + About(u64), +} + +impl ExpectedRows { + pub(crate) fn total(self) -> u64 { + match self { + Self::Exact(total) | Self::About(total) => total, + } + } +} diff --git a/linux/crates/app/src/ui/export/export_job_item.rs b/linux/crates/app/src/ui/export/export_job_item.rs new file mode 100644 index 0000000000..702d0e860d --- /dev/null +++ b/linux/crates/app/src/ui/export/export_job_item.rs @@ -0,0 +1,64 @@ +use std::cell::{Cell, OnceCell, RefCell}; + +use gtk4::glib; +use gtk4::prelude::*; +use gtk4::subclass::prelude::*; +use tokio_util::sync::CancellationToken; + +use super::ExpectedRows; + +mod imp { + use super::*; + + #[derive(Default, glib::Properties)] + #[properties(wrapper_type = super::ExportJobItem)] + pub struct ExportJobItem { + /// The file's name as the file manager shows it. + #[property(get, set)] + pub title: RefCell, + /// Rows written to the file so far. + #[property(get, set)] + pub rows: Cell, + pub expected: Cell>, + pub cancel: OnceCell, + } + + #[glib::object_subclass] + impl ObjectSubclass for ExportJobItem { + const NAME: &'static str = "TableProExportJobItem"; + type Type = super::ExportJobItem; + } + + #[glib::derived_properties] + impl ObjectImpl for ExportJobItem {} +} + +glib::wrapper! { + /// A running export as the operations popover lists it. + pub struct ExportJobItem(ObjectSubclass); +} + +impl ExportJobItem { + pub(crate) fn new(title: &str, expected: Option, cancel: CancellationToken) -> Self { + let item: Self = glib::Object::builder().property("title", title).build(); + item.imp().expected.set(expected); + let _ = item.imp().cancel.set(cancel); + item + } + + pub(crate) fn expected(&self) -> Option { + self.imp().expected.get() + } + + /// The share of the rows written, while the total is known. + pub(crate) fn fraction(&self) -> Option { + let total = self.expected()?.total(); + (total > 0).then(|| (self.rows() as f64 / total as f64).min(1.0)) + } + + pub(crate) fn stop(&self) { + if let Some(cancel) = self.imp().cancel.get() { + cancel.cancel(); + } + } +} diff --git a/linux/crates/app/src/ui/export/export_ledger/mod.rs b/linux/crates/app/src/ui/export/export_ledger/mod.rs new file mode 100644 index 0000000000..8840c8c9bc --- /dev/null +++ b/linux/crates/app/src/ui/export/export_ledger/mod.rs @@ -0,0 +1,438 @@ +use std::cell::RefCell; +use std::collections::BTreeMap; +use std::future::Future; +use std::pin::pin; +use std::rc::Rc; + +use futures::channel::oneshot; +use futures::future::{Either, LocalBoxFuture, select}; +use gtk4::prelude::*; +use gtk4::{gio, glib}; +use tablepro_core::StreamSummary; +use tablepro_session::OperationError; +use tablepro_session::runtime::{HoldToken, ProcessHold, TaskResult, Tasks}; +use tokio::sync::mpsc; +use tokio_util::sync::CancellationToken; +use uuid::Uuid; + +use super::export_producer::{start_query_producer, start_rows_producer}; +use super::{ + ExportFailure, ExportJob, ExportJobId, ExportJobItem, ExportMessage, ExportOutcome, ExportQuery, ExportReporter, + ExportSinkParts, ExportSource, ExportStreamFailure, FinishError, ReplaceFileWriter, +}; +use crate::i18n::{gettext, ngettext_f}; +use crate::ui::format::plural_count; +use crate::workspace::{CloseBlockPrompt, CloseBlocker, InhibitReason, WindowInhibitor, WorkOutcome}; + +/// Chunks in flight between the encoder and the file. Few, so a slow +/// disk holds the encoder back within a few hundred kilobytes. +const CHANNEL_CHUNKS: usize = 4; + +type EndedListener = Rc; + +/// The exports a workspace has running. +/// +/// Each one holds the process, so quitting while one writes still lets +/// it finish or clean up, and while any runs the session is asked not +/// to log out or suspend under it. +pub(crate) struct ExportLedger { + tasks: Tasks, + hold: Rc, + reporter: ExportReporter, + inhibitor: RefCell, + /// The running jobs, as the operations popover lists them. + jobs: gio::ListStore, + state: RefCell, + ended: RefCell>, +} + +#[derive(Default)] +struct LedgerState { + next: u64, + running: BTreeMap, + waiters: Vec>, + /// The workspace shut down: its close already asked about the + /// exports, and a new one would run with no window to report to. + shut_down: bool, +} + +struct Running { + cancel: CancellationToken, + _hold: HoldToken, +} + +pub(crate) struct ExportLedgerInit { + pub tasks: Tasks, + pub hold: Rc, + pub reporter: ExportReporter, + pub inhibitor: WindowInhibitor, +} + +/// A job the ledger started. Dropping it leaves the job running, and +/// the ledger reports how it ended either way. +pub(crate) struct ExportJobHandle { + outcome: oneshot::Receiver, +} + +impl ExportJobHandle { + /// How the job ended. `None` when the ledger went before it did. + #[cfg_attr( + not(test), + expect(dead_code, reason = "the app hears the outcome through the ledger's toast or alert") + )] + pub(crate) async fn outcome(self) -> Option { + self.outcome.await.ok() + } +} + +impl ExportLedger { + pub(crate) fn new(init: ExportLedgerInit) -> Rc { + Rc::new(Self { + tasks: init.tasks, + hold: init.hold, + reporter: init.reporter, + inhibitor: RefCell::new(init.inhibitor), + jobs: gio::ListStore::new::(), + state: RefCell::default(), + ended: RefCell::default(), + }) + } + + /// Call `listener` with the tab and the outcome of each job that + /// came from a tab, once the job has settled. + pub(crate) fn on_ended(&self, listener: impl Fn(Uuid, WorkOutcome) + 'static) { + self.ended.borrow_mut().push(Rc::new(listener)); + } + + pub(crate) fn jobs(&self) -> gio::ListStore { + self.jobs.clone() + } + + /// Start `job`, unless the workspace has shut down. A file chooser + /// still open when the window closed can hand one over after that. + pub(crate) fn begin(self: &Rc, job: ExportJob) -> Option { + if self.state.borrow().shut_down { + tracing::debug!("an export began after its workspace shut down, and was not started"); + return None; + } + let cancel = CancellationToken::new(); + let name = file_name(&job.destination); + let item = ExportJobItem::new(&name, job.expected_rows, cancel.clone()); + let id = { + let mut state = self.state.borrow_mut(); + state.next += 1; + let id = ExportJobId::new(state.next); + state.running.insert( + id, + Running { + cancel: cancel.clone(), + _hold: self.hold.acquire(), + }, + ); + id + }; + self.jobs.append(&item); + self.refresh_inhibitor(); + + let (reply, outcome) = oneshot::channel(); + let ledger = self.clone(); + glib::spawn_future_local(async move { + let destination = job.destination.clone(); + let tab = job.tab; + let ended = run(&ledger.tasks, job, &cancel, &item).await; + ledger.settle(id, &item); + ledger.reporter.report(&destination, &item.title(), &ended); + if let Some(tab) = tab { + ledger.tell_ended(tab, WorkOutcome::from(&ended)); + } + let _ = reply.send(ended); + }); + Some(ExportJobHandle { outcome }) + } + + pub(crate) fn in_flight(&self) -> usize { + self.state.borrow().running.len() + } + + /// Resolves once no export is running. + pub(crate) fn when_idle(&self) -> impl Future + use<> { + let receiver = if self.in_flight() == 0 { + None + } else { + let (sender, receiver) = oneshot::channel(); + self.state.borrow_mut().waiters.push(sender); + Some(receiver) + }; + async move { + if let Some(receiver) = receiver { + // A ledger dropped with jobs running wakes nobody, and a + // waiter that outlived it has nothing left to wait on. + let _ = receiver.await; + } + } + } + + /// Stop every running export. Each settles once its file is cleaned + /// up. + pub(crate) fn cancel_all(&self) { + for running in self.state.borrow().running.values() { + running.cancel.cancel(); + } + } + + /// Stop every running export and refuse new ones, for a workspace + /// that is going away. + pub(crate) fn shut_down(&self) { + self.state.borrow_mut().shut_down = true; + self.cancel_all(); + } + + fn settle(&self, id: ExportJobId, item: &ExportJobItem) { + let (finished, woken) = { + let mut state = self.state.borrow_mut(); + let finished = state.running.remove(&id); + let woken = if state.running.is_empty() { + std::mem::take(&mut state.waiters) + } else { + Vec::new() + }; + (finished, woken) + }; + if let Some(position) = self.jobs.find(item) { + self.jobs.remove(position); + } + self.refresh_inhibitor(); + // The hold goes only after the state is consistent, in case + // dropping it ends the main loop. + drop(finished); + for waiter in woken { + let _ = waiter.send(()); + } + } + + fn tell_ended(&self, tab: Uuid, outcome: WorkOutcome) { + let listeners: Vec = self.ended.borrow().clone(); + for listener in listeners { + listener(tab, outcome); + } + } + + fn refresh_inhibitor(&self) { + let reason = (self.in_flight() > 0).then_some(InhibitReason::Exporting); + self.inhibitor.borrow_mut().update(reason); + } + + /// Whether the session is held back for the running exports. + pub(crate) fn is_inhibiting(&self) -> bool { + self.inhibitor.borrow().is_held() + } +} + +impl CloseBlocker for ExportLedger { + fn blocking(&self) -> Option { + let running = self.in_flight(); + (running > 0).then(|| CloseBlockPrompt { + heading: gettext("Stop Exports?"), + body: ngettext_f( + "{n} export is still running. Stopping it leaves the file unfinished.", + "{n} exports are still running. Stopping them leaves the files unfinished.", + plural_count(running as u64), + &[("n", &running.to_string())], + ), + keep_label: gettext("_Keep Exporting"), + stop_label: gettext("_Stop Exports"), + }) + } + + fn stop(&self) -> LocalBoxFuture<'static, ()> { + self.cancel_all(); + Box::pin(self.when_idle()) + } +} + +impl std::fmt::Debug for ExportLedger { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ExportLedger") + .field("in_flight", &self.in_flight()) + .finish_non_exhaustive() + } +} + +/// The file's name as it is shown until GIO answers with the one the +/// file manager uses. +fn file_name(file: &gio::File) -> String { + file.basename() + .map(|name| name.to_string_lossy().into_owned()) + .unwrap_or_else(|| file.parse_name().to_string()) +} + +/// Asked once the file exists: a file not yet made has no display name. +async fn show_display_name(file: &gio::File, item: &ExportJobItem) { + let info = file + .query_info_future( + gio::FILE_ATTRIBUTE_STANDARD_DISPLAY_NAME, + gio::FileQueryInfoFlags::NONE, + glib::Priority::DEFAULT, + ) + .await; + match info { + Ok(info) => item.set_title(info.display_name()), + Err(error) => tracing::debug!(%error, "an export's file has no display name"), + } +} + +/// Write the job's file: open it, start the producer, and take its +/// chunks until it finishes, fails or the job is stopped. +async fn run(tasks: &Tasks, job: ExportJob, cancel: &CancellationToken, item: &ExportJobItem) -> ExportOutcome { + let writer = match ReplaceFileWriter::open(&job.destination).await { + Ok(writer) => writer, + Err(error) => { + return ExportOutcome::Failed { + failure: error.into(), + file: None, + }; + } + }; + show_display_name(&job.destination, item).await; + if cancel.is_cancelled() { + return ExportOutcome::Stopped(writer.abort().await); + } + let (messages, mut received) = mpsc::channel(CHANNEL_CHUNKS); + let parts = ExportSinkParts { + messages, + format: job.format, + tasks: tasks.clone(), + }; + let producer = Producer::start(tasks, job.source, parts, cancel); + loop { + let Some(next) = until_cancelled(cancel, received.recv()).await else { + return ExportOutcome::Stopped(writer.abort().await); + }; + match next { + Some(ExportMessage::Chunk { bytes, rows }) => { + if let Err(error) = writer.write(bytes).await { + cancel.cancel(); + return failed(writer, error.into()).await; + } + item.set_rows(rows); + } + Some(ExportMessage::Progress { rows }) => item.set_rows(rows), + Some(ExportMessage::Failed(failure)) => { + cancel.cancel(); + return failed(writer, failure.into()).await; + } + Some(ExportMessage::Finished { rows }) => { + // A read can still fail once its rows are in, as a + // statement that writes after it reads does, and the + // file is kept only for a read that ended well. + let Some(ended) = until_cancelled(cancel, producer.finish()).await else { + return ExportOutcome::Stopped(writer.abort().await); + }; + // A sink that stopped the read on a second result set + // says so here, and a driver may still end that read + // Ok, as SQL Server's does. + if let Some(failure) = sent_failure(&mut received) { + return failed(writer, failure).await; + } + if let Err(failure) = ended { + return failed(writer, failure).await; + } + return match writer.finish().await { + Ok(()) => ExportOutcome::Exported { rows }, + Err(FinishError { error, file }) => ExportOutcome::Failed { + failure: error.into(), + file: Some(file), + }, + }; + } + // The producer let go of its sender without finishing: + // stopped by the cancel, which the next turn sees, or ended + // by a failure or a panic. + None => { + if cancel.is_cancelled() { + return ExportOutcome::Stopped(writer.abort().await); + } + let failure = match producer.finish().await { + Ok(()) => ExportStreamFailure::NoResultSet.into(), + Err(failure) => failure, + }; + return failed(writer, failure).await; + } + } + } +} + +/// `work`'s output, or `None` once the job is stopped. +async fn until_cancelled(cancel: &CancellationToken, work: impl Future) -> Option { + let stopped = pin!(cancel.cancelled()); + let work = pin!(work); + match select(stopped, work).await { + Either::Left(((), _)) => None, + Either::Right((output, _)) => Some(output), + } +} + +/// The failure a sink sent after its result finished. It says more than +/// the read's own end: a second result set stops the read, which then +/// reports only that it was stopped, or nothing wrong at all. +fn sent_failure(received: &mut mpsc::Receiver) -> Option { + while let Ok(message) = received.try_recv() { + if let ExportMessage::Failed(failure) = message { + return Some(failure.into()); + } + } + None +} + +async fn failed(writer: ReplaceFileWriter, failure: ExportFailure) -> ExportOutcome { + ExportOutcome::Failed { + failure, + file: Some(writer.abort().await), + } +} + +/// What feeds a job's sink: rows a grid already holds, or a read +/// streamed from the server. +enum Producer { + Rows(TaskResult<()>), + Query { + task: TaskResult>, + rerun: bool, + }, +} + +impl Producer { + /// Neither kind is aborted when the job stops. The cancel reaches + /// both: the rows producer checks it between batches, and a read + /// stops its statement on the server, which an abort would leave + /// running behind a dropped future. + fn start(tasks: &Tasks, source: ExportSource, parts: ExportSinkParts, cancel: &CancellationToken) -> Self { + match source { + ExportSource::Rows { columns, rows } => { + Self::Rows(start_rows_producer(tasks, columns, rows, parts, cancel.clone())) + } + ExportSource::Query { session, query } => { + let rerun = matches!(query, ExportQuery::Editor { .. }); + Self::Query { + task: start_query_producer(tasks, session, query, parts, cancel.clone()), + rerun, + } + } + } + } + + async fn finish(self) -> Result<(), ExportFailure> { + match self { + Self::Rows(task) => task.await.map_err(ExportFailure::from), + Self::Query { task, rerun } => match task.await { + Ok(Ok(_)) => Ok(()), + Ok(Err(error)) if rerun => Err(ExportFailure::Statement(error)), + Ok(Err(error)) => Err(ExportFailure::Table(error)), + Err(failure) => Err(failure.into()), + }, + } + } +} + +#[cfg(test)] +mod tests; diff --git a/linux/crates/app/src/ui/export/export_ledger/tests.rs b/linux/crates/app/src/ui/export/export_ledger/tests.rs new file mode 100644 index 0000000000..da884b4be7 --- /dev/null +++ b/linux/crates/app/src/ui/export/export_ledger/tests.rs @@ -0,0 +1,782 @@ +use std::cell::{Cell, RefCell}; +use std::path::Path; +use std::sync::Arc; +use std::time::Duration; + +use gtk4 as gtk; +use libadwaita as adw; +use libadwaita::prelude::*; +use tablepro_core::column::{ColumnDefault, ColumnType, ResultColumn}; +use tablepro_core::dialect::SqlDialect; +use tablepro_core::export::CsvOptions; +use tablepro_core::meta::{EngineRowId, RowIdentity, TableRef}; +use tablepro_core::{ + ColumnInfo, DriverError, ErrorCategory, FilterSet, ReadOnlyRefusal, ResultEvent, ServerDiagnostics, ServerInfo, + Truncation, UserSql, Value, +}; +use tablepro_session::runtime::CountingHold; +use tablepro_test_support::{ConnectionScript, FakeReply, StreamEnd}; + +use super::*; +use crate::test_support::{FakeSession, descendants, finish_on_main, first_descendant_of_type, wait_until}; +use crate::ui::export::{AbortOutcome, ExpectedRows, ExportFormat, ExportOperationsButton, RowFormat, TableExport}; +use crate::ui::grid::RowSnapshot; +use crate::workspace::{InhibitTarget, WorkOutcome, answer, visible_alert}; + +const WAIT: Duration = Duration::from_secs(20); + +/// Hands out cookies, and keeps the ones still held. +#[derive(Clone, Default)] +struct Session { + held: Rc>>, + next: Rc>, +} + +impl InhibitTarget for Session { + fn inhibit(&self, flags: gtk::ApplicationInhibitFlags, _reason: &str) -> u32 { + self.next.set(self.next.get() + 1); + self.held.borrow_mut().push((self.next.get(), flags)); + self.next.get() + } + + fn uninhibit(&self, cookie: u32) { + self.held.borrow_mut().retain(|(held, _)| *held != cookie); + } +} + +struct Harness { + window: adw::Window, + ledger: Rc, + session: Session, + hold: Rc, + dir: tempfile::TempDir, + runtime: Option, +} + +impl Harness { + fn new() -> Self { + adw::init().expect("libadwaita initialises under the test backend"); + crate::register_test_resources(); + if let Some(settings) = gtk::Settings::default() { + settings.set_gtk_enable_animations(false); + } + let window = adw::Window::new(); + let toasts = adw::ToastOverlay::new(); + window.set_content(Some(&toasts)); + window.present(); + let runtime = crate::test_support::test_runtime(); + let session = Session::default(); + let hold = Rc::new(CountingHold::new()); + let ledger = ExportLedger::new(ExportLedgerInit { + tasks: runtime.tasks(), + hold: hold.clone(), + reporter: ExportReporter::new( + &window, + &toasts, + Rc::new(crate::ui::file_reveal::RecordingRevealer::succeeding()), + ), + inhibitor: WindowInhibitor::new(session.clone()), + }); + Self { + window, + ledger, + session, + hold, + dir: tempfile::tempdir().expect("tempdir"), + runtime: Some(runtime), + } + } + + fn path(&self, name: &str) -> std::path::PathBuf { + self.dir.path().join(name) + } + + /// A session whose reads stream `events`. + fn session(&self, events: FakeReply>, dialect: Option>) -> FakeSession { + self.scripted(events, |script| { + if let Some(dialect) = dialect { + script.dialect = dialect; + } + }) + } + + fn scripted(&self, events: FakeReply>, adjust: impl FnOnce(&mut ConnectionScript)) -> FakeSession { + let mut script = ConnectionScript::default(); + script.probe.always(FakeReply::ok(())); + script.stream_read.always(events); + adjust(&mut script); + let tasks = self.runtime.as_ref().expect("the runtime").tasks(); + FakeSession::start(script, [], tasks) + } + + fn finish(&self, handle: ExportJobHandle) -> ExportOutcome { + finish_on_main(WAIT, handle.outcome()) + .expect("the export to end") + .expect("an outcome") + } +} + +impl Drop for Harness { + fn drop(&mut self) { + crate::test_support::drop_focus(&self.window); + self.window.destroy(); + crate::test_support::drain_main_context(); + if let Some(runtime) = self.runtime.take() { + runtime.shutdown(); + } + } +} + +fn job(path: &Path, count: usize) -> ExportJob { + let columns: Arc<[ResultColumn]> = ["id", "name", "note"] + .iter() + .map(|name| ResultColumn::new(*name, ColumnType::untyped_text("text"))) + .collect(); + let rows = (0..count) + .map(|row| { + RowSnapshot::new( + vec![ + Value::Int(row as i64), + Value::Text(format!("name {row}")), + Value::Text("a note of some length".to_owned()), + ] + .into(), + Vec::new(), + ) + }) + .collect(); + ExportJob { + destination: gio::File::for_path(path), + format: ExportFormat::Rows(RowFormat::Csv(CsvOptions::default())), + source: ExportSource::Rows { columns, rows }, + expected_rows: Some(ExpectedRows::Exact(count as u64)), + tab: None, + } +} + +fn table_column(name: &str, primary_key: bool) -> ColumnInfo { + ColumnInfo { + name: name.to_owned(), + column_type: ColumnType::untyped_text("text"), + nullable: !primary_key, + primary_key, + is_auto_increment: false, + is_generated: false, + default: ColumnDefault::None, + comment: None, + } +} + +fn users(identity: RowIdentity) -> ExportQuery { + ExportQuery::Table(TableExport { + table: TableRef::new(None, "users"), + columns: vec![table_column("id", true), table_column("name", false)].into(), + filter: FilterSet::default(), + sort: Vec::new(), + identity, + }) +} + +fn by_id() -> RowIdentity { + RowIdentity::UniqueKey { + columns: vec!["id".to_owned()], + } +} + +fn started(names: &[&str]) -> ResultEvent { + ResultEvent::ResultSetStarted { + columns: names + .iter() + .map(|name| ResultColumn::new(*name, ColumnType::untyped_text("text"))) + .collect(), + } +} + +fn finished(rows: u64) -> ResultEvent { + ResultEvent::ResultSetFinished { + retained: rows, + total_rows: Some(rows), + truncation: Truncation::Complete, + } +} + +/// `batches` batches of `size` rows each, numbered from zero. +fn streamed(batches: usize, size: usize) -> Vec { + let mut events = vec![started(&["id", "name"])]; + for batch in 0..batches { + let rows = (0..size) + .map(|row| { + let id = (batch * size + row) as i64; + vec![Value::Int(id), Value::Text(format!("user {id}"))] + }) + .collect(); + events.push(ResultEvent::Rows(rows)); + } + events.push(finished((batches * size) as u64)); + events +} + +fn read_job(path: &Path, session: &FakeSession, query: ExportQuery) -> ExportJob { + ExportJob { + destination: gio::File::for_path(path), + format: ExportFormat::Rows(RowFormat::Csv(CsvOptions::default())), + source: ExportSource::Query { + session: session.session.clone(), + query, + }, + expected_rows: None, + tab: None, + } +} + +#[gtk4::test] +fn entire_table_export_streams_every_row() { + let harness = Harness::new(); + let path = harness.path("users.csv"); + let fake = harness.session(FakeReply::ok(streamed(100, 250)), None); + + let outcome = harness.finish( + harness + .ledger + .begin(read_job(&path, &fake, users(by_id()))) + .expect("started"), + ); + + assert!( + matches!(outcome, ExportOutcome::Exported { rows: 25_000 }), + "{outcome:?}" + ); + let text = std::fs::read_to_string(&path).expect("the export"); + let lines: Vec<&str> = text.lines().collect(); + assert_eq!(lines.len(), 25_001); + assert_eq!(lines[0], "\"id\",\"name\""); + assert_eq!(lines[25_000], "\"24999\",\"user 24999\""); + assert_eq!( + fake.connection.observed_deadlines("stream_read"), + [None], + "an export ran under a deadline" + ); +} + +#[gtk4::test] +fn entire_table_export_has_no_identity_columns() { + let harness = Harness::new(); + let path = harness.path("users.csv"); + let dialect: Arc = Arc::new(tablepro_driver_postgres::PgDialect::new(&ServerInfo::new( + "PostgreSQL", + "16.4", + 16, + 4, + 0, + ))); + let fake = harness.session(FakeReply::ok(streamed(1, 2)), Some(dialect)); + let identity = RowIdentity::EngineRowId(EngineRowId::PostgresCtid); + + let outcome = harness.finish( + harness + .ledger + .begin(read_job(&path, &fake, users(identity))) + .expect("started"), + ); + + assert!(matches!(outcome, ExportOutcome::Exported { rows: 2 }), "{outcome:?}"); + let sql = fake.connection.observed_sql("stream_read"); + let [Some(sql)] = sql.as_slice() else { + panic!("one read, not {sql:?}"); + }; + let projection = sql + .strip_prefix("SELECT ") + .and_then(|rest| rest.split_once(" FROM ")) + .map(|(projection, _)| projection) + .expect("a projection"); + assert!(!projection.contains("ctid"), "the row id was projected: {sql}"); + let projected: Vec<&str> = projection.split(", ").collect(); + assert_eq!(projected.len(), 2, "{sql}"); + assert!( + projected[0].ends_with(r#""id""#) && projected[1].ends_with(r#""name""#), + "{sql}" + ); + let text = std::fs::read_to_string(&path).expect("the export"); + assert_eq!(text.lines().next(), Some("\"id\",\"name\"")); +} + +#[gtk4::test] +fn stopping_entire_table_export_cancels_stream() { + let harness = Harness::new(); + let path = harness.path("users.csv"); + std::fs::write(&path, "old").expect("the existing file"); + let fake = harness.session(FakeReply::UntilStopped, None); + let handle = harness + .ledger + .begin(read_job(&path, &fake, users(by_id()))) + .expect("started"); + wait_until(WAIT, || fake.connection.observed_cancels("stream_read").len() == 1).expect("the read to start"); + + harness.ledger.cancel_all(); + let outcome = harness.finish(handle); + + assert!( + matches!(outcome, ExportOutcome::Stopped(AbortOutcome::Untouched)), + "{outcome:?}" + ); + let cancel = fake.connection.observed_cancels("stream_read").remove(0); + wait_until(WAIT, || cancel.is_cancelled()).expect("the stop to reach the read"); + assert_eq!(std::fs::read_to_string(&path).expect("the file"), "old"); +} + +#[gtk4::test] +fn editor_all_rows_export_uses_user_sql_read() { + let harness = Harness::new(); + let path = harness.path("rows.csv"); + let fake = harness.session(FakeReply::ok(streamed(1, 3)), None); + let statement = "SELECT id, name FROM users WHERE id < 3"; + let query = ExportQuery::Editor { + sql: UserSql::new(statement), + }; + + let outcome = harness.finish(harness.ledger.begin(read_job(&path, &fake, query)).expect("started")); + + assert!(matches!(outcome, ExportOutcome::Exported { rows: 3 }), "{outcome:?}"); + assert_eq!( + fake.connection.observed_sql("stream_read"), + [Some(statement.to_owned())] + ); + assert_eq!(fake.connection.observed_deadlines("stream_read"), [None]); +} + +#[gtk4::test] +fn editor_all_rows_multiple_result_sets_presents_alert() { + let harness = Harness::new(); + let path = harness.path("rows.csv"); + let mut events = streamed(1, 3); + events.extend(streamed(1, 1)); + let fake = harness.session(FakeReply::ok(events), None); + let query = ExportQuery::Editor { + sql: UserSql::new("SELECT 1; SELECT 2"), + }; + + let outcome = harness.finish(harness.ledger.begin(read_job(&path, &fake, query)).expect("started")); + + assert!( + matches!( + outcome, + ExportOutcome::Failed { + failure: ExportFailure::Stream(ExportStreamFailure::MultipleResultSets), + file: Some(AbortOutcome::Removed), + } + ), + "{outcome:?}" + ); + wait_until(WAIT, || visible_alert(&harness.window).is_some()).expect("the alert"); + let alert = visible_alert(&harness.window).expect("the alert"); + assert_eq!(alert.heading().as_deref(), Some("Couldn't Export")); + assert!(alert.body().contains("more than one result"), "{}", alert.body()); + assert!(!path.exists(), "the failed export left its file"); +} + +#[gtk4::test] +fn a_second_result_set_fails_the_export_when_the_read_ends_ok() { + let harness = Harness::new(); + let path = harness.path("rows.csv"); + let mut events = streamed(1, 3); + events.extend(streamed(1, 1)); + let fake = harness.scripted(FakeReply::ok(events), |script| { + script.stream_end = StreamEnd::SummaryWhenStopped; + }); + let query = ExportQuery::Editor { + sql: UserSql::new("SELECT * FROM a; SELECT * FROM b"), + }; + + let outcome = harness.finish(harness.ledger.begin(read_job(&path, &fake, query)).expect("started")); + + assert!( + matches!( + outcome, + ExportOutcome::Failed { + failure: ExportFailure::Stream(ExportStreamFailure::MultipleResultSets), + file: Some(AbortOutcome::Removed), + } + ), + "{outcome:?}" + ); + assert!(!path.exists(), "the first result's rows were kept as the export"); +} + +#[gtk4::test] +fn a_read_that_fails_after_its_rows_removes_the_file() { + let harness = Harness::new(); + let path = harness.path("users.csv"); + let failure = DriverError::Server(Box::new(ServerDiagnostics::new(None, "division by zero"))); + let fake = harness.scripted(FakeReply::ok(streamed(1, 3)), |script| { + script.stream_end = StreamEnd::Failed(failure); + }); + + let outcome = harness.finish( + harness + .ledger + .begin(read_job(&path, &fake, users(by_id()))) + .expect("started"), + ); + + let ExportOutcome::Failed { + failure: ExportFailure::Table(error), + file: Some(AbortOutcome::Removed), + } = &outcome + else { + panic!("{outcome:?}"); + }; + assert_eq!(error.category(), Some(ErrorCategory::Server)); + assert!(!path.exists(), "the failed read left its file"); +} + +#[gtk4::test] +fn a_statement_with_no_result_set_says_it_returned_no_rows() { + let harness = Harness::new(); + let path = harness.path("rows.csv"); + let fake = harness.session(FakeReply::ok(Vec::new()), None); + let query = ExportQuery::Editor { + sql: UserSql::new("SET search_path TO app"), + }; + + let outcome = harness.finish(harness.ledger.begin(read_job(&path, &fake, query)).expect("started")); + + assert!( + matches!( + outcome, + ExportOutcome::Failed { + failure: ExportFailure::Stream(ExportStreamFailure::NoResultSet), + file: Some(AbortOutcome::Removed), + } + ), + "{outcome:?}" + ); + wait_until(WAIT, || visible_alert(&harness.window).is_some()).expect("the alert"); + let body = visible_alert(&harness.window).expect("the alert").body(); + assert!(body.contains("returned no rows to export"), "{body}"); + assert!(!path.exists(), "the empty export left its file"); +} + +#[gtk4::test] +fn a_rerun_refused_as_a_write_says_export_all_only_reads() { + let harness = Harness::new(); + let path = harness.path("rows.csv"); + let refused = DriverError::ReadOnly(ReadOnlyRefusal::server(ServerDiagnostics::new( + None, + "cannot execute INSERT in a read-only transaction", + ))); + let fake = harness.session(FakeReply::err(refused), None); + let query = ExportQuery::Editor { + sql: UserSql::new("INSERT INTO t VALUES (1) RETURNING *"), + }; + + let outcome = harness.finish(harness.ledger.begin(read_job(&path, &fake, query)).expect("started")); + + let ExportOutcome::Failed { + failure: ExportFailure::Statement(error), + .. + } = &outcome + else { + panic!("{outcome:?}"); + }; + assert_eq!(error.category(), Some(ErrorCategory::ReadOnly)); + wait_until(WAIT, || visible_alert(&harness.window).is_some()).expect("the alert"); + let body = visible_alert(&harness.window).expect("the alert").body(); + assert!( + body.starts_with("Export All only runs statements that read data."), + "{body}" + ); +} + +#[gtk4::test] +fn snapshot_export_of_10k_rows_keeps_main_loop_responsive() { + let harness = Harness::new(); + let path = harness.path("rows.csv"); + let ticks = Rc::new(Cell::new(0_u32)); + let counted = ticks.clone(); + let ticking = glib::timeout_add_local(Duration::from_millis(1), move || { + counted.set(counted.get() + 1); + glib::ControlFlow::Continue + }); + + let handle = harness.ledger.begin(job(&path, 10_000)).expect("started"); + let outcome = harness.finish(handle); + ticking.remove(); + + assert!( + matches!(outcome, ExportOutcome::Exported { rows: 10_000 }), + "{outcome:?}" + ); + assert!(ticks.get() > 0, "the main loop never ran while the export did"); + let text = std::fs::read_to_string(&path).expect("the export"); + assert_eq!(text.lines().count(), 10_001); + assert!( + text.starts_with("\"id\",\"name\",\"note\"\n\"0\",\"name 0\""), + "{}", + &text[..40] + ); + assert_eq!(harness.ledger.in_flight(), 0); + assert_eq!(harness.hold.live(), 0, "the export kept the process held"); +} + +#[gtk4::test] +fn stop_button_cancels_job_and_removes_row() { + let harness = Harness::new(); + let path = harness.path("stopped.csv"); + let button = ExportOperationsButton::default(); + button.bind(&harness.ledger.jobs()); + assert!(!button.is_visible(), "the button showed with nothing running"); + + let handle = harness.ledger.begin(job(&path, 10_000)).expect("started"); + assert!(button.is_visible()); + let row = button.job_list().row_at_index(0).expect("the job's row"); + let stop = first_descendant_of_type::(&row).expect("the stop button"); + let progress = first_descendant_of_type::(&row).expect("the job's bar"); + assert!(gtk::test_accessible_has_relation( + &stop, + gtk::AccessibleRelation::DescribedBy + )); + assert!(gtk::test_accessible_has_relation( + &progress, + gtk::AccessibleRelation::LabelledBy + )); + stop.emit_clicked(); + let outcome = harness.finish(handle); + + assert!( + matches!(outcome, ExportOutcome::Stopped(AbortOutcome::Removed)), + "{outcome:?}" + ); + assert!(button.job_list().row_at_index(0).is_none(), "the row stayed"); + assert!(!button.is_visible(), "the button stayed with nothing running"); + assert!(!path.exists(), "the stopped export left its file"); +} + +#[gtk4::test] +fn ledger_holds_inhibit_cookie_while_exporting() { + let harness = Harness::new(); + assert!(!harness.ledger.is_inhibiting()); + + let handle = harness + .ledger + .begin(job(&harness.path("held.csv"), 100)) + .expect("started"); + assert!(harness.ledger.is_inhibiting()); + assert_eq!( + harness.session.held.borrow().as_slice(), + [( + 1, + gtk::ApplicationInhibitFlags::LOGOUT | gtk::ApplicationInhibitFlags::SUSPEND + )] + ); + assert_eq!(harness.hold.live(), 1); + harness.finish(handle); + + assert!(!harness.ledger.is_inhibiting()); + assert!(harness.session.held.borrow().is_empty(), "the cookie was kept"); + assert_eq!(harness.hold.live(), 0); +} + +#[gtk4::test] +fn close_blocker_reports_running_exports_and_stop_waits_for_idle() { + let harness = Harness::new(); + assert_eq!(harness.ledger.blocking(), None); + + let handle = harness + .ledger + .begin(job(&harness.path("blocked.csv"), 10_000)) + .expect("started"); + let prompt = harness.ledger.blocking().expect("a running export blocks the close"); + assert_eq!(prompt.heading, "Stop Exports?"); + assert!(prompt.body.starts_with("1 export is still running"), "{}", prompt.body); + let stopping = harness.ledger.stop(); + finish_on_main(WAIT, stopping).expect("the stop to settle"); + + assert_eq!(harness.ledger.in_flight(), 0); + assert_eq!(harness.ledger.blocking(), None); + let outcome = harness.finish(handle); + assert!(matches!(outcome, ExportOutcome::Stopped(_)), "{outcome:?}"); +} + +#[gtk4::test] +fn a_tab_hears_how_its_export_ended_once_it_settled() { + let harness = Harness::new(); + let heard = Rc::new(RefCell::new(Vec::new())); + let (log, ledger) = (heard.clone(), Rc::downgrade(&harness.ledger)); + harness.ledger.on_ended(move |tab, outcome| { + let running = ledger.upgrade().map(|ledger| ledger.in_flight()); + log.borrow_mut().push((tab, outcome, running)); + }); + let (finished, failed, stopped) = (uuid::Uuid::new_v4(), uuid::Uuid::new_v4(), uuid::Uuid::new_v4()); + let from = |job: ExportJob, tab| ExportJob { tab: Some(tab), ..job }; + + harness.finish( + harness + .ledger + .begin(from(job(&harness.path("rows.csv"), 10), finished)) + .expect("started"), + ); + harness.finish( + harness + .ledger + .begin(from(job(&harness.path("missing").join("rows.csv"), 10), failed)) + .expect("started"), + ); + let fake = harness.session(FakeReply::UntilStopped, None); + let stopping = harness + .ledger + .begin(from( + read_job(&harness.path("users.csv"), &fake, users(by_id())), + stopped, + )) + .expect("started"); + wait_until(WAIT, || fake.connection.observed_cancels("stream_read").len() == 1).expect("the read to start"); + harness.ledger.cancel_all(); + harness.finish(stopping); + harness.finish( + harness + .ledger + .begin(job(&harness.path("untold.csv"), 10)) + .expect("started"), + ); + + assert_eq!( + *heard.borrow(), + [ + (finished, WorkOutcome::Succeeded, Some(0)), + (failed, WorkOutcome::Failed, Some(0)), + (stopped, WorkOutcome::Cancelled, Some(0)), + ], + "a job with no tab was told, or one was told before it settled" + ); +} + +#[gtk4::test] +fn failed_export_presents_alert() { + let harness = Harness::new(); + let path = harness.path("missing").join("rows.csv"); + + let outcome = harness.finish(harness.ledger.begin(job(&path, 10)).expect("started")); + + assert!( + matches!( + &outcome, + ExportOutcome::Failed { + failure: ExportFailure::File(error), + file: None, + } if error.matches(gio::IOErrorEnum::NotFound) + ), + "{outcome:?}" + ); + wait_until(WAIT, || visible_alert(&harness.window).is_some()).expect("the alert"); + let alert = visible_alert(&harness.window).expect("the alert"); + assert_eq!(alert.heading().as_deref(), Some("Couldn't Export")); + assert!(alert.body().contains("rows.csv"), "{}", alert.body()); +} + +#[gtk4::test] +fn a_window_close_asks_to_stop_running_exports() { + let test = crate::ui::session_window::tests::TestWindow::connected("ExportClose"); + let exports = test.workspace().exports().expect("the workspace's exports"); + let path = test.dir().join("closing.csv"); + let _running = exports.begin(job(&path, 10_000)).expect("started"); + + test.window.close(); + wait_until(WAIT, || { + visible_alert(&test.window).is_some_and(|alert| alert.heading().as_deref() == Some("Stop Exports?")) + }) + .expect("the close to ask about the export"); + assert!(answer(&test.window, "stop")); + + wait_until(WAIT, || !test.window.is_visible()).expect("the window to close once the export stopped"); + assert_eq!(exports.in_flight(), 0); +} + +#[gtk4::test] +fn a_shut_down_ledger_refuses_new_exports() { + let harness = Harness::new(); + let path = harness.path("late.csv"); + + harness.ledger.shut_down(); + let refused = harness.ledger.begin(job(&path, 10)); + crate::test_support::drain_main_context(); + + assert!(refused.is_none(), "an export began after the shutdown"); + assert_eq!(harness.ledger.in_flight(), 0); + assert_eq!(harness.hold.live(), 0); + assert!(!path.exists(), "the refused export made its file"); +} + +#[gtk4::test] +fn a_new_file_shows_under_its_display_name() { + use std::os::unix::ffi::OsStrExt; + + let harness = Harness::new(); + // Not UTF-8, so the name GIO shows differs from the bytes on disk. + let path = harness.dir.path().join(std::ffi::OsStr::from_bytes(b"caf\xe9.csv")); + let handle = harness.ledger.begin(job(&path, 10)).expect("started"); + let item = harness + .ledger + .jobs() + .item(0) + .and_downcast::() + .expect("the job's row"); + + let outcome = harness.finish(handle); + + assert!(matches!(outcome, ExportOutcome::Exported { rows: 10 }), "{outcome:?}"); + assert!(item.title().ends_with("(invalid encoding)"), "{}", item.title()); +} + +/// Both exports would race for the file, and the one stopped would +/// remove what the other finished. +#[gtk4::test] +fn a_second_export_to_a_file_being_written_is_refused() { + let harness = Harness::new(); + let path = harness.path("rows.csv"); + + let first = harness.ledger.begin(job(&path, 10_000)).expect("started"); + let second = harness.ledger.begin(job(&path, 10)).expect("started"); + let refused = harness.finish(second); + let exported = harness.finish(first); + + assert!( + matches!( + refused, + ExportOutcome::Failed { + failure: ExportFailure::InUse, + file: None, + } + ), + "{refused:?}" + ); + assert!( + matches!(exported, ExportOutcome::Exported { rows: 10_000 }), + "{exported:?}" + ); + let text = std::fs::read_to_string(&path).expect("the export"); + assert_eq!(text.lines().count(), 10_001); + let alert = visible_alert(&harness.window).expect("the alert"); + assert_eq!( + alert.body(), + "An export to rows.csv is already running. Wait for it to finish, or export to another file." + ); +} + +/// The toast's title is plain text: as markup, the `&` would leave it +/// empty. +#[gtk4::test] +fn a_file_name_with_an_ampersand_shows_in_its_toast() { + let harness = Harness::new(); + let path = harness.path("R&D.csv"); + + let outcome = harness.finish(harness.ledger.begin(job(&path, 10)).expect("started")); + + assert!(matches!(outcome, ExportOutcome::Exported { rows: 10 }), "{outcome:?}"); + let shown = || { + descendants(&harness.window) + .into_iter() + .filter_map(|widget| widget.downcast::().ok()) + .any(|label| label.text() == "Exported 10 rows to R&D.csv") + }; + wait_until(WAIT, shown).expect("the toast to name the file"); +} diff --git a/linux/crates/app/src/ui/export/export_message.rs b/linux/crates/app/src/ui/export/export_message.rs new file mode 100644 index 0000000000..298b0714d1 --- /dev/null +++ b/linux/crates/app/src/ui/export/export_message.rs @@ -0,0 +1,42 @@ +use tablepro_core::export::{EncodeError, RenderError}; +use tablepro_session::runtime::TaskFailure; +use thiserror::Error; + +/// What the producer hands the writer on the GTK thread. +#[derive(Debug)] +pub(crate) enum ExportMessage { + /// Encoded bytes, and how many rows the file holds once they are + /// written. + Chunk { + bytes: Vec, + rows: u64, + }, + /// Rows read so far for a document, which is written only once the + /// last one is in. + Progress { + rows: u64, + }, + Failed(ExportStreamFailure), + Finished { + rows: u64, + }, +} + +/// Why the rows could not be turned into the file's bytes. +#[derive(Debug, Error)] +pub(crate) enum ExportStreamFailure { + #[error(transparent)] + Encode(#[from] EncodeError), + #[error("the statement returned more than one result set")] + MultipleResultSets, + #[error("the read returned no result set")] + NoResultSet, + #[error(transparent)] + Document(#[from] RenderError), + /// A sheet is full and more rows are still coming, so how many there + /// are in all is never read. + #[error("a sheet holds {limit} rows, and the result has more")] + SheetFull { limit: usize }, + #[error(transparent)] + Task(#[from] TaskFailure), +} diff --git a/linux/crates/app/src/ui/export/export_operations_button.rs b/linux/crates/app/src/ui/export/export_operations_button.rs new file mode 100644 index 0000000000..f7a06880a4 --- /dev/null +++ b/linux/crates/app/src/ui/export/export_operations_button.rs @@ -0,0 +1,153 @@ +use gtk4 as gtk; +use gtk4::prelude::*; +use gtk4::subclass::prelude::*; +use gtk4::{gio, glib}; +use libadwaita as adw; +use libadwaita::subclass::prelude::*; + +use super::ExportJobItem; +use super::export_failure_text::progress_text; +use crate::i18n::gettext; + +mod imp { + use super::*; + + #[derive(Default, gtk::CompositeTemplate)] + #[template(resource = "/app/tablepro/TablePro/ui/export-operations-button.ui")] + pub struct ExportOperationsButton { + #[template_child] + pub menu_button: TemplateChild, + #[template_child] + pub job_list: TemplateChild, + } + + #[glib::object_subclass] + impl ObjectSubclass for ExportOperationsButton { + const NAME: &'static str = "TableProExportOperationsButton"; + type Type = super::ExportOperationsButton; + type ParentType = adw::Bin; + + fn class_init(klass: &mut Self::Class) { + klass.bind_template(); + } + + fn instance_init(object: &glib::subclass::InitializingObject) { + object.init_template(); + } + } + + impl ObjectImpl for ExportOperationsButton {} + impl WidgetImpl for ExportOperationsButton {} + impl BinImpl for ExportOperationsButton {} +} + +glib::wrapper! { + /// The header bar's button for the exports a workspace has running, + /// with a row for each that shows its progress and stops it. + pub struct ExportOperationsButton(ObjectSubclass) + @extends adw::Bin, gtk::Widget, + @implements gtk::Accessible, gtk::Buildable, gtk::ConstraintTarget; +} + +impl Default for ExportOperationsButton { + fn default() -> Self { + glib::Object::new() + } +} + +impl ExportOperationsButton { + /// List `jobs`, and show the button only while there is one. + pub(crate) fn bind(&self, jobs: &gio::ListStore) { + let imp = self.imp(); + imp.job_list.bind_model(Some(jobs), |item| { + item.downcast_ref::() + .map_or_else(|| gtk::ListBoxRow::new().upcast(), job_row) + }); + self.set_visible(jobs.n_items() > 0); + let button = self.downgrade(); + jobs.connect_items_changed(move |jobs, _, _, _| { + if let Some(button) = button.upgrade() { + let running = jobs.n_items() > 0; + if !running { + button.imp().menu_button.popdown(); + } + button.set_visible(running); + } + }); + } + + #[cfg(test)] + pub(crate) fn job_list(&self) -> gtk::ListBox { + self.imp().job_list.get() + } +} + +fn job_row(item: &ExportJobItem) -> gtk::Widget { + let title = gtk::Label::builder() + .xalign(0.0) + .ellipsize(gtk::pango::EllipsizeMode::Middle) + .build(); + item.bind_property("title", &title, "label").sync_create().build(); + let progress = gtk::ProgressBar::new(); + let subtitle = gtk::Label::builder().xalign(0.0).build(); + subtitle.add_css_class("caption"); + subtitle.add_css_class("dim-label"); + let text = gtk::Box::builder() + .orientation(gtk::Orientation::Vertical) + .spacing(6) + .hexpand(true) + .build(); + text.append(&title); + text.append(&progress); + text.append(&subtitle); + + let stop = gtk::Button::from_icon_name(crate::ui::icons::PROCESS_STOP); + crate::ui::a11y::describe_icon_button(&stop, &gettext("Stop Export"), None); + // Every job has a bar and a Stop Export, so each says which file. + progress.update_relation(&[gtk::accessible::Relation::LabelledBy(&[title.upcast_ref()])]); + stop.update_relation(&[gtk::accessible::Relation::DescribedBy(&[title.upcast_ref()])]); + stop.set_valign(gtk::Align::Center); + stop.add_css_class("flat"); + stop.add_css_class("circular"); + let stopping = item.downgrade(); + stop.connect_clicked(move |_| { + if let Some(item) = stopping.upgrade() { + item.stop(); + } + }); + + let content = gtk::Box::builder() + .spacing(12) + .margin_top(12) + .margin_bottom(12) + .margin_start(12) + .margin_end(12) + .build(); + content.append(&text); + content.append(&stop); + + show_progress(item, &progress, &subtitle); + let (progress_bar, subtitle_label) = (progress.downgrade(), subtitle.downgrade()); + item.connect_rows_notify(move |item| { + if let (Some(progress), Some(subtitle)) = (progress_bar.upgrade(), subtitle_label.upgrade()) { + show_progress(item, &progress, &subtitle); + } + }); + + gtk::ListBoxRow::builder() + .activatable(false) + .child(&content) + .build() + .upcast() +} + +/// A bar that fills when the total is known, and pulses with each +/// chunk written when it is not. +fn show_progress(item: &ExportJobItem, progress: >k::ProgressBar, subtitle: >k::Label) { + match item.fraction() { + Some(fraction) => progress.set_fraction(fraction), + None if item.rows() > 0 => progress.pulse(), + None => {} + } + subtitle.set_label(&progress_text(item.rows(), item.expected())); +} diff --git a/linux/crates/app/src/ui/export/export_outcome.rs b/linux/crates/app/src/ui/export/export_outcome.rs new file mode 100644 index 0000000000..996c9c89b2 --- /dev/null +++ b/linux/crates/app/src/ui/export/export_outcome.rs @@ -0,0 +1,60 @@ +use gtk4::glib; +use tablepro_session::OperationError; +use tablepro_session::runtime::TaskFailure; +use thiserror::Error; + +use super::{AbortOutcome, ExportStreamFailure, OpenError}; +use crate::workspace::WorkOutcome; + +/// How an export ended. +#[derive(Debug)] +pub(crate) enum ExportOutcome { + Exported { + rows: u64, + }, + Stopped(AbortOutcome), + /// `file` says what became of the file, when the export had opened + /// it. + Failed { + failure: ExportFailure, + file: Option, + }, +} + +impl From<&ExportOutcome> for WorkOutcome { + fn from(outcome: &ExportOutcome) -> Self { + match outcome { + ExportOutcome::Exported { .. } => Self::Succeeded, + ExportOutcome::Stopped(_) => Self::Cancelled, + ExportOutcome::Failed { .. } => Self::Failed, + } + } +} + +#[derive(Debug, Error)] +pub(crate) enum ExportFailure { + #[error(transparent)] + Stream(#[from] ExportStreamFailure), + #[error(transparent)] + File(#[from] glib::Error), + #[error(transparent)] + Task(#[from] TaskFailure), + /// Reading the table failed. + #[error("reading the table failed: {0}")] + Table(OperationError), + /// Running an editor's statement again failed. It ran on a + /// connection of its own, which the alert has to explain. + #[error("running the statement again failed: {0}")] + Statement(OperationError), + #[error("another export is writing to this file")] + InUse, +} + +impl From for ExportFailure { + fn from(error: OpenError) -> Self { + match error { + OpenError::InUse => Self::InUse, + OpenError::File(error) => Self::File(error), + } + } +} diff --git a/linux/crates/app/src/ui/export/export_producer.rs b/linux/crates/app/src/ui/export/export_producer.rs new file mode 100644 index 0000000000..df5881d999 --- /dev/null +++ b/linux/crates/app/src/ui/export/export_producer.rs @@ -0,0 +1,126 @@ +use std::sync::Arc; + +use tablepro_core::browse::build_export; +use tablepro_core::column::ResultColumn; +use tablepro_core::{ReadStatement, ResultEvent, SinkFlow, StreamSummary, Truncation}; +use tablepro_session::runtime::{TaskResult, Tasks}; +use tablepro_session::{OperationError, OperationKind, Session}; +use tokio_util::sync::CancellationToken; + +use super::{ExportQuery, ExportSinkParts}; +use crate::ui::grid::RowSnapshot; + +/// Rows a producer encodes before it looks at the cancel again. +const ROWS_PER_BATCH: usize = 256; + +/// Encode rows the grid already holds, on the runtime, through the same +/// sink a streamed read feeds, so both kinds of export write the same +/// bytes. +pub(crate) fn start_rows_producer( + tasks: &Tasks, + columns: Arc<[ResultColumn]>, + rows: Vec, + parts: ExportSinkParts, + cancel: CancellationToken, +) -> TaskResult<()> { + tasks.spawn_task(async move { + let mut sink = parts.sink(); + if sink.accept(ResultEvent::ResultSetStarted { columns }).await == SinkFlow::ConsumerGone { + return; + } + for batch in rows.chunks(ROWS_PER_BATCH) { + if cancel.is_cancelled() { + return; + } + let batch = batch.iter().map(RowSnapshot::to_values).collect(); + if sink.accept(ResultEvent::Rows(batch)).await == SinkFlow::ConsumerGone { + return; + } + } + if cancel.is_cancelled() { + return; + } + let count = rows.len() as u64; + sink.accept(ResultEvent::ResultSetFinished { + retained: count, + total_rows: Some(count), + truncation: Truncation::Complete, + }) + .await; + }) +} + +/// Stream every row of `query` through `session` into a sink built +/// from `parts`, on a connection of the read's own. The SQL is built +/// from the lease that streams it, so it is spelled for that server. +pub(crate) fn start_query_producer( + tasks: &Tasks, + session: Arc, + query: ExportQuery, + parts: ExportSinkParts, + cancel: CancellationToken, +) -> TaskResult> { + let query = Arc::new(query); + tasks.spawn_task(async move { + session + .run_cancellable(OperationKind::Export, &cancel, move |lease, options| { + let parts = parts.clone(); + let query = Arc::clone(&query); + async move { + let read = match &*query { + ExportQuery::Table(export) => { + ReadStatement::Generated(build_export(&*lease.dialect(), &export.request())?) + } + ExportQuery::Editor { sql } => ReadStatement::User(sql.clone()), + }; + Ok(lease.stream_read(&read, parts.sink(), options).await?) + } + }) + .await + }) +} + +#[cfg(test)] +mod tests { + use std::time::Duration; + + use tablepro_core::Value; + use tablepro_core::column::ColumnType; + use tablepro_core::export::CsvOptions; + use tokio::sync::mpsc; + + use super::*; + use crate::test_support::paused_tasks; + use crate::ui::export::{ExportFormat, ExportMessage, RowFormat}; + + #[tokio::test(start_paused = true)] + async fn rows_producer_stops_on_cancel() { + let columns: Arc<[ResultColumn]> = vec![ResultColumn::new("a", ColumnType::untyped_text("text"))].into(); + let rows: Vec = (0..10_000) + .map(|row| RowSnapshot::new(vec![Value::Text(format!("{row:01023}"))].into(), Vec::new())) + .collect(); + let (messages, mut received) = mpsc::channel(1); + let cancel = CancellationToken::new(); + let parts = ExportSinkParts { + messages, + format: ExportFormat::Rows(RowFormat::Csv(CsvOptions::default())), + tasks: paused_tasks(), + }; + let producer = start_rows_producer(&paused_tasks(), columns, rows, parts, cancel.clone()); + + let first = received.recv().await; + assert!(matches!(first, Some(ExportMessage::Chunk { .. })), "{first:?}"); + cancel.cancel(); + let mut last_rows = 0; + while let Some(message) = received.recv().await { + match message { + ExportMessage::Chunk { rows, .. } => last_rows = rows, + other => panic!("the producer went on past the cancel: {other:?}"), + } + tokio::time::sleep(Duration::from_millis(1)).await; + } + + producer.await.expect("the producer"); + assert!(last_rows < 10_000, "every row was encoded"); + } +} diff --git a/linux/crates/app/src/ui/export/export_query.rs b/linux/crates/app/src/ui/export/export_query.rs new file mode 100644 index 0000000000..abfd4922e7 --- /dev/null +++ b/linux/crates/app/src/ui/export/export_query.rs @@ -0,0 +1,37 @@ +use std::sync::Arc; + +use tablepro_core::browse::{ExportRequest, SortKey}; +use tablepro_core::meta::{RowIdentity, TableRef}; +use tablepro_core::{ColumnInfo, FilterSet, UserSql}; + +/// A read an export runs on the server rather than rows it already +/// holds. +#[derive(Debug, Clone)] +pub enum ExportQuery { + /// Every row of a table under the grid's filter and sort. + Table(TableExport), + /// The statement behind an editor result, run again from the top. + Editor { sql: UserSql }, +} + +/// A table as a browse tab filters and sorts it. +#[derive(Debug, Clone)] +pub struct TableExport { + pub table: TableRef, + pub columns: Arc<[ColumnInfo]>, + pub filter: FilterSet, + pub sort: Vec, + pub identity: RowIdentity, +} + +impl TableExport { + pub fn request(&self) -> ExportRequest<'_> { + ExportRequest { + table: &self.table, + columns: &self.columns, + filter: &self.filter, + sort: &self.sort, + identity: &self.identity, + } + } +} diff --git a/linux/crates/app/src/ui/export/export_scope.rs b/linux/crates/app/src/ui/export/export_scope.rs new file mode 100644 index 0000000000..572326b7fc --- /dev/null +++ b/linux/crates/app/src/ui/export/export_scope.rs @@ -0,0 +1,313 @@ +use std::sync::Arc; + +use tablepro_session::Session; + +use super::dialog::SqlTarget; +use super::{ExpectedRows, ExportQuery, ExportSource}; +use crate::i18n::{gettext, ngettext_f, pgettext}; +use crate::ui::browse_tab::RowTotal; +use crate::ui::format::{format_thousands, plural_count}; + +/// What a tab asks the window to export. +#[derive(Debug, Clone)] +pub(crate) struct ExportOffer { + pub origin: ExportOrigin, + /// The session the tab reads through, which a read of every row + /// goes through too. + pub session: Option>, + pub name: String, + pub target: Option, +} + +/// What the user was looking at when they asked to export, and what +/// else they could export from there. +#[derive(Debug, Clone)] +pub(crate) enum ExportOrigin { + Browse { + /// The page on screen, pending edits included. + page: ExportSource, + query: ExportQuery, + total: RowTotal, + has_unsaved_changes: bool, + }, + Editor { + /// The rows the result holds, which the run's row limit may + /// have cut short. + shown: ExportSource, + query: ExportQuery, + shown_rows: u64, + truncated: bool, + }, +} + +/// Which rows a scope exports. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ScopeSource { + /// The rows the grid holds. + Shown, + /// Every row, read again from the server. + Query, +} + +/// One entry of the dialog's Rows list. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct ScopeChoice { + pub title: String, + pub subtitle: String, + pub source: ScopeSource, + pub expected: Option, +} + +pub(crate) fn scope_choices(origin: &ExportOrigin) -> Vec { + scope_choices_with(origin, format_thousands) +} + +fn scope_choices_with(origin: &ExportOrigin, format: impl Fn(u64) -> String) -> Vec { + match origin { + ExportOrigin::Browse { + page, + query, + total, + has_unsaved_changes, + } => { + let shown = page.row_count().unwrap_or_default() as u64; + let filtered = matches!(query, ExportQuery::Table(export) if !export.filter.is_empty()); + let mut table = vec![table_total(total, &format)]; + if filtered { + table.push(gettext("filters apply")); + } + if *has_unsaved_changes { + table.push(gettext("unsaved changes aren't included")); + } + vec![ + ScopeChoice { + title: gettext("Current Page"), + subtitle: rows_text(shown, &format), + source: ScopeSource::Shown, + expected: Some(ExpectedRows::Exact(shown)), + }, + ScopeChoice { + title: gettext("Entire Table"), + subtitle: table.join(&pgettext("list separator", ", ")), + source: ScopeSource::Query, + expected: expected_total(total), + }, + ] + } + ExportOrigin::Editor { shown_rows, .. } => vec![ + ScopeChoice { + title: gettext("Shown Rows"), + subtitle: rows_text(*shown_rows, &format), + source: ScopeSource::Shown, + expected: Some(ExpectedRows::Exact(*shown_rows)), + }, + ScopeChoice { + title: gettext("All Rows"), + subtitle: gettext( + "Runs the statement again on a separate read-only connection, without this editor's USE or SET changes", + ), + source: ScopeSource::Query, + expected: None, + }, + ], + } +} + +impl ExportOrigin { + /// The choice the dialog opens on: every row when the editor's + /// result was cut short, since that is why the user is here. + pub(crate) fn preselected(&self) -> ScopeSource { + match self { + Self::Editor { truncated: true, .. } => ScopeSource::Query, + Self::Browse { .. } | Self::Editor { .. } => ScopeSource::Shown, + } + } + + /// The rows the grid held. + #[cfg(test)] + pub(crate) fn shown(&self) -> &ExportSource { + match self { + Self::Browse { page, .. } => page, + Self::Editor { shown, .. } => shown, + } + } + + /// The rows `source` stands for. A read needs `session`, and there + /// is none once the window has disconnected. + pub(crate) fn into_source(self, source: ScopeSource, session: Option>) -> Option { + let (shown, query) = match self { + Self::Browse { page, query, .. } => (page, query), + Self::Editor { shown, query, .. } => (shown, query), + }; + match source { + ScopeSource::Shown => Some(shown), + ScopeSource::Query => session.map(|session| ExportSource::Query { session, query }), + } + } +} + +fn rows_text(rows: u64, format: &impl Fn(u64) -> String) -> String { + ngettext_f("{n} row", "{n} rows", plural_count(rows), &[("n", &format(rows))]) +} + +fn table_total(total: &RowTotal, format: &impl Fn(u64) -> String) -> String { + match (total.exact(), total.estimate()) { + (Some(exact), _) => rows_text(exact, format), + (None, Some(estimate)) => ngettext_f( + "About {n} row", + "About {n} rows", + plural_count(estimate), + &[("n", &format(estimate))], + ), + (None, None) => gettext("Every row"), + } +} + +fn expected_total(total: &RowTotal) -> Option { + match (total.exact(), total.estimate()) { + (Some(exact), _) => Some(ExpectedRows::Exact(exact)), + (None, Some(estimate)) => Some(ExpectedRows::About(estimate)), + (None, None) => None, + } +} + +#[cfg(test)] +mod tests { + use tablepro_core::meta::{RowIdentity, TableRef}; + use tablepro_core::{FilterOp, FilterRule, FilterSet, UserSql}; + + use super::*; + use crate::ui::export::TableExport; + use crate::ui::format::Thousands; + + fn english(origin: &ExportOrigin) -> Vec { + let english = Thousands::for_posix_locale("en_US.UTF-8"); + scope_choices_with(origin, |n| english.format(n)) + } + + fn page(rows: usize) -> ExportSource { + ExportSource::Rows { + columns: Vec::new().into(), + rows: (0..rows) + .map(|_| crate::ui::grid::RowSnapshot::new(Vec::new().into(), Vec::new())) + .collect(), + } + } + + fn table(filter: FilterSet) -> ExportQuery { + ExportQuery::Table(TableExport { + table: TableRef::new(None, "users".to_owned()), + columns: Vec::new().into(), + filter, + sort: Vec::new(), + identity: RowIdentity::Unordered, + }) + } + + fn browse(total: RowTotal, filter: FilterSet, has_unsaved_changes: bool) -> ExportOrigin { + ExportOrigin::Browse { + page: page(3), + query: table(filter), + total, + has_unsaved_changes, + } + } + + fn editor(shown_rows: u64, truncated: bool) -> ExportOrigin { + ExportOrigin::Editor { + shown: page(0), + query: ExportQuery::Editor { + sql: UserSql::new("SELECT 1"), + }, + shown_rows, + truncated, + } + } + + fn filtered() -> FilterSet { + FilterSet { + rules: vec![FilterRule { + column: "name".to_owned(), + op: FilterOp::IsNull, + value: None, + }], + ..FilterSet::default() + } + } + + #[test] + fn scope_choices_browse_entire_table_uses_about_for_estimates() { + let estimated = english(&browse(RowTotal::Estimated(5_000_000), FilterSet::default(), false)); + let exact = english(&browse(RowTotal::Exact(1), FilterSet::default(), false)); + let unknown = english(&browse(RowTotal::Unknown, FilterSet::default(), false)); + let counting = english(&browse( + RowTotal::Counting { estimate: Some(40) }, + FilterSet::default(), + false, + )); + + assert_eq!(estimated[0].title, "Current Page"); + assert_eq!(estimated[0].subtitle, "3 rows"); + assert_eq!(estimated[0].expected, Some(ExpectedRows::Exact(3))); + assert_eq!(estimated[1].title, "Entire Table"); + assert_eq!(estimated[1].subtitle, "About 5,000,000 rows"); + assert_eq!(estimated[1].source, ScopeSource::Query); + assert_eq!(estimated[1].expected, Some(ExpectedRows::About(5_000_000))); + assert_eq!(exact[1].subtitle, "1 row"); + assert_eq!(exact[1].expected, Some(ExpectedRows::Exact(1))); + assert_eq!(counting[1].subtitle, "About 40 rows"); + assert_eq!(unknown[1].subtitle, "Every row"); + assert_eq!(unknown[1].expected, None, "an unknown total has nothing to fill a bar"); + } + + #[test] + fn scope_choices_browse_notes_unsaved_changes() { + let clean = english(&browse(RowTotal::Exact(10), FilterSet::default(), false)); + let dirty = english(&browse(RowTotal::Exact(10), filtered(), true)); + + assert_eq!(clean[1].subtitle, "10 rows"); + assert_eq!( + dirty[1].subtitle, + "10 rows, filters apply, unsaved changes aren't included" + ); + assert_eq!(dirty[0].subtitle, "3 rows", "the page holds the pending edits"); + } + + #[test] + fn scope_choices_editor_preselects_all_rows_when_truncated() { + let truncated = editor(1_000, true); + let complete = editor(10, false); + let choices = english(&truncated); + + assert_eq!( + choices.iter().map(|choice| choice.title.as_str()).collect::>(), + ["Shown Rows", "All Rows"] + ); + assert_eq!(choices[0].subtitle, "1,000 rows"); + assert_eq!(choices[1].expected, None); + assert_eq!(truncated.preselected(), ScopeSource::Query); + assert_eq!(complete.preselected(), ScopeSource::Shown); + assert_eq!( + browse(RowTotal::Unknown, FilterSet::default(), false).preselected(), + ScopeSource::Shown + ); + } + + #[test] + fn scope_subtitles_use_thousands_separators() { + let browse = english(&browse(RowTotal::Exact(1_234_567), FilterSet::default(), false)); + let editor = english(&editor(12_345, true)); + + assert_eq!(browse[1].subtitle, "1,234,567 rows"); + assert_eq!(editor[0].subtitle, "12,345 rows"); + } + + #[test] + fn a_read_needs_a_session() { + let shown = editor(1, true).into_source(ScopeSource::Shown, None); + let read = editor(1, true).into_source(ScopeSource::Query, None); + + assert!(matches!(shown, Some(ExportSource::Rows { .. }))); + assert!(read.is_none()); + } +} diff --git a/linux/crates/app/src/ui/export/export_sink_parts.rs b/linux/crates/app/src/ui/export/export_sink_parts.rs new file mode 100644 index 0000000000..0c082e3c3f --- /dev/null +++ b/linux/crates/app/src/ui/export/export_sink_parts.rs @@ -0,0 +1,76 @@ +use tablepro_core::ResultSink; +use tablepro_session::runtime::Tasks; +use tokio::sync::mpsc; + +use super::{DocumentSink, EncodingSink, ExportFormat, ExportMessage}; + +/// What a sink for one export is made from. The channel is bounded, so +/// a disk slower than the encoder holds the encoder back rather than +/// queueing the whole result in memory. +#[derive(Clone)] +pub(crate) struct ExportSinkParts { + pub messages: mpsc::Sender, + pub format: ExportFormat, + /// Where a document is rendered once its last row is in. + pub tasks: Tasks, +} + +impl ExportSinkParts { + /// A fresh sink, for one run of the read. + pub(crate) fn sink(&self) -> Box { + match &self.format { + ExportFormat::Rows(format) => Box::new(EncodingSink::new(self.messages.clone(), format.clone())), + ExportFormat::Document(format) => Box::new(DocumentSink::new( + self.messages.clone(), + format.clone(), + self.tasks.clone(), + )), + } + } +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use tablepro_core::column::{ColumnType, ResultColumn}; + use tablepro_core::{ResultEvent, SinkFlow, Value}; + use tablepro_test_support::FakeDialect; + + use super::*; + use crate::test_support::paused_tasks; + use crate::ui::export::{InsertTarget, RowFormat}; + + #[tokio::test(start_paused = true)] + async fn html_xml_and_sql_are_written_before_the_last_row_is_in() { + let formats = [ + RowFormat::Html { title: "t".to_owned() }, + RowFormat::Xml, + RowFormat::Sql(InsertTarget { + table: tablepro_core::meta::TableRef::unqualified("t"), + generated: Arc::from([]), + dialect: Arc::new(FakeDialect), + }), + ]; + for format in formats { + let (messages, mut received) = mpsc::channel(64); + let parts = ExportSinkParts { + messages, + format: ExportFormat::Rows(format.clone()), + tasks: paused_tasks(), + }; + let mut sink = parts.sink(); + let columns: Arc<[ResultColumn]> = vec![ResultColumn::new("a", ColumnType::untyped_text("text"))].into(); + let rows = (0..200).map(|row| vec![Value::Text(format!("{row:01023}"))]).collect(); + + sink.accept(ResultEvent::ResultSetStarted { columns }).await; + assert_eq!(sink.accept(ResultEvent::Rows(rows)).await, SinkFlow::Continue); + + let first = received.try_recv(); + assert!( + matches!(first, Ok(ExportMessage::Chunk { .. })), + "{format:?} held its rows until the end: {first:?}" + ); + } + } +} diff --git a/linux/crates/app/src/ui/export/export_source.rs b/linux/crates/app/src/ui/export/export_source.rs new file mode 100644 index 0000000000..1819e205f8 --- /dev/null +++ b/linux/crates/app/src/ui/export/export_source.rs @@ -0,0 +1,33 @@ +use std::sync::Arc; + +use tablepro_core::column::ResultColumn; +use tablepro_session::Session; + +use super::ExportQuery; +use crate::ui::grid::RowSnapshot; + +/// Where an export reads its rows. +#[derive(Debug, Clone)] +pub enum ExportSource { + /// The rows a grid showed when the user asked, pending edits + /// included. They share the cells the grid holds rather than copy + /// them. + Rows { + columns: Arc<[ResultColumn]>, + rows: Vec, + }, + /// A read streamed from the server through `session`, every row of + /// it. + Query { session: Arc, query: ExportQuery }, +} + +impl ExportSource { + /// The rows it holds. `None` for a read, whose count only the + /// server knows. + pub fn row_count(&self) -> Option { + match self { + Self::Rows { rows, .. } => Some(rows.len()), + Self::Query { .. } => None, + } + } +} diff --git a/linux/crates/app/src/ui/export/mod.rs b/linux/crates/app/src/ui/export/mod.rs new file mode 100644 index 0000000000..b9f246bfb7 --- /dev/null +++ b/linux/crates/app/src/ui/export/mod.rs @@ -0,0 +1,40 @@ +//! Exports that write their file off the GTK thread: rows a grid holds, +//! or every row of a read streamed from the server, are encoded on the +//! runtime and handed over in chunks, GIO writes them, and an export +//! that stops or fails leaves the file as close to how it was as GIO +//! allows. The workspace's ledger tracks the running ones for the +//! header bar, for logout and for the close prompts. + +pub(super) mod dialog; +mod document_sink; +mod encoding_sink; +mod export_failure_text; +mod export_format; +mod export_job; +mod export_job_item; +mod export_ledger; +mod export_message; +mod export_operations_button; +mod export_outcome; +mod export_producer; +mod export_query; +mod export_scope; +mod export_sink_parts; +mod export_source; +mod replace_file_writer; + +pub(crate) use document_sink::DocumentSink; +pub(crate) use encoding_sink::EncodingSink; +pub(crate) use export_failure_text::{ExportReporter, failure_text}; +pub(crate) use export_format::{DocumentFormat, ExportFormat, InsertTarget, RowFormat}; +pub(crate) use export_job::{ExpectedRows, ExportJob, ExportJobId}; +pub(crate) use export_job_item::ExportJobItem; +pub(crate) use export_ledger::{ExportLedger, ExportLedgerInit}; +pub(crate) use export_message::{ExportMessage, ExportStreamFailure}; +pub(crate) use export_operations_button::ExportOperationsButton; +pub(crate) use export_outcome::{ExportFailure, ExportOutcome}; +pub(crate) use export_query::{ExportQuery, TableExport}; +pub(crate) use export_scope::{ExportOffer, ExportOrigin, ScopeChoice, ScopeSource, scope_choices}; +pub(crate) use export_sink_parts::ExportSinkParts; +pub(crate) use export_source::ExportSource; +pub(crate) use replace_file_writer::{AbortOutcome, FinishError, OpenError, ReplaceFileWriter}; diff --git a/linux/crates/app/src/ui/export/replace_file_writer/mod.rs b/linux/crates/app/src/ui/export/replace_file_writer/mod.rs new file mode 100644 index 0000000000..edf271bfc7 --- /dev/null +++ b/linux/crates/app/src/ui/export/replace_file_writer/mod.rs @@ -0,0 +1,284 @@ +use std::cell::RefCell; +use std::collections::HashSet; + +use futures::channel::oneshot; +use gtk4::prelude::*; +use gtk4::{gio, glib}; +use thiserror::Error; + +/// How a stopped export left its file. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum AbortOutcome { + /// The export made the file, and it is gone again. + Removed, + /// The file that was there is as it was. + Untouched, + /// The file holds part of the export. + Incomplete, +} + +/// A close that failed, and what it left of the file. +#[derive(Debug)] +pub(crate) struct FinishError { + pub error: glib::Error, + pub file: AbortOutcome, +} + +#[derive(Debug, Error)] +pub(crate) enum OpenError { + /// Another export in this process is writing the file. Both would + /// race for it, and the one that stopped would remove what the + /// other finished. + #[error("another export is writing to this file")] + InUse, + #[error(transparent)] + File(#[from] glib::Error), +} + +/// How the writer came to hold the file. +#[derive(Debug, Clone)] +enum Strategy { + /// `made` names the file the writer made, so a stopped export + /// removes that one and not a file put at its path since. `None` + /// where the backend has no inode, and the file then stays, since + /// it cannot be told from one put there since. + Created { made: Option }, + /// `before` is the file's tag when it was opened, to tell afterwards + /// whether a stopped export changed it. + Replaced { before: Option }, +} + +/// Writes an export to its file without losing the file that was there +/// when the export stops partway. +/// +/// GIO writes on its own worker threads, so the GTK thread only waits. +/// A local replace stream renames its temporary file over the +/// destination when it closes, unless the close sees a cancelled +/// cancellable, and dispose closes without one. GLib takes the +/// temporary-file path only for an existing regular file with a single +/// link whose directory takes the temporary file; otherwise it truncates +/// the destination when it opens it. So an abort leaves the old file +/// untouched only on that path, and comparing tags reports the rest. +pub(crate) struct ReplaceFileWriter { + file: gio::File, + /// Taken by `finish` or `abort`. One still here at drop belongs to an + /// export that never finished. + stream: Option, + strategy: Strategy, + /// Let go once the file is cleaned up, which for a dropped writer is + /// after its abort. + claim: Option, +} + +impl ReplaceFileWriter { + pub(crate) async fn open(file: &gio::File) -> Result { + let claim = DestinationClaim::take(file).ok_or(OpenError::InUse)?; + // An export holds table rows or query history, so a new file is + // the user's alone. A replaced file keeps the mode it had. + let (stream, strategy) = match file + .create_future(gio::FileCreateFlags::PRIVATE, glib::Priority::DEFAULT) + .await + { + Ok(stream) => { + let made = FileIdentity::of_stream(&stream).await; + (stream, Strategy::Created { made }) + } + Err(error) if error.matches(gio::IOErrorEnum::Exists) => { + let before = etag(file).await; + // Writing through a symbolic link rather than replacing + // it, as GtkSourceView's file saver does. + let stream = file + .replace_future(None, false, gio::FileCreateFlags::NONE, glib::Priority::DEFAULT) + .await?; + (stream, Strategy::Replaced { before }) + } + Err(error) => return Err(error.into()), + }; + Ok(Self { + file: file.clone(), + stream: Some(stream), + strategy, + claim: Some(claim), + }) + } + + pub(crate) async fn write(&self, chunk: Vec) -> Result<(), glib::Error> { + let Some(stream) = self.stream.as_ref() else { + return Err(closed()); + }; + match stream.write_all_future(chunk, glib::Priority::DEFAULT).await { + Ok((_, _, None)) => Ok(()), + Ok((_, _, Some(error))) | Err((_, error)) => Err(error), + } + } + + /// Close the file, which for a replaced one puts the new content in + /// its place. A close can still fail, on a network or full disk, and + /// the file is then cleaned up as a stopped export's is. + pub(crate) async fn finish(mut self) -> Result<(), FinishError> { + let Some(stream) = self.stream.take() else { + return Err(FinishError { + error: closed(), + file: AbortOutcome::Incomplete, + }); + }; + match stream.close_future(glib::Priority::DEFAULT).await { + Ok(()) => Ok(()), + Err(error) => Err(FinishError { + error, + file: left_behind(&self.file, &self.strategy).await, + }), + } + } + + /// Stop writing and leave the file as close to how it was as GIO + /// allows. + pub(crate) async fn abort(mut self) -> AbortOutcome { + match self.stream.take() { + Some(stream) => abort(&self.file, &stream, &self.strategy).await, + None => AbortOutcome::Incomplete, + } + } +} + +impl Drop for ReplaceFileWriter { + fn drop(&mut self) { + if let Some(stream) = self.stream.take() { + let (file, strategy, claim) = (self.file.clone(), self.strategy.clone(), self.claim.take()); + glib::spawn_future_local(async move { + let outcome = abort(&file, &stream, &strategy).await; + drop(claim); + tracing::debug!(?outcome, "an unfinished export was abandoned"); + }); + } + } +} + +async fn abort(file: &gio::File, stream: &gio::FileOutputStream, strategy: &Strategy) -> AbortOutcome { + close_cancelled(stream).await; + left_behind(file, strategy).await +} + +/// Remove a file the export made, if it is still the one at the path, +/// and tell whether one it replaced is as it was. A replace stream +/// whose close failed before its rename has dropped its temporary file, +/// so the old file can still be whole. +async fn left_behind(file: &gio::File, strategy: &Strategy) -> AbortOutcome { + match strategy { + Strategy::Created { made: None } => AbortOutcome::Incomplete, + Strategy::Created { made: Some(made) } => { + if FileIdentity::at_path(file).await != Some(*made) { + tracing::debug!("a stopped export's file was replaced at its path, which is left as it is"); + return AbortOutcome::Removed; + } + match file.delete_future(glib::Priority::DEFAULT).await { + Ok(()) => AbortOutcome::Removed, + Err(error) => { + tracing::warn!(%error, "a stopped export's file could not be removed"); + AbortOutcome::Incomplete + } + } + } + Strategy::Replaced { before } => match (before, etag(file).await) { + (Some(before), Some(after)) if *before == after => AbortOutcome::Untouched, + _ => AbortOutcome::Incomplete, + }, + } +} + +/// Close `stream` with a cancellable that is already cancelled, which +/// a replace stream takes as the order to drop its temporary file. +async fn close_cancelled(stream: &gio::FileOutputStream) { + let cancellable = gio::Cancellable::new(); + cancellable.cancel(); + let (sender, receiver) = oneshot::channel(); + stream.close_async(glib::Priority::DEFAULT, Some(&cancellable), move |closed| { + let _ = sender.send(closed); + }); + // The close reports the cancel; what it did to the file is read + // back by the caller. + let _ = receiver.await; +} + +async fn etag(file: &gio::File) -> Option { + file.query_info_future( + gio::FILE_ATTRIBUTE_ETAG_VALUE, + gio::FileQueryInfoFlags::NONE, + glib::Priority::DEFAULT, + ) + .await + .ok() + .and_then(|info| info.etag()) +} + +const IDENTITY_ATTRIBUTES: &str = "unix::device,unix::inode"; + +/// A file as the file system knows it, whatever path leads to it. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct FileIdentity { + device: u32, + inode: u64, +} + +impl FileIdentity { + async fn of_stream(stream: &gio::FileOutputStream) -> Option { + let info = stream + .query_info_future(IDENTITY_ATTRIBUTES, glib::Priority::DEFAULT) + .await + .inspect_err(|error| tracing::debug!(%error, "an export's new file has no identity")) + .ok()?; + Self::from_info(&info) + } + + async fn at_path(file: &gio::File) -> Option { + let info = file + .query_info_future( + IDENTITY_ATTRIBUTES, + gio::FileQueryInfoFlags::NOFOLLOW_SYMLINKS, + glib::Priority::DEFAULT, + ) + .await + .ok()?; + Self::from_info(&info) + } + + fn from_info(info: &gio::FileInfo) -> Option { + let known = + info.has_attribute(gio::FILE_ATTRIBUTE_UNIX_DEVICE) && info.has_attribute(gio::FILE_ATTRIBUTE_UNIX_INODE); + known.then(|| Self { + device: info.attribute_uint32(gio::FILE_ATTRIBUTE_UNIX_DEVICE), + inode: info.attribute_uint64(gio::FILE_ATTRIBUTE_UNIX_INODE), + }) + } +} + +thread_local! { + /// The destinations a writer in this process holds. Every window + /// writes to the same file system, so one list serves them all. + static CLAIMED: RefCell> = RefCell::default(); +} + +#[derive(Debug)] +struct DestinationClaim(String); + +impl DestinationClaim { + fn take(file: &gio::File) -> Option { + let uri = file.uri().to_string(); + CLAIMED + .with_borrow_mut(|claimed| claimed.insert(uri.clone())) + .then(|| Self(uri)) + } +} + +impl Drop for DestinationClaim { + fn drop(&mut self) { + CLAIMED.with_borrow_mut(|claimed| claimed.remove(&self.0)); + } +} + +fn closed() -> glib::Error { + glib::Error::new(gio::IOErrorEnum::Closed, "the export's file is already closed") +} + +#[cfg(test)] +mod tests; diff --git a/linux/crates/app/src/ui/export/replace_file_writer/tests.rs b/linux/crates/app/src/ui/export/replace_file_writer/tests.rs new file mode 100644 index 0000000000..7d88ebc38c --- /dev/null +++ b/linux/crates/app/src/ui/export/replace_file_writer/tests.rs @@ -0,0 +1,301 @@ +use std::future::Future; +use std::path::Path; +use std::time::{Duration, SystemTime}; + +use super::*; +use crate::test_support::{finish_on_main, wait_until}; + +const WAIT: Duration = Duration::from_secs(10); + +fn run(future: impl Future + 'static) -> T { + finish_on_main(WAIT, future).expect("the file operation to end") +} + +fn open(path: &Path) -> ReplaceFileWriter { + let file = gio::File::for_path(path); + run(async move { ReplaceFileWriter::open(&file).await }).expect("the writer") +} + +fn write(writer: ReplaceFileWriter, chunks: &[&str]) -> ReplaceFileWriter { + let chunks: Vec> = chunks.iter().map(|chunk| chunk.as_bytes().to_vec()).collect(); + run(async move { + for chunk in chunks { + writer.write(chunk).await.expect("a written chunk"); + } + writer + }) +} + +fn entries(dir: &Path) -> Vec { + let mut names: Vec = std::fs::read_dir(dir) + .expect("the directory") + .map(|entry| entry.expect("an entry").file_name().to_string_lossy().into_owned()) + .collect(); + names.sort(); + names +} + +/// A file last changed long ago, so a write now changes its tag. +fn seed(path: &Path, content: &str) { + std::fs::write(path, content).expect("seed"); + std::fs::File::options() + .write(true) + .open(path) + .and_then(|file| file.set_modified(SystemTime::UNIX_EPOCH + Duration::from_secs(1_000_000))) + .expect("an old modification time"); +} + +#[gtk4::test] +fn writer_creates_new_file_and_finish_keeps_it() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("export.csv"); + + let writer = write(open(&path), &["a,b\n", "1,2\n"]); + run(async move { writer.finish().await }).expect("closed"); + + assert_eq!(std::fs::read_to_string(&path).expect("read back"), "a,b\n1,2\n"); + assert_eq!(entries(dir.path()), ["export.csv"]); +} + +#[gtk4::test] +fn abort_after_create_removes_new_file() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("export.csv"); + + let writer = write(open(&path), &["a,b\n"]); + let outcome = run(async move { writer.abort().await }); + + assert_eq!(outcome, AbortOutcome::Removed); + assert!(entries(dir.path()).is_empty(), "{:?}", entries(dir.path())); +} + +#[gtk4::test] +fn abort_on_regular_file_leaves_content_and_reports_untouched() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("export.csv"); + seed(&path, "old"); + + let writer = write(open(&path), &["new and partial"]); + let outcome = run(async move { writer.abort().await }); + + assert_eq!(outcome, AbortOutcome::Untouched); + assert_eq!(std::fs::read_to_string(&path).expect("read back"), "old"); + assert_eq!( + entries(dir.path()), + ["export.csv"], + "the temporary file was left behind" + ); +} + +/// GLib renames its temporary file over the destination as the stream +/// closes. With that file gone the rename fails after every chunk was +/// written, the way a close on a full or network disk does. +#[gtk4::test] +fn failed_finish_on_regular_file_reports_untouched() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("export.csv"); + seed(&path, "old"); + + let writer = write(open(&path), &["new"]); + let temporary = entries(dir.path()) + .into_iter() + .find(|name| name != "export.csv") + .expect("the temporary file"); + std::fs::remove_file(dir.path().join(temporary)).expect("the temporary file removed"); + let failed = run(async move { writer.finish().await }).expect_err("the rename to fail"); + + assert_eq!(failed.file, AbortOutcome::Untouched); + assert_eq!(std::fs::read_to_string(&path).expect("read back"), "old"); + assert_eq!(entries(dir.path()), ["export.csv"]); +} + +#[gtk4::test] +fn file_made_by_export_whose_close_failed_is_removed() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("export.csv"); + std::fs::write(&path, "partial").expect("a partial file"); + let file = gio::File::for_path(&path); + + let outcome = run(async move { + let made = FileIdentity::at_path(&file).await; + assert!(made.is_some(), "a local file has an inode"); + left_behind(&file, &Strategy::Created { made }).await + }); + + assert_eq!(outcome, AbortOutcome::Removed); + assert!(entries(dir.path()).is_empty(), "{:?}", entries(dir.path())); +} + +#[gtk4::test] +fn file_whose_identity_is_unknown_is_kept() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("export.csv"); + std::fs::write(&path, "partial").expect("a partial file"); + let file = gio::File::for_path(&path); + + let outcome = run(async move { left_behind(&file, &Strategy::Created { made: None }).await }); + + assert_eq!(outcome, AbortOutcome::Incomplete); + assert_eq!(entries(dir.path()), ["export.csv"]); +} + +#[gtk4::test] +fn finish_through_symlink_keeps_link_and_writes_target() { + let dir = tempfile::tempdir().expect("tempdir"); + let target = dir.path().join("target.csv"); + let link = dir.path().join("link.csv"); + seed(&target, "old"); + std::os::unix::fs::symlink(&target, &link).expect("a link"); + + let writer = write(open(&link), &["new"]); + run(async move { writer.finish().await }).expect("closed"); + + assert!( + std::fs::symlink_metadata(&link) + .expect("the link") + .file_type() + .is_symlink(), + "the link was replaced by a file" + ); + assert_eq!(std::fs::read_to_string(&target).expect("read back"), "new"); +} + +#[gtk4::test] +fn abort_through_symlink_reports_incomplete() { + let dir = tempfile::tempdir().expect("tempdir"); + let target = dir.path().join("target.csv"); + let link = dir.path().join("link.csv"); + seed(&target, "old"); + std::os::unix::fs::symlink(&target, &link).expect("a link"); + + let writer = write(open(&link), &["partial"]); + let outcome = run(async move { writer.abort().await }); + + assert_eq!(outcome, AbortOutcome::Incomplete); +} + +#[gtk4::test] +fn dropping_unfinished_writer_leaves_existing_file_untouched() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("export.csv"); + seed(&path, "old"); + + let writer = write(open(&path), &["new and partial"]); + assert_eq!(entries(dir.path()).len(), 2, "no temporary file to write into"); + drop(writer); + + wait_until(WAIT, || entries(dir.path()).len() == 1).expect("the temporary file to go"); + crate::test_support::drain_main_context(); + assert_eq!(std::fs::read_to_string(&path).expect("read back"), "old"); +} + +/// A directory's permissions do not stop root, which the CI container +/// runs as, so a folder that is not there stands in for one the user +/// cannot write to. +#[gtk4::test] +fn writer_error_on_missing_directory_reports_glib_error() { + let dir = tempfile::tempdir().expect("tempdir"); + let file = gio::File::for_path(dir.path().join("missing").join("export.csv")); + + let error = run(async move { ReplaceFileWriter::open(&file).await.map(drop) }).expect_err("nowhere to write"); + + assert!( + matches!(&error, OpenError::File(error) if error.matches(gio::IOErrorEnum::NotFound)), + "{error}" + ); +} + +/// GLib's replace renames its temporary file over the path, as another +/// program's save does. The export's own file is gone then, and the +/// one at the path is not its to remove. +#[gtk4::test] +fn abort_after_create_keeps_a_file_put_at_the_path_since() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("export.csv"); + let writer = write(open(&path), &["partial"]); + let theirs = dir.path().join("theirs.tmp"); + std::fs::write(&theirs, "finished elsewhere").expect("their file"); + std::fs::rename(&theirs, &path).expect("their file renamed over the export"); + + let outcome = run(async move { writer.abort().await }); + + assert_eq!(outcome, AbortOutcome::Removed); + assert_eq!( + std::fs::read_to_string(&path).expect("their file"), + "finished elsewhere" + ); +} + +#[gtk4::test] +fn a_second_writer_to_a_file_being_written_is_refused() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("export.csv"); + let first = write(open(&path), &["first"]); + let try_open = || { + let file = gio::File::for_path(&path); + run(async move { ReplaceFileWriter::open(&file).await.map(drop) }) + }; + + let refused = try_open(); + let refused_again = try_open(); + run(async move { first.finish().await }).expect("closed"); + + assert!(matches!(refused, Err(OpenError::InUse)), "{refused:?}"); + assert!( + matches!(refused_again, Err(OpenError::InUse)), + "a refused open let go of the first writer's claim: {refused_again:?}" + ); + assert_eq!(std::fs::read_to_string(&path).expect("read back"), "first"); + let again = write(open(&path), &["again"]); + run(async move { again.finish().await }).expect("the file is free once the first closed"); + assert_eq!(std::fs::read_to_string(&path).expect("read back"), "again"); +} + +#[gtk4::test] +fn a_dropped_writer_frees_its_file_once_cleaned_up() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("export.csv"); + + drop(write(open(&path), &["partial"])); + // The file goes on a GIO thread, and the claim on the GTK thread once + // that delete has answered, so the file can be gone a while first. + let uri = gio::File::for_path(&path).uri().to_string(); + wait_until(WAIT, || { + entries(dir.path()).is_empty() && !CLAIMED.with_borrow(|claimed| claimed.contains(&uri)) + }) + .expect("the abandoned file to go, and its claim with it"); + + let writer = write(open(&path), &["next"]); + run(async move { writer.finish().await }).expect("closed"); + assert_eq!(std::fs::read_to_string(&path).expect("read back"), "next"); +} + +#[gtk4::test] +fn a_new_file_is_readable_by_its_owner_only() { + use std::os::unix::fs::PermissionsExt; + + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("export.csv"); + + let writer = write(open(&path), &["a,b\n"]); + run(async move { writer.finish().await }).expect("closed"); + + let mode = std::fs::metadata(&path).expect("the file").permissions().mode(); + assert_eq!(mode & 0o777, 0o600, "{mode:o}"); +} + +#[gtk4::test] +fn a_replaced_file_keeps_its_mode() { + use std::os::unix::fs::PermissionsExt; + + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("export.csv"); + seed(&path, "old"); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o640)).expect("a mode"); + + let writer = write(open(&path), &["new"]); + run(async move { writer.finish().await }).expect("closed"); + + let mode = std::fs::metadata(&path).expect("the file").permissions().mode(); + assert_eq!(mode & 0o777, 0o640, "{mode:o}"); +} diff --git a/linux/crates/app/src/ui/file_read.rs b/linux/crates/app/src/ui/file_read.rs new file mode 100644 index 0000000000..fc007e6cab --- /dev/null +++ b/linux/crates/app/src/ui/file_read.rs @@ -0,0 +1,128 @@ +use gtk4::glib; +use gtk4::prelude::*; + +const READ_CHUNK_BYTES: usize = 256 * 1024; + +/// Why a file the user chose was not read. The caller words it, since +/// what is too large depends on what the file was for. +#[derive(Debug)] +pub(crate) enum FileReadError { + /// A pipe, a device or a socket. Opening a pipe waits for a writer + /// that may never come, and a device has no end to read to. + NotRegular, + TooLarge, + Io(glib::Error), +} + +/// Read a file whole off the main thread, giving up once it passes +/// `limit` bytes. +pub(crate) async fn read_capped_file(file: &gio::File, limit: usize) -> Result, FileReadError> { + let info = file + .query_info_future( + "standard::type,standard::size", + gio::FileQueryInfoFlags::NONE, + glib::Priority::DEFAULT, + ) + .await; + if let Ok(info) = info { + if info.has_attribute(gio::FILE_ATTRIBUTE_STANDARD_TYPE) && info.file_type() == gio::FileType::Special { + return Err(FileReadError::NotRegular); + } + if info.has_attribute(gio::FILE_ATTRIBUTE_STANDARD_SIZE) + && u64::try_from(info.size()).is_ok_and(|size| size > limit as u64) + { + return Err(FileReadError::TooLarge); + } + } + let stream = file + .read_future(glib::Priority::DEFAULT) + .await + .map_err(FileReadError::Io)?; + let read = read_capped(stream.upcast_ref(), limit).await; + if let Err(error) = stream.close_future(glib::Priority::DEFAULT).await { + tracing::debug!(%error, "closing a file that was read whole failed"); + } + read +} + +/// Read to the end of the stream, giving up once it passes `limit`. +/// +/// The size a file reports is not enough: a remote file may report +/// none, and a file can grow between being measured and being read. +async fn read_capped(stream: &gio::InputStream, limit: usize) -> Result, FileReadError> { + let mut contents = Vec::new(); + loop { + let chunk = stream + .read_bytes_future(READ_CHUNK_BYTES, glib::Priority::DEFAULT) + .await + .map_err(FileReadError::Io)?; + if chunk.is_empty() { + return Ok(contents); + } + if contents.len() + chunk.len() > limit { + return Err(FileReadError::TooLarge); + } + contents.extend_from_slice(&chunk); + } +} + +#[cfg(test)] +mod tests { + use std::time::Duration; + + use super::*; + use crate::test_support::finish_on_main; + + const WAIT: Duration = Duration::from_secs(10); + + fn capped(bytes: Vec, limit: usize) -> Result, FileReadError> { + let stream = gio::MemoryInputStream::from_bytes(&glib::Bytes::from_owned(bytes)); + finish_on_main(WAIT, async move { read_capped(stream.upcast_ref(), limit).await }).expect("the read to end") + } + + fn read(path: &std::path::Path, limit: usize) -> Result, FileReadError> { + let file = gio::File::for_path(path); + finish_on_main(WAIT, async move { read_capped_file(&file, limit).await }).expect("the read to end") + } + + #[gtk4::test] + fn a_stream_past_the_limit_is_refused_without_a_reported_size() { + let read = capped(vec![b'x'; READ_CHUNK_BYTES * 3], READ_CHUNK_BYTES * 2); + + assert!(matches!(read, Err(FileReadError::TooLarge)), "{read:?}"); + } + + #[gtk4::test] + fn a_stream_at_the_limit_is_read_whole() { + let read = capped(vec![b'x'; READ_CHUNK_BYTES * 2], READ_CHUNK_BYTES * 2).expect("the contents"); + + assert_eq!(read.len(), READ_CHUNK_BYTES * 2); + } + + #[gtk4::test] + fn a_device_is_refused_before_it_is_read() { + let refused = read(std::path::Path::new("/dev/zero"), 1024); + + assert!(matches!(refused, Err(FileReadError::NotRegular)), "{refused:?}"); + } + + #[gtk4::test] + fn a_file_reporting_more_than_the_limit_is_refused() { + let root = tempfile::tempdir().expect("tempdir"); + let path = root.path().join("big"); + std::fs::write(&path, vec![b' '; 1025]).expect("write"); + + let refused = read(&path, 1024); + + assert!(matches!(refused, Err(FileReadError::TooLarge)), "{refused:?}"); + } + + #[gtk4::test] + fn a_missing_file_is_an_io_error() { + let root = tempfile::tempdir().expect("tempdir"); + + let missing = read(&root.path().join("gone"), 1024); + + assert!(matches!(missing, Err(FileReadError::Io(_))), "{missing:?}"); + } +} diff --git a/linux/crates/app/src/ui/file_reveal/file_launcher_revealer.rs b/linux/crates/app/src/ui/file_reveal/file_launcher_revealer.rs new file mode 100644 index 0000000000..d433f53919 --- /dev/null +++ b/linux/crates/app/src/ui/file_reveal/file_launcher_revealer.rs @@ -0,0 +1,15 @@ +use futures::future::LocalBoxFuture; +use gtk4 as gtk; +use gtk4::{gio, glib}; + +use super::FileRevealer; + +/// Opens the folder with the file selected, through the file manager or, +/// inside Flatpak, the OpenURI portal. +pub(crate) struct FileLauncherRevealer; + +impl FileRevealer for FileLauncherRevealer { + fn reveal(&self, file: gio::File, parent: Option) -> LocalBoxFuture<'static, Result<(), glib::Error>> { + gtk::FileLauncher::new(Some(&file)).open_containing_folder_future(parent.as_ref()) + } +} diff --git a/linux/crates/app/src/ui/file_reveal/file_revealer.rs b/linux/crates/app/src/ui/file_reveal/file_revealer.rs new file mode 100644 index 0000000000..18b9dbdbf9 --- /dev/null +++ b/linux/crates/app/src/ui/file_reveal/file_revealer.rs @@ -0,0 +1,9 @@ +use futures::future::LocalBoxFuture; +use gtk4 as gtk; +use gtk4::{gio, glib}; + +/// Shows a file in the file manager. A trait so a test can see what +/// would have been shown without starting one. +pub(crate) trait FileRevealer { + fn reveal(&self, file: gio::File, parent: Option) -> LocalBoxFuture<'static, Result<(), glib::Error>>; +} diff --git a/linux/crates/app/src/ui/file_reveal/mod.rs b/linux/crates/app/src/ui/file_reveal/mod.rs new file mode 100644 index 0000000000..a0500bc881 --- /dev/null +++ b/linux/crates/app/src/ui/file_reveal/mod.rs @@ -0,0 +1,13 @@ +//! Showing a file in the file manager, selected in its folder. + +mod file_launcher_revealer; +mod file_revealer; +#[cfg(test)] +mod recording_revealer; +mod reveal_failure_toast; + +pub(crate) use file_launcher_revealer::FileLauncherRevealer; +pub(crate) use file_revealer::FileRevealer; +#[cfg(test)] +pub(crate) use recording_revealer::RecordingRevealer; +pub(crate) use reveal_failure_toast::reveal_failure_toast; diff --git a/linux/crates/app/src/ui/file_reveal/recording_revealer.rs b/linux/crates/app/src/ui/file_reveal/recording_revealer.rs new file mode 100644 index 0000000000..4425dd7607 --- /dev/null +++ b/linux/crates/app/src/ui/file_reveal/recording_revealer.rs @@ -0,0 +1,49 @@ +use std::cell::RefCell; +use std::path::PathBuf; + +use futures::future::LocalBoxFuture; +use gtk4 as gtk; +use gtk4::prelude::*; +use gtk4::{gio, glib}; + +use super::FileRevealer; + +/// Remembers each file it was asked to show and answers with `outcome`. +pub(crate) struct RecordingRevealer { + revealed: RefCell>, + outcome: Result<(), glib::Error>, +} + +impl RecordingRevealer { + pub(crate) fn succeeding() -> Self { + Self { + revealed: RefCell::default(), + outcome: Ok(()), + } + } + + pub(crate) fn failing() -> Self { + Self { + revealed: RefCell::default(), + outcome: Err(glib::Error::new(gio::IOErrorEnum::NotSupported, "no file manager")), + } + } + + pub(crate) fn revealed(&self) -> Vec { + self.revealed.borrow().clone() + } +} + +impl FileRevealer for RecordingRevealer { + fn reveal( + &self, + file: gio::File, + _parent: Option, + ) -> LocalBoxFuture<'static, Result<(), glib::Error>> { + if let Some(path) = file.path() { + self.revealed.borrow_mut().push(path); + } + let outcome = self.outcome.clone(); + Box::pin(async move { outcome }) + } +} diff --git a/linux/crates/app/src/ui/file_reveal/reveal_failure_toast.rs b/linux/crates/app/src/ui/file_reveal/reveal_failure_toast.rs new file mode 100644 index 0000000000..fdc8e679d5 --- /dev/null +++ b/linux/crates/app/src/ui/file_reveal/reveal_failure_toast.rs @@ -0,0 +1,35 @@ +use gtk4::glib; +use libadwaita as adw; + +use crate::i18n::gettext; + +/// What to tell the user when the file manager could not show the file. +/// The error is logged; its text is the platform's, not ours. +pub(crate) fn reveal_failure_toast(result: &Result<(), glib::Error>) -> Option { + let Err(error) = result else { + return None; + }; + tracing::warn!(%error, "could not show a file in the file manager"); + Some(crate::ui::plain_toast(&gettext("Could not show the file in Files"))) +} + +#[cfg(test)] +mod tests { + use gtk4::gio; + + use super::*; + + #[gtk4::test] + fn failed_reveal_produces_toast() { + let failed = Err(glib::Error::new(gio::IOErrorEnum::NotSupported, "no file manager")); + + let toast = reveal_failure_toast(&failed).expect("a toast"); + + assert_eq!(toast.title().as_deref(), Some("Could not show the file in Files")); + assert!(!toast.uses_markup()); + assert!( + reveal_failure_toast(&Ok(())).is_none(), + "a reveal that worked made a toast" + ); + } +} diff --git a/linux/crates/app/src/ui/file_write.rs b/linux/crates/app/src/ui/file_write.rs new file mode 100644 index 0000000000..da37c3ca01 --- /dev/null +++ b/linux/crates/app/src/ui/file_write.rs @@ -0,0 +1,81 @@ +use gtk4::glib; +use gtk4::prelude::*; + +/// Replace `file` with `contents`. GIO writes a local file on its own +/// worker thread, so a slow disk or a network mount never stalls the +/// window that asked. +/// +/// A new file is the user's alone, as a row export is: the connection +/// export names every host, user and key path. A replaced file keeps +/// the mode it had. +pub(crate) async fn replace_file(file: &gio::File, contents: Vec) -> Result<(), glib::Error> { + file.replace_contents_future(contents, None, false, gio::FileCreateFlags::PRIVATE) + .await + .map(drop) + .map_err(|(_, error)| error) +} + +#[cfg(test)] +mod tests { + use std::time::Duration; + + use super::*; + + fn replace(file: gio::File, contents: &[u8]) -> Result<(), glib::Error> { + let contents = contents.to_vec(); + crate::test_support::finish_on_main( + Duration::from_secs(10), + async move { replace_file(&file, contents).await }, + ) + .expect("the write to end") + } + + #[gtk4::test] + fn a_file_is_replaced_whole() { + let root = tempfile::tempdir().expect("tempdir"); + let path = root.path().join("export.csv"); + std::fs::write(&path, "an older, longer export\n").expect("seed"); + + replace(gio::File::for_path(&path), b"a,b\n").expect("written"); + + assert_eq!(std::fs::read(&path).expect("read back"), b"a,b\n"); + } + + #[gtk4::test] + fn a_new_file_is_readable_by_its_owner_only() { + use std::os::unix::fs::PermissionsExt; + + let root = tempfile::tempdir().expect("tempdir"); + let path = root.path().join("tablepro-connections.json"); + + replace(gio::File::for_path(&path), b"{}").expect("written"); + + let mode = std::fs::metadata(&path).expect("the file").permissions().mode(); + assert_eq!(mode & 0o777, 0o600, "{mode:o}"); + } + + #[gtk4::test] + fn a_replaced_file_keeps_its_mode() { + use std::os::unix::fs::PermissionsExt; + + let root = tempfile::tempdir().expect("tempdir"); + let path = root.path().join("tablepro-connections.json"); + std::fs::write(&path, "{}").expect("seed"); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o640)).expect("a mode"); + + replace(gio::File::for_path(&path), b"{\"connections\":[]}").expect("written"); + + let mode = std::fs::metadata(&path).expect("the file").permissions().mode(); + assert_eq!(mode & 0o777, 0o640, "{mode:o}"); + } + + #[gtk4::test] + fn a_folder_that_is_not_there_is_an_error() { + let root = tempfile::tempdir().expect("tempdir"); + let file = gio::File::for_path(root.path().join("missing").join("export.csv")); + + let error = replace(file, b"a,b\n").expect_err("no folder to write into"); + + assert!(error.matches(gio::IOErrorEnum::NotFound), "{error}"); + } +} diff --git a/linux/crates/app/src/ui/filter_strip/filter_rule_object.rs b/linux/crates/app/src/ui/filter_strip/filter_rule_object.rs new file mode 100644 index 0000000000..45fa0d1782 --- /dev/null +++ b/linux/crates/app/src/ui/filter_strip/filter_rule_object.rs @@ -0,0 +1,55 @@ +use std::cell::RefCell; + +use gtk4::glib; +use gtk4::subclass::prelude::*; +use tablepro_core::{FilterOp, FilterRule}; + +mod imp { + use super::*; + + pub struct FilterRuleObject { + pub(super) rule: RefCell, + } + + impl Default for FilterRuleObject { + fn default() -> Self { + Self { + rule: RefCell::new(FilterRule { + column: String::new(), + op: FilterOp::Eq, + value: None, + }), + } + } + } + + #[glib::object_subclass] + impl ObjectSubclass for FilterRuleObject { + const NAME: &'static str = "TableProFilterRuleObject"; + type Type = super::FilterRuleObject; + } + + impl ObjectImpl for FilterRuleObject {} +} + +glib::wrapper! { + /// One rule as the strip's list model holds it. Its row edits it in + /// place, so Apply reads what the user typed from the model alone. + pub struct FilterRuleObject(ObjectSubclass); +} + +impl FilterRuleObject { + pub(super) fn new(rule: FilterRule) -> Self { + let object: Self = glib::Object::new(); + object.imp().rule.replace(rule); + object + } + + pub(super) fn rule(&self) -> FilterRule { + self.imp().rule.borrow().clone() + } + + pub(super) fn update(&self, change: impl FnOnce(&mut FilterRule)) { + change(&mut self.imp().rule.borrow_mut()); + } +} diff --git a/linux/crates/app/src/ui/filter_strip/imp.rs b/linux/crates/app/src/ui/filter_strip/imp.rs new file mode 100644 index 0000000000..a70cbda66b --- /dev/null +++ b/linux/crates/app/src/ui/filter_strip/imp.rs @@ -0,0 +1,97 @@ +use std::cell::{Cell, OnceCell, RefCell}; +use std::rc::Rc; +use std::sync::OnceLock; + +use glib::subclass::Signal; +use gtk4 as gtk; +use gtk4::glib; +use gtk4::prelude::*; +use gtk4::subclass::prelude::*; +use libadwaita as adw; +use libadwaita::subclass::prelude::*; +use tablepro_core::ColumnInfo; + +use crate::application::{ShortcutOwner, local_shortcut_controller}; + +#[derive(Default, gtk::CompositeTemplate, glib::Properties)] +#[template(resource = "/app/tablepro/TablePro/ui/filter-strip.ui")] +#[properties(wrapper_type = super::FilterStrip)] +pub struct FilterStrip { + #[template_child] + pub revealer: TemplateChild, + #[template_child] + pub match_revealer: TemplateChild, + #[template_child] + pub combinator_dropdown: TemplateChild, + #[template_child] + pub clear_button: TemplateChild, + #[template_child] + pub apply_button: TemplateChild, + #[template_child] + pub close_button: TemplateChild, + #[template_child] + pub raw_entry: TemplateChild, + #[template_child] + pub rules_expander: TemplateChild, + #[template_child] + pub rule_list: TemplateChild, + #[template_child] + pub add_rule_button: TemplateChild, + + #[property(get, set)] + pub revealed: Cell, + + /// The rules being edited, one `FilterRuleObject` each. + pub(super) rules: OnceCell, + /// The columns a rule can name, blobs left out. + pub(super) columns: RefCell>, + /// Where the focus was when it came into the strip, and goes back to + /// when the strip closes. + pub(super) focus_before: glib::WeakRef, +} + +#[glib::object_subclass] +impl ObjectSubclass for FilterStrip { + const NAME: &'static str = "TableProFilterStrip"; + type Type = super::FilterStrip; + type ParentType = adw::Bin; + + fn class_init(klass: &mut Self::Class) { + klass.bind_template(); + klass.install_action("filter.close", None, |strip, _, _| strip.close()); + klass.install_action("filter.apply", None, |strip, _, _| strip.apply()); + klass.install_action("filter.clear", None, |strip, _, _| strip.clear()); + klass.install_action("filter.add-rule", None, |strip, _, _| strip.add_rule()); + } + + fn instance_init(object: &glib::subclass::InitializingObject) { + object.init_template(); + } +} + +#[glib::derived_properties] +impl ObjectImpl for FilterStrip { + fn signals() -> &'static [Signal] { + static SIGNALS: OnceLock> = OnceLock::new(); + SIGNALS.get_or_init(|| { + vec![ + Signal::builder("applied") + .param_types([glib::BoxedAnyObject::static_type()]) + .build(), + ] + }) + } + + fn constructed(&self) { + self.parent_constructed(); + let strip = self.obj(); + let close = self.close_button.get(); + close.set_icon_name(crate::ui::icons::WINDOW_CLOSE); + crate::ui::a11y::describe_icon_button(&close, &crate::i18n::gettext("Close"), Some("filter.close")); + strip.add_controller(local_shortcut_controller(ShortcutOwner::FilterStrip)); + strip.connect_parts(); + } +} + +impl WidgetImpl for FilterStrip {} +impl BinImpl for FilterStrip {} diff --git a/linux/crates/app/src/ui/filter_strip/mod.rs b/linux/crates/app/src/ui/filter_strip/mod.rs new file mode 100644 index 0000000000..a3a6ab1f09 --- /dev/null +++ b/linux/crates/app/src/ui/filter_strip/mod.rs @@ -0,0 +1,234 @@ +//! The inline filter strip: a WHERE clause editor that slides in above +//! the browse tab's grid, from the Filter button or Ctrl+F. +//! +//! Inline rather than a dialog: the user is filtering rows they can +//! see, so the strip stays open while they tune a rule, applies on +//! demand, and closes with Escape or Close, as GtkSearchBar does. + +mod filter_rule_object; +mod imp; +mod rule_operators; +mod rule_row; +mod rule_value_editor; +#[cfg(test)] +mod tests; + +use std::rc::Rc; + +use gtk4 as gtk; +use gtk4::glib; +use gtk4::prelude::*; +use gtk4::subclass::prelude::*; +use libadwaita as adw; +use tablepro_core::{ColumnInfo, Combinator, FilterOp, FilterRule, FilterSet, FilterValue}; + +use self::filter_rule_object::FilterRuleObject; +use self::rule_operators::is_filterable; +pub(super) use self::rule_row::FilterRuleRow; + +glib::wrapper! { + /// The browse tab's filter editor. The rules live in a list model + /// the rows are built from, and Apply reports the filter through + /// `applied`. + pub struct FilterStrip(ObjectSubclass) + @extends adw::Bin, gtk::Widget, + @implements gtk::Accessible, gtk::Buildable, gtk::ConstraintTarget; +} + +impl FilterStrip { + /// A strip showing `initial`, with its rule editor open when the + /// saved filter has rules in it. + pub fn new(columns: Vec, initial: &FilterSet) -> Self { + let strip: Self = glib::Object::new(); + strip.set_columns(columns); + strip.set_filter(initial); + strip.imp().rules_expander.set_expanded(!initial.rules.is_empty()); + strip + } + + /// Slide in, or out, as the Filter button does. + pub fn toggle(&self) { + match self.revealed() { + true => self.close(), + false => self.open(), + } + } + + /// Slide in with the cursor in the SQL field, the strip's main input, + /// or, already in, take the focus there, as Ctrl+F does in a search + /// bar that shows. + pub fn open(&self) { + if crate::ui::a11y::holds_focus(self) { + return; + } + let focus = self.root().and_then(|root| root.focus()); + self.imp().focus_before.set(focus.as_ref()); + self.set_revealed(true); + self.imp().raw_entry.grab_focus(); + } + + /// Slide out. The focus inside goes back where it came from, before + /// GTK moves it to the window's first control. + fn close(&self) { + if crate::ui::a11y::holds_focus(self) + && let Some(before) = self.imp().focus_before.upgrade() + && before.root() == self.root() + { + before.grab_focus(); + } + self.set_revealed(false); + } + + /// Rebuild the rows against new columns: each row's column and + /// operator choices come from them. + pub fn set_columns(&self, columns: Vec) { + let filterable: Rc<[ColumnInfo]> = columns.into_iter().filter(is_filterable).collect(); + self.imp().columns.replace(filterable); + let rules = self.rules(); + let items: Vec = rules.iter::().filter_map(Result::ok).collect(); + rules.splice(0, rules.n_items(), &items); + } + + /// Show the filter in effect, which may have been applied from + /// elsewhere, such as a saved filter restored when the tab opens. + pub fn set_filter(&self, set: &FilterSet) { + let imp = self.imp(); + imp.combinator_dropdown.set_selected(match set.combinator { + Combinator::And => 0, + Combinator::Or => 1, + }); + imp.raw_entry.set_text(set.extra_sql.as_deref().unwrap_or_default()); + let items: Vec = set.rules.iter().cloned().map(FilterRuleObject::new).collect(); + let rules = self.rules(); + rules.splice(0, rules.n_items(), &items); + } + + /// The filter as the strip shows it now. + pub fn filter(&self) -> FilterSet { + let imp = self.imp(); + let text = imp.raw_entry.text(); + FilterSet { + combinator: match imp.combinator_dropdown.selected() { + 1 => Combinator::Or, + _ => Combinator::And, + }, + rules: self + .rules() + .iter::() + .filter_map(Result::ok) + .map(|rule| rule.rule()) + .collect(), + extra_sql: (!text.trim().is_empty()).then(|| text.to_string()), + } + } + + /// The user applied a filter, or cleared it. + pub fn connect_applied(&self, f: F) -> glib::SignalHandlerId { + self.connect_closure( + "applied", + false, + glib::closure_local!(move |strip: &Self, payload: glib::BoxedAnyObject| { + f(strip, payload.borrow::().clone()); + }), + ) + } + + fn rules(&self) -> gio::ListStore { + self.imp() + .rules + .get_or_init(gio::ListStore::new::) + .clone() + } + + fn apply(&self) { + self.emit_applied(self.filter()); + } + + /// Clear reports an empty filter and closes. The strip itself + /// empties only when the browse tab applies it, through `set_filter`. + fn clear(&self) { + self.emit_applied(FilterSet::default()); + } + + fn emit_applied(&self, set: FilterSet) { + self.emit_by_name::<()>("applied", &[&glib::BoxedAnyObject::new(set)]); + self.close(); + } + + fn add_rule(&self) { + let column = self + .imp() + .columns + .borrow() + .first() + .map(|column| column.name.clone()) + .unwrap_or_default(); + self.rules().append(&FilterRuleObject::new(FilterRule { + column, + op: FilterOp::Eq, + value: Some(FilterValue::Single(String::new())), + })); + } + + fn remove_rule(&self, rule: &FilterRuleObject) { + let rules = self.rules(); + if let Some(position) = rules.find(rule) { + rules.remove(position); + } + } + + fn connect_parts(&self) { + let imp = self.imp(); + let rules = self.rules(); + let strip = self.downgrade(); + imp.rule_list.bind_model(Some(&rules), move |item| { + let row = strip + .upgrade() + .zip(item.downcast_ref::()) + .map(|(strip, rule)| strip.row_for(rule)); + row.map_or_else(|| gtk::ListBoxRow::new().upcast(), Cast::upcast) + }); + let strip = self.downgrade(); + rules.connect_items_changed(move |_, _, _, _| { + if let Some(strip) = strip.upgrade() { + strip.reflect_clauses(); + } + }); + + let strip = self.downgrade(); + imp.raw_entry.connect_changed(move |_| { + if let Some(strip) = strip.upgrade() { + strip.reflect_clauses(); + } + }); + let strip = self.downgrade(); + imp.raw_entry.connect_activate(move |_| { + if let Some(strip) = strip.upgrade() { + strip.apply(); + } + }); + self.reflect_clauses(); + } + + fn row_for(&self, rule: &FilterRuleObject) -> FilterRuleRow { + let row = FilterRuleRow::new(rule, self.imp().columns.borrow().clone()); + let strip = self.downgrade(); + row.connect_remove(move |row| { + if let (Some(strip), Some(rule)) = (strip.upgrade(), row.rule()) { + strip.remove_rule(&rule); + } + }); + row + } + + /// All or any means something only once two clauses meet: two rules, + /// or a rule and the SQL. An empty rule list is hidden, so the strip + /// reads as ready for input rather than already filled. + fn reflect_clauses(&self) { + let imp = self.imp(); + let rules = self.rules().n_items(); + let sql = !imp.raw_entry.text().trim().is_empty(); + imp.match_revealer.set_reveal_child(rules >= 2 || (rules >= 1 && sql)); + imp.rule_list.set_visible(rules > 0); + } +} diff --git a/linux/crates/app/src/ui/filter_strip/rule_operators.rs b/linux/crates/app/src/ui/filter_strip/rule_operators.rs new file mode 100644 index 0000000000..206ac90fd0 --- /dev/null +++ b/linux/crates/app/src/ui/filter_strip/rule_operators.rs @@ -0,0 +1,140 @@ +use tablepro_core::{ColumnInfo, FilterOp, FilterValue}; + +/// An operator as the operator dropdown shows it. +pub(super) struct OpEntry { + pub(super) op: FilterOp, + pub(super) label: &'static str, + pub(super) shape: ValueShape, +} + +/// What a rule compares against, which decides the value editor. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum ValueShape { + None, + Single, + Pair, + List, +} + +impl ValueShape { + /// The blank value a rule takes when its operator changes to one of + /// this shape. + pub(super) fn empty_value(self) -> Option { + match self { + Self::None => None, + Self::Single => Some(FilterValue::Single(String::new())), + Self::Pair => Some(FilterValue::Pair(String::new(), String::new())), + Self::List => Some(FilterValue::List(Vec::new())), + } + } +} + +/// The operators this column allows, with the label and value shape +/// the dropdown needs for each. +/// +/// The set itself comes from core, so what the strip offers and what +/// the builder accepts cannot drift apart: an operator the user can +/// pick is one the filter can be built from. +pub(super) fn operators_for(column: &ColumnInfo) -> Vec { + tablepro_core::operators_for(&column.column_type.kind) + .iter() + .map(|op| OpEntry { + op: *op, + label: label_for(*op), + shape: shape_for(*op), + }) + .collect() +} + +fn label_for(op: FilterOp) -> &'static str { + match op { + FilterOp::Eq => "equals", + FilterOp::NotEq => "doesn't equal", + FilterOp::Lt => "less than", + FilterOp::LtEq => "at most", + FilterOp::Gt => "greater than", + FilterOp::GtEq => "at least", + FilterOp::Contains => "contains", + FilterOp::StartsWith => "starts with", + FilterOp::EndsWith => "ends with", + FilterOp::Like => "matches pattern", + FilterOp::NotLike => "doesn't match pattern", + FilterOp::Ilike => "matches, ignoring case", + FilterOp::IsNull => "is empty", + FilterOp::IsNotNull => "is not empty", + FilterOp::In => "is one of", + FilterOp::NotIn => "is none of", + FilterOp::Between => "between", + } +} + +fn shape_for(op: FilterOp) -> ValueShape { + match op { + FilterOp::IsNull | FilterOp::IsNotNull => ValueShape::None, + FilterOp::In | FilterOp::NotIn => ValueShape::List, + FilterOp::Between => ValueShape::Pair, + _ => ValueShape::Single, + } +} + +/// A column the user can filter on at all. +/// +/// A blob is left out of the dropdown: nothing they could type would +/// compare against one, so offering it would only lead to a filter +/// that never matches. +pub(super) fn is_filterable(column: &ColumnInfo) -> bool { + !matches!(column.column_type.kind, tablepro_core::column::ColumnKind::Bytes { .. }) +} + +/// A column the strip can fall back on when the schema has not +/// loaded, so a saved rule still renders with its own name. +pub(super) fn placeholder_column(name: &str) -> ColumnInfo { + ColumnInfo { + name: name.to_owned(), + column_type: tablepro_core::column::ColumnType::unknown(), + nullable: true, + primary_key: false, + is_auto_increment: false, + is_generated: false, + default: tablepro_core::column::ColumnDefault::None, + comment: None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn each_shape_starts_blank() { + assert_eq!(ValueShape::None.empty_value(), None); + assert_eq!( + ValueShape::Single.empty_value(), + Some(FilterValue::Single(String::new())) + ); + assert_eq!( + ValueShape::Pair.empty_value(), + Some(FilterValue::Pair(String::new(), String::new())) + ); + assert_eq!(ValueShape::List.empty_value(), Some(FilterValue::List(Vec::new()))); + } + + #[test] + fn a_blob_is_not_filterable() { + let mut blob = placeholder_column("payload"); + blob.column_type.kind = tablepro_core::column::ColumnKind::Bytes { + length: tablepro_core::column::TextLength::Unbounded, + }; + + assert!(!is_filterable(&blob)); + assert!(is_filterable(&placeholder_column("name"))); + } + + #[test] + fn the_null_tests_take_no_value_and_between_takes_two() { + assert_eq!(shape_for(FilterOp::IsNull), ValueShape::None); + assert_eq!(shape_for(FilterOp::Between), ValueShape::Pair); + assert_eq!(shape_for(FilterOp::In), ValueShape::List); + assert_eq!(shape_for(FilterOp::Eq), ValueShape::Single); + } +} diff --git a/linux/crates/app/src/ui/filter_strip/rule_row.rs b/linux/crates/app/src/ui/filter_strip/rule_row.rs new file mode 100644 index 0000000000..4481e283f6 --- /dev/null +++ b/linux/crates/app/src/ui/filter_strip/rule_row.rs @@ -0,0 +1,288 @@ +use std::cell::{Cell, OnceCell}; +use std::rc::Rc; +use std::sync::OnceLock; + +use glib::subclass::Signal; +use gtk4 as gtk; +use gtk4::glib; +use gtk4::prelude::*; +use gtk4::subclass::prelude::*; +use libadwaita as adw; +use libadwaita::prelude::*; +use tablepro_core::ColumnInfo; + +use super::filter_rule_object::FilterRuleObject; +use super::rule_operators::{ValueShape, operators_for, placeholder_column}; +use super::rule_value_editor::value_editor; + +mod imp { + use libadwaita::subclass::prelude::*; + + use super::*; + + #[derive(Default, gtk::CompositeTemplate)] + #[template(resource = "/app/tablepro/TablePro/ui/filter-rule-row.ui")] + pub struct FilterRuleRow { + #[template_child] + pub column_dropdown: TemplateChild, + #[template_child] + pub operator_dropdown: TemplateChild, + #[template_child] + pub value_slot: TemplateChild, + #[template_child] + pub remove_button: TemplateChild, + pub(super) rule: OnceCell, + /// The strip's filterable columns when the row was built. + pub(super) columns: OnceCell>, + /// Set while the row fills its own dropdowns, whose `selected` + /// notifications are then not the user's choice. + pub(super) syncing: Cell, + } + + #[glib::object_subclass] + impl ObjectSubclass for FilterRuleRow { + const NAME: &'static str = "TableProFilterRuleRow"; + type Type = super::FilterRuleRow; + type ParentType = adw::ActionRow; + + fn class_init(klass: &mut Self::Class) { + klass.bind_template(); + klass.install_action("rule.remove", None, |row, _, _| { + row.emit_by_name::<()>("remove", &[]); + }); + } + + fn instance_init(object: &glib::subclass::InitializingObject) { + object.init_template(); + } + } + + impl ObjectImpl for FilterRuleRow { + fn signals() -> &'static [Signal] { + static SIGNALS: OnceLock> = OnceLock::new(); + SIGNALS.get_or_init(|| vec![Signal::builder("remove").build()]) + } + + fn constructed(&self) { + self.parent_constructed(); + let remove = self.remove_button.get(); + remove.set_icon_name(crate::ui::icons::USER_TRASH); + crate::ui::a11y::describe_icon_button(&remove, &crate::i18n::gettext("Remove Rule"), None); + // The row has no title, its controls being the rule, and + // libadwaita names a row by its title label alone. + let row = self.obj(); + let row = row.upcast_ref::(); + row.reset_relation(gtk::AccessibleRelation::LabelledBy); + row.update_property(&[gtk::accessible::Property::Label(&crate::i18n::gettext("Filter Rule"))]); + } + } + + impl WidgetImpl for FilterRuleRow {} + impl ListBoxRowImpl for FilterRuleRow {} + impl PreferencesRowImpl for FilterRuleRow {} + impl ActionRowImpl for FilterRuleRow {} +} + +glib::wrapper! { + /// One rule of the filter strip: its column, its operator and the + /// value it compares against, each written into the rule as the + /// user picks it. + pub struct FilterRuleRow(ObjectSubclass) + @extends adw::ActionRow, adw::PreferencesRow, gtk::ListBoxRow, gtk::Widget, + @implements gtk::Accessible, gtk::Actionable, gtk::Buildable, gtk::ConstraintTarget; +} + +impl Default for FilterRuleRow { + fn default() -> Self { + glib::Object::new() + } +} + +impl FilterRuleRow { + pub(super) fn new(rule: &FilterRuleObject, columns: Rc<[ColumnInfo]>) -> Self { + let row = Self::default(); + let imp = row.imp(); + let _ = imp.rule.set(rule.clone()); + let _ = imp.columns.set(columns); + row.show_rule(); + row.connect_choices(); + row + } + + /// The rule the row edits. + pub(super) fn rule(&self) -> Option { + self.imp().rule.get().cloned() + } + + /// The user asked to drop the row's rule. + pub(super) fn connect_remove(&self, f: F) -> glib::SignalHandlerId { + self.connect_closure("remove", false, glib::closure_local!(move |row: &Self| f(row))) + } + + fn columns(&self) -> &[ColumnInfo] { + self.imp().columns.get().map(|columns| &columns[..]).unwrap_or_default() + } + + fn rule_column_name(&self) -> String { + self.rule().map(|rule| rule.rule().column).unwrap_or_default() + } + + /// Where the rule's column is among the choices. A rule whose column + /// is not in the table shows the first one. + fn shown_index(&self) -> usize { + let name = self.rule_column_name(); + self.columns() + .iter() + .position(|column| column.name == name) + .unwrap_or(0) + } + + /// The column the dropdown shows for the rule, or, with no columns + /// loaded yet, one under the rule's own name. + fn shown_column(&self) -> ColumnInfo { + self.columns() + .get(self.shown_index()) + .cloned() + .unwrap_or_else(|| placeholder_column(&self.rule_column_name())) + } + + fn show_rule(&self) { + let imp = self.imp(); + let names: Vec<&str> = self.columns().iter().map(|column| column.name.as_str()).collect(); + imp.syncing.set(true); + imp.column_dropdown.set_model(Some(>k::StringList::new(&names))); + imp.column_dropdown + .set_selected(u32::try_from(self.shown_index()).unwrap_or_default()); + imp.syncing.set(false); + self.show_operators(); + } + + fn show_operators(&self) { + let imp = self.imp(); + let Some(rule) = self.rule() else { + return; + }; + let column = self.shown_column(); + let ops = operators_for(&column); + let labels: Vec<&str> = ops.iter().map(|entry| entry.label).collect(); + let op = rule.rule().op; + let picked = ops.iter().position(|entry| entry.op == op).unwrap_or(0); + imp.syncing.set(true); + imp.operator_dropdown.set_model(Some(>k::StringList::new(&labels))); + imp.operator_dropdown + .set_selected(u32::try_from(picked).unwrap_or_default()); + imp.syncing.set(false); + let shape = ops + .iter() + .find(|entry| entry.op == op) + .map_or(ValueShape::Single, |entry| entry.shape); + self.show_value_editor(&rule, &column, shape); + } + + fn show_value_editor(&self, rule: &FilterRuleObject, column: &ColumnInfo, shape: ValueShape) { + let slot = self.imp().value_slot.get(); + let editor = value_editor(rule, column, shape); + slot.set_visible(editor.is_some()); + slot.set_child(editor.as_ref()); + } + + fn connect_choices(&self) { + let imp = self.imp(); + let row = self.downgrade(); + imp.column_dropdown.connect_selected_notify(move |dropdown| { + if let Some(row) = row.upgrade() { + row.on_column_picked(dropdown.selected()); + } + }); + let row = self.downgrade(); + imp.operator_dropdown.connect_selected_notify(move |dropdown| { + if let Some(row) = row.upgrade() { + row.on_operator_picked(dropdown.selected()); + } + }); + } + + fn on_column_picked(&self, index: u32) { + let Some(rule) = self.rule().filter(|_| !self.imp().syncing.get()) else { + return; + }; + let Some(column) = usize::try_from(index).ok().and_then(|index| self.columns().get(index)) else { + return; + }; + let first = operators_for(column).into_iter().next(); + rule.update(|rule| { + rule.column = column.name.clone(); + // An operator the new type lacks would build SQL the driver + // rejects at fetch time. + if let Some(first) = first { + rule.op = first.op; + rule.value = first.shape.empty_value(); + } + }); + self.show_operators(); + } + + fn on_operator_picked(&self, index: u32) { + let Some(rule) = self.rule().filter(|_| !self.imp().syncing.get()) else { + return; + }; + let column = self.shown_column(); + let ops = operators_for(&column); + let Some(entry) = usize::try_from(index).ok().and_then(|index| ops.get(index)) else { + return; + }; + rule.update(|rule| { + rule.op = entry.op; + rule.value = entry.shape.empty_value(); + }); + self.show_value_editor(&rule, &column, entry.shape); + } +} + +#[cfg(test)] +mod tests { + use tablepro_core::{FilterOp, FilterRule, FilterValue}; + + use super::*; + use crate::test_support::SignalLog; + + fn row_for(column: &str, columns: &[&str]) -> (FilterRuleRow, FilterRuleObject) { + crate::register_test_resources(); + let rule = FilterRuleObject::new(FilterRule { + column: column.to_owned(), + op: FilterOp::Eq, + value: Some(FilterValue::Single("1".to_owned())), + }); + let columns: Rc<[ColumnInfo]> = columns.iter().map(|name| placeholder_column(name)).collect(); + (FilterRuleRow::new(&rule, columns), rule) + } + + #[gtk4::test] + fn a_rule_on_a_dropped_column_shows_the_first_one_and_keeps_its_name() { + let (row, rule) = row_for("gone", &["a", "b"]); + + assert_eq!(row.imp().column_dropdown.selected(), 0); + assert_eq!(rule.rule().column, "gone"); + } + + #[gtk4::test] + fn without_columns_a_rule_still_offers_its_operators() { + let (row, _) = row_for("name", &[]); + let operators = row.imp().operator_dropdown.model().map_or(0, |model| model.n_items()); + + assert_eq!(row.imp().column_dropdown.model().map(|model| model.n_items()), Some(0)); + assert!(operators > 0, "a rule with no columns loaded offers no operator"); + } + + #[gtk4::test] + fn remove_asks_the_strip_to_drop_the_rule() { + let (row, _) = row_for("a", &["a"]); + let removals = SignalLog::connect(&row, "remove", |_| ()); + + row.activate_action("rule.remove", None) + .expect("the row's remove action"); + + assert_eq!(removals.count(), 1); + assert_eq!(row.imp().remove_button.action_name().as_deref(), Some("rule.remove")); + } +} diff --git a/linux/crates/app/src/ui/filter_strip/rule_value_editor.rs b/linux/crates/app/src/ui/filter_strip/rule_value_editor.rs new file mode 100644 index 0000000000..7daced66c9 --- /dev/null +++ b/linux/crates/app/src/ui/filter_strip/rule_value_editor.rs @@ -0,0 +1,200 @@ +use gtk4 as gtk; +use gtk4::glib; +use gtk4::prelude::*; +use tablepro_core::{ColumnInfo, FilterValue}; + +use super::filter_rule_object::FilterRuleObject; +use super::rule_operators::ValueShape; + +/// The widget a rule's value is typed into, for the operator's value +/// shape, filled from the rule and writing each edit back into it. +/// The null tests take no value, so they have none. +pub(super) fn value_editor(rule: &FilterRuleObject, column: &ColumnInfo, shape: ValueShape) -> Option { + match shape { + ValueShape::None => None, + ValueShape::Single => Some(single_editor(rule, column).upcast()), + ValueShape::Pair => Some(pair_editor(rule, column).upcast()), + ValueShape::List => Some(list_editor(rule).upcast()), + } +} + +fn single_editor(rule: &FilterRuleObject, column: &ColumnInfo) -> gtk::Entry { + let label = crate::i18n::gettext("Value"); + let entry = gtk::Entry::builder() + .placeholder_text(&label) + .valign(gtk::Align::Center) + .hexpand(true) + .input_purpose(input_purpose_for(column)) + .build(); + name(&entry, &label); + if let Some(FilterValue::Single(text)) = rule.rule().value { + entry.set_text(&text); + } + let rule = rule.clone(); + entry.connect_changed(move |entry| { + rule.update(|rule| rule.value = Some(FilterValue::Single(entry.text().to_string()))); + }); + entry +} + +fn pair_editor(rule: &FilterRuleObject, column: &ColumnInfo) -> gtk::Box { + let bound = |placeholder: &str| { + let entry = gtk::Entry::builder() + .placeholder_text(placeholder) + .valign(gtk::Align::Center) + .input_purpose(input_purpose_for(column)) + .build(); + name(&entry, placeholder); + entry + }; + let low = bound(&crate::i18n::gettext("From")); + let high = bound(&crate::i18n::gettext("To")); + if let Some(FilterValue::Pair(from, to)) = rule.rule().value { + low.set_text(&from); + high.set_text(&to); + } + // Each entry reads the other, so neither may hold it strongly. + low.connect_changed(glib::clone!( + #[weak] + high, + #[strong] + rule, + move |low| { + rule.update(|rule| { + rule.value = Some(FilterValue::Pair(low.text().to_string(), high.text().to_string())); + }); + } + )); + high.connect_changed(glib::clone!( + #[weak] + low, + #[strong] + rule, + move |high| { + rule.update(|rule| { + rule.value = Some(FilterValue::Pair(low.text().to_string(), high.text().to_string())); + }); + } + )); + let pair = gtk::Box::builder() + .orientation(gtk::Orientation::Horizontal) + .spacing(6) + .build(); + pair.append(&low); + pair.append(&high); + pair +} + +fn list_editor(rule: &FilterRuleObject) -> gtk::Entry { + let entry = gtk::Entry::builder() + .placeholder_text(crate::i18n::gettext("a, b, c")) + .valign(gtk::Align::Center) + .hexpand(true) + .build(); + name(&entry, &crate::i18n::gettext("Values")); + if let Some(FilterValue::List(items)) = rule.rule().value { + entry.set_text(&items.join(", ")); + } + let rule = rule.clone(); + entry.connect_changed(move |entry| { + rule.update(|rule| rule.value = Some(FilterValue::List(list_items(&entry.text())))); + }); + entry +} + +/// A placeholder is gone once the field holds text, so it names nothing. +fn name(entry: >k::Entry, label: &str) { + entry.update_property(&[gtk::accessible::Property::Label(label)]); +} + +/// The comma-separated items of a list value, with blanks dropped. +fn list_items(text: &str) -> Vec { + text.split(',') + .map(|item| item.trim().to_owned()) + .filter(|item| !item.is_empty()) + .collect() +} + +/// A numeric column gets the number keypad on a touch keyboard. +fn input_purpose_for(column: &ColumnInfo) -> gtk::InputPurpose { + match column.column_type.kind.is_numeric() { + true => gtk::InputPurpose::Number, + false => gtk::InputPurpose::FreeForm, + } +} + +#[cfg(test)] +mod tests { + use tablepro_core::{FilterOp, FilterRule}; + + use super::super::rule_operators::placeholder_column; + use super::*; + use crate::test_support::descendants; + + fn rule_with(op: FilterOp, value: Option) -> FilterRuleObject { + FilterRuleObject::new(FilterRule { + column: "a".to_owned(), + op, + value, + }) + } + + fn entries(widget: >k::Widget) -> Vec { + std::iter::once(widget.clone()) + .chain(descendants(widget)) + .filter_map(|widget| widget.downcast::().ok()) + .collect() + } + + #[gtk4::test] + fn the_null_tests_have_no_editor() { + let rule = rule_with(FilterOp::IsNull, None); + + assert!(value_editor(&rule, &placeholder_column("a"), ValueShape::None).is_none()); + } + + #[gtk4::test] + fn a_single_value_is_shown_and_written_back() { + let rule = rule_with(FilterOp::Eq, Some(FilterValue::Single("1".to_owned()))); + let editor = value_editor(&rule, &placeholder_column("a"), ValueShape::Single).expect("an editor"); + let [entry] = entries(&editor).try_into().expect("one entry"); + assert_eq!(entry.text(), "1"); + + entry.set_text("42"); + + assert_eq!(rule.rule().value, Some(FilterValue::Single("42".to_owned()))); + } + + #[gtk4::test] + fn a_list_value_splits_on_commas_and_drops_blanks() { + let rule = rule_with( + FilterOp::In, + Some(FilterValue::List(vec!["x".to_owned(), "y".to_owned()])), + ); + let editor = value_editor(&rule, &placeholder_column("a"), ValueShape::List).expect("an editor"); + let [entry] = entries(&editor).try_into().expect("one entry"); + assert_eq!(entry.text(), "x, y"); + + entry.set_text(" a, ,b ,"); + + assert_eq!( + rule.rule().value, + Some(FilterValue::List(vec!["a".to_owned(), "b".to_owned()])) + ); + } + + #[gtk4::test] + fn a_numeric_column_asks_for_the_number_keypad() { + let mut amount = placeholder_column("amount"); + amount.column_type.kind = tablepro_core::column::ColumnKind::Integer(tablepro_core::column::IntegerKind { + width: tablepro_core::column::IntegerWidth::W32, + signed: true, + }); + let rule = rule_with(FilterOp::Between, ValueShape::Pair.empty_value()); + let editor = value_editor(&rule, &amount, ValueShape::Pair).expect("an editor"); + + let purposes: Vec = entries(&editor).iter().map(gtk::Entry::input_purpose).collect(); + + assert_eq!(purposes, [gtk::InputPurpose::Number, gtk::InputPurpose::Number]); + } +} diff --git a/linux/crates/app/src/ui/filter_strip/tests.rs b/linux/crates/app/src/ui/filter_strip/tests.rs new file mode 100644 index 0000000000..dc7d507c0c --- /dev/null +++ b/linux/crates/app/src/ui/filter_strip/tests.rs @@ -0,0 +1,492 @@ +use tablepro_core::column::{ColumnKind, IntegerKind, IntegerWidth, TextLength}; + +use super::rule_operators::placeholder_column; +use super::*; +use crate::application::ShortcutOwner; +use crate::test_support::{ + SignalLog, assert_labelled, descendants, drain_main_context, first_descendant_of_type, tooltips_with_keys, + unlabelled_icon_buttons, +}; + +fn rule(column: &str) -> FilterRule { + FilterRule { + column: column.to_owned(), + op: FilterOp::Eq, + value: Some(FilterValue::Single("1".to_owned())), + } +} + +fn new_strip(columns: Vec, initial: &FilterSet) -> FilterStrip { + crate::register_test_resources(); + FilterStrip::new(columns, initial) +} + +fn strip_with(rules: Vec) -> FilterStrip { + new_strip( + vec![placeholder_column("a"), placeholder_column("b")], + &FilterSet { + rules, + ..FilterSet::default() + }, + ) +} + +/// A strip in a window. Its buttons reach its actions only once it has +/// a root, as the browse tab's window gives it. +struct Hosted { + window: gtk::Window, + strip: FilterStrip, +} + +impl Hosted { + fn new(strip: FilterStrip) -> Self { + let window = gtk::Window::new(); + window.set_child(Some(&strip)); + Self { window, strip } + } +} + +impl std::ops::Deref for Hosted { + type Target = FilterStrip; + + fn deref(&self) -> &FilterStrip { + &self.strip + } +} + +impl Drop for Hosted { + fn drop(&mut self) { + self.window.set_child(None::<>k::Widget>); + self.window.destroy(); + } +} + +fn integer(name: &str) -> ColumnInfo { + let mut column = placeholder_column(name); + column.column_type.kind = ColumnKind::Integer(IntegerKind { + width: IntegerWidth::W32, + signed: true, + }); + column +} + +fn blob(name: &str) -> ColumnInfo { + let mut column = placeholder_column(name); + column.column_type.kind = ColumnKind::Bytes { + length: TextLength::Unbounded, + }; + column +} + +fn rows(strip: &FilterStrip) -> Vec { + descendants(&strip.imp().rule_list.get()) + .into_iter() + .filter_map(|widget| widget.downcast::().ok()) + .collect() +} + +fn applied(strip: &FilterStrip) -> SignalLog { + SignalLog::connect(strip, "applied", |values| { + values[1] + .get::() + .expect("the applied filter") + .borrow::() + .clone() + }) +} + +fn column_choices(row: &FilterRuleRow) -> Vec { + let model = row.imp().column_dropdown.model().expect("the column choices"); + (0..model.n_items()) + .filter_map(|position| model.item(position).and_downcast::()) + .map(|choice| choice.string().to_string()) + .collect() +} + +#[gtk4::test] +fn escape_closes_the_strip_through_the_table() { + use crate::test_support::shortcut_keys::{keystroke, owner_controller, shortcuts, trigger_keys}; + let strip = strip_with(vec![rule("a")]); + strip.set_revealed(true); + let escape = shortcuts(&owner_controller(&strip, ShortcutOwner::FilterStrip).expect("the strip's keys")) + .into_iter() + .find(|shortcut| { + shortcut + .trigger() + .is_some_and(|trigger| trigger_keys(&trigger).contains(&keystroke("Escape"))) + }) + .and_then(|shortcut| shortcut.action()) + .expect("Escape is bound"); + + assert!(escape.activate(gtk::ShortcutActionFlags::empty(), &strip, None)); + + assert!(!strip.revealed()); + assert!(!strip.imp().revealer.reveals_child()); +} + +#[gtk4::test] +fn toggle_opens_onto_the_sql_field_and_closes_again() { + let strip = strip_with(Vec::new()); + + strip.toggle(); + assert!(strip.revealed()); + assert!(strip.imp().revealer.reveals_child()); + + strip.toggle(); + assert!(!strip.imp().revealer.reveals_child()); +} + +/// Ctrl+F on an open strip takes the focus into it rather than closing +/// it, and closing gives the focus back to where it came from. +#[gtk4::test] +fn ctrl_f_focuses_an_open_strip_and_close_gives_the_focus_back() { + let strip = strip_with(vec![rule("a")]); + let outside = gtk::Button::with_label("Rows"); + let column = gtk::Box::new(gtk::Orientation::Vertical, 0); + column.append(&strip); + column.append(&outside); + let window = crate::test_support::OwnedWindow::presented(gtk::Window::builder().child(&column).build()); + let focus = || gtk::prelude::GtkWindowExt::focus(&*window); + assert!(outside.grab_focus()); + + strip.open(); + assert!(strip.revealed()); + assert!(crate::ui::a11y::holds_focus(&strip)); + assert!(outside.grab_focus()); + strip.open(); + assert!(strip.revealed(), "Ctrl+F closed the open strip"); + assert!(crate::ui::a11y::holds_focus(&strip)); + + strip.activate_action("filter.close", None).expect("filter.close"); + + assert!(!strip.revealed()); + assert_eq!(focus().as_ref(), Some(outside.upcast_ref())); +} + +#[gtk4::test] +fn strip_freed_after_drop() { + let strip = strip_with(vec![rule("a"), rule("b")]); + let rules = first_descendant_of_type::(&strip) + .expect("the rule list") + .downgrade(); + let row = rows(&strip).first().expect("a rule row").downgrade(); + let freed = strip.downgrade(); + + drop(strip); + drain_main_context(); + + assert!(rules.upgrade().is_none(), "the rule list outlived the strip"); + assert!(row.upgrade().is_none(), "a rule row outlived the strip"); + assert!(freed.upgrade().is_none(), "the strip outlived its owner"); +} + +#[gtk4::test] +fn a_between_rule_frees_its_entries() { + let strip = new_strip( + vec![integer("amount")], + &FilterSet { + rules: vec![FilterRule { + column: "amount".to_owned(), + op: FilterOp::Between, + value: Some(FilterValue::Pair("1".to_owned(), "2".to_owned())), + }], + ..FilterSet::default() + }, + ); + let entry = |text: &str| { + descendants(&strip) + .into_iter() + .filter_map(|widget| widget.downcast::().ok()) + .find(|entry| entry.text() == text) + }; + let from = entry("1").expect("the From entry"); + let to = entry("2").expect("the To entry"); + from.set_text("5"); + assert_eq!( + strip.filter().rules[0].value, + Some(FilterValue::Pair("5".to_owned(), "2".to_owned())) + ); + to.set_text("9"); + assert_eq!( + strip.filter().rules[0].value, + Some(FilterValue::Pair("5".to_owned(), "9".to_owned())) + ); + let freed = [from.downgrade(), to.downgrade()]; + + drop((strip, from, to)); + drain_main_context(); + + let alive = freed.iter().filter(|entry| entry.upgrade().is_some()).count(); + assert_eq!(alive, 0, "the From and To entries outlived the strip"); +} + +#[gtk4::test] +fn icon_buttons_are_labelled_without_accelerator_hints() { + let strip = strip_with(vec![rule("a")]); + strip.set_revealed(true); + let remove = descendants(&strip) + .into_iter() + .filter_map(|widget| widget.downcast::().ok()) + .find(|button| { + button + .icon_name() + .is_some_and(|icon| icon.as_str() == crate::ui::icons::USER_TRASH) + }) + .expect("the rule's Remove Rule button"); + + assert!(gtk::test_accessible_has_property( + &remove, + gtk::AccessibleProperty::Label + )); + assert!(gtk::test_accessible_has_property( + &strip.imp().close_button.get(), + gtk::AccessibleProperty::KeyShortcuts + )); + assert_eq!(unlabelled_icon_buttons(&strip), Vec::::new()); + assert_eq!(tooltips_with_keys(&strip), Vec::::new()); +} + +/// Placeholders go once text is typed, so each field, and the row that +/// holds a rule, has a name of its own. +#[gtk4::test] +fn every_field_of_the_strip_is_named() { + let rule = |column: &str, op: FilterOp, value: Option| FilterRule { + column: column.to_owned(), + op, + value, + }; + let strip = new_strip( + vec![integer("amount"), placeholder_column("name")], + &FilterSet { + rules: vec![ + rule("name", FilterOp::Eq, Some(FilterValue::Single("x".to_owned()))), + rule( + "amount", + FilterOp::Between, + Some(FilterValue::Pair("1".to_owned(), "2".to_owned())), + ), + rule("name", FilterOp::In, Some(FilterValue::List(vec!["a".to_owned()]))), + ], + ..FilterSet::default() + }, + ); + strip.set_revealed(true); + let fields: Vec = descendants(&strip) + .into_iter() + .filter(|widget| widget.is::() || widget.is::() || widget.is::()) + .collect(); + assert_eq!(fields.len(), 1 + 1 + 3 * 3 + 1 + 2 + 1, "the strip's fields"); + + for field in fields { + assert_eq!(assert_labelled(&field), Ok(()), "{}", field.type_().name()); + } +} + +#[gtk4::test] +fn an_applied_filter_rebuilds_the_rule_list() { + let strip = strip_with(vec![rule("a"), rule("b")]); + assert_eq!(rows(&strip).len(), 2); + + strip.set_filter(&FilterSet { + rules: vec![rule("b")], + ..FilterSet::default() + }); + + assert_eq!(rows(&strip).len(), 1); +} + +#[gtk4::test] +fn a_saved_filter_opens_the_rule_editor_and_an_empty_one_does_not() { + assert!(strip_with(vec![rule("a")]).imp().rules_expander.is_expanded()); + assert!(!strip_with(Vec::new()).imp().rules_expander.is_expanded()); +} + +#[gtk4::test] +fn add_rule_appends_a_row_for_the_first_filterable_column() { + let strip = Hosted::new(new_strip( + vec![blob("payload"), placeholder_column("name")], + &FilterSet::default(), + )); + assert!(!strip.imp().rule_list.get_visible(), "an empty rule list is shown"); + // A collapsed expander holds its content out of the window, so the + // button is reached the way the user reaches it. + strip.imp().rules_expander.set_expanded(true); + + strip.imp().add_rule_button.emit_clicked(); + + assert_eq!(rows(&strip).len(), 1); + assert!(strip.imp().rule_list.get_visible()); + assert_eq!( + strip.filter().rules, + [FilterRule { + column: "name".to_owned(), + op: FilterOp::Eq, + value: Some(FilterValue::Single(String::new())), + }] + ); +} + +#[gtk4::test] +fn apply_emits_the_rules_and_sql_as_edited() { + let strip = Hosted::new(strip_with(vec![rule("a"), rule("b")])); + strip.set_revealed(true); + let applied = applied(&strip); + let value = descendants(&rows(&strip)[1]) + .into_iter() + .find_map(|widget| widget.downcast::().ok()) + .expect("the second rule's value"); + value.set_text("2"); + strip.imp().combinator_dropdown.set_selected(1); + strip.imp().raw_entry.set_text("id > 3"); + + strip.imp().apply_button.emit_clicked(); + + let mut second = rule("b"); + second.value = Some(FilterValue::Single("2".to_owned())); + assert_eq!( + applied.take(), + [FilterSet { + combinator: Combinator::Or, + rules: vec![rule("a"), second], + extra_sql: Some("id > 3".to_owned()), + }] + ); + assert!(!strip.revealed(), "Apply left the strip open"); +} + +#[gtk4::test] +fn enter_in_the_sql_field_applies() { + let strip = strip_with(Vec::new()); + let applied = applied(&strip); + strip.imp().raw_entry.set_text(" "); + + strip.imp().raw_entry.emit_activate(); + + assert_eq!(applied.take(), [FilterSet::default()], "blank SQL is not a predicate"); +} + +#[gtk4::test] +fn remove_rule_drops_it_from_the_next_apply() { + let strip = Hosted::new(strip_with(vec![rule("a"), rule("b")])); + let applied = applied(&strip); + + rows(&strip)[0].imp().remove_button.emit_clicked(); + strip.imp().apply_button.emit_clicked(); + + assert_eq!(rows(&strip).len(), 1); + assert_eq!(applied.take()[0].rules, [rule("b")]); +} + +#[gtk4::test] +fn clear_all_reports_an_empty_filter_and_closes() { + let strip = Hosted::new(strip_with(vec![rule("a")])); + strip.set_revealed(true); + let applied = applied(&strip); + + strip.imp().clear_button.emit_clicked(); + + assert_eq!(applied.take(), [FilterSet::default()]); + assert!(!strip.revealed()); +} + +#[gtk4::test] +fn set_columns_rebuilds_the_column_choices() { + let strip = strip_with(vec![rule("a")]); + assert_eq!(column_choices(&rows(&strip)[0]), ["a", "b"]); + + strip.set_columns(vec![placeholder_column("a"), blob("payload"), integer("total")]); + + let [row] = rows(&strip).try_into().expect("one rule row"); + assert_eq!(column_choices(&row), ["a", "total"]); + assert_eq!(strip.filter().rules, [rule("a")], "new columns changed the rule"); +} + +#[gtk4::test] +fn the_match_row_shows_once_two_clauses_meet() { + let strip = Hosted::new(strip_with(vec![rule("a")])); + let shown = || strip.imp().match_revealer.reveals_child(); + assert!(!shown(), "one rule has nothing to combine with"); + + strip.imp().raw_entry.set_text("id > 3"); + assert!(shown(), "a rule and SQL are two clauses"); + + strip.imp().raw_entry.set_text(""); + strip.imp().add_rule_button.emit_clicked(); + assert!(shown(), "two rules are two clauses"); +} + +#[gtk4::test] +fn set_filter_shows_its_combinator_and_sql() { + let strip = strip_with(Vec::new()); + strip.imp().combinator_dropdown.set_selected(1); + + strip.set_filter(&FilterSet { + combinator: Combinator::And, + rules: vec![rule("a")], + extra_sql: Some("id > 3".to_owned()), + }); + + assert_eq!(strip.imp().combinator_dropdown.selected(), 0); + assert_eq!(strip.imp().raw_entry.text(), "id > 3"); + assert_eq!(strip.filter().combinator, Combinator::And); +} + +#[gtk4::test] +fn picking_a_column_resets_the_operator_to_one_it_allows() { + let strip = new_strip( + vec![placeholder_column("name"), integer("amount")], + &FilterSet { + rules: vec![FilterRule { + column: "name".to_owned(), + op: FilterOp::IsNull, + value: None, + }], + ..FilterSet::default() + }, + ); + let row = rows(&strip)[0].clone(); + assert!(!row.imp().value_slot.get_visible(), "a null test shows a value field"); + + row.imp().column_dropdown.set_selected(1); + + let picked = strip.filter().rules[0].clone(); + let first = tablepro_core::operators_for(&integer("amount").column_type.kind)[0]; + assert_eq!(picked.column, "amount"); + assert_eq!(picked.op, first); + assert_eq!(row.imp().operator_dropdown.selected(), 0); + assert!(row.imp().value_slot.get_visible()); +} + +#[gtk4::test] +fn picking_an_operator_resets_the_value_to_its_shape() { + let strip = new_strip( + vec![integer("amount")], + &FilterSet { + rules: vec![FilterRule { + column: "amount".to_owned(), + op: FilterOp::Eq, + value: Some(FilterValue::Single("7".to_owned())), + }], + ..FilterSet::default() + }, + ); + let row = rows(&strip)[0].clone(); + let between = rule_operators::operators_for(&integer("amount")) + .iter() + .position(|entry| entry.op == FilterOp::Between) + .expect("an integer takes between"); + + row.imp() + .operator_dropdown + .set_selected(u32::try_from(between).expect("a small index")); + + assert_eq!( + strip.filter().rules[0].value, + Some(FilterValue::Pair(String::new(), String::new())) + ); + let entries = descendants(&row.imp().value_slot.get()) + .into_iter() + .filter(|widget| widget.is::()) + .count(); + assert_eq!(entries, 2, "between shows From and To"); +} diff --git a/linux/crates/app/src/ui/format/mod.rs b/linux/crates/app/src/ui/format/mod.rs new file mode 100644 index 0000000000..ade92482ee --- /dev/null +++ b/linux/crates/app/src/ui/format/mod.rs @@ -0,0 +1,13 @@ +//! Numbers as the user reads them. + +mod thousands; + +#[cfg(test)] +pub(crate) use self::thousands::Thousands; +pub(crate) use self::thousands::format_thousands; + +/// The count gettext picks a plural form by. It takes a u32, and a +/// result past that is rare enough for the largest u32 to stand in. +pub(crate) fn plural_count(n: u64) -> u32 { + u32::try_from(n).unwrap_or(u32::MAX) +} diff --git a/linux/crates/app/src/ui/format/thousands.rs b/linux/crates/app/src/ui/format/thousands.rs new file mode 100644 index 0000000000..b3395e50df --- /dev/null +++ b/linux/crates/app/src/ui/format/thousands.rs @@ -0,0 +1,100 @@ +use gtk4::glib; +use icu_decimal::DecimalFormatter; +use icu_decimal::input::Decimal; +use icu_locale_core::Locale; + +/// Groups a count's digits the way a locale does: 1,000 in English, +/// 1.000 in German. The digits stay ASCII whatever digits the locale +/// writes by default, as the grid's numbers do. +pub(crate) struct Thousands { + formatter: Option, +} + +impl Thousands { + /// `name` is a POSIX locale name, as LC_NUMERIC holds it: + /// `de_DE.UTF-8`. The C locale, and a name ICU does not know, group + /// as the root locale does. + pub(crate) fn for_posix_locale(name: &str) -> Self { + let language = name.split(['.', '@']).next().unwrap_or_default().replace('_', "-"); + let locale = + Locale::try_from_str(&format!("{language}-u-nu-latn")).or_else(|_| Locale::try_from_str("und-u-nu-latn")); + let formatter = match locale { + Ok(locale) => DecimalFormatter::try_new((&locale).into(), Default::default()) + .inspect_err(|error| tracing::debug!(%error, "no number format for the locale")) + .ok(), + Err(error) => { + tracing::debug!(%error, "the root locale did not parse"); + None + } + }; + Self { formatter } + } + + pub(crate) fn format(&self, n: u64) -> String { + match &self.formatter { + Some(formatter) => formatter.format_to_string(&Decimal::from(n)), + None => n.to_string(), + } + } +} + +// A cache, not state: every window formats with the process's locale, +// read once, and nothing writes to it after. +thread_local! { + static USER: Thousands = Thousands::for_posix_locale(&numeric_locale()); +} + +/// What LC_NUMERIC names, or LC_ALL or LANG in its place. +fn numeric_locale() -> String { + glib::language_names_with_category("LC_NUMERIC") + .first() + .map(|name| name.to_string()) + .unwrap_or_default() +} + +/// `n` grouped as the user's locale groups digits. +pub(crate) fn format_thousands(n: u64) -> String { + USER.with(|thousands| thousands.format(n)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn format_thousands_edges() { + let english = Thousands::for_posix_locale("en_US.UTF-8"); + + assert_eq!(english.format(0), "0"); + assert_eq!(english.format(1), "1"); + assert_eq!(english.format(999), "999"); + assert_eq!(english.format(1_000), "1,000"); + assert_eq!(english.format(10_000), "10,000"); + assert_eq!(english.format(1_000_000), "1,000,000"); + assert_eq!(english.format(u64::MAX), "18,446,744,073,709,551,615"); + } + + #[test] + fn digits_are_grouped_as_the_locale_groups_them() { + assert_eq!( + Thousands::for_posix_locale("de_DE.UTF-8").format(1_234_567), + "1.234.567" + ); + assert_eq!(Thousands::for_posix_locale("sr_RS@latin").format(1_000), "1.000"); + } + + #[test] + fn a_locale_with_its_own_digits_keeps_ascii_digits() { + assert_eq!( + Thousands::for_posix_locale("bn_BD.UTF-8").format(1_000_007), + "10,00,007" + ); + } + + #[test] + fn the_c_locale_and_an_unknown_name_still_group() { + assert_eq!(Thousands::for_posix_locale("C").format(1_000), "1,000"); + assert_eq!(Thousands::for_posix_locale("").format(1_000), "1,000"); + assert_eq!(Thousands::for_posix_locale("not a locale").format(1_000), "1,000"); + } +} diff --git a/linux/crates/app/src/ui/grid/calendar_editor.rs b/linux/crates/app/src/ui/grid/calendar_editor.rs new file mode 100644 index 0000000000..c57e4d5493 --- /dev/null +++ b/linux/crates/app/src/ui/grid/calendar_editor.rs @@ -0,0 +1,201 @@ +use chrono::Datelike; +use gtk4::prelude::*; +use gtk4::{self as gtk, glib}; +use tablepro_core::edit::CellInput; +use tablepro_core::value::Temporal; +use tablepro_core::{ColumnInfo, Value}; + +use super::cell_accessibility::name_editor; +use super::edit_commit::{commit_edit, send_commit}; +use super::inline_editor::{InlineEditor, InlineOptions}; +use super::{EditSnapshot, GridCell}; +use crate::application::component_matches; + +#[derive(Debug, Clone)] +enum CalendarStart { + On(glib::DateTime), + /// A NULL the column may keep, which stays until a day is picked. + Today, +} + +/// A date column's editor: a calendar for a day it can show, and the +/// one-line field for anything else, such as an infinity. +pub struct CalendarEditor; + +impl CalendarEditor { + /// `text` is the cell's edit text, for the one-line field. + pub(super) fn open( + cell: &GridCell, + snapshot: EditSnapshot, + column: &ColumnInfo, + text: String, + inline: InlineOptions, + ) { + let Some(start) = calendar_start(&snapshot.original, column) else { + InlineEditor::begin(cell, snapshot, &text, inline); + return; + }; + cell.cancel_edit(); + let calendar = gtk::Calendar::new(); + name_editor(&calendar, cell); + // Before the handler: set_date emits day-selected when the day + // changes, and that is not a pick. + if let CalendarStart::On(date) = &start { + calendar.set_date(date); + } + let type_value = gtk::Button::with_label(&crate::i18n::gettext("Type a Value")); + type_value.add_css_class("flat"); + let content = gtk::Box::builder() + .orientation(gtk::Orientation::Vertical) + .spacing(6) + .build(); + content.append(&calendar); + content.append(&type_value); + let popover = gtk::Popover::builder().child(&content).build(); + + let weak_cell = cell.downgrade(); + calendar.connect_day_selected(move |calendar| { + if let Some(cell) = weak_cell.upgrade() { + pick(&cell, calendar); + } + }); + // day-selected fires only when the date changes, so a click or + // Space on the day already selected, today on a NULL or the same + // day after a month change, would pick nothing. + let click = gtk::GestureClick::new(); + let weak_cell = cell.downgrade(); + click.connect_released(move |gesture, _, x, y| { + let Some(calendar) = gesture.widget().and_downcast::() else { + return; + }; + let on_a_day = calendar + .pick(x, y, gtk::PickFlags::DEFAULT) + .is_some_and(|label| label.has_css_class("day-number")); + if !on_a_day { + return; + } + if let Some(cell) = weak_cell.upgrade() { + pick(&cell, &calendar); + } + }); + calendar.add_controller(click); + let keys = gtk::EventControllerKey::new(); + let weak_cell = cell.downgrade(); + keys.connect_key_released(move |controller, keyval, _, state| { + if !component_matches("cell-editor.pick-date", keyval, state) { + return; + } + let Some(calendar) = controller.widget().and_downcast::() else { + return; + }; + if let Some(cell) = weak_cell.upgrade() { + pick(&cell, &calendar); + } + }); + calendar.add_controller(keys); + let weak_cell = cell.downgrade(); + type_value.connect_clicked(move |_| { + let Some(cell) = weak_cell.upgrade() else { + return; + }; + let Some(snapshot) = cell.take_edit() else { + return; + }; + if let Some(popover) = cell.take_popover() { + popover.popdown(); + } + InlineEditor::begin(&cell, snapshot, &text, inline.clone()); + }); + + cell.begin_edit(snapshot); + cell.present_popover(&popover); + calendar.grab_focus(); + } +} + +/// The popover closes before the tab hears of the day, as every editor's +/// does. A second pick of the same click finds the edit taken. +fn pick(cell: &GridCell, calendar: >k::Calendar) { + let Some(snapshot) = cell.take_edit() else { + return; + }; + let (year, month, day) = calendar.date().ymd(); + let day = format!("{year:04}-{month:02}-{day:02}"); + if let Some(popover) = cell.take_popover() { + popover.popdown(); + } + let Some(drawing) = cell.drawing() else { + return; + }; + let Some(column) = drawing.catalog.as_ref() else { + return; + }; + let result = commit_edit(column, &snapshot, &day); + send_commit(cell, snapshot, result); +} + +/// `None` for a value the calendar cannot show: an infinity, a year +/// outside 1 to 9999, the server's default or a NULL the column refuses. +fn calendar_start(input: &CellInput, column: &ColumnInfo) -> Option { + match input { + CellInput::Value(Value::Date(Temporal::Finite(date))) if (1..=9999).contains(&date.year()) => { + let month = i32::try_from(date.month()).ok()?; + let day = i32::try_from(date.day()).ok()?; + glib::DateTime::from_utc(date.year(), month, day, 0, 0, 0.0) + .ok() + .map(CalendarStart::On) + } + CellInput::Value(Value::Null) if column.nullable => Some(CalendarStart::Today), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use chrono::NaiveDate; + use tablepro_core::column::{ColumnDefault, ColumnKind, ColumnType}; + use tablepro_core::edit::CellInput; + use tablepro_core::value::Temporal; + use tablepro_core::{ColumnInfo, Value}; + + use super::{CalendarStart, calendar_start}; + + fn date_column(nullable: bool) -> ColumnInfo { + let mut column_type = ColumnType::untyped_text("date"); + column_type.kind = ColumnKind::Date; + ColumnInfo { + name: "day".to_owned(), + column_type, + nullable, + primary_key: false, + is_auto_increment: false, + is_generated: false, + default: ColumnDefault::None, + comment: None, + } + } + + fn date(year: i32, month: u32, day: u32) -> CellInput { + CellInput::Value(Value::Date(Temporal::Finite( + NaiveDate::from_ymd_opt(year, month, day).expect("a date"), + ))) + } + + #[test] + fn the_calendar_opens_only_on_a_day_it_can_show() { + let nullable = date_column(true); + + let Some(CalendarStart::On(start)) = calendar_start(&date(2026, 9, 15), &nullable) else { + panic!("a date did not open the calendar on its day"); + }; + assert_eq!(start.ymd(), (2026, 9, 15)); + assert!(matches!( + calendar_start(&CellInput::Value(Value::Null), &nullable), + Some(CalendarStart::Today) + )); + assert!(calendar_start(&CellInput::Value(Value::Null), &date_column(false)).is_none()); + assert!(calendar_start(&CellInput::Default, &nullable).is_none()); + assert!(calendar_start(&date(12_000, 1, 1), &nullable).is_none()); + assert!(calendar_start(&CellInput::Value(Value::Date(Temporal::Infinity)), &nullable).is_none()); + } +} diff --git a/linux/crates/app/src/ui/grid/cell_accessibility.rs b/linux/crates/app/src/ui/grid/cell_accessibility.rs new file mode 100644 index 0000000000..635270877c --- /dev/null +++ b/linux/crates/app/src/ui/grid/cell_accessibility.rs @@ -0,0 +1,94 @@ +use gtk4 as gtk; +use gtk4::prelude::*; + +use super::GridCell; +use super::row_state_icon::RowPendingState; +use crate::services::change_tracker::CellState; + +/// An editor opened over a cell takes its column's name: Tab moves the +/// edit across columns, and the header that names them is never focused. +pub(super) fn name_editor(editor: &impl IsA, cell: &GridCell) { + if let Some(name) = cell + .drawing() + .and_then(|drawing| drawing.catalog.as_ref().map(|column| column.name.clone())) + { + editor + .as_ref() + .update_property(&[gtk::accessible::Property::Label(&name)]); + } +} + +/// What a screen reader adds to a cell's value. A new row's cells say +/// nothing of their own: the row says it is new. A NULL is named, since +/// it reads as the text "NULL" would. +pub fn cell_description(state: CellState, null: bool) -> Option { + let text = match (state, null) { + (CellState::Modified, true) => crate::i18n::gettext("Modified, NULL value"), + (CellState::Modified, false) => crate::i18n::gettext("Modified"), + (_, true) => crate::i18n::gettext("NULL value"), + (_, false) => return None, + }; + Some(text) +} + +/// What a screen reader adds to a row. An edited row says nothing of its +/// own: its edited cells do. +pub fn row_description(state: RowPendingState) -> Option { + match state { + RowPendingState::Modified => None, + other => pending_state_text(other), + } +} + +pub(super) fn pending_state_text(state: RowPendingState) -> Option { + let text = match state { + RowPendingState::Unchanged => return None, + RowPendingState::Modified => crate::i18n::gettext("Modified"), + RowPendingState::Inserted => crate::i18n::gettext("New row"), + RowPendingState::MarkedForDeletion => crate::i18n::gettext("Marked for deletion"), + RowPendingState::SaveFailed => crate::i18n::gettext("Not saved"), + }; + Some(text) +} + +#[cfg(test)] +mod tests { + use super::{cell_description, row_description}; + use crate::services::change_tracker::CellState; + use crate::ui::grid::row_state_icon::RowPendingState; + + #[test] + fn cell_and_row_descriptions() { + assert_eq!( + cell_description(CellState::Modified, false).as_deref(), + Some("Modified") + ); + assert_eq!(cell_description(CellState::Clean, false), None); + assert_eq!(cell_description(CellState::InsertDraft, false), None); + assert_eq!(row_description(RowPendingState::Inserted).as_deref(), Some("New row")); + assert_eq!( + row_description(RowPendingState::MarkedForDeletion).as_deref(), + Some("Marked for deletion") + ); + assert_eq!( + row_description(RowPendingState::SaveFailed).as_deref(), + Some("Not saved") + ); + assert_eq!(row_description(RowPendingState::Unchanged), None); + assert_eq!(row_description(RowPendingState::Modified), None); + } + + #[test] + fn cell_description_marks_null_and_modified() { + assert_eq!( + cell_description(CellState::Modified, true).as_deref(), + Some("Modified, NULL value") + ); + assert_eq!(cell_description(CellState::Clean, true).as_deref(), Some("NULL value")); + assert_eq!( + cell_description(CellState::InsertDraft, true).as_deref(), + Some("NULL value") + ); + assert_eq!(cell_description(CellState::Clean, false), None); + } +} diff --git a/linux/crates/app/src/ui/grid/cell_binding.rs b/linux/crates/app/src/ui/grid/cell_binding.rs new file mode 100644 index 0000000000..fe124c0ba5 --- /dev/null +++ b/linux/crates/app/src/ui/grid/cell_binding.rs @@ -0,0 +1,39 @@ +use gtk4::glib; +use gtk4::prelude::*; + +use super::{GridCell, RowObject}; + +/// A cell's hold on the row it shows. Dropping it disconnects the cell +/// from the row's revision, so a recycled cell never draws for a row it +/// left. +#[derive(Debug)] +pub struct CellBinding { + row: RowObject, + handler: Option, +} + +impl CellBinding { + pub fn new(cell: &GridCell, row: &RowObject) -> Self { + let handler = row.connect_revision_notify(glib::clone!( + #[weak] + cell, + move |_| cell.render() + )); + Self { + row: row.clone(), + handler: Some(handler), + } + } + + pub fn row(&self) -> &RowObject { + &self.row + } +} + +impl Drop for CellBinding { + fn drop(&mut self) { + if let Some(handler) = self.handler.take() { + self.row.disconnect(handler); + } + } +} diff --git a/linux/crates/app/src/ui/grid/cell_edit.rs b/linux/crates/app/src/ui/grid/cell_edit.rs new file mode 100644 index 0000000000..6fc3b0ddeb --- /dev/null +++ b/linux/crates/app/src/ui/grid/cell_edit.rs @@ -0,0 +1,149 @@ +use gtk4 as gtk; +use gtk4::prelude::*; +use tablepro_core::edit::{CellInput, EditParseError, EditText, edit_text}; + +use super::calendar_editor::CalendarEditor; +use super::cell_editor_kind::{CellEditorKind, EDIT_MAX_BYTES}; +use super::edit_commit::empty_placeholder; +use super::inline_editor::{InlineEditor, InlineOptions}; +use super::multiline_editor::MultilineEditor; +use super::{EditSnapshot, GridCell, GridMsg}; +use crate::ui::error_text::edit_parse_message; + +/// Open the editor the cell's column and value call for. It starts from +/// the whole value with any pending edit, never from the text the cell +/// draws, which is cut short or stands in for the value. +/// +/// A boolean cell opens nothing: its check button is the editor, so +/// opening it never writes a value. +pub(crate) fn begin_edit(cell: &GridCell) { + let (Some(row), Some(drawing)) = (cell.row(), cell.drawing()) else { + return; + }; + let Some(column) = drawing.catalog.as_ref() else { + return; + }; + let kind = CellEditorKind::for_cell(column, cell.lock().map_or(Ok(()), Err)); + match kind { + CellEditorKind::ReadOnly(_) | CellEditorKind::Toggle(_) => return, + CellEditorKind::Inline { .. } | CellEditorKind::Multiline { .. } | CellEditorKind::Calendar => {} + } + let original = drawing.tab_ctx.edit_input(&row, cell.column()); + let text = match &original { + CellInput::Value(value) => edit_text(value, &column.column_type), + CellInput::Default => EditText::Editable(String::new()), + }; + let editable = match &text { + // A GTK editor would hold the text only up to the NUL, and + // committing it would cut the value there. + EditText::Editable(editable) if editable.contains('\0') => { + if let Some(sink) = cell.sink() { + sink(GridMsg::ShowToast(crate::i18n::gettext( + "Values with a NUL character can't be edited here", + ))); + } + return; + } + EditText::Editable(editable) if editable.len() > EDIT_MAX_BYTES => { + if let Some(sink) = cell.sink() { + sink(GridMsg::ShowToast(too_large_to_edit())); + } + return; + } + EditText::Editable(editable) => editable.clone(), + EditText::ReadOnly(reason) => { + if let Some(sink) = cell.sink() { + sink(GridMsg::ShowToast(edit_parse_message(&EditParseError::NotEditable( + *reason, + )))); + } + return; + } + }; + let inline = |accepts_line_breaks, purpose| InlineOptions { + accepts_line_breaks, + purpose, + placeholder: empty_placeholder(column, row.id()), + }; + let snapshot = EditSnapshot { + row: row.clone(), + column: cell.column(), + original, + }; + match kind.for_text(&text) { + CellEditorKind::Inline { + accepts_line_breaks, + purpose, + } => InlineEditor::begin(cell, snapshot, &editable, inline(accepts_line_breaks, purpose)), + CellEditorKind::Multiline { language } => MultilineEditor::open(cell, snapshot, &editable, language), + CellEditorKind::Calendar => CalendarEditor::open( + cell, + snapshot, + column, + editable, + inline(false, gtk::InputPurpose::FreeForm), + ), + CellEditorKind::Toggle(_) | CellEditorKind::ReadOnly(_) => {} + } +} + +fn too_large_to_edit() -> String { + let size = gtk::glib::format_size(EDIT_MAX_BYTES as u64); + crate::i18n::gettext_f("Values over {size} can't be edited here", &[("size", size.as_str())]) +} + +pub(super) fn install_edit_triggers(cell: &GridCell) { + // Editing takes a double-click, since a single click in a grid + // selects the row. Capture phase, because the column view's row + // selection takes the press in the bubble phase. The handler reads + // the cell from its gesture: the cell owns its controllers, so + // capturing it would be a cycle. + let gesture = gtk::GestureClick::builder().button(gtk::gdk::BUTTON_PRIMARY).build(); + gesture.set_propagation_phase(gtk::PropagationPhase::Capture); + gesture.connect_pressed(move |gesture, n_press, _, _| { + if n_press != 2 { + return; + } + let Some(cell) = gesture.widget().and_downcast::() else { + return; + }; + if cell.lock().is_some() || cell.is_editing() { + return; + } + gesture.set_state(gtk::EventSequenceState::Claimed); + begin_edit(&cell); + }); + cell.add_controller(gesture); +} + +#[cfg(test)] +mod tests { + use tablepro_core::Value; + + use super::*; + use crate::ui::grid::test_grid::{Grid, named}; + + #[gtk4::test] + fn a_value_too_large_to_lay_out_does_not_open_and_says_why() { + let large = "x".repeat(EDIT_MAX_BYTES + 1); + let grid = Grid::open( + vec![named("id", "integer", true), named("body", "text", false)], + vec![vec![Value::Int(1), Value::Text(large)]], + ); + let cell = grid.cell_of(1, 1); + + begin_edit(&cell); + + assert!(!cell.is_editing(), "an editor opened on the whole value"); + let toasts: Vec = grid + .take_messages() + .into_iter() + .filter_map(|message| match message { + GridMsg::ShowToast(text) => Some(text), + _ => None, + }) + .collect(); + // GLib puts a no-break space between the number and the unit. + assert_eq!(toasts, ["Values over 1.0\u{a0}MB can't be edited here"]); + } +} diff --git a/linux/crates/app/src/ui/grid/cell_editability.rs b/linux/crates/app/src/ui/grid/cell_editability.rs new file mode 100644 index 0000000000..a4016b0d47 --- /dev/null +++ b/linux/crates/app/src/ui/grid/cell_editability.rs @@ -0,0 +1,156 @@ +use tablepro_core::ColumnInfo; +use tablepro_core::edit::read_only_reason; +use tablepro_core::meta::RowIdentity; + +use super::{CellLock, GridRowId}; + +/// A draft is a new row, so it may set the key a saved row may not. +/// An auto-increment column stays locked on a draft too: an insert +/// leaves it to the server, and SQL Server IDENTITY and PostgreSQL +/// GENERATED ALWAYS refuse a value without a statement-level override. +pub fn cell_editability( + column: &ColumnInfo, + row: &GridRowId, + identity: &RowIdentity, + read_only: bool, +) -> Result<(), CellLock> { + match row { + GridRowId::Result => return Err(CellLock::ResultRow), + GridRowId::Unkeyed => return Err(CellLock::UnkeyedRow), + GridRowId::Persisted(_) | GridRowId::Draft(_) => {} + } + if read_only { + return Err(CellLock::ReadOnlySession); + } + if column.is_generated { + return Err(CellLock::Generated); + } + if let Some(reason) = read_only_reason(&column.column_type.kind) { + return Err(CellLock::Value(reason)); + } + if column.is_auto_increment { + return Err(CellLock::AutoIncrement); + } + if matches!(row, GridRowId::Persisted(_)) && names_rows(column, identity) { + return Err(CellLock::KeyColumn); + } + Ok(()) +} + +/// Whether some row of this column can be edited, which decides the +/// widget the column's cells are built with. A draft has the fewest +/// locks, so a column a draft cannot edit no row can. +pub fn column_takes_edits(column: &ColumnInfo, identity: &RowIdentity, read_only: bool) -> bool { + cell_editability(column, &GridRowId::Draft(0), identity, read_only).is_ok() +} + +fn names_rows(column: &ColumnInfo, identity: &RowIdentity) -> bool { + match identity { + RowIdentity::UniqueKey { columns } => columns.contains(&column.name), + RowIdentity::SortingKey { expressions } => expressions.contains(&column.name), + RowIdentity::EngineRowId(_) | RowIdentity::Unordered => false, + } +} + +#[cfg(test)] +mod tests { + use tablepro_core::column::{ColumnDefault, ColumnType}; + use tablepro_core::edit::ReadOnlyReason; + use tablepro_core::meta::RowIdentity; + use tablepro_core::{ColumnInfo, Value}; + + use super::{cell_editability, column_takes_edits}; + use crate::services::change_tracker::RowKey; + use crate::ui::grid::{CellLock, GridRowId}; + + fn column(name: &str, data_type: &str) -> ColumnInfo { + let kind = tablepro_core::column::classify_type_name(data_type); + ColumnInfo { + name: name.to_owned(), + column_type: ColumnType { + kind, + ..ColumnType::untyped_text(data_type) + }, + nullable: true, + primary_key: false, + is_auto_increment: false, + is_generated: false, + default: ColumnDefault::None, + comment: None, + } + } + + fn keyed_by_id() -> RowIdentity { + RowIdentity::UniqueKey { + columns: vec!["id".to_owned()], + } + } + + fn persisted() -> GridRowId { + GridRowId::Persisted(RowKey::from_pk_values(&[Value::Int(1)]).expect("a key")) + } + + #[test] + fn cell_editability_matrix() { + let identity = keyed_by_id(); + let id = column("id", "integer"); + let name = column("name", "text"); + let mut serial = column("seq", "integer"); + serial.is_auto_increment = true; + let mut total = column("total", "integer"); + total.is_generated = true; + let blob = column("data", "bytea"); + let draft = GridRowId::Draft(0); + let check = + |column: &ColumnInfo, row: &GridRowId, read_only: bool| cell_editability(column, row, &identity, read_only); + + assert_eq!(check(&name, &persisted(), false), Ok(())); + assert_eq!(check(&id, &persisted(), false), Err(CellLock::KeyColumn)); + assert_eq!(check(&id, &draft, false), Ok(()), "a draft sets its own key"); + assert_eq!(check(&total, &draft, false), Err(CellLock::Generated)); + assert_eq!(check(&total, &persisted(), false), Err(CellLock::Generated)); + assert_eq!(check(&serial, &persisted(), false), Err(CellLock::AutoIncrement)); + assert_eq!( + check(&serial, &draft, false), + Err(CellLock::AutoIncrement), + "an insert leaves an auto-increment column to the server" + ); + assert_eq!(check(&name, &GridRowId::Unkeyed, false), Err(CellLock::UnkeyedRow)); + assert_eq!(check(&name, &GridRowId::Result, false), Err(CellLock::ResultRow)); + assert_eq!(check(&name, &persisted(), true), Err(CellLock::ReadOnlySession)); + assert_eq!(check(&name, &draft, true), Err(CellLock::ReadOnlySession)); + assert_eq!(check(&blob, &draft, false), Err(CellLock::Value(ReadOnlyReason::Bytes))); + } + + #[test] + fn a_sorting_key_column_is_locked_on_saved_rows() { + let identity = RowIdentity::SortingKey { + expressions: vec!["day".to_owned()], + }; + + assert_eq!( + cell_editability(&column("day", "date"), &persisted(), &identity, false), + Err(CellLock::KeyColumn) + ); + assert_eq!( + cell_editability(&column("hits", "integer"), &persisted(), &identity, false), + Ok(()) + ); + } + + #[test] + fn a_column_no_row_can_edit_is_built_read_only() { + let identity = keyed_by_id(); + let mut total = column("total", "integer"); + total.is_generated = true; + + let mut serial = column("seq", "integer"); + serial.is_auto_increment = true; + + assert!(column_takes_edits(&column("id", "integer"), &identity, false)); + assert!(!column_takes_edits(&total, &identity, false)); + assert!(!column_takes_edits(&serial, &identity, false)); + assert!(!column_takes_edits(&column("data", "blob"), &identity, false)); + assert!(!column_takes_edits(&column("name", "text"), &identity, true)); + } +} diff --git a/linux/crates/app/src/ui/grid/cell_editor_kind.rs b/linux/crates/app/src/ui/grid/cell_editor_kind.rs new file mode 100644 index 0000000000..4a64716827 --- /dev/null +++ b/linux/crates/app/src/ui/grid/cell_editor_kind.rs @@ -0,0 +1,303 @@ +use gtk4 as gtk; + +use tablepro_core::ColumnInfo; +use tablepro_core::column::{BoolStorage, ColumnKind, IntegerKind}; +use tablepro_core::edit::{EditText, ReadOnlyReason, read_only_reason}; + +use super::CellLock; + +/// Past this many bytes a value opens in the multi-line editor: a +/// one-line entry lays all of it out at once and shows almost none. +pub const INLINE_EDIT_MAX_BYTES: usize = 4096; + +/// Past this many bytes a value does not open for editing at all: the +/// multi-line editor lays out every line of it on the GTK thread, and a +/// value of megabytes stalls the window. +pub const EDIT_MAX_BYTES: usize = 1_000_000; + +/// The editor a cell opens with. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CellEditorKind { + Toggle(BoolStorage), + Inline { + accepts_line_breaks: bool, + purpose: gtk::InputPurpose, + }, + Multiline { + language: Option<&'static str>, + }, + Calendar, + ReadOnly(CellLock), +} + +impl CellEditorKind { + pub fn for_cell(column: &ColumnInfo, editability: Result<(), CellLock>) -> Self { + if let Err(lock) = editability { + return Self::ReadOnly(lock); + } + let kind = &column.column_type.kind; + let inline = |purpose| Self::Inline { + accepts_line_breaks: kind.accepts_line_breaks(), + purpose, + }; + match kind { + ColumnKind::Bool(storage) => Self::Toggle(*storage), + ColumnKind::Json { .. } => Self::Multiline { language: Some("json") }, + ColumnKind::Xml => Self::Multiline { language: Some("xml") }, + ColumnKind::Date => Self::Calendar, + ColumnKind::Integer(IntegerKind { signed: false, .. }) => inline(gtk::InputPurpose::Digits), + ColumnKind::Integer(_) | ColumnKind::Float(_) | ColumnKind::Decimal { .. } | ColumnKind::Money { .. } => { + inline(gtk::InputPurpose::Number) + } + ColumnKind::Text { .. } + | ColumnKind::Enum { .. } + | ColumnKind::Uuid + | ColumnKind::Network(_) + | ColumnKind::Interval + | ColumnKind::Time { .. } + | ColumnKind::Timestamp { .. } + | ColumnKind::BitString { .. } + | ColumnKind::Other => inline(gtk::InputPurpose::FreeForm), + ColumnKind::Bytes { .. } + | ColumnKind::Spatial + | ColumnKind::Array { .. } + | ColumnKind::Variant + | ColumnKind::Composite => Self::ReadOnly(CellLock::Value( + read_only_reason(kind).unwrap_or(ReadOnlyReason::Composite), + )), + } + } + + /// The editor for this value: none for a value with no text form, + /// and the multi-line one for text a one-line entry would mangle. + pub fn for_text(self, text: &EditText) -> Self { + match (self, text) { + (_, EditText::ReadOnly(reason)) => Self::ReadOnly(CellLock::Value(*reason)), + (Self::Inline { .. }, EditText::Editable(text)) + if text.contains(['\n', '\r']) || text.len() > INLINE_EDIT_MAX_BYTES => + { + Self::Multiline { language: None } + } + (kind, EditText::Editable(_)) => kind, + } + } +} + +#[cfg(test)] +mod tests { + use gtk4 as gtk; + use tablepro_core::ColumnInfo; + use tablepro_core::column::{ + BoolStorage, ColumnDefault, ColumnKind, ColumnType, FloatWidth, IntegerKind, IntegerWidth, NetworkFamily, + TemporalZone, TextLength, TimeRange, + }; + use tablepro_core::edit::{EditText, ReadOnlyReason}; + + use super::{CellEditorKind, INLINE_EDIT_MAX_BYTES}; + use crate::ui::grid::CellLock; + + fn column(kind: ColumnKind) -> ColumnInfo { + let mut column_type = ColumnType::untyped_text("t"); + column_type.kind = kind; + ColumnInfo { + name: "c".to_owned(), + column_type, + nullable: true, + primary_key: false, + is_auto_increment: false, + is_generated: false, + default: ColumnDefault::None, + comment: None, + } + } + + fn integer(signed: bool) -> ColumnKind { + ColumnKind::Integer(IntegerKind { + width: IntegerWidth::W32, + signed, + }) + } + + const NUMBER: CellEditorKind = CellEditorKind::Inline { + accepts_line_breaks: false, + purpose: gtk::InputPurpose::Number, + }; + + fn free(accepts_line_breaks: bool) -> CellEditorKind { + CellEditorKind::Inline { + accepts_line_breaks, + purpose: gtk::InputPurpose::FreeForm, + } + } + + fn assert_editors(cases: Vec<(ColumnKind, CellEditorKind)>) { + for (kind, expected) in cases { + assert_eq!( + CellEditorKind::for_cell(&column(kind.clone()), Ok(())), + expected, + "{kind:?}" + ); + } + } + + #[test] + fn booleans_toggle_and_numbers_take_a_number_keyboard() { + assert_editors(vec![ + ( + ColumnKind::Bool(BoolStorage::Native), + CellEditorKind::Toggle(BoolStorage::Native), + ), + ( + ColumnKind::Bool(BoolStorage::Integer), + CellEditorKind::Toggle(BoolStorage::Integer), + ), + ( + integer(false), + CellEditorKind::Inline { + accepts_line_breaks: false, + purpose: gtk::InputPurpose::Digits, + }, + ), + (integer(true), NUMBER), + (ColumnKind::Float(FloatWidth::F64), NUMBER), + ( + ColumnKind::Decimal { + precision: Some(10), + scale: Some(2), + }, + NUMBER, + ), + (ColumnKind::Money { scale: Some(2) }, NUMBER), + ]); + } + + #[test] + fn text_like_kinds_edit_inline_and_only_text_takes_line_breaks() { + assert_editors(vec![ + ( + ColumnKind::Text { + length: TextLength::Unbounded, + }, + free(true), + ), + ( + ColumnKind::Enum { + labels: vec!["a".to_owned()], + multiple: false, + }, + free(false), + ), + (ColumnKind::Uuid, free(false)), + (ColumnKind::Network(NetworkFamily::Inet), free(false)), + (ColumnKind::Interval, free(false)), + ( + ColumnKind::Time { + fractional_digits: None, + with_offset: false, + range: TimeRange::TimeOfDay, + }, + free(false), + ), + ( + ColumnKind::Timestamp { + fractional_digits: Some(6), + zone: TemporalZone::Offset, + }, + free(false), + ), + ( + ColumnKind::BitString { + length: Some(8), + varying: false, + }, + free(false), + ), + (ColumnKind::Other, free(true)), + ]); + } + + #[test] + fn documents_and_dates_open_their_editor_and_opaque_kinds_stay_read_only() { + assert_editors(vec![ + ( + ColumnKind::Json { binary: true }, + CellEditorKind::Multiline { language: Some("json") }, + ), + (ColumnKind::Xml, CellEditorKind::Multiline { language: Some("xml") }), + (ColumnKind::Date, CellEditorKind::Calendar), + ( + ColumnKind::Bytes { + length: TextLength::Unbounded, + }, + CellEditorKind::ReadOnly(CellLock::Value(ReadOnlyReason::Bytes)), + ), + ( + ColumnKind::Spatial, + CellEditorKind::ReadOnly(CellLock::Value(ReadOnlyReason::Spatial)), + ), + ( + ColumnKind::Array { + element: Box::new(integer(true)), + }, + CellEditorKind::ReadOnly(CellLock::Value(ReadOnlyReason::Composite)), + ), + ( + ColumnKind::Variant, + CellEditorKind::ReadOnly(CellLock::Value(ReadOnlyReason::Variant)), + ), + ( + ColumnKind::Composite, + CellEditorKind::ReadOnly(CellLock::Value(ReadOnlyReason::Composite)), + ), + ]); + } + + #[test] + fn a_locked_cell_is_read_only_for_its_lock() { + let text = column(ColumnKind::Text { + length: TextLength::Unbounded, + }); + for lock in [ + CellLock::Value(ReadOnlyReason::Undecodable), + CellLock::KeyColumn, + CellLock::Generated, + CellLock::AutoIncrement, + CellLock::UnkeyedRow, + CellLock::ResultRow, + CellLock::ReadOnlySession, + ] { + assert_eq!( + CellEditorKind::for_cell(&text, Err(lock)), + CellEditorKind::ReadOnly(lock) + ); + } + } + + #[test] + fn inline_escalates_to_multiline_for_line_breaks_and_4096_bytes() { + let inline = CellEditorKind::Inline { + accepts_line_breaks: true, + purpose: gtk::InputPurpose::FreeForm, + }; + let multiline = CellEditorKind::Multiline { language: None }; + let editable = |text: String| EditText::Editable(text); + + assert_eq!(inline.for_text(&editable("one line".to_owned())), inline); + assert_eq!(inline.for_text(&editable("two\nlines".to_owned())), multiline); + assert_eq!(inline.for_text(&editable("carriage\rreturn".to_owned())), multiline); + assert_eq!(inline.for_text(&editable("x".repeat(INLINE_EDIT_MAX_BYTES))), inline); + assert_eq!( + inline.for_text(&editable("x".repeat(INLINE_EDIT_MAX_BYTES + 1))), + multiline + ); + assert_eq!( + CellEditorKind::Calendar.for_text(&editable("x".repeat(INLINE_EDIT_MAX_BYTES + 1))), + CellEditorKind::Calendar, + "only the one-line editor gives way" + ); + assert_eq!( + inline.for_text(&EditText::ReadOnly(ReadOnlyReason::Bytes)), + CellEditorKind::ReadOnly(CellLock::Value(ReadOnlyReason::Bytes)) + ); + } +} diff --git a/linux/crates/app/src/ui/grid/cell_focus.rs b/linux/crates/app/src/ui/grid/cell_focus.rs new file mode 100644 index 0000000000..e385abcbb4 --- /dev/null +++ b/linux/crates/app/src/ui/grid/cell_focus.rs @@ -0,0 +1,513 @@ +use gtk4 as gtk; +use gtk4::prelude::*; + +use super::GridCell; +use super::cell_edit::begin_edit; +use super::edit_navigation::{EditDirection, next_edit_target}; +use super::grid_columns::ShownColumn; +use super::inline_editor::commit_inline; +use super::selection::row_at; + +fn window_focus(widget: &impl IsA) -> Option { + let window = widget.root().and_downcast::()?; + gtk::prelude::GtkWindowExt::focus(&window) +} + +pub(super) fn focused_cell(widget: &impl IsA) -> Option { + GridCell::from_focus(&window_focus(widget)?) +} + +/// Unlike `focused_cell`, not a cell whose editor has the focus. +fn cell_holding_focus(view: >k::ColumnView) -> Option { + window_focus(view)?.first_child()?.downcast().ok() +} + +fn n_rows(view: >k::ColumnView) -> u32 { + view.model().map_or(0, |model| model.n_items()) +} + +pub(super) fn focus_cell(view: >k::ColumnView, position: u32, column: &ShownColumn) -> Option { + if position >= n_rows(view) { + return None; + } + view.scroll_to(position, Some(&column.view_column), gtk::ListScrollFlags::FOCUS, None); + let landed = |cell: &GridCell| { + cell.position() == Some(position) && cell.column() == column.index && cell.column_view().as_ref() == Some(view) + }; + // `scroll_to` moves the focus only while it is in the list already. + // Otherwise it marks the cell the list focuses next, which the grab + // then reaches. + if !cell_holding_focus(view).is_some_and(|cell| landed(&cell)) { + view.grab_focus(); + } + cell_holding_focus(view).filter(landed) +} + +/// Give the focus back to `cell` once its editor closed, since the +/// editor took the focus with it. +pub(super) fn return_focus(cell: &GridCell) { + let (Some(view), Some(position), Some(columns)) = (cell.column_view(), cell.position(), cell.columns()) else { + return; + }; + if let Some(column) = columns + .shown(&view) + .into_iter() + .find(|column| column.index == cell.column()) + { + focus_cell(&view, position, &column); + } +} + +/// Commit the one-line edit open in `view`, as Return does. +pub(crate) fn commit_open_edit(view: >k::ColumnView) { + let Some(cell) = focused_cell(view) else { + return; + }; + if cell.inline_text().is_none() || cell.column_view().as_ref() != Some(view) { + return; + } + commit_inline(&cell); + return_focus(&cell); +} + +/// Edit the cell Tab or Shift+Tab goes to from `cell`. With none left +/// on the page, the focus stays on `cell`. +pub(super) fn edit_next(cell: &GridCell, direction: EditDirection) { + let (Some(view), Some(position), Some(columns)) = (cell.column_view(), cell.position(), cell.columns()) else { + return; + }; + let shown = columns.shown(&view); + let Some(from) = shown.iter().position(|column| column.index == cell.column()) else { + return; + }; + let opens_editor = |row: u32, column: usize| { + shown + .get(column) + .zip(row_at(&view, row)) + .is_some_and(|(column, row)| column.opens_editor(&row)) + }; + let target = next_edit_target(opens_editor, shown.len(), from, position, n_rows(&view), direction) + .and_then(|(row, column)| Some((row, shown.get(column)?))); + match target { + Some((row, column)) => { + if let Some(next) = focus_cell(&view, row, column) { + begin_edit(&next); + } + } + None => { + if let Some(column) = shown.get(from) { + focus_cell(&view, position, column); + } + } + } +} + +/// Select the row at `position` and edit its first cell that opens an +/// editor. A row with none keeps the focus on the cell the row gave it. +pub(crate) fn edit_row(view: >k::ColumnView, position: u32) { + if position >= n_rows(view) { + return; + } + // Every cell knows the grid's columns, so the row takes the focus + // first and its cell finds the column to edit. The grab reaches the + // row when the focus was outside the list, where `scroll_to` only + // marks it. + view.scroll_to( + position, + None, + gtk::ListScrollFlags::FOCUS | gtk::ListScrollFlags::SELECT, + None, + ); + view.grab_focus(); + let (Some(columns), Some(row)) = ( + focused_cell(view).and_then(|cell| cell.columns()), + row_at(view, position), + ) else { + return; + }; + let Some(column) = columns.shown(view).into_iter().find(|column| column.opens_editor(&row)) else { + return; + }; + if let Some(cell) = focus_cell(view, position, &column) { + begin_edit(&cell); + } +} + +#[cfg(test)] +mod tests { + use std::time::Duration; + + use gtk4::prelude::*; + use gtk4::{self as gtk, gdk, glib}; + use tablepro_core::Value; + use tablepro_core::edit::CellInput; + use tablepro_core::value::Temporal; + + use super::edit_row; + use crate::test_support::{ + FocusThief, after_next_frame, assert_labelled, drain_main_context, first_descendant_of_type, wait_until, + }; + use crate::ui::grid::test_grid::{Grid, WAIT, edited_rows, key, named, text}; + use crate::ui::grid::{GridCell, begin_edit}; + + /// Press `key` in the cell's open one-line editor, through the + /// handler a real key press reaches first. + fn press_in_editor(cell: &GridCell, key: gdk::Key, modifiers: gdk::ModifierType) -> bool { + let editor = cell.inline_text().expect("an open one-line editor"); + let controllers = editor.observe_controllers(); + let keys = (0..controllers.n_items()) + .filter_map(|position| controllers.item(position).and_downcast::()) + .find(|controller| controller.propagation_phase() == gtk::PropagationPhase::Capture) + .expect("the editor's key handler"); + keys.emit_by_name::("key-pressed", &[&key, &0u32, &modifiers]) + } + + /// The editing cell once the focus is in its editor. + fn editing(grid: &Grid) -> GridCell { + let mut editing = None; + wait_until(WAIT, || { + editing = grid.focused_cell().filter(|cell| cell.inline_text().is_some()); + editing.is_some() + }) + .expect("a cell is open for editing with the focus"); + editing.expect("an editing cell") + } + + fn at(cell: &GridCell) -> (Option, usize) { + (cell.position(), cell.column()) + } + + /// A key, a text, a check box and a text: the key is locked on the + /// saved rows, and a check box opens no editor. + fn mixed_rows() -> Grid { + Grid::open( + vec![ + named("id", "integer", true), + named("name", "text", false), + named("flag", "boolean", false), + named("note", "text", false), + ], + vec![ + vec![ + Value::Int(1), + Value::Text("a".into()), + Value::Bool(false), + Value::Text("x".into()), + ], + vec![ + Value::Int(2), + Value::Text("b".into()), + Value::Bool(true), + Value::Text("y".into()), + ], + ], + ) + } + + #[gtk4::test] + fn tab_in_edit_mode_moves_to_next_editable_column() { + let grid = mixed_rows(); + let name = grid.focus(0, 1); + crate::ui::grid::begin_edit(&name); + name.inline_text().expect("an editor").set_text("a2"); + let none = gdk::ModifierType::empty(); + + assert!(press_in_editor(&name, gdk::Key::Tab, none)); + let note = editing(&grid); + assert_eq!(at(¬e), (Some(0), 3), "Tab did not skip the check box"); + assert_eq!( + note.inline_text().map(|editor| editor.text().to_string()).as_deref(), + Some("x") + ); + + assert!(press_in_editor(¬e, gdk::Key::Tab, none)); + let next_row = editing(&grid); + assert_eq!(at(&next_row), (Some(1), 1), "Tab did not skip the saved row's key"); + + assert!(press_in_editor( + &next_row, + gdk::Key::ISO_Left_Tab, + gdk::ModifierType::SHIFT_MASK + )); + let back = editing(&grid); + assert_eq!(back, note, "Shift+Tab did not go back a row"); + + assert!(!name.is_editing() && !next_row.is_editing()); + assert_eq!(edited_rows(&grid.take_messages()), vec![(Some(key(1)), 1, text("a2"))]); + } + + #[gtk4::test] + fn tab_past_the_last_editable_cell_keeps_the_focus_there() { + let grid = mixed_rows(); + let note = grid.focus(1, 3); + crate::ui::grid::begin_edit(¬e); + + assert!(press_in_editor(¬e, gdk::Key::Tab, gdk::ModifierType::empty())); + + assert!(!note.is_editing()); + assert_eq!(grid.focused_cell().as_ref(), Some(¬e)); + } + + #[gtk4::test] + fn escape_cancels_and_returns_focus_to_cell() { + let grid = mixed_rows(); + let name = grid.focus(1, 1); + crate::ui::grid::begin_edit(&name); + name.inline_text().expect("an editor").set_text("typed"); + + assert!(press_in_editor(&name, gdk::Key::Escape, gdk::ModifierType::empty())); + + assert!(!name.is_editing()); + assert_eq!(grid.focused_cell().as_ref(), Some(&name), "the focus did not come back"); + assert!(grid.take_messages().is_empty(), "Escape sent the typed text"); + } + + /// GTK ends the focus change by walking up from the field the edit + /// was in. GTK 4.22 logs a critical forever and never returns when the + /// field has left its cell by then. + #[gtk4::test] + fn the_focus_moving_to_another_cell_commits_the_edit() { + let grid = mixed_rows(); + let name = grid.focus(0, 1); + crate::ui::grid::begin_edit(&name); + name.inline_text().expect("an editor").set_text("a2"); + + let previous = glib::log_set_always_fatal(glib::LogLevels::LEVEL_CRITICAL); + let note = grid.focus(1, 3); + glib::log_set_always_fatal(previous); + + assert!(!name.is_editing(), "the edit outlived the focus"); + assert_eq!(edited_rows(&grid.take_messages()), vec![(Some(key(1)), 1, text("a2"))]); + assert_eq!(grid.focused_cell().as_ref(), Some(¬e)); + assert!( + !name.state_flags().contains(gtk::StateFlags::FOCUS_WITHIN), + "the cell the edit left still shows the focus" + ); + } + + /// Another window taking the focus is no focus change in this one, + /// so the edit stays open, and typing goes on there once it is back. + #[gtk4::test] + fn an_edit_stays_open_while_another_window_has_the_focus() { + let grid = mixed_rows(); + let name = grid.focus(1, 1); + crate::ui::grid::begin_edit(&name); + let editor = name.inline_text().expect("an editor"); + editor.set_text("typed"); + + let thief = FocusThief::take_from(&grid.window).expect("another window takes the focus"); + after_next_frame(&grid.window); + assert!(name.is_editing(), "another window taking the focus ended the edit"); + thief.give_back(&grid.window); + drain_main_context(); + after_next_frame(&grid.window); + + assert!(name.is_editing(), "the edit ended when the window came back"); + assert!(grid.take_messages().is_empty(), "the half-typed value was sent"); + assert_eq!( + gtk::prelude::GtkWindowExt::focus(&grid.window).as_ref(), + Some(editor.upcast_ref::()), + "the focus left the field" + ); + } + + #[gtk4::test] + fn ctrl_tab_in_an_editor_is_left_to_the_window() { + let grid = mixed_rows(); + let name = grid.focus(1, 1); + crate::ui::grid::begin_edit(&name); + + assert!(!press_in_editor(&name, gdk::Key::Tab, gdk::ModifierType::CONTROL_MASK)); + assert!(name.is_editing()); + } + + #[gtk4::test] + fn new_draft_enters_edit_mode_on_first_editable_cell() { + let mut serial = named("id", "integer", true); + serial.is_auto_increment = true; + let grid = Grid::open( + vec![serial, named("name", "text", false), named("note", "text", false)], + vec![vec![Value::Int(1), Value::Text("a".into()), Value::Text("x".into())]], + ); + grid.insert_draft(); + + edit_row(&grid.view, 0); + + let cell = editing(&grid); + assert!(cell.row().is_some_and(|row| row.draft_id().is_some())); + assert_eq!(cell.column(), 1, "the edit did not skip the auto-increment key"); + let selection = grid.view.model().expect("a selection model"); + assert!(selection.is_selected(0) && !selection.is_selected(1)); + } + + fn focus_in(grid: &Grid, popover: >k::Popover) -> bool { + gtk::prelude::GtkWindowExt::focus(&grid.window).is_some_and(|focus| focus.is_ancestor(popover)) + } + + /// Open the cell's editor popover with the focus in it. Test processes + /// share one X display, where another test's window can close an + /// autohide popover before it maps. + fn open_popover(grid: &Grid, cell: &GridCell) -> gtk::Popover { + for _ in 0..5 { + begin_edit(cell); + if let Some(popover) = first_descendant_of_type::(cell) + && wait_until(Duration::from_secs(2), || { + popover.is_mapped() && focus_in(grid, &popover) + }) + .is_ok() + { + return popover; + } + cell.cancel_edit(); + drain_main_context(); + } + panic!("the editor popover never showed"); + } + + fn button(popover: >k::Popover, label: &str) -> gtk::Button { + crate::test_support::descendants(popover) + .into_iter() + .filter_map(|widget| widget.downcast::().ok()) + .find(|button| button.label().as_deref() == Some(label)) + .expect("the popover's button") + } + + fn day(day: u32) -> Value { + Value::Date(Temporal::Finite( + chrono::NaiveDate::from_ymd_opt(2024, 3, day).expect("a date"), + )) + } + + fn dated_row() -> Grid { + Grid::open( + vec![named("id", "integer", true), named("day", "date", false)], + vec![vec![Value::Int(1), day(15)]], + ) + } + + #[gtk4::test] + fn picking_a_day_returns_focus_to_cell() { + let grid = dated_row(); + let cell = grid.focus(0, 1); + let popover = open_popover(&grid, &cell); + let calendar = first_descendant_of_type::(&popover).expect("the calendar"); + + calendar.set_date(&glib::DateTime::from_utc(2024, 3, 16, 0, 0, 0.0).expect("a day")); + after_next_frame(&grid.window); + + assert!(!cell.is_editing()); + assert_eq!(grid.focused_cell().as_ref(), Some(&cell), "the focus left the grid"); + assert_eq!( + edited_rows(&grid.take_messages()), + vec![(Some(key(1)), 1, CellInput::Value(day(16)))] + ); + } + + #[gtk4::test] + fn closing_the_calendar_returns_focus_to_cell() { + let grid = dated_row(); + let cell = grid.focus(0, 1); + let popover = open_popover(&grid, &cell); + + // As Escape does. + popover.popdown(); + after_next_frame(&grid.window); + + assert!(!cell.is_editing()); + assert_eq!(grid.focused_cell().as_ref(), Some(&cell), "the focus left the grid"); + assert!(grid.take_messages().is_empty()); + } + + #[gtk4::test] + fn removing_the_row_under_an_open_calendar_drops_its_edit() { + let grid = Grid::open( + vec![named("id", "integer", true), named("day", "date", false)], + vec![vec![Value::Int(1), day(15)], vec![Value::Int(2), day(20)]], + ); + let cell = grid.focus(0, 1); + let popover = open_popover(&grid, &cell); + + grid.store.remove(0); + after_next_frame(&grid.window); + + assert!(popover.parent().is_none(), "the popover outlived its row"); + assert!(!cell.is_editing()); + assert!(grid.take_messages().is_empty()); + let focus = gtk::prelude::GtkWindowExt::focus(&grid.window); + assert!( + focus.is_some_and(|focus| focus == *grid.view.upcast_ref::() || focus.is_ancestor(&grid.view)), + "the focus left the grid" + ); + } + + /// Tab moves an edit across the columns, and no header is focused on + /// the way, so each editor names the column it edits. + #[gtk4::test] + fn every_cell_editor_is_named_by_its_column() { + let grid = Grid::open( + vec![ + named("id", "integer", true), + named("note", "text", false), + named("day", "date", false), + ], + vec![ + vec![Value::Int(1), Value::Text("one\ntwo".into()), day(15)], + vec![Value::Int(2), Value::Text("plain".into()), day(16)], + ], + ); + + let one_line = grid.cell_of(2, 1); + begin_edit(&one_line); + let field = one_line.inline_text().expect("the one-line editor"); + assert_eq!(assert_labelled(&field), Ok(())); + one_line.cancel_edit(); + + let lines = grid.cell_of(1, 1); + begin_edit(&lines); + let view = first_descendant_of_type::(&lines).expect("the multi-line editor"); + assert_eq!(assert_labelled(&view), Ok(())); + let save = crate::test_support::descendants(&lines) + .into_iter() + .filter_map(|widget| widget.downcast::().ok()) + .find(|button| button.label().as_deref() == Some("Save")) + .expect("the Save button"); + assert!(gtk::test_accessible_has_property( + &save, + gtk::AccessibleProperty::KeyShortcuts + )); + lines.cancel_edit(); + + let dated = grid.cell_of(1, 2); + begin_edit(&dated); + let calendar = first_descendant_of_type::(&dated).expect("the calendar"); + assert_eq!(assert_labelled(&calendar), Ok(())); + dated.cancel_edit(); + } + + #[gtk4::test] + fn the_multi_line_editor_returns_focus_to_cell() { + let grid = Grid::open( + vec![named("id", "integer", true), named("note", "text", false)], + vec![vec![Value::Int(1), Value::Text("one\ntwo".into())]], + ); + let cell = grid.focus(0, 1); + + let popover = open_popover(&grid, &cell); + button(&popover, "Cancel").emit_clicked(); + after_next_frame(&grid.window); + assert!(!cell.is_editing()); + assert_eq!(grid.focused_cell().as_ref(), Some(&cell), "Cancel left the grid"); + + let popover = open_popover(&grid, &cell); + let view = first_descendant_of_type::(&popover).expect("the multi-line editor"); + view.buffer().set_text("one\nthree"); + button(&popover, "Save").emit_clicked(); + after_next_frame(&grid.window); + assert!(!cell.is_editing()); + assert_eq!(grid.focused_cell().as_ref(), Some(&cell), "Save left the grid"); + assert_eq!( + edited_rows(&grid.take_messages()), + vec![(Some(key(1)), 1, text("one\nthree"))] + ); + } +} diff --git a/linux/crates/app/src/ui/grid/cell_lock.rs b/linux/crates/app/src/ui/grid/cell_lock.rs new file mode 100644 index 0000000000..5ad3165190 --- /dev/null +++ b/linux/crates/app/src/ui/grid/cell_lock.rs @@ -0,0 +1,15 @@ +use tablepro_core::edit::ReadOnlyReason; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CellLock { + /// The column's values have no text form to type. + Value(ReadOnlyReason), + /// The row is named by this column, so changing it in place would + /// lose the row it names. + KeyColumn, + Generated, + AutoIncrement, + UnkeyedRow, + ResultRow, + ReadOnlySession, +} diff --git a/linux/crates/app/src/ui/grid/cell_preset.rs b/linux/crates/app/src/ui/grid/cell_preset.rs new file mode 100644 index 0000000000..61c6cab363 --- /dev/null +++ b/linux/crates/app/src/ui/grid/cell_preset.rs @@ -0,0 +1,95 @@ +use tablepro_core::ColumnInfo; + +use super::GridRowId; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CellPreset { + Empty, + Null, + /// Leave the column out of the new row's INSERT. + Default, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct CellPresets { + pub empty: bool, + pub null: bool, + pub default: bool, +} + +/// Only an INSERT can leave a column out, so Default is for new rows +/// alone, and only where the server has something to fill in. +pub fn cell_presets(column: &ColumnInfo, row: &GridRowId) -> CellPresets { + let server_fills = !column.default.is_none() || column.is_auto_increment || column.is_generated; + CellPresets { + empty: column.column_type.kind.accepts_empty_string(), + null: column.nullable, + default: matches!(row, GridRowId::Draft(_)) && server_fills, + } +} + +#[cfg(test)] +mod tests { + use tablepro_core::column::{ColumnDefault, ColumnKind, ColumnType, IntegerKind, IntegerWidth, TextLength}; + use tablepro_core::{ColumnInfo, Value}; + + use super::{CellPresets, cell_presets}; + use crate::services::change_tracker::RowKey; + use crate::ui::grid::GridRowId; + + fn column(kind: ColumnKind, nullable: bool, default: ColumnDefault) -> ColumnInfo { + let mut column_type = ColumnType::untyped_text("t"); + column_type.kind = kind; + ColumnInfo { + name: "c".to_owned(), + column_type, + nullable, + primary_key: false, + is_auto_increment: false, + is_generated: false, + default, + comment: None, + } + } + + #[test] + #[expect( + clippy::disallowed_methods, + reason = "a test fixture stands in for the catalogue, which is where a default expression comes from" + )] + fn presets_enabled_per_kind_nullability_and_draft() { + let text = ColumnKind::Text { + length: TextLength::Unbounded, + }; + let integer = ColumnKind::Integer(IntegerKind { + width: IntegerWidth::W32, + signed: true, + }); + let now = ColumnDefault::Expression(tablepro_core::column::SqlExpression::from_catalog_text("now()")); + let saved = GridRowId::Persisted(RowKey::from_pk_values(&[Value::Int(1)]).expect("a key")); + let draft = GridRowId::Draft(1); + let presets = |empty, null, default| CellPresets { empty, null, default }; + + assert_eq!( + cell_presets(&column(text.clone(), true, ColumnDefault::None), &saved), + presets(true, true, false) + ); + assert_eq!( + cell_presets(&column(text, false, ColumnDefault::None), &draft), + presets(true, false, false), + "a column with nothing to fill offered Default" + ); + assert_eq!( + cell_presets(&column(integer.clone(), false, now.clone()), &draft), + presets(false, false, true) + ); + assert_eq!( + cell_presets(&column(integer.clone(), true, now), &saved), + presets(false, true, false), + "a saved row offered Default" + ); + let mut serial = column(integer, false, ColumnDefault::None); + serial.is_auto_increment = true; + assert_eq!(cell_presets(&serial, &draft), presets(false, false, true)); + } +} diff --git a/linux/crates/app/src/ui/grid/cell_tooltip.rs b/linux/crates/app/src/ui/grid/cell_tooltip.rs new file mode 100644 index 0000000000..6683fdc6ea --- /dev/null +++ b/linux/crates/app/src/ui/grid/cell_tooltip.rs @@ -0,0 +1,61 @@ +/// Shorter text fits a typical 200 px column, so its ellipsized display +/// needs no tooltip. +const TOOLTIP_MIN_CHARS: usize = 40; +/// A tooltip is a glance, not a viewer: past this the full value is a +/// menu item away. +pub(super) const TOOLTIP_MAX_CHARS: usize = 300; + +/// The tooltip for a cell's text. `None` for a placeholder and for text +/// short enough to read in the cell. +pub(super) fn cell_tooltip(text: &str, truncated_bytes: usize, placeholder: bool) -> Option { + if placeholder { + return None; + } + let mut chars = text.char_indices(); + if truncated_bytes == 0 && chars.nth(TOOLTIP_MIN_CHARS).is_none() { + return None; + } + let cut = text.char_indices().nth(TOOLTIP_MAX_CHARS).map(|(index, _)| index); + let mut tooltip = match cut { + Some(end) => format!("{}…", &text[..end]), + None => text.to_owned(), + }; + if cut.is_some() || truncated_bytes > 0 { + tooltip.push('\n'); + tooltip.push_str(&crate::i18n::gettext("Use Show Row as JSON to see the full value")); + } + Some(tooltip) +} + +#[cfg(test)] +mod tests { + use super::{TOOLTIP_MAX_CHARS, cell_tooltip}; + + #[test] + fn cell_tooltip_none_for_short_and_placeholder() { + assert_eq!(cell_tooltip("short", 0, false), None); + assert_eq!(cell_tooltip(&"x".repeat(40), 0, false), None); + assert_eq!(cell_tooltip(&"x".repeat(500), 0, true), None, "a placeholder"); + assert_eq!( + cell_tooltip(&"x".repeat(41), 0, false).as_deref(), + Some("x".repeat(41).as_str()) + ); + } + + #[test] + fn cell_tooltip_capped_at_300_chars_with_hint() { + let tooltip = cell_tooltip(&"é".repeat(1_000), 0, false).expect("a tooltip"); + let (head, hint) = tooltip.split_once('\n').expect("a hint line"); + + assert_eq!(head.chars().count(), TOOLTIP_MAX_CHARS + 1); + assert!(head.ends_with('…')); + assert!(hint.contains("Show Row as JSON"), "{hint}"); + } + + #[test] + fn a_cut_cell_says_so_even_when_its_text_is_short() { + let tooltip = cell_tooltip("head", 12, false).expect("a tooltip"); + + assert!(tooltip.starts_with("head\n"), "{tooltip}"); + } +} diff --git a/linux/crates/app/src/ui/grid/column_drawing.rs b/linux/crates/app/src/ui/grid/column_drawing.rs new file mode 100644 index 0000000000..0759379a4a --- /dev/null +++ b/linux/crates/app/src/ui/grid/column_drawing.rs @@ -0,0 +1,147 @@ +use std::rc::Rc; + +use gtk4 as gtk; +use gtk4::prelude::*; + +use tablepro_core::column::{BoolStorage, ColumnKind, ColumnType}; +use tablepro_core::{ColumnInfo, Value}; + +use super::cell_tooltip::cell_tooltip; +use super::column_figures::ColumnFigures; +use super::display_text::{DisplayText, display_text}; +use super::grid_attributes::{GridAttributes, TextMark}; +use super::toggle_cell::{ToggleCell, toggle_state}; +use super::{CellLock, GridCell, GridRowId, RowObject, TabGridContext, cell_editability}; +use crate::services::change_tracker::CellState; + +const PENDING_CSS_CLASSES: &[&str] = &[ + "tp-cell-modified", + "tp-row-pending-delete", + "tp-row-pending-insert", + "tp-row-leftmost-error-flash", +]; + +/// What a column's cells read to draw a row. The column makes one and +/// every cell it builds shares it. +#[derive(Debug)] +pub struct ColumnDrawing { + /// `None` for a column the grid knows only from the result set. + pub catalog: Option, + /// The type the result set gives the column, which a value is + /// written out in. + pub column_type: ColumnType, + pub tab_ctx: TabGridContext, + /// The server fills these in, so a new row shows a placeholder + /// rather than an empty cell the user should type in. + pub auto_filled: bool, + pub attributes: Rc, + pub figures: ColumnFigures, +} + +impl ColumnDrawing { + pub fn lock(&self, row: &GridRowId) -> Option { + match self.catalog.as_ref() { + Some(column) => cell_editability(column, row, &self.tab_ctx.identity, !self.tab_ctx.editable).err(), + None => Some(CellLock::ResultRow), + } + } + + pub fn draw(&self, cell: &GridCell, row: &RowObject) -> DrawnCell { + let column = cell.column(); + let (pending_classes, state) = self.tab_ctx.pending_marks(row, column); + self.tab_ctx.with_effective_value(row, column, |value, default| { + let drawn = DrawnCell { + state, + null: !default && value.is_null(), + }; + // An edit opened on a value the row no longer has would, once + // it ended, send that value back. An edit on the same value + // keeps what was typed. + if cell.edit_left_behind(value, default) { + cell.cancel_edit(); + } + let Some(content) = cell.content() else { + return drawn; + }; + apply_pending_classes(content, &pending_classes); + let pending_delete = pending_classes.contains(&"tp-row-pending-delete"); + if let Some(check) = content.downcast_ref::() { + let storage = match self.catalog.as_ref().map(|column| &column.column_type.kind) { + Some(ColumnKind::Bool(storage)) => *storage, + _ => BoolStorage::Native, + }; + ToggleCell::draw(cell, check, &toggle_state(value, default, &self.column_type, storage)); + check.set_sensitive(cell.lock().is_none()); + // A check box has no strikethrough, so a pending delete dims it. + check.set_opacity(if pending_delete { 0.5 } else { 1.0 }); + } else if let Some(display) = content.downcast_ref::() { + let mark = match (pending_delete, state) { + (true, _) => TextMark::PendingDelete, + (false, CellState::Modified) => TextMark::Modified, + (false, _) => TextMark::Plain, + }; + let shown = CellText { + value, + default, + editable: cell.lock().is_none(), + mark, + }; + cell.with_scratch(|scratch| self.draw_text(display, &shown, scratch)); + } + drawn + }) + } + + /// An editable cell shows NULL as ``, so it reads differently + /// from an empty string, and a locked one dims a plain NULL. + fn draw_text(&self, display: >k::Inscription, cell: &CellText<'_>, scratch: &mut String) { + let attributes = &self.attributes; + let shown = match (cell.default, cell.value) { + (true, _) if self.auto_filled => DisplayText::placeholder(&attributes.auto_filled), + (true, _) => DisplayText::placeholder("DEFAULT"), + (false, Value::Null) if cell.editable => DisplayText::placeholder(&attributes.editable_null), + (false, value) => display_text(value, &self.column_type, scratch), + }; + self.figures.apply(display, shown.text, shown.placeholder); + display.set_text(Some(shown.text)); + let widget = display.upcast_ref::(); + widget.set_tooltip_text(cell_tooltip(shown.text, shown.truncated_bytes, shown.placeholder).as_deref()); + set_class( + widget, + "dimmed", + !cell.default && cell.value.is_null() && !cell.editable, + ); + display.set_attributes(attributes.for_text(cell.mark, shown.placeholder)); + } +} + +/// What a draw tells a screen reader about the cell. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct DrawnCell { + pub state: CellState, + /// The cell holds NULL, not a draft's cell left to the server. + pub null: bool, +} + +/// One cell's value and the state it is drawn in. +struct CellText<'a> { + value: &'a Value, + /// A draft's cell left to the server. + default: bool, + editable: bool, + mark: TextMark, +} + +fn set_class(widget: >k::Widget, class: &str, on: bool) { + if on { + widget.add_css_class(class); + } else { + widget.remove_css_class(class); + } +} + +fn apply_pending_classes(widget: >k::Widget, pending_classes: &[&'static str]) { + for class in PENDING_CSS_CLASSES { + set_class(widget, class, pending_classes.contains(class)); + } +} diff --git a/linux/crates/app/src/ui/grid/column_factory.rs b/linux/crates/app/src/ui/grid/column_factory.rs new file mode 100644 index 0000000000..113c5841bd --- /dev/null +++ b/linux/crates/app/src/ui/grid/column_factory.rs @@ -0,0 +1,1084 @@ +use std::cell::RefCell; +use std::rc::Rc; + +use gtk4::prelude::*; +use gtk4::{self as gtk, gio, glib}; + +use tablepro_core::ColumnInfo; +use tablepro_core::column::{ColumnKind, ResultColumn}; + +use super::cell_edit::install_edit_triggers; +use super::cell_editability::column_takes_edits; +use super::column_drawing::ColumnDrawing; +use super::column_figures::ColumnFigures; +use super::context_menu::attach_cell_gesture; +use super::grid_attributes::GridAttributes; +use super::grid_columns::GridColumns; +use super::grid_keyboard::GridKeyboard; +use super::grid_menus::{GridMenuInit, GridMenus, install_grid_context_menus}; +use super::result_sorters::column_sorter; +use super::row_factory::install_row_factory; +use super::row_state_column::row_state_column; +use super::toggle_cell::ToggleCell; +use super::{GridCell, GridContent, GridMsg, GridSink, RowObject, TabGridContext}; + +const WIDE_TABLE_THRESHOLD: usize = 8; +/// About 14 characters at the default font size. +const MIN_COLUMN_WIDTH_PX: i32 = 120; + +/// How a grid's headers sort its rows. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum GridSorting { + /// The server sorts: a header click sends `SortChanged` and the rows + /// stay in the order they arrived, which `initial` describes. + Server { initial: Option<(usize, bool)> }, + /// The grid sorts the rows it holds, each column by its own sorter. + Client, +} + +pub(crate) fn build_column_view( + content: GridContent, + schema_columns: &[ColumnInfo], + sink: GridSink, + sorting: GridSorting, + tab_ctx: TabGridContext, +) -> (gtk::ColumnView, gtk::MultiSelection) { + let GridContent { + columns: result_columns, + rows, + } = content; + let store = gio::ListStore::new::(); + store.splice(0, 0, &rows); + let grid_columns = Rc::new(GridColumns::default()); + // A browse page splices its rows into this store, so the columns + // measure them before the view binds any of them. + store.connect_items_changed({ + let grid_columns = grid_columns.clone(); + move |store, position, _, added| grid_columns.measure_change(store, position, added) + }); + // Tab leaves the grid, and the arrow keys move between cells. + let column_view = gtk::ColumnView::builder() + .show_row_separators(true) + .show_column_separators(true) + .tab_behavior(gtk::ListTabBehavior::Cell) + .build(); + let selection = match sorting { + GridSorting::Client => sorted_selection(store, column_view.sorter()), + GridSorting::Server { .. } => gtk::MultiSelection::new(Some(store)), + }; + column_view.set_model(Some(&selection)); + // GTK's own denser rows for a grid of data. + column_view.add_css_class("data-table"); + install_row_factory(&column_view, tab_ctx.clone()); + + let grid_menus = install_grid_context_menus(GridMenuInit { + column_view: &column_view, + sink: sink.clone(), + columns: result_columns.clone(), + tab_ctx: tab_ctx.clone(), + }); + GridKeyboard::install(&column_view, grid_menus.clone(), sink.clone()); + + // Sharing the viewport by `expand` alone leaves a wide table with 20 + // to 30 px columns that show nothing, so a text column with no saved + // width starts at a readable minimum there. + let default_min_width = if result_columns.len() > WIDE_TABLE_THRESHOLD { + Some(MIN_COLUMN_WIDTH_PX) + } else { + None + }; + let mut columns: Vec = Vec::with_capacity(result_columns.len()); + let mut releases = Vec::with_capacity(result_columns.len() + 1); + let attributes = Rc::new(GridAttributes::new()); + for (i, column) in result_columns.iter().enumerate() { + let sorter = match sorting { + // The server orders the rows, so the column's own sorter + // only marks the header and must leave them where they are. + GridSorting::Server { .. } => gtk::CustomSorter::new(|_, _| gtk::Ordering::Equal).upcast(), + GridSorting::Client => column_sorter(i, column), + }; + let (col, width_saver) = build_column(ColumnInit { + info: column, + idx: i, + rows: &rows, + catalog: schema_columns.get(i).cloned(), + sink: sink.clone(), + sorter, + tab_ctx: tab_ctx.clone(), + default_min_width, + column_view: column_view.downgrade(), + grid_menus: grid_menus.clone(), + grid_columns: grid_columns.clone(), + attributes: attributes.clone(), + }); + column_view.append_column(&col); + releases.push(ColumnRelease { + column: col.downgrade(), + width_saver, + }); + columns.push(col); + } + if tab_ctx.tracks_changes() { + let state_column = row_state_column(tab_ctx.clone()); + column_view.insert_column(0, &state_column); + releases.push(ColumnRelease { + column: state_column.downgrade(), + width_saver: None, + }); + } + release_columns_with(&column_view, releases); + wire_server_sorting(&column_view, columns, sorting, sink); + + (column_view, selection) +} + +fn wire_server_sorting( + column_view: >k::ColumnView, + columns: Vec, + sorting: GridSorting, + sink: GridSink, +) { + // Applied before the notify handlers are wired, so it does not echo + // back as a SortChanged. + if let GridSorting::Server { + initial: Some((col_idx, ascending)), + } = sorting + && let Some(col) = columns.get(col_idx) + { + let direction = if ascending { + gtk::SortType::Ascending + } else { + gtk::SortType::Descending + }; + column_view.sort_by_column(Some(col), direction); + } + + if matches!(sorting, GridSorting::Server { .. }) + && let Some(view_sorter) = column_view + .sorter() + .and_then(|s| s.downcast::().ok()) + { + // A second click on the same column flips only + // `primary-sort-order`, so both notifies are wired. Each sends the + // sorter's post-click pair, which the receiver drops when it + // already matches. + let dispatch = move |sorter: >k::ColumnViewSorter| { + let Some(active) = sorter.primary_sort_column() else { + return; + }; + let ascending = matches!(sorter.primary_sort_order(), gtk::SortType::Ascending); + if let Some(idx) = columns.iter().position(|col| col == &active) { + sink(GridMsg::SortChanged(idx, ascending)); + } + }; + view_sorter.connect_primary_sort_column_notify({ + let dispatch = dispatch.clone(); + move |sorter| dispatch(sorter) + }); + view_sorter.connect_primary_sort_order_notify(move |sorter| dispatch(sorter)); + } +} + +/// A selection over `store` sorted by `sorter`, sorted at once on each +/// click rather than in steps, so the rows never show half sorted and a +/// copy right after a click takes the order on screen. +/// `GtkMultiSelection` follows its selected rows to where a sort puts +/// them. +fn sorted_selection(store: gio::ListStore, sorter: Option) -> gtk::MultiSelection { + let sorted = gtk::SortListModel::new(Some(store), sorter); + sorted.set_incremental(false); + gtk::MultiSelection::new(Some(sorted)) +} + +/// How a column's cells are built. Editors are made when an edit +/// opens, so only the display is chosen here. +#[derive(Clone, Copy)] +enum CellKind { + /// Drawn and never edited, so it takes no edit triggers. + ReadOnly, + Toggle, + Text, +} + +struct ColumnInit<'a> { + info: &'a ResultColumn, + idx: usize, + rows: &'a [RowObject], + /// `None` for a column the grid knows only from the result set. + catalog: Option, + sink: GridSink, + sorter: gtk::Sorter, + tab_ctx: TabGridContext, + default_min_width: Option, + column_view: glib::WeakRef, + grid_menus: GridMenus, + grid_columns: Rc, + attributes: Rc, +} + +fn build_column(init: ColumnInit<'_>) -> (gtk::ColumnViewColumn, Option) { + let ColumnInit { + info, + idx, + rows, + catalog, + sink, + sorter, + tab_ctx, + default_min_width, + column_view, + grid_menus, + grid_columns, + attributes, + } = init; + let factory = gtk::SignalListItemFactory::new(); + + let kind = CellKind::for_column(catalog.as_ref(), &tab_ctx); + let auto_filled = catalog + .as_ref() + .is_some_and(|column| column.is_auto_increment || column.is_generated); + let figures = ColumnFigures::default(); + figures.measure(&info.column_type, idx, rows); + let drawing = Rc::new(ColumnDrawing { + catalog, + column_type: info.column_type.clone(), + tab_ctx: tab_ctx.clone(), + auto_filled, + attributes, + figures, + }); + + let setup_drawing = drawing.clone(); + let setup_columns = grid_columns.clone(); + let setup_name = info.name.clone(); + factory.connect_setup(move |_, item| { + let Some(cell) = item.downcast_ref::() else { + return; + }; + // The focus lands on the cell, never inside it, so every key + // the grid answers sees the same widget. Set through the + // property: `ListItemExt::set_focusable` sets the GtkListItem + // field, which the column view's cell widget never reads. + cell.set_property("focusable", true); + let grid_cell = GridCell::new(); + grid_cell.attach(cell, idx); + grid_cell.set_drawing(setup_drawing.clone()); + grid_cell.set_sink(sink.clone()); + grid_cell.set_columns(setup_columns.clone()); + match kind { + CellKind::ReadOnly => grid_cell.set_content(&text_display(&setup_drawing.figures)), + CellKind::Toggle => ToggleCell::setup(&grid_cell, &setup_name), + CellKind::Text => { + grid_cell.set_content(&text_display(&setup_drawing.figures)); + install_edit_triggers(&grid_cell); + } + } + attach_cell_gesture(&grid_cell, &column_view, &grid_menus); + cell.set_child(Some(&grid_cell)); + }); + bind_rows_to_cells(&factory); + + let column = gtk::ColumnViewColumn::builder() + .title(&info.name) + .factory(&factory) + .resizable(true) + .expand(true) + .build(); + column.set_sorter(Some(&sorter)); + // A number column is as wide as its widest number already. + let default_min_width = default_min_width.filter(|_| !drawing.figures.numbers()); + let width_saver = size_column(&column, &info.name, &tab_ctx, default_min_width); + grid_columns.push(idx, &column, drawing, default_min_width); + (column, width_saver) +} + +struct ColumnRelease { + column: glib::WeakRef, + width_saver: Option, +} + +// GTK 4.22 leaks a reference to the column next to the focus column when +// the view removes the focus column, so a column can outlive its view. +// Its factory and width saver must not keep the grid alive with it. +fn release_columns_with(column_view: >k::ColumnView, releases: Vec) { + let releases = RefCell::new(releases); + column_view.connect_destroy(move |_| { + for release in releases.take() { + let Some(column) = release.column.upgrade() else { + continue; + }; + if let Some(handler) = release.width_saver { + column.disconnect(handler); + } + column.set_factory(None::<>k::ListItemFactory>); + } + }); +} + +impl CellKind { + fn for_column(catalog: Option<&ColumnInfo>, tab_ctx: &TabGridContext) -> CellKind { + match catalog { + Some(column) if tab_ctx.editable && column_takes_edits(column, &tab_ctx.identity, false) => { + match column.column_type.kind { + ColumnKind::Bool(_) => CellKind::Toggle, + _ => CellKind::Text, + } + } + _ => CellKind::ReadOnly, + } + } +} + +fn bind_rows_to_cells(factory: >k::SignalListItemFactory) { + factory.connect_bind(|_, item| { + let Some(cell) = item.downcast_ref::() else { + return; + }; + if let (Some(row), Some(grid_cell)) = ( + cell.item().and_downcast::(), + cell.child().and_downcast::(), + ) { + grid_cell.bind(&row); + } + }); + + factory.connect_unbind(|_, item| { + if let Some(grid_cell) = item + .downcast_ref::() + .and_then(|cell| cell.child()) + .and_downcast::() + { + grid_cell.unbind(); + } + }); +} + +/// The width the tab saved for the column, or `default_min_width`, and +/// every later drag saved back. +fn size_column( + column: >k::ColumnViewColumn, + name: &str, + tab_ctx: &TabGridContext, + default_min_width: Option, +) -> Option { + if let Some(widths) = tab_ctx.column_widths.clone() { + if let Some(saved) = widths.width(name) { + column.set_fixed_width(saved); + } else if let Some(min) = default_min_width { + column.set_fixed_width(min); + } + let column_name = name.to_owned(); + Some(column.connect_fixed_width_notify(move |column| { + widths.record(&column_name, column.fixed_width()); + })) + } else { + if let Some(min) = default_min_width { + // Editor results remember no widths, but can be just as wide. + column.set_fixed_width(min); + } + None + } +} + +/// Every cell but a check box draws its value in an Inscription, which +/// lays out only what fits and offers no text selection of its own. +fn text_display(figures: &ColumnFigures) -> gtk::Inscription { + let display = gtk::Inscription::new(None); + display.set_hexpand(true); + display.set_text_overflow(gtk::InscriptionOverflow::EllipsizeEnd); + display.set_margin_start(8); + display.set_margin_end(8); + display.add_css_class("tp-cell-text"); + figures.apply(&display, "", true); + display +} + +#[cfg(test)] +mod tests { + use gtk4::prelude::*; + use gtk4::{self as gtk, glib}; + + use tablepro_core::Value; + use tablepro_core::edit::CellInput; + + use super::{GridSorting, build_column_view}; + use crate::services::change_tracker::RowKey; + use crate::services::tab_tracker_handle::TabTrackerHandle; + use crate::services::tracker_edit::TrackerEdit; + use crate::test_support::{OwnedWindow, descendants, drain_main_context, first_descendant_of_type, wait_until}; + use crate::ui::grid::cell_editability::column_takes_edits; + use crate::ui::grid::display_text::editable_null_sentinel; + use crate::ui::grid::test_grid::{ + Grid, WAIT, col, content, display_text, edited_rows, flag_rows, key, keyed_by_id, named, persisted, recorder, + text, tracked, two_text_rows, type_into, + }; + use crate::ui::grid::{CellPreset, ColumnWidthBinding, GridCell, GridMsg, RowObject, TabGridContext, begin_edit}; + + #[gtk4::test] + fn f004_edit_after_draft_insert_targets_edited_row() { + let grid = two_text_rows(); + grid.insert_draft(); + + let cell = grid.focus(2, 1); + assert_eq!(cell.row().and_then(|row| row.key().cloned()), Some(key(2))); + assert_eq!(cell.column(), 1); + let editor = type_into(&cell, "renamed"); + editor.emit_activate(); + + assert_eq!( + edited_rows(&grid.take_messages()), + vec![(Some(key(2)), 1, text("renamed"))] + ); + } + + #[gtk4::test] + fn f004_menu_set_null_after_draft_removal_targets_clicked_row() { + let grid = two_text_rows(); + grid.insert_draft(); + let cell = grid.cell_of(2, 1); + let gesture = menu_gesture(cell.upcast_ref()).expect("a cell answers a right-click"); + gesture.emit_by_name::<()>("pressed", &[&1i32, &1.0f64, &1.0f64]); + + grid.store.remove(0); + drain_main_context(); + grid.view + .activate_action("cell.set-null", None) + .expect("the menu's Set to NULL"); + + let set: Vec<(Option, usize, CellPreset)> = grid + .take_messages() + .iter() + .filter_map(|msg| match msg { + GridMsg::SetCellValue { row, column, preset } => Some((row.key().cloned(), *column, *preset)), + _ => None, + }) + .collect(); + assert_eq!(set, vec![(Some(key(2)), 1, CellPreset::Null)]); + } + + #[gtk4::test] + fn edit_started_before_rows_shift_commits_to_original_row() { + let grid = two_text_rows(); + let cell = grid.cell_of(2, 1); + let editor = type_into(&cell, "renamed"); + + grid.insert_draft(); + editor.emit_activate(); + + assert_eq!( + edited_rows(&grid.take_messages()), + vec![(Some(key(2)), 1, text("renamed"))] + ); + } + + #[gtk4::test] + fn toggle_after_draft_insert_targets_toggled_row() { + let grid = Grid::open( + vec![named("id", "integer", true), named("flag", "boolean", false)], + vec![ + vec![Value::Int(1), Value::Bool(true)], + vec![Value::Int(2), Value::Bool(false)], + ], + ); + grid.insert_draft(); + let toggle = grid.cell_of(2, 1).check_button().expect("a check button"); + + toggle.set_active(true); + + assert_eq!( + edited_rows(&grid.take_messages()), + vec![(Some(key(2)), 1, CellInput::Value(Value::Bool(true)))] + ); + } + + #[gtk4::test] + fn a_text_with_a_nul_draws_and_refuses_an_editor() { + let grid = Grid::open( + vec![named("id", "integer", true), named("name", "text", false)], + vec![vec![Value::Int(1), Value::Text("a\0b".into())]], + ); + let cell = grid.cell_of(1, 1); + assert_eq!(display_text(&cell), "a\u{2400}b"); + + begin_edit(&cell); + + assert!( + cell.inline_text().is_none(), + "an editor opened on a value it cannot hold" + ); + assert!( + matches!(grid.take_messages().as_slice(), [GridMsg::ShowToast(_)]), + "the refusal was not explained" + ); + } + + #[gtk4::test] + fn opening_a_boolean_cell_writes_nothing() { + let grid = flag_rows(); + let cell = grid.cell_of(1, 1); + + begin_edit(&cell); + + assert!(grid.take_messages().is_empty(), "opening the cell flipped it"); + assert!(!cell.check_button().expect("a check button").is_active()); + } + + #[gtk4::test] + fn a_saved_rows_key_is_locked_and_a_drafts_is_not() { + let grid = two_text_rows(); + grid.insert_draft(); + let draft_key_cell = grid + .cells() + .into_iter() + .find(|cell| cell.column() == 0 && cell.row().is_some_and(|row| row.draft_id().is_some())) + .expect("the draft's key cell"); + + assert!( + grid.cell_of(1, 0).lock().is_some(), + "a saved row's key was offered for editing" + ); + assert!(grid.cell_of(1, 1).lock().is_none()); + assert!(draft_key_cell.lock().is_none(), "a draft could not set its own key"); + + begin_edit(&grid.cell_of(1, 0)); + assert!(!grid.cell_of(1, 0).is_editing()); + begin_edit(&draft_key_cell); + assert!(draft_key_cell.inline_text().is_some()); + } + + #[gtk4::test] + fn locked_cells_draw_in_an_inscription_with_no_label() { + let grid = two_text_rows(); + let locked = grid.cell_of(1, 0); + + assert!( + locked.display().is_some(), + "a locked cell does not draw in an Inscription" + ); + assert!( + descendants(&locked).iter().all(|widget| !widget.is::()), + "a locked cell carries a label, whose own menu takes Shift+F10" + ); + assert_eq!(display_text(&locked), "1"); + } + + #[gtk4::test] + fn a_revision_bump_draws_the_pending_value_in_the_same_cell() { + let grid = two_text_rows(); + let cell = grid.cell_of(1, 1); + let delta = grid.tracker.apply(TrackerEdit::Cell { + row_key: key(1), + col: 1, + original: Value::Text("A".into()), + value: Value::Text("new".into()), + }); + + grid.store + .item(0) + .and_downcast::() + .expect("a row") + .bump_revision(); + + assert_eq!(delta.pending_count, 1); + assert_eq!(display_text(&cell), "new"); + assert!( + cell.content() + .is_some_and(|content| content.has_css_class("tp-cell-modified")) + ); + } + + #[gtk4::test] + fn an_edit_in_progress_keeps_its_text_when_the_row_is_drawn_again() { + let grid = two_text_rows(); + let cell = grid.cell_of(2, 1); + let editor = type_into(&cell, "typed"); + let row = cell.row().expect("a bound row"); + + row.bump_revision(); + + assert_eq!(editor.text(), "typed", "the redraw overwrote what was typed"); + cell.cancel_edit(); + assert_eq!(display_text(&cell), "B", "Escape did not go back to the row's value"); + assert!(cell.inline_text().is_none()); + assert_eq!(edited_rows(&grid.take_messages()), vec![]); + } + + #[gtk4::test] + fn unbind_disconnects_revision_handler() { + let grid = two_text_rows(); + let cell = grid.cell_of(2, 1); + let row = cell.row().expect("a bound row"); + let bound_renders = cell.render_count(); + row.bump_revision(); + assert_eq!(cell.render_count(), bound_renders + 1, "a bound cell did not redraw"); + + cell.unbind(); + row.bump_revision(); + + assert_eq!( + cell.render_count(), + bound_renders + 1, + "a cell drew again for a row it no longer shows" + ); + } + + fn name_rows(first: Value) -> Grid { + Grid::open( + vec![named("id", "integer", true), named("name", "text", false)], + vec![vec![Value::Int(1), first], vec![Value::Int(2), Value::Text("B".into())]], + ) + } + + /// Row 1's name edited from `from` to `to`, its editor opened on + /// `to`, then the edit undone under the open editor. + fn undo_under_open_editor(from: Value, to: Value) -> (Grid, GridCell) { + let grid = name_rows(from.clone()); + let cell = grid.cell_of(1, 1); + grid.redraw(&grid.tracker.apply(TrackerEdit::Cell { + row_key: key(1), + col: 1, + original: from, + value: to, + })); + begin_edit(&cell); + assert!(cell.inline_text().is_some(), "the cell did not open for editing"); + + grid.redraw(&grid.tracker.undo().expect("an edit to undo")); + + (grid, cell) + } + + #[gtk4::test] + fn escape_after_an_undo_under_the_editor_sends_nothing() { + let (grid, cell) = undo_under_open_editor(Value::Text("A".into()), Value::Text("X".into())); + assert!( + !cell.is_editing(), + "the editor stayed open on a value the row no longer has" + ); + + cell.cancel_edit(); + cell.unbind(); + + assert_eq!(edited_rows(&grid.take_messages()), vec![]); + assert_eq!(display_text(&cell), "A"); + assert_eq!(grid.tracker.pending_count(), 0); + } + + #[gtk4::test] + fn escape_after_undoing_an_edit_of_null_sends_nothing() { + let (grid, cell) = undo_under_open_editor(Value::Null, Value::Text("hello".into())); + + cell.cancel_edit(); + + assert_eq!(edited_rows(&grid.take_messages()), vec![]); + assert_eq!(display_text(&cell), editable_null_sentinel()); + } + + #[gtk4::test] + fn escape_on_a_null_cell_drawn_again_under_its_editor_sends_nothing() { + let grid = name_rows(Value::Null); + let cell = grid.cell_of(1, 1); + begin_edit(&cell); + + cell.row().expect("a bound row").bump_revision(); + assert!(cell.is_editing(), "a redraw of the same value closed the editor"); + cell.cancel_edit(); + + assert_eq!(edited_rows(&grid.take_messages()), vec![]); + } + + #[gtk4::test] + fn a_null_text_cell_left_as_it_opened_stays_null() { + let grid = name_rows(Value::Null); + let cell = grid.cell_of(1, 1); + + let editor = type_into(&cell, ""); + + assert_eq!( + editor.placeholder_text().unwrap_or_default(), + "", + "a text column keeps the empty string, so empty means nothing more" + ); + editor.emit_activate(); + assert_eq!(edited_rows(&grid.take_messages()), vec![]); + } + + /// Past the length the grid cuts a cell's text at. + fn long_text() -> String { + format!("tpyo{}", "x".repeat(60_000)) + } + + fn source_view(cell: &GridCell) -> Option { + first_descendant_of_type::(cell) + } + + fn buffer_text(view: &sourceview5::View) -> String { + let buffer = view.buffer(); + buffer.text(&buffer.start_iter(), &buffer.end_iter(), true).to_string() + } + + fn save_button(cell: &GridCell) -> gtk::Button { + descendants(cell) + .into_iter() + .filter_map(|widget| widget.downcast::().ok()) + .find(|button| button.has_css_class("suggested-action")) + .expect("the Save button") + } + + #[gtk4::test] + fn editing_a_long_text_starts_from_the_whole_value() { + let grid = name_rows(Value::Text(long_text())); + let cell = grid.cell_of(1, 1); + + begin_edit(&cell); + + assert!(cell.inline_text().is_none(), "a long text opened in a one-line field"); + let opened = buffer_text(&source_view(&cell).expect("the multi-line editor")); + assert_eq!(opened.len(), long_text().len(), "the editor opened on the cut text"); + assert_eq!(opened, long_text()); + } + + #[gtk4::test] + fn a_long_text_saved_from_its_editor_sends_the_whole_value() { + let grid = name_rows(Value::Text(long_text())); + let cell = grid.cell_of(1, 1); + begin_edit(&cell); + let fixed = long_text().replacen("tpyo", "typo", 1); + let view = source_view(&cell).expect("the multi-line editor"); + + view.buffer().set_text(&fixed); + save_button(&cell).emit_clicked(); + + assert_eq!( + edited_rows(&grid.take_messages()), + vec![(Some(key(1)), 1, text(&fixed))] + ); + assert!(!cell.is_editing()); + assert!(display_text(&cell).len() < 20_000, "the cell drew the whole value"); + } + + #[gtk4::test] + fn a_text_that_reads_as_the_null_sentinel_opens_as_itself() { + let grid = name_rows(Value::Text(editable_null_sentinel())); + let cell = grid.cell_of(1, 1); + + let opened = cell_editor_text(&cell); + + assert_eq!(opened, Some(editable_null_sentinel())); + } + + fn cell_editor_text(cell: &GridCell) -> Option { + begin_edit(cell); + cell.inline_text().map(|editor| editor.text().to_string()) + } + + #[gtk4::test] + fn a_blob_in_a_text_column_does_not_open_on_its_placeholder() { + let grid = name_rows(Value::Bytes(vec![1, 2, 3])); + let cell = grid.cell_of(1, 1); + + begin_edit(&cell); + + assert!(!cell.is_editing(), "the editor opened on the size placeholder"); + assert!( + grid.take_messages() + .iter() + .any(|msg| matches!(msg, GridMsg::ShowToast(_))), + "the cell did not say why it would not open" + ); + } + + /// A closed editor popover must leave nothing holding its cell. + #[gtk4::test] + fn a_closed_popover_editor_frees_its_cell() { + let grid = Grid::open( + vec![ + named("id", "integer", true), + named("doc", "jsonb", false), + named("day", "date", false), + ], + vec![vec![ + Value::Int(1), + Value::Json(tablepro_core::value::JsonText::parse("[1]".to_owned()).expect("json")), + Value::Date(tablepro_core::value::Temporal::Finite( + chrono::NaiveDate::from_ymd_opt(2026, 9, 15).expect("a date"), + )), + ]], + ); + for column in [1, 2] { + let cell = grid.cell_of(1, column); + begin_edit(&cell); + let popover = first_descendant_of_type::(&cell).expect("the editor popover"); + assert!(cell.is_editing()); + + popover.popdown(); + wait_until(WAIT, || popover.parent().is_none()).expect("the popover closes"); + + assert!(!cell.is_editing(), "column {column} left its popover in the cell"); + } + assert_eq!(edited_rows(&grid.take_messages()), vec![]); + let freed: Vec> = grid.cells().iter().map(ObjectExt::downgrade).collect(); + + drop(grid); + + assert!( + freed.iter().all(|cell| cell.upgrade().is_none()), + "an editor popover kept its cell alive" + ); + } + + fn width_binding(tasks: &tablepro_session::runtime::Tasks, root: &tempfile::TempDir) -> ColumnWidthBinding { + ColumnWidthBinding { + store: crate::persistence::ColumnWidthStore::load(root.path().join("column-widths.json"), tasks), + connection_id: uuid::Uuid::new_v4(), + schema: None, + table: "t".to_owned(), + } + } + + #[gtk4::test] + fn column_freed_with_column_view() { + let runtime = crate::test_support::test_runtime(); + let root = tempfile::tempdir().expect("tempdir"); + let columns = vec![named("name", "text", false)]; + let rows = vec![vec![Value::Text("a".into())]]; + let (sink, _messages) = recorder(); + let context = TabGridContext { + editable: true, + column_widths: Some(width_binding(&runtime.tasks(), &root)), + ..TabGridContext::default() + }; + let (view, selection) = build_column_view( + content(&columns, persisted(&rows)), + &columns, + sink, + GridSorting::Server { + initial: Some((0, true)), + }, + context, + ); + let column = view + .columns() + .item(0) + .and_downcast::() + .expect("a column") + .downgrade(); + let view_ref = view.downgrade(); + + drop((view, selection)); + drain_main_context(); + + assert!(column.upgrade().is_none(), "the column outlived its column view"); + assert!(view_ref.upgrade().is_none(), "the column view outlived its owner"); + } + + #[gtk4::test] + fn a_column_left_behind_by_its_view_lets_go_of_the_grid() { + let columns = vec![named("id", "integer", true), named("name", "text", false)]; + let rows = vec![vec![Value::Int(7), Value::Text("a".into())]]; + let tracker = TabTrackerHandle::default(); + let (sink, messages) = recorder(); + let (view, selection) = build_column_view( + content(&columns, persisted(&rows)), + &columns, + sink, + GridSorting::Server { initial: None }, + tracked(&tracker), + ); + let window = OwnedWindow::presented(gtk::Window::new()); + window.set_child(Some(&view)); + // GTK 4.22 leaks the column after the focus column as the view + // removes its columns from the first. + let id = crate::ui::grid::data_columns(&view) + .into_iter() + .next() + .expect("the id column"); + wait_until(WAIT, || descendants(&view).iter().any(|widget| widget.is::())) + .expect("the cells are drawn"); + view.scroll_to(0, Some(&id), gtk::ListScrollFlags::FOCUS, None); + view.grab_focus(); + wait_until(WAIT, || { + gtk::prelude::GtkWindowExt::focus(&*window) + .and_then(|focus| GridCell::from_focus(&focus)) + .is_some_and(|cell| cell.column() == 0) + }) + .expect("the id cell takes the focus"); + + drop(id); + drop(window); + drop((view, selection)); + drain_main_context(); + + assert_eq!( + std::rc::Rc::strong_count(&messages), + 1, + "a column the view left behind kept the grid's sink" + ); + } + + fn menu_gesture(cell: >k::Widget) -> Option { + let controllers = cell.observe_controllers(); + (0..controllers.n_items()) + .filter_map(|index| controllers.item(index).and_downcast::()) + .find(|gesture| gesture.button() == 3) + } + + #[gtk4::test] + fn cells_freed_with_column_view() { + let columns = vec![ + named("id", "integer", true), + named("name", "text", false), + named("flag", "boolean", false), + ]; + let rows = vec![vec![Value::Int(7), Value::Text("a".into()), Value::Bool(true)]]; + let tracker = TabTrackerHandle::default(); + let (sink, _messages) = recorder(); + let (view, selection) = build_column_view( + content(&columns, persisted(&rows)), + &columns, + sink, + GridSorting::Server { initial: None }, + tracked(&tracker), + ); + let window = gtk::Window::new(); + window.set_child(Some(&view)); + window.present(); + let cells = || -> Vec { + descendants(&view) + .into_iter() + .filter_map(|widget| widget.downcast::().ok()) + .filter(|cell| cell.row().is_some()) + .collect() + }; + wait_until(WAIT, || cells().len() == 3).expect("the key, text and bool cells are drawn"); + // A right-click is what fills the menu's target, and an open + // editor is another hold on a cell, so both are in the picture. + for cell in cells() { + let gesture = menu_gesture(cell.upcast_ref()).expect("a cell answers a right-click"); + gesture.emit_by_name::<()>("pressed", &[&1i32, &1.0f64, &1.0f64]); + } + if let Some(name) = cells().into_iter().find(|cell| cell.column() == 1) { + begin_edit(&name); + } + let freed: Vec> = cells().iter().map(ObjectExt::downgrade).collect(); + + window.destroy(); + drop(window); + drop((view, selection)); + drain_main_context(); + + let alive = freed.iter().filter(|cell| cell.upgrade().is_some()).count(); + assert_eq!(alive, 0, "cells outlived their column view"); + } + + #[gtk4::test] + fn column_view_carries_data_table_class() { + let grid = two_text_rows(); + + assert!(grid.view.has_css_class("data-table")); + } + + #[gtk4::test] + fn numeric_column_cells_align_end_with_numeric_class() { + let grid = Grid::open( + vec![ + named("id", "integer", true), + named("name", "text", false), + named("price", "numeric(10,2)", false), + ], + vec![vec![ + Value::Int(1), + Value::Text("tea".into()), + Value::Decimal("4.5".parse().expect("a decimal")), + ]], + ); + let display = |column| grid.cell_of(1, column).display().expect("a text cell"); + + for column in [0, 2] { + assert_eq!(display(column).xalign(), 1.0, "column {column}"); + assert!(display(column).has_css_class("numeric"), "column {column}"); + } + assert_eq!(display(1).xalign(), 0.0); + assert!(!display(1).has_css_class("numeric")); + assert_eq!(display_text(&grid.cell_of(1, 2)), "4.50", "the column's scale"); + } + + #[gtk4::test] + fn pending_delete_row_uses_shared_strikethrough_attributes() { + let grid = two_text_rows(); + let attributes = |id, column| { + grid.cell_of(id, column) + .display() + .and_then(|display| display.attributes()) + .map(|attrs| attrs.as_ptr()) + }; + assert_eq!(attributes(1, 1), None, "plain text carries no attributes"); + + grid.redraw(&grid.tracker.apply(TrackerEdit::DeleteRows { + persisted: vec![(key(1), vec![Value::Int(1), Value::Text("A".into())].into())], + drafts: Vec::new(), + })); + + let struck = attributes(1, 0).expect("the deleted row's text is struck through"); + assert_eq!(attributes(1, 1), Some(struck), "each cell built its own list"); + assert_eq!(attributes(2, 1), None); + } + + #[gtk4::test] + fn a_null_placeholder_is_italic_and_a_locked_one_dimmed() { + let grid = name_rows(Value::Null); + let name = grid.cell_of(1, 1).display().expect("a text cell"); + + assert_eq!(name.text().as_deref(), Some(editable_null_sentinel().as_str())); + assert!(name.attributes().is_some(), "a placeholder reads like a value"); + assert!(!name.has_css_class("dimmed"), "an editable NULL was dimmed"); + assert!(name.tooltip_text().is_none()); + } + + #[test] + fn only_columns_with_an_empty_value_accept_an_empty_string() { + // Clearing a text cell stores the empty string; clearing a + // number or a date can only mean NULL. + for text in ["text", "VARCHAR(255)", "char(3)", "longtext"] { + assert!( + col(text, false).column_type.kind.accepts_empty_string(), + "{text} should accept an empty string" + ); + } + for other in [ + "integer", + "bigint", + "numeric(10,2)", + "date", + "timestamp", + "uuid", + "boolean", + ] { + assert!( + !col(other, false).column_type.kind.accepts_empty_string(), + "{other} should not accept an empty string" + ); + } + } + + #[test] + fn a_blob_is_never_editable_whatever_the_engine_calls_it() { + let identity = keyed_by_id(); + for name in [ + "BYTEA", + "blob", + "LONGBLOB", + "mediumblob", + "tinyblob", + "VARBINARY", + "binary", + ] { + assert!( + !column_takes_edits(&col(name, false), &identity, false), + "{name} was offered as an editable cell" + ); + } + assert!(column_takes_edits(&col("text", false), &identity, false)); + assert!(column_takes_edits(&col("integer", false), &identity, false)); + } +} diff --git a/linux/crates/app/src/ui/grid/column_figures.rs b/linux/crates/app/src/ui/grid/column_figures.rs new file mode 100644 index 0000000000..2449d5b558 --- /dev/null +++ b/linux/crates/app/src/ui/grid/column_figures.rs @@ -0,0 +1,418 @@ +use std::cell::{Cell, RefCell}; + +use gtk4::prelude::*; +use gtk4::{self as gtk, pango}; +use tablepro_core::Value; +use tablepro_core::column::{ColumnKind, ColumnType}; + +use super::RowObject; +use super::display_text::display_text; + +const TEXT_MIN_CHARS: u32 = 3; +const TEXT_NAT_CHARS: u32 = 14; + +/// How a column of either grid lays out its text. A column of numbers +/// lines them up on the last digit in tabular figures and asks for the +/// width of its longest number, so the column view never cuts one. +#[derive(Debug)] +pub struct ColumnFigures { + numbers: Cell, + /// The longest number the column has held, in characters. + chars: Cell, + /// An untyped column has held a value, and every one was a number. + held_value: Cell, + all_numbers: Cell, + /// The last width asked of `digits_width`, which every numeric cell + /// of the column asks again as it is bound. + digits: RefCell>, +} + +impl Default for ColumnFigures { + fn default() -> Self { + Self { + numbers: Cell::new(false), + chars: Cell::new(0), + held_value: Cell::new(false), + all_numbers: Cell::new(true), + digits: RefCell::new(None), + } + } +} + +#[derive(Debug)] +struct DigitsWidth { + chars: u32, + font: Option, + dpi: i32, + width: i32, +} + +impl ColumnFigures { + /// Decide from the column's type, or from `rows` when the driver + /// could not type it. + pub fn measure(&self, column_type: &ColumnType, index: usize, rows: &[RowObject]) { + self.numbers.set(false); + self.chars.set(0); + self.held_value.set(false); + self.all_numbers.set(true); + self.extend(column_type, index, rows); + } + + /// Take in rows added to those already measured. A draft has no + /// cells, so it changes nothing. + pub fn extend(&self, column_type: &ColumnType, index: usize, rows: &[RowObject]) { + let values = rows + .iter() + .filter_map(|row| row.cell(index)) + .filter(|value| !value.is_null()); + if column_type.kind.is_numeric() { + self.numbers.set(true); + self.chars.set(self.chars.get().max(widest(column_type, values))); + return; + } + // PostgreSQL editor results carry no type (ADR 0016), so such a + // column aligns by what it holds, as a spreadsheet does. + if !matches!(column_type.kind, ColumnKind::Other | ColumnKind::Variant) { + return; + } + let mut scratch = String::new(); + for value in values { + if !self.all_numbers.get() { + break; + } + self.held_value.set(true); + match is_number(value) { + true => self.chars.set( + self.chars + .get() + .max(char_count(display_text(value, column_type, &mut scratch).text)), + ), + false => self.all_numbers.set(false), + } + } + self.numbers.set(self.held_value.get() && self.all_numbers.get()); + } + + pub fn numbers(&self) -> bool { + self.numbers.get() + } + + /// Lay out `display` for `text`, the value it is about to show. + pub fn apply(&self, display: >k::Inscription, text: &str, placeholder: bool) { + if !self.numbers.get() { + display.set_xalign(0.0); + display.set_min_chars(TEXT_MIN_CHARS); + display.set_nat_chars(TEXT_NAT_CHARS); + display.set_width_request(-1); + display.remove_css_class("numeric"); + return; + } + // An edit can hold a longer number than any row did. + if !placeholder { + self.chars.set(self.chars.get().max(char_count(text))); + } + display.set_xalign(1.0); + display.set_min_chars(TEXT_MIN_CHARS); + display.set_nat_chars(TEXT_MIN_CHARS); + display.add_css_class("numeric"); + // GtkInscription sizes by an average character, which a run of + // digits outgrows, so the cell asks for the digits' own width. + display.set_width_request(self.digits_width(display, self.chars.get())); + } + + /// The width of `chars` digits, laid out once per width, font and + /// text scale rather than once per cell. + fn digits_width(&self, display: >k::Inscription, chars: u32) -> i32 { + let font = display.pango_context().font_description(); + let dpi = display.settings().gtk_xft_dpi(); + let mut cached = self.digits.borrow_mut(); + if let Some(digits) = cached.as_ref() + && digits.chars == chars + && digits.dpi == dpi + && digits.font == font + { + return digits.width; + } + let width = digits_width(display, chars); + *cached = Some(DigitsWidth { + chars, + font, + dpi, + width, + }); + width + } +} + +fn is_number(value: &Value) -> bool { + match value { + Value::Int(_) + | Value::UInt(_) + | Value::WideInt(_) + | Value::Float32(_) + | Value::Float64(_) + | Value::Decimal(_) => true, + Value::Text(text) => text.parse::().is_ok(), + Value::Other(other) => other.text.parse::().is_ok(), + _ => false, + } +} + +fn widest<'a>(column_type: &ColumnType, values: impl Iterator) -> u32 { + let mut scratch = String::new(); + values + .map(|value| char_count(display_text(value, column_type, &mut scratch).text)) + .max() + .unwrap_or(0) +} + +/// How wide `chars` digits are in tabular figures, which libadwaita's +/// `numeric` class draws. +fn digits_width(widget: &impl IsA, chars: u32) -> i32 { + tabular_width(widget, &"0".repeat(usize::try_from(chars).unwrap_or_default())) +} + +fn tabular_width(widget: &impl IsA, text: &str) -> i32 { + let layout = widget.create_pango_layout(Some(text)); + let tabular = pango::AttrList::new(); + tabular.insert(pango::AttrFontFeatures::new("tnum")); + layout.set_attributes(Some(&tabular)); + layout.pixel_size().0 +} + +fn char_count(text: &str) -> u32 { + u32::try_from(text.chars().count()).unwrap_or(u32::MAX) +} + +#[cfg(test)] +mod tests { + use gtk4::prelude::*; + use gtk4::{self as gtk, gio}; + use tablepro_core::column::ColumnType; + use tablepro_core::{ColumnInfo, Value}; + + use super::{ColumnFigures, tabular_width}; + use crate::services::tab_tracker_handle::TabTrackerHandle; + use crate::test_support::{descendants, wait_until}; + use crate::ui::grid::test_grid::{WAIT, col, content, named, persisted, recorder, tracked}; + use crate::ui::grid::{GridCell, GridSorting, RowObject, build_column_view}; + + fn rows(values: &[Value]) -> Vec { + values + .iter() + .map(|value| RowObject::result(vec![value.clone()].into())) + .collect() + } + + fn measured(column_type: &ColumnType, values: &[Value]) -> ColumnFigures { + let figures = ColumnFigures::default(); + figures.measure(column_type, 0, &rows(values)); + figures + } + + fn text(value: &str) -> Value { + Value::Text(value.to_owned()) + } + + #[test] + fn a_typed_column_is_numbers_by_its_type_alone() { + assert!(measured(&col("integer", false).column_type, &[]).numbers()); + assert!(!measured(&col("text", false).column_type, &[text("12"), text("7")]).numbers()); + } + + #[test] + fn an_untyped_column_is_numbers_when_every_value_is_one() { + let untyped = ColumnType::untyped_text("text"); + + for numbers in [ + vec![ + text("6"), + text("-18.15"), + Value::Null, + text("1e-05"), + text("NaN"), + text("Infinity"), + ], + vec![Value::Int(2), Value::Float64(0.5)], + ] { + assert!(measured(&untyped, &numbers).numbers(), "{numbers:?}"); + } + for not_numbers in [ + vec![text("6"), text("Coffee")], + vec![text(" 6")], + vec![text("")], + vec![Value::Null], + vec![], + ] { + assert!(!measured(&untyped, ¬_numbers).numbers(), "{not_numbers:?}"); + } + } + + #[test] + fn rows_added_later_widen_the_column_and_a_draft_changes_nothing() { + let integer = col("integer", false).column_type; + let figures = measured(&integer, &[Value::Int(12)]); + + figures.extend(&integer, 0, &[RowObject::draft(1)]); + assert_eq!(figures.chars.get(), 2); + figures.extend(&integer, 0, &rows(&[Value::Int(-1_234)])); + assert_eq!(figures.chars.get(), 5, "the widest row so far"); + figures.measure(&integer, 0, &rows(&[Value::Int(7)])); + assert_eq!(figures.chars.get(), 1, "a new page measures from scratch"); + } + + #[test] + fn an_untyped_column_stops_being_numbers_at_the_first_word_added() { + let untyped = ColumnType::untyped_text("text"); + let figures = measured(&untyped, &[text("6")]); + assert!(figures.numbers()); + + figures.extend(&untyped, 0, &rows(&[Value::Null, text("Coffee")])); + assert!(!figures.numbers()); + figures.extend(&untyped, 0, &rows(&[text("7")])); + assert!(!figures.numbers(), "one word in the column is enough"); + } + + /// A browse page in a window too narrow for every column to get the + /// width it asks for. + struct Crowded { + window: gtk::Window, + view: gtk::ColumnView, + store: gio::ListStore, + } + + impl Drop for Crowded { + fn drop(&mut self) { + self.window.destroy(); + } + } + + fn crowded(columns: &[ColumnInfo], rows: &[Vec]) -> Crowded { + let (view, selection) = build_column_view( + content(columns, persisted(rows)), + columns, + recorder().0, + GridSorting::Server { initial: None }, + tracked(&TabTrackerHandle::default()), + ); + let store = selection + .model() + .and_downcast::() + .expect("a list store"); + let window = gtk::Window::new(); + window.set_default_size(725, 400); + window.set_child(Some(>k::ScrolledWindow::builder().child(&view).build())); + window.present(); + Crowded { window, view, store } + } + + fn shop_columns(more_text: usize) -> Vec { + let mut columns = vec![ + named("id", "bigint", true), + named("sku", "text", false), + named("name", "text", false), + named("price", "numeric(20,4)", false), + named("stock", "integer", false), + named("updated_at", "text", false), + named("note", "text", false), + named("comment", "text", false), + ]; + columns.extend((0..more_text).map(|index| named(&format!("tag_{index}"), "text", false))); + columns + } + + const NUMBER_COLUMNS: [usize; 3] = [0, 3, 4]; + + fn shop_row(id: i64, price: &str, width: usize) -> Vec { + let mut row = vec![ + Value::Int(id), + text("CF-1001"), + text("Ethiopia Yirgacheffe, 250 g"), + Value::Decimal(price.parse().expect("a decimal")), + Value::Int(123_456), + text("2026-09-02 09:14:00+00"), + text("Ground for a paper filter"), + text("Ships in two days"), + ]; + row.resize(width, text("seasonal")); + row + } + + /// The text of `columns` in the rows on screen. + fn drawn(view: >k::ColumnView, columns: &[usize]) -> Vec { + descendants(view) + .into_iter() + .filter_map(|widget| widget.downcast::().ok()) + .filter(|cell| cell.row().is_some() && columns.contains(&cell.column()) && cell.is_mapped()) + .filter_map(|cell| cell.display()) + .collect() + } + + fn shows_whole(display: >k::Inscription) -> bool { + let text = display.text().unwrap_or_default(); + display.width() > 0 && tabular_width(display, &text) <= display.width() + } + + #[gtk4::test] + fn a_number_column_is_as_wide_as_its_widest_number() { + // Eight columns share the width; past eight, each starts at a + // readable width of its own. + for more_text in [0, 2] { + let columns = shop_columns(more_text); + let rows: Vec> = (0..20) + .map(|offset| shop_row(100_000_001 + offset, "123456789012.3456", columns.len())) + .collect(); + let grid = crowded(&columns, &rows); + + let whole = wait_until(WAIT, || { + let numbers = drawn(&grid.view, &NUMBER_COLUMNS); + !numbers.is_empty() && numbers.iter().all(shows_whole) + }); + + assert!(whole.is_ok(), "a number was cut among {} columns", columns.len()); + } + } + + #[gtk4::test] + fn a_new_page_is_measured_down_to_its_last_row() { + let columns = shop_columns(0); + let first: Vec> = (1..=20).map(|id| shop_row(id, "1.5", columns.len())).collect(); + let grid = crowded(&columns, &first); + wait_until(WAIT, || !drawn(&grid.view, &[0]).is_empty()).expect("the first page is drawn"); + let mut next: Vec> = (21..=320).map(|id| shop_row(id, "1.5", columns.len())).collect(); + if let Some(last) = next.last_mut() { + last[0] = Value::Int(100_000_000_001); + } + + grid.store.splice(0, grid.store.n_items(), &persisted(&next)); + + let wide = wait_until(WAIT, || { + drawn(&grid.view, &[0]) + .iter() + .any(|display| display.width() >= tabular_width(display, "100000000001")) + }); + assert!(wide.is_ok(), "the column waits for its widest row to scroll into view"); + } + + #[gtk4::test] + fn adding_a_draft_keeps_the_width_the_page_measured() { + let columns = shop_columns(0); + let mut first: Vec> = (1..=20).map(|id| shop_row(id, "1.5", columns.len())).collect(); + if let Some(last) = first.last_mut() { + last[0] = Value::Int(100_000_000_001); + } + let grid = crowded(&columns, &first); + wait_until(WAIT, || !drawn(&grid.view, &[0]).is_empty()).expect("the page is drawn"); + + grid.store.insert(0, &RowObject::draft(1)); + + let wide = wait_until(WAIT, || { + let ids = drawn(&grid.view, &[0]); + !ids.is_empty() + && ids + .iter() + .all(|display| display.width() >= tabular_width(display, "100000000001")) + }); + assert!(wide.is_ok(), "the draft narrowed the column its rows had widened"); + } +} diff --git a/linux/crates/app/src/ui/grid/column_width_binding.rs b/linux/crates/app/src/ui/grid/column_width_binding.rs new file mode 100644 index 0000000000..bd75914d48 --- /dev/null +++ b/linux/crates/app/src/ui/grid/column_width_binding.rs @@ -0,0 +1,36 @@ +/// The schema is part of it, so `public.users` and `audit.users` keep +/// their own widths. +#[derive(Clone)] +pub struct ColumnWidthBinding { + pub store: crate::persistence::ColumnWidthStore, + pub connection_id: uuid::Uuid, + pub schema: Option, + pub table: String, +} + +impl std::fmt::Debug for ColumnWidthBinding { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ColumnWidthBinding") + .field("connection_id", &self.connection_id) + .field("schema", &self.schema) + .field("table", &self.table) + .finish() + } +} + +impl ColumnWidthBinding { + pub(super) fn width(&self, column: &str) -> Option { + self.store + .width(self.connection_id, self.schema.as_deref(), &self.table, column) + } + + pub(super) fn record(&self, column: &str, width: i32) { + self.store + .record(self.connection_id, self.schema.as_deref(), &self.table, column, width); + } + + pub(super) fn forget(&self, column: &str) { + self.store + .forget(self.connection_id, self.schema.as_deref(), &self.table, column); + } +} diff --git a/linux/crates/app/src/ui/grid/context_menu.rs b/linux/crates/app/src/ui/grid/context_menu.rs new file mode 100644 index 0000000000..57a9688ed3 --- /dev/null +++ b/linux/crates/app/src/ui/grid/context_menu.rs @@ -0,0 +1,298 @@ +use std::cell::RefCell; +use std::collections::HashSet; + +use gtk4::prelude::*; +use gtk4::{self as gtk, glib}; + +use super::grid_menus::GridMenus; +use super::menu_rows::menu_rows_of; +use super::selection::select_row_for_menu; +use super::{GridCell, RowObject}; + +/// What a menu was opened on, fixed as it opens. A page that lands while +/// the menu is open rebinds the cell to another row, so an item chosen +/// then acts on the rows the menu was opened for, or on nothing once +/// they have left the grid. Everything is held weakly: the cell holds +/// the menu through its gesture, and a row the grid let go of is gone. +#[derive(Default)] +pub(super) struct CellContext { + opened_on: RefCell>, +} + +struct OpenedOn { + cell: glib::WeakRef, + row: glib::WeakRef, + rows: Vec>, +} + +/// The cell, the row it showed and the rows a chosen item acts on. +pub(super) struct MenuTarget { + pub(super) cell: GridCell, + pub(super) row: RowObject, + pub(super) rows: Vec, +} + +impl CellContext { + fn set(&self, cell: &GridCell, rows: &[RowObject]) { + let opened_on = cell.row().map(|row| OpenedOn { + cell: cell.downgrade(), + row: row.downgrade(), + rows: rows.iter().map(|row| row.downgrade()).collect(), + }); + self.opened_on.replace(opened_on); + } + + /// The target while the grid still shows every row of it. + pub(super) fn target(&self, column_view: >k::ColumnView) -> Option { + let opened_on = self.opened_on.borrow(); + let opened_on = opened_on.as_ref()?; + let target = MenuTarget { + cell: opened_on.cell.upgrade()?, + row: opened_on.row.upgrade()?, + rows: opened_on + .rows + .iter() + .map(|row| row.upgrade()) + .collect::>>()?, + }; + let model = column_view.model()?; + let shown: HashSet = (0..model.n_items()) + .filter_map(|position| model.item(position).and_downcast::()) + .collect(); + let all_shown = shown.contains(&target.row) && target.rows.iter().all(|row| shown.contains(row)); + all_shown.then_some(target) + } +} + +/// Arm the cell menu for `cell` and pick the one its lock calls for. +/// The cell's lock follows the row bound to it, so the menu is picked +/// when it opens. +pub(super) fn prepare_menu(menus: &GridMenus, cell: &GridCell, column_view: >k::ColumnView) -> gtk::PopoverMenu { + if let Some(position) = cell.position() { + select_row_for_menu(column_view, position); + } + let rows = menu_rows_of(column_view, cell); + menus.context.set(cell, &rows); + menus.actions.arm(cell, &rows); + match cell.lock() { + None => menus.editable_popover.clone(), + Some(_) => menus.readonly_popover.clone(), + } +} + +pub(super) fn attach_cell_gesture(cell: &GridCell, column_view: &glib::WeakRef, menus: &GridMenus) { + // The handler reads the cell from its gesture: the cell owns its + // controllers, so capturing it would be a cycle. + let view_for_gesture = column_view.clone(); + let menus = menus.clone(); + let gesture = gtk::GestureClick::new(); + gesture.set_button(3); + gesture.connect_pressed(move |g, _, x, y| { + let (Some(cv), Some(cell)) = (view_for_gesture.upgrade(), g.widget().and_downcast::()) else { + return; + }; + g.set_state(gtk::EventSequenceState::Claimed); + let popover = prepare_menu(&menus, &cell, &cv); + // The popover is parented to the column view, so it points in + // the view's coordinates, not the cell's. + let local = gtk::graphene::Point::new(x as f32, y as f32); + let (cv_x, cv_y) = cell + .compute_point(&cv, &local) + .map(|p| (p.x() as i32, p.y() as i32)) + .unwrap_or((x as i32, y as i32)); + popover.set_pointing_to(Some(>k::gdk::Rectangle::new(cv_x, cv_y, 1, 1))); + popover.popup(); + }); + cell.add_controller(gesture); +} + +#[cfg(test)] +mod tests { + use gtk4 as gtk; + use gtk4::prelude::*; + + use crate::services::change_tracker::RowKey; + use crate::test_support::{FocusThief, after_next_frame, descendants, drain_main_context, wait_until}; + use crate::ui::grid::test_grid::{ + Grid, WAIT, focus_in_grid, key, page, two_text_columns, two_text_rows, two_text_values, + }; + use crate::ui::grid::{ColumnWidthBinding, GridCell, GridMsg, RowObject}; + + fn open_menu(cell: &GridCell) { + let controllers = cell.observe_controllers(); + let gesture = (0..controllers.n_items()) + .filter_map(|index| controllers.item(index).and_downcast::()) + .find(|gesture| gesture.button() == 3) + .expect("a cell answers a right-click"); + gesture.emit_by_name::<()>("pressed", &[&1i32, &1.0f64, &1.0f64]); + } + + fn choose(grid: &Grid, actions: &[&str]) { + for action in actions { + grid.view + .activate_action(action, None) + .unwrap_or_else(|_| panic!("the menu has no {action}")); + } + } + + /// Each row-changing message the menu sent, by the rows it names. + fn acted_on(grid: &Grid) -> Vec<(&'static str, Vec>)> { + let keys = |rows: &[RowObject]| rows.iter().map(|row| row.key().cloned()).collect(); + grid.take_messages() + .into_iter() + .filter_map(|msg| match msg { + GridMsg::SetCellValue { row, .. } => Some(("set", vec![row.key().cloned()])), + GridMsg::DeleteRows { rows } => Some(("delete", keys(&rows))), + GridMsg::DuplicateRows { rows } => Some(("duplicate", keys(&rows))), + GridMsg::CopyRowsAsInsert { rows } => Some(("insert", keys(&rows))), + _ => None, + }) + .collect() + } + + #[gtk4::test] + fn a_page_that_lands_under_an_open_menu_leaves_its_items_acting_on_nothing() { + let grid = two_text_rows(); + let cell = grid.cell_of(2, 1); + open_menu(&cell); + + grid.store.splice(0, grid.store.n_items(), &page(3..5)); + drain_main_context(); + choose( + &grid, + &[ + "cell.set-null", + "cell.delete-row", + "cell.duplicate-row", + "cell.copy-row-insert", + ], + ); + + assert_eq!(acted_on(&grid), Vec::new(), "the menu acted on a row of the new page"); + } + + fn sorts(grid: &Grid) -> Vec> { + let mut sorts: Vec> = grid + .take_messages() + .into_iter() + .filter_map(|msg| match msg { + GridMsg::SortChanged(column, ascending) => Some(Some((column, ascending))), + GridMsg::SortCleared => Some(None), + _ => None, + }) + .collect(); + // A sort on a new column notifies the column, then the order. + sorts.dedup(); + sorts + } + + /// A header's click and drag take a pointer, so the menu, which the + /// Menu key opens, sorts and resets the width too. + #[gtk4::test] + fn the_menu_sorts_by_its_column_and_clears_the_sort() { + let grid = two_text_rows(); + open_menu(&grid.cell_of(1, 1)); + choose(&grid, &["cell.clear-sort"]); + assert!(sorts(&grid).is_empty(), "Clear Sort was on with no sort"); + + choose(&grid, &["cell.sort-descending"]); + open_menu(&grid.cell_of(1, 1)); + choose(&grid, &["cell.clear-sort"]); + + assert_eq!(sorts(&grid), [Some((1, false)), None]); + let sorter = grid + .view + .sorter() + .and_downcast::() + .expect("the view's sorter"); + assert!(sorter.primary_sort_column().is_none()); + } + + #[gtk4::test] + fn reset_width_forgets_the_dragged_width() { + let runtime = crate::test_support::test_runtime(); + let root = tempfile::tempdir().expect("tempdir"); + let widths = ColumnWidthBinding { + store: crate::persistence::ColumnWidthStore::load(root.path().join("column-widths.json"), &runtime.tasks()), + connection_id: uuid::Uuid::new_v4(), + schema: None, + table: "t".to_owned(), + }; + let grid = Grid::open_with_widths(two_text_columns(), two_text_values(), widths.clone()); + grid.column(1).set_fixed_width(300); + assert_eq!(widths.width("name"), Some(300)); + + open_menu(&grid.cell_of(1, 1)); + choose(&grid, &["cell.reset-width"]); + + assert_eq!(grid.column(1).fixed_width(), -1); + assert_eq!(widths.width("name"), None); + } + + fn shown_menu(grid: &Grid) -> Option { + descendants(&grid.view) + .into_iter() + .filter_map(|widget| widget.downcast::().ok()) + .find(|menu| menu.is_visible()) + } + + /// A menu closed, or kept from opening by another client holding the + /// pointer, leaves the focus on a cell. A page that then takes that + /// cell's row out, and the window taking the focus back before the + /// next frame, crashed GTK 4.22 on the detached row. + #[gtk4::test] + fn a_page_under_a_closed_menu_then_a_focus_in_keeps_the_focus_in_the_grid() { + let grid = two_text_rows(); + grid.focus(0, 1); + open_menu(&grid.cell_of(2, 1)); + if let Some(menu) = shown_menu(&grid) { + menu.popdown(); + } + assert!( + grid.focused_cell().is_some(), + "the closed menu left the focus off the grid" + ); + let thief = FocusThief::take_from(&grid.window).expect("another window takes the focus"); + + grid.store.splice(0, grid.store.n_items(), &page(3..5)); + thief.give_back(&grid.window); + drain_main_context(); + after_next_frame(&grid.window); + + assert!(focus_in_grid(&grid), "the focus left the grid"); + } + + /// The list keeps the widgets of the first row for its focus, so the + /// menu opens on a row further down, which scrolling hands on. + #[gtk4::test] + fn a_cell_scrolled_onto_another_row_leaves_the_menu_on_its_own() { + let grid = two_text_rows(); + grid.store.splice(grid.store.n_items(), 0, &page(3..300)); + let cell = drawn_cell(&grid, 6, 1); + open_menu(&cell); + + grid.view + .scroll_to(grid.store.n_items() - 1, None, gtk::ListScrollFlags::NONE, None); + wait_until(WAIT, || cell.row().is_some_and(|row| row.key() != Some(&key(6)))) + .expect("the scroll did not rebind the clicked cell"); + choose(&grid, &["cell.set-null", "cell.delete-row"]); + + assert_eq!( + acted_on(&grid), + [("set", vec![Some(key(6))]), ("delete", vec![Some(key(6))])] + ); + } + + fn drawn_cell(grid: &Grid, id: i64, column: usize) -> GridCell { + let mut drawn = None; + wait_until(WAIT, || { + drawn = grid + .cells() + .into_iter() + .find(|cell| cell.column() == column && cell.row().is_some_and(|row| row.key() == Some(&key(id)))); + drawn.is_some() + }) + .expect("the row's cell is drawn"); + drawn.expect("a drawn cell") + } +} diff --git a/linux/crates/app/src/ui/grid/delete_availability.rs b/linux/crates/app/src/ui/grid/delete_availability.rs new file mode 100644 index 0000000000..d5c462712c --- /dev/null +++ b/linux/crates/app/src/ui/grid/delete_availability.rs @@ -0,0 +1,91 @@ +use super::GridRowId; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DeleteAvailability { + Available, + Unavailable(DeleteBlock), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DeleteBlock { + NoRows, + /// A DELETE names a row by its key, and these rows have none. + UnkeyedRows, + ReadOnlySession, +} + +impl DeleteBlock { + /// `None` with nothing selected, which needs no explaining. + pub fn explanation(self) -> Option { + match self { + Self::NoRows => None, + Self::UnkeyedRows => Some(crate::i18n::gettext( + "Rows without a primary key or unique key can't be deleted here", + )), + Self::ReadOnlySession => Some(crate::i18n::gettext("This connection is read-only")), + } + } +} + +/// Whether Delete can act on `rows`. A draft needs no key: deleting it +/// only drops it. +pub fn delete_availability<'a>(rows: impl IntoIterator, read_only: bool) -> DeleteAvailability { + if read_only { + return DeleteAvailability::Unavailable(DeleteBlock::ReadOnlySession); + } + let mut any = false; + for row in rows { + match row { + GridRowId::Persisted(_) | GridRowId::Draft(_) => any = true, + GridRowId::Unkeyed | GridRowId::Result => { + return DeleteAvailability::Unavailable(DeleteBlock::UnkeyedRows); + } + } + } + match any { + true => DeleteAvailability::Available, + false => DeleteAvailability::Unavailable(DeleteBlock::NoRows), + } +} + +#[cfg(test)] +mod tests { + use tablepro_core::Value; + + use super::{DeleteAvailability, DeleteBlock, delete_availability}; + use crate::services::change_tracker::RowKey; + use crate::ui::grid::GridRowId; + + #[test] + fn delete_availability_blocks_unkeyed_result_and_read_only() { + let saved = GridRowId::Persisted(RowKey::from_pk_values(&[Value::Int(1)]).expect("a key")); + let draft = GridRowId::Draft(2); + let blocked = |block| DeleteAvailability::Unavailable(block); + + assert_eq!( + delete_availability(&[saved.clone(), draft.clone()], false), + DeleteAvailability::Available + ); + assert_eq!( + delete_availability(std::slice::from_ref(&draft), false), + DeleteAvailability::Available, + "a draft needs no key to be dropped" + ); + assert_eq!(delete_availability(&[], false), blocked(DeleteBlock::NoRows)); + assert_eq!( + delete_availability(&[saved.clone(), GridRowId::Unkeyed], false), + blocked(DeleteBlock::UnkeyedRows) + ); + assert_eq!( + delete_availability(&[GridRowId::Result], false), + blocked(DeleteBlock::UnkeyedRows) + ); + assert_eq!( + delete_availability(&[saved], true), + blocked(DeleteBlock::ReadOnlySession) + ); + assert_eq!(DeleteBlock::NoRows.explanation(), None); + assert!(DeleteBlock::UnkeyedRows.explanation().is_some()); + assert!(DeleteBlock::ReadOnlySession.explanation().is_some()); + } +} diff --git a/linux/crates/app/src/ui/grid/display_text.rs b/linux/crates/app/src/ui/grid/display_text.rs new file mode 100644 index 0000000000..2148a7821a --- /dev/null +++ b/linux/crates/app/src/ui/grid/display_text.rs @@ -0,0 +1,334 @@ +use std::fmt::Write; + +use tablepro_core::Value; +use tablepro_core::column::ColumnType; + +/// A million-character value would hang the main thread in Pango +/// layout, and the cell shapes all it is given though a line of it +/// shows. The model keeps the whole value; only the cell is cut, past +/// what its tooltip quotes. +pub(super) const DISPLAY_MAX_CHARS: usize = 1_000; + +/// What a cell shows for a value. `text` borrows the value where it +/// can, so a text cell draws with no copy of its own. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) struct DisplayText<'a> { + pub text: &'a str, + /// The text stands in for a value rather than spelling it. + pub placeholder: bool, + /// How much of the value's text the cell left out. + pub truncated_bytes: usize, +} + +impl<'a> DisplayText<'a> { + pub(super) fn placeholder(text: &'a str) -> Self { + Self { + text, + placeholder: true, + truncated_bytes: 0, + } + } +} + +/// The brackets keep it apart from a literal `NULL` text value. +pub(super) fn editable_null_sentinel() -> String { + crate::i18n::gettext("") +} + +/// A read-only cell dims its NULL instead, so it needs no brackets. +/// A technical term, so it is not translated. +const READONLY_NULL: &str = "NULL"; + +/// For a column the server fills in on INSERT, so a new draft row does +/// not read as if its id were stored as NULL. +pub(super) fn auto_filled_sentinel() -> String { + crate::i18n::gettext("(auto)") +} + +/// The text of `value` as the cell draws it. Text, JSON and a value the +/// driver kept as the server's text are borrowed; anything else is +/// written into `scratch`, the cell's own buffer. +pub(super) fn display_text<'a>(value: &'a Value, column: &ColumnType, scratch: &'a mut String) -> DisplayText<'a> { + scratch.clear(); + match value { + Value::Null => DisplayText::placeholder(READONLY_NULL), + Value::Text(text) => shown(text, scratch), + // The server's own text, never parsed and written out again. + Value::Json(json) => shown(json.as_str(), scratch), + Value::Other(other) => shown(&other.text, scratch), + // A blob has no readable text form, so the grid says how big it + // is rather than showing bytes. + Value::Bytes(bytes) => { + let count = bytes.len(); + *scratch = crate::i18n::ngettext_f( + "<{count} byte>", + "<{count} bytes>", + plural_count(count), + &[("count", &count.to_string())], + ); + DisplayText::placeholder(scratch) + } + // The driver could not read this, and saying so is better than an + // empty cell that looks like a NULL. + Value::Undecodable(undecodable) => { + *scratch = crate::i18n::gettext_f("", &[("type", &undecodable.type_name)]); + DisplayText::placeholder(scratch) + } + other => { + write_value(other, column, scratch); + cut_in_place(scratch) + } + } +} + +/// Everything else renders the way it exports, so what the grid shows +/// and what a copy produces are the same text. The common scalars are +/// written straight into the buffer. +fn write_value(value: &Value, column: &ColumnType, scratch: &mut String) { + // Writing to a String cannot fail. + let _ = match value { + Value::Bool(flag) => scratch.write_str(if *flag { "true" } else { "false" }), + Value::Int(number) => write!(scratch, "{number}"), + Value::UInt(number) => write!(scratch, "{number}"), + Value::Float32(number) if number.is_finite() => write!(scratch, "{number}"), + Value::Float64(number) if number.is_finite() => write!(scratch, "{number}"), + Value::Uuid(id) => write!(scratch, "{id}"), + other => { + if let Some(text) = tablepro_core::export::value_text(other, column) { + *scratch = text; + } + Ok(()) + } + }; +} + +/// `text` as it is when it is short and holds no NUL, else its head, +/// cleaned, in `scratch`. +fn shown<'a>(text: &'a str, scratch: &'a mut String) -> DisplayText<'a> { + let end = cut_at(text).unwrap_or(text.len()); + let head = &text[..end]; + if end == text.len() && !head.contains('\0') { + return DisplayText { + text, + placeholder: false, + truncated_bytes: 0, + }; + } + push_without_nul(scratch, head); + finish_cut(scratch, text.len() - end) +} + +fn cut_in_place(scratch: &mut String) -> DisplayText<'_> { + let truncated_bytes = match cut_at(scratch) { + Some(end) => { + let left_out = scratch.len() - end; + scratch.truncate(end); + left_out + } + None => 0, + }; + if scratch.contains('\0') { + *scratch = scratch.replace('\0', "\u{2400}"); + } + finish_cut(scratch, truncated_bytes) +} + +fn finish_cut(scratch: &mut String, truncated_bytes: usize) -> DisplayText<'_> { + if truncated_bytes > 0 { + scratch.push_str(&crate::i18n::ngettext_f( + "… ({count} more byte)", + "… ({count} more bytes)", + plural_count(truncated_bytes), + &[("count", &truncated_bytes.to_string())], + )); + } + DisplayText { + text: scratch, + placeholder: false, + truncated_bytes, + } +} + +/// Where the cell cuts `text`, on a char boundary. A string of no more +/// bytes than the cap has no more chars either, so it is not walked. +fn cut_at(text: &str) -> Option { + if text.len() <= DISPLAY_MAX_CHARS { + return None; + } + text.char_indices().nth(DISPLAY_MAX_CHARS).map(|(index, _)| index) +} + +/// GTK takes C strings, which end at a NUL, so the cell shows U+2400 in +/// its place rather than a value cut short. +fn push_without_nul(scratch: &mut String, text: &str) { + let mut parts = text.split('\0'); + if let Some(first) = parts.next() { + scratch.push_str(first); + } + for part in parts { + scratch.push('\u{2400}'); + scratch.push_str(part); + } +} + +fn plural_count(count: usize) -> u32 { + u32::try_from(count).unwrap_or(u32::MAX) +} + +#[cfg(test)] +mod tests { + use tablepro_core::Value; + use tablepro_core::column::ColumnType; + use tablepro_core::value::{JsonText, OffsetTimestamp, SqlTime, Temporal, UndecodableReason, UndecodedValue}; + + use super::{DISPLAY_MAX_CHARS, DisplayText, display_text}; + + fn text_column() -> ColumnType { + ColumnType::untyped_text("text") + } + + fn shown(value: &Value) -> String { + let mut scratch = String::new(); + display_text(value, &text_column(), &mut scratch).text.to_owned() + } + + #[test] + fn display_text_borrows_text_without_allocating() { + let value = Value::Text("hello".into()); + let mut scratch = String::new(); + + let display = display_text(&value, &text_column(), &mut scratch); + + let Value::Text(stored) = &value else { + unreachable!("built as text"); + }; + assert_eq!(display.text, "hello"); + assert!( + std::ptr::eq(display.text.as_ptr(), stored.as_ptr()), + "the text was copied" + ); + assert!(!display.placeholder); + assert_eq!(scratch.capacity(), 0, "the scratch buffer was written to"); + } + + #[test] + fn display_text_truncates_emoji_at_char_boundary_with_byte_suffix() { + let value = Value::Text("🦀".repeat(DISPLAY_MAX_CHARS + 5)); + let mut scratch = String::new(); + + let display = display_text(&value, &text_column(), &mut scratch); + + assert_eq!(display.truncated_bytes, 5 * 4, "the bytes left out"); + assert!(display.text.starts_with(&"🦀".repeat(DISPLAY_MAX_CHARS))); + assert!( + !display.text.starts_with(&"🦀".repeat(DISPLAY_MAX_CHARS + 1)), + "the cut kept more than the cap" + ); + assert!( + display.text.ends_with("… (20 more bytes)"), + "{}", + &display.text[40_000..] + ); + } + + #[test] + fn a_long_formatted_value_is_cut_too() { + let value = Value::Array(vec![Value::Int(1); DISPLAY_MAX_CHARS]); + let mut scratch = String::new(); + + let display = display_text(&value, &text_column(), &mut scratch); + + assert!(display.truncated_bytes > 0); + assert!(display.text.contains("more bytes")); + } + + #[test] + fn display_text_json_shows_stored_text_verbatim() { + // Spacing and key order a re-serialisation would change. + let stored = r#"{ "b":1, "a" : [2,3] }"#; + let value = Value::Json(JsonText::parse(stored.to_owned()).expect("valid json")); + let mut scratch = String::new(); + + let display = display_text(&value, &text_column(), &mut scratch); + + assert_eq!(display.text, stored); + assert_eq!(scratch.capacity(), 0, "the JSON was copied"); + } + + #[test] + fn display_text_bytes_and_undecodable_placeholders() { + let undecodable = Value::Undecodable(Box::new(UndecodedValue { + type_name: "geography".to_owned(), + reason: UndecodableReason::UnsupportedType, + })); + let mut scratch = String::new(); + + assert_eq!( + display_text(&Value::Bytes(vec![0u8; 16]), &text_column(), &mut scratch), + DisplayText::placeholder("<16 bytes>") + ); + assert_eq!(shown(&Value::Bytes(vec![7])), "<1 byte>"); + let unreadable = display_text(&undecodable, &text_column(), &mut scratch); + assert!(unreadable.placeholder); + assert!(unreadable.text.contains("geography"), "{}", unreadable.text); + assert_eq!( + display_text(&Value::Null, &text_column(), &mut scratch), + DisplayText::placeholder("NULL") + ); + } + + #[test] + fn a_nul_in_text_shows_as_its_symbol() { + assert_eq!(shown(&Value::Text("a\0b".into())), "a\u{2400}b"); + } + + #[test] + fn display_text_primitive_variants() { + assert_eq!(shown(&Value::Bool(true)), "true"); + assert_eq!(shown(&Value::Int(42)), "42"); + assert_eq!(shown(&Value::UInt(7)), "7"); + assert_eq!(shown(&Value::Float64(1.5)), "1.5"); + assert_eq!(shown(&Value::Float32(0.1)), "0.1"); + assert_eq!(shown(&Value::Text("hello".into())), "hello"); + } + + #[test] + fn display_text_temporal_variants() { + let date = chrono::NaiveDate::from_ymd_opt(2026, 4, 26).expect("a date"); + assert_eq!(shown(&Value::Date(Temporal::Finite(date))), "2026-04-26"); + + let time = chrono::NaiveTime::from_hms_opt(14, 30, 0).expect("a time"); + assert_eq!(shown(&Value::Time(SqlTime::from_time_of_day(time))), "14:30:00"); + + let datetime = chrono::NaiveDateTime::new(date, time); + assert_eq!( + shown(&Value::Timestamp(Temporal::Finite(datetime))), + "2026-04-26 14:30:00" + ); + + let tz = chrono::DateTime::::from_naive_utc_and_offset(datetime, chrono::Utc); + assert_eq!( + shown(&Value::TimestampTz(Temporal::Finite(OffsetTimestamp::from_datetime( + tz.fixed_offset() + )))), + "2026-04-26 14:30:00+00:00" + ); + } + + #[test] + fn display_text_extended_variants() { + let dec: tablepro_core::value::SqlDecimal = "1234.56789".parse().expect("a decimal"); + assert_eq!(shown(&Value::Decimal(dec)), "1234.56789"); + + let id = uuid::Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").expect("a uuid"); + assert_eq!(shown(&Value::Uuid(id)), "550e8400-e29b-41d4-a716-446655440000"); + } + + #[test] + fn a_huge_text_value_draws_bounded() { + let display = shown(&Value::Text("x".repeat(1_000_000))); + + assert!(display.len() < 1_100, "{}", display.len()); + assert!(display.ends_with("… (999000 more bytes)")); + } +} diff --git a/linux/crates/app/src/ui/grid/edit_commit.rs b/linux/crates/app/src/ui/grid/edit_commit.rs new file mode 100644 index 0000000000..4a0afefd5d --- /dev/null +++ b/linux/crates/app/src/ui/grid/edit_commit.rs @@ -0,0 +1,281 @@ +use tablepro_core::edit::{ + CellInput, EditContext, EditText, EmptyInputMeaning, edit_text, empty_input_meaning, is_unchanged, parse_edit_text, +}; +use tablepro_core::{ColumnInfo, Value}; + +use super::{EditSnapshot, GridCell, GridMsg, GridRowId}; +use crate::services::tracker_edit::TrackerEdit; +use crate::ui::error_text::edit_parse_message; + +/// What committing an editor's text comes to. +#[derive(Debug, Clone, PartialEq)] +pub enum CommitResult { + Edit(TrackerEdit), + Unchanged, + /// The text is not a value of the column, with what to tell the user. + Rejected(String), +} + +/// Only a new row can leave a column to the server. +pub fn edit_context(row: &GridRowId) -> EditContext { + match row { + GridRowId::Draft(_) => EditContext::InsertDraft, + GridRowId::Result | GridRowId::Unkeyed | GridRowId::Persisted(_) => EditContext::UpdateRow, + } +} + +/// The text an editor ends with, as an edit of the row it began on. +/// Every editor commits through here, and none reads the cell's display. +/// +/// Text left as the editor opened it is no edit: NULL and the empty +/// string open as the same empty field, and committing it untouched must +/// not turn one into the other. +pub fn commit_edit(column: &ColumnInfo, snapshot: &EditSnapshot, input: &str) -> CommitResult { + let opened_with = match &snapshot.original { + CellInput::Value(value) => edit_text(value, &column.column_type), + CellInput::Default => EditText::Editable(String::new()), + }; + if matches!(&opened_with, EditText::Editable(text) if text == input) { + return CommitResult::Unchanged; + } + match parse_edit_text(input, column, edit_context(snapshot.row.id())) { + Ok(parsed) => commit_input(column, snapshot, parsed), + Err(error) => { + tracing::debug!(%error, column = %column.name, "refused an edit"); + CommitResult::Rejected(edit_parse_message(&error)) + } + } +} + +/// `input` as an edit of the row the snapshot names, or no edit when it +/// leaves the cell as it was. +pub fn commit_input(column: &ColumnInfo, snapshot: &EditSnapshot, input: CellInput) -> CommitResult { + let unchanged = match (&snapshot.original, &input) { + (CellInput::Value(original), CellInput::Value(value)) => is_unchanged(original, value, &column.column_type), + (CellInput::Default, CellInput::Default) => true, + _ => false, + }; + if unchanged { + return CommitResult::Unchanged; + } + match (snapshot.row.id(), input) { + (GridRowId::Draft(draft_id), input) => CommitResult::Edit(TrackerEdit::DraftCell { + draft_id: *draft_id, + col: snapshot.column, + input, + }), + (GridRowId::Persisted(row_key), CellInput::Value(value)) => CommitResult::Edit(TrackerEdit::Cell { + row_key: row_key.clone(), + col: snapshot.column, + original: snapshot.row.cell(snapshot.column).cloned().unwrap_or(Value::Null), + value, + }), + (GridRowId::Persisted(_), CellInput::Default) => { + tracing::error!("a saved row's cell was given the server's default"); + CommitResult::Unchanged + } + (GridRowId::Result | GridRowId::Unkeyed, _) => CommitResult::Unchanged, + } +} + +/// Hand the outcome to the tab. A refused value is said in a toast and +/// the cell draws its value again. +pub(super) fn send_commit(cell: &GridCell, snapshot: EditSnapshot, result: CommitResult) { + let Some(sink) = cell.sink() else { + return; + }; + match result { + CommitResult::Edit(edit) => sink(GridMsg::Edit { + row: snapshot.row, + column: snapshot.column, + edit, + }), + CommitResult::Unchanged => {} + CommitResult::Rejected(message) => { + sink(GridMsg::ShowToast(message)); + cell.render(); + } + } +} + +/// What an empty editor commits, said where the text would be. The +/// keywords are SQL and stay untranslated. +pub fn empty_placeholder(column: &ColumnInfo, row: &GridRowId) -> Option { + match empty_input_meaning(column, edit_context(row)) { + EmptyInputMeaning::EmptyText => None, + EmptyInputMeaning::Default => Some("DEFAULT".to_owned()), + EmptyInputMeaning::Null => Some("NULL".to_owned()), + EmptyInputMeaning::Required => Some(crate::i18n::gettext("Required")), + } +} + +#[cfg(test)] +mod tests { + use tablepro_core::column::{ColumnDefault, ColumnType}; + use tablepro_core::edit::CellInput; + use tablepro_core::{ColumnInfo, Value}; + + use super::{CommitResult, commit_edit, empty_placeholder}; + use crate::services::change_tracker::RowKey; + use crate::services::tracker_edit::TrackerEdit; + use crate::ui::grid::{EditSnapshot, GridRowId, RowObject}; + + #[expect( + clippy::disallowed_methods, + reason = "a test fixture stands in for the catalogue, which is where this text comes from in production" + )] + fn column(data_type: &str, nullable: bool, default: Option<&str>) -> ColumnInfo { + let kind = tablepro_core::column::classify_type_name(data_type); + ColumnInfo { + name: "c".to_owned(), + column_type: ColumnType { + kind, + ..ColumnType::untyped_text(data_type) + }, + nullable, + primary_key: false, + is_auto_increment: false, + is_generated: false, + default: match default { + Some(text) => ColumnDefault::Expression(tablepro_core::column::SqlExpression::from_catalog_text(text)), + None => ColumnDefault::None, + }, + comment: None, + } + } + + fn saved(value: Value) -> EditSnapshot { + let key = RowKey::from_pk_values(&[Value::Int(1)]).expect("a key"); + EditSnapshot { + row: RowObject::persisted(Some(key), vec![Value::Int(1), value.clone()].into()), + column: 1, + original: CellInput::Value(value), + } + } + + fn draft(original: CellInput) -> EditSnapshot { + EditSnapshot { + row: RowObject::draft(3), + column: 1, + original, + } + } + + fn value_of(result: CommitResult) -> Option { + match result { + CommitResult::Edit(TrackerEdit::Cell { value, .. }) => Some(CellInput::Value(value)), + CommitResult::Edit(TrackerEdit::DraftCell { input, .. }) => Some(input), + _ => None, + } + } + + #[test] + fn text_left_as_it_opened_is_no_edit() { + let text = column("text", true, None); + let stamped = column("timestamp", false, Some("now()")); + + assert_eq!( + commit_edit(&text, &saved(Value::Null), ""), + CommitResult::Unchanged, + "an untouched NULL became the empty string" + ); + assert_eq!( + commit_edit(&stamped, &draft(CellInput::Default), ""), + CommitResult::Unchanged + ); + assert_eq!( + value_of(commit_edit(&text, &saved(Value::Null), "a")), + Some(CellInput::Value(Value::Text("a".into()))) + ); + } + + #[test] + fn clearing_a_cell_means_what_the_column_makes_of_empty() { + let text = column("text", true, None); + let stamp = column("timestamp", true, None); + let stamped = column("timestamp", false, Some("now()")); + + assert_eq!( + value_of(commit_edit(&text, &saved(Value::Text("a".into())), "")), + Some(CellInput::Value(Value::Text(String::new()))) + ); + assert_eq!( + value_of(commit_edit(&stamp, &saved(Value::Int(1)), "")), + Some(CellInput::Value(Value::Null)) + ); + assert_eq!( + value_of(commit_edit(&stamped, &draft(CellInput::Value(Value::Int(1))), "")), + Some(CellInput::Default), + "a new row leaves an empty cell to the server" + ); + assert!( + matches!( + commit_edit(&stamped, &saved(Value::Int(1)), ""), + CommitResult::Rejected(_) + ), + "an UPDATE cannot ask for the default" + ); + } + + #[test] + fn a_commit_names_the_row_and_the_value_it_was_read_with() { + let text = column("text", true, None); + let snapshot = saved(Value::Text("old".into())); + + let result = commit_edit(&text, &snapshot, "new"); + + assert_eq!( + result, + CommitResult::Edit(TrackerEdit::Cell { + row_key: RowKey::from_pk_values(&[Value::Int(1)]).expect("a key"), + col: 1, + original: Value::Text("old".into()), + value: Value::Text("new".into()), + }) + ); + assert_eq!(commit_edit(&text, &snapshot, "old"), CommitResult::Unchanged); + } + + #[test] + fn a_value_the_column_cannot_hold_is_refused_with_its_reason() { + let integer = column("integer", false, None); + + let CommitResult::Rejected(message) = commit_edit(&integer, &saved(Value::Int(1)), "thirty") else { + panic!("text in an integer column was accepted"); + }; + + assert_eq!(message, "Enter a whole number"); + } + + #[test] + fn a_cell_that_has_no_row_to_write_to_records_nothing() { + let text = column("text", true, None); + let snapshot = EditSnapshot { + row: RowObject::result(vec![Value::Text("a".into())].into()), + column: 0, + original: CellInput::Value(Value::Text("a".into())), + }; + + assert_eq!(commit_edit(&text, &snapshot, "b"), CommitResult::Unchanged); + } + + #[test] + fn the_placeholder_says_what_empty_commits() { + let saved_row = GridRowId::Persisted(RowKey::from_pk_values(&[Value::Int(1)]).expect("a key")); + let draft_row = GridRowId::Draft(1); + + assert_eq!(empty_placeholder(&column("text", true, None), &saved_row), None); + assert_eq!( + empty_placeholder(&column("integer", true, None), &saved_row).as_deref(), + Some("NULL") + ); + assert_eq!( + empty_placeholder(&column("integer", false, Some("0")), &draft_row).as_deref(), + Some("DEFAULT") + ); + assert_eq!( + empty_placeholder(&column("integer", false, None), &saved_row).as_deref(), + Some("Required") + ); + } +} diff --git a/linux/crates/app/src/ui/grid/edit_navigation.rs b/linux/crates/app/src/ui/grid/edit_navigation.rs new file mode 100644 index 0000000000..029e5d6dbc --- /dev/null +++ b/linux/crates/app/src/ui/grid/edit_navigation.rs @@ -0,0 +1,89 @@ +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum EditDirection { + Forward, + Backward, +} + +/// The cell Tab or Shift+Tab edits next, as `(row, column)` with the +/// column counted in the order the grid shows them: the next cell along +/// the row that opens an editor, then the row after it. +/// +/// The walk stops one row out. Drafts sit together above the saved +/// rows and rows of one kind lock the same columns, so a row with +/// nothing to edit has no editable row past it either. +pub fn next_edit_target( + opens_editor: impl Fn(u32, usize) -> bool, + n_columns: usize, + column: usize, + position: u32, + n_rows: u32, + direction: EditDirection, +) -> Option<(u32, usize)> { + if position >= n_rows || column >= n_columns { + return None; + } + let candidates: Vec<(u32, usize)> = match direction { + EditDirection::Forward => { + let rest = (column + 1..n_columns).map(|c| (position, c)); + let below = position + .checked_add(1) + .filter(|row| *row < n_rows) + .into_iter() + .flat_map(|row| (0..n_columns).map(move |c| (row, c))); + rest.chain(below).collect() + } + EditDirection::Backward => { + let rest = (0..column).rev().map(|c| (position, c)); + let above = position + .checked_sub(1) + .into_iter() + .flat_map(|row| (0..n_columns).rev().map(move |c| (row, c))); + rest.chain(above).collect() + } + }; + candidates.into_iter().find(|&(row, c)| opens_editor(row, c)) +} + +#[cfg(test)] +mod tests { + use super::{EditDirection, next_edit_target}; + + #[test] + fn next_edit_target_skips_locked_and_wraps_rows() { + // Column 0 is a key, locked on the saved rows 1 and 2 and open + // on the draft in row 0; column 2 is generated. + let opens = |row: u32, column: usize| match column { + 0 => row == 0, + 1 | 3 => true, + _ => false, + }; + let next = |column, position, direction| next_edit_target(opens, 4, column, position, 3, direction); + + assert_eq!(next(1, 1, EditDirection::Forward), Some((1, 3)), "the generated column"); + assert_eq!(next(3, 1, EditDirection::Forward), Some((2, 1)), "a saved row's key"); + assert_eq!(next(3, 0, EditDirection::Forward), Some((1, 1))); + assert_eq!(next(0, 0, EditDirection::Forward), Some((0, 1))); + assert_eq!(next(1, 1, EditDirection::Backward), Some((0, 3)), "a saved row's key"); + assert_eq!(next(1, 0, EditDirection::Backward), Some((0, 0)), "a draft's own key"); + assert_eq!(next(3, 2, EditDirection::Forward), None, "past the last row"); + assert_eq!(next(0, 0, EditDirection::Backward), None, "before the first row"); + } + + #[test] + fn a_row_with_nothing_to_edit_ends_the_walk() { + let opens = |row: u32, _column: usize| row != 1; + + assert_eq!(next_edit_target(opens, 2, 1, 0, 3, EditDirection::Forward), None); + assert_eq!(next_edit_target(opens, 2, 0, 2, 3, EditDirection::Backward), None); + assert_eq!( + next_edit_target(|_, _| true, 2, 0, 5, 5, EditDirection::Forward), + None, + "a row past the page" + ); + assert_eq!( + next_edit_target(|_, _| true, 0, 0, 0, 1, EditDirection::Forward), + None, + "a grid with no shown columns" + ); + } +} diff --git a/linux/crates/app/src/ui/grid/edit_snapshot.rs b/linux/crates/app/src/ui/grid/edit_snapshot.rs new file mode 100644 index 0000000000..85473a3110 --- /dev/null +++ b/linux/crates/app/src/ui/grid/edit_snapshot.rs @@ -0,0 +1,13 @@ +use tablepro_core::edit::CellInput; + +use super::RowObject; + +/// An edit as it stood when it began. The commit goes to `row`, not to +/// whatever row the cell shows by then, and is measured against +/// `original`, the cell's value with any pending edit. +#[derive(Debug, Clone)] +pub struct EditSnapshot { + pub row: RowObject, + pub column: usize, + pub original: CellInput, +} diff --git a/linux/crates/app/src/ui/grid/grid_attributes.rs b/linux/crates/app/src/ui/grid/grid_attributes.rs new file mode 100644 index 0000000000..231c0afc85 --- /dev/null +++ b/linux/crates/app/src/ui/grid/grid_attributes.rs @@ -0,0 +1,74 @@ +use gtk4::pango; + +use super::display_text::{auto_filled_sentinel, editable_null_sentinel}; + +/// How a pending change marks a cell's text, beside its tint, so the +/// change reads without colour. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TextMark { + Plain, + Modified, + /// Wins over `Modified`: an edited cell in a row marked for deletion + /// is going away with it. + PendingDelete, +} + +/// How a grid's cells mark their text, built once per column view and +/// shared by every cell in it, so a bind sets a list it does not build. +/// Italic marks a placeholder. Pango attributes, because CSS +/// `text-decoration` does not reach an Inscription's text reliably. +#[derive(Debug)] +pub struct GridAttributes { + modified: pango::AttrList, + modified_placeholder: pango::AttrList, + strikethrough: pango::AttrList, + placeholder: pango::AttrList, + strikethrough_placeholder: pango::AttrList, + /// The stand-in texts, translated once rather than on every bind. + pub editable_null: String, + pub auto_filled: String, +} + +impl Default for GridAttributes { + fn default() -> Self { + Self::new() + } +} + +impl GridAttributes { + pub fn new() -> Self { + let list = |mark: TextMark, italic: bool| { + let attrs = pango::AttrList::new(); + match mark { + TextMark::Plain => {} + TextMark::Modified => attrs.insert(pango::AttrInt::new_weight(pango::Weight::Bold)), + TextMark::PendingDelete => attrs.insert(pango::AttrInt::new_strikethrough(true)), + } + if italic { + attrs.insert(pango::AttrInt::new_style(pango::Style::Italic)); + } + attrs + }; + Self { + modified: list(TextMark::Modified, false), + modified_placeholder: list(TextMark::Modified, true), + strikethrough: list(TextMark::PendingDelete, false), + placeholder: list(TextMark::Plain, true), + strikethrough_placeholder: list(TextMark::PendingDelete, true), + editable_null: editable_null_sentinel(), + auto_filled: auto_filled_sentinel(), + } + } + + /// `None` for plain text. + pub fn for_text(&self, mark: TextMark, placeholder: bool) -> Option<&pango::AttrList> { + match (mark, placeholder) { + (TextMark::Plain, false) => None, + (TextMark::Plain, true) => Some(&self.placeholder), + (TextMark::Modified, false) => Some(&self.modified), + (TextMark::Modified, true) => Some(&self.modified_placeholder), + (TextMark::PendingDelete, false) => Some(&self.strikethrough), + (TextMark::PendingDelete, true) => Some(&self.strikethrough_placeholder), + } + } +} diff --git a/linux/crates/app/src/ui/grid/grid_cell.rs b/linux/crates/app/src/ui/grid/grid_cell.rs new file mode 100644 index 0000000000..33809fd70e --- /dev/null +++ b/linux/crates/app/src/ui/grid/grid_cell.rs @@ -0,0 +1,615 @@ +use std::cell::{Cell, OnceCell, RefCell}; +use std::rc::Rc; + +use gtk4::prelude::*; +use gtk4::subclass::prelude::*; +use gtk4::{self as gtk, glib}; +use tablepro_core::Value; +use tablepro_core::edit::CellInput; + +use super::cell_accessibility::cell_description; +use super::cell_binding::CellBinding; +use super::cell_focus::return_focus; +use super::column_drawing::ColumnDrawing; +use super::grid_columns::GridColumns; +use super::inline_editor::{InlineEditor, commit_inline}; +use super::{CellLock, EditSnapshot, GridSink, RowObject}; + +/// Enough for any number, date or short text. +const SCRATCH_KEPT_BYTES: usize = 256; + +mod imp { + use super::*; + + #[derive(Default)] + pub struct GridCell { + pub list_item: glib::WeakRef, + pub column: Cell, + /// What the cell draws its value with: an Inscription, or a check + /// button for a boolean column. + pub content: OnceCell, + /// The one-line editor, laid over the hidden display while open. + pub inline: RefCell>, + /// A one-line field that lost the focus, hidden until the cell's + /// next edit. + pub closed_field: RefCell>, + /// An open editor popover. Unbind pops it down, or the recycled + /// cell is finalized with a GtkPopover child still in it. + pub popover: RefCell>, + pub edit: RefCell>, + /// Set while the bind draws a check button, which would otherwise + /// echo back as an edit of the value it just drew. + pub suppress_toggle: Cell, + pub lock: Cell>, + pub drawing: OnceCell>, + /// Where the cell's editors send their edits. + pub sink: OnceCell, + pub(super) columns: OnceCell>, + pub binding: RefCell>, + /// Where a value that is not text is written to draw it, kept + /// from one bind to the next. + pub scratch: RefCell, + #[cfg(test)] + pub renders: Cell, + } + + #[glib::object_subclass] + impl ObjectSubclass for GridCell { + const NAME: &'static str = "TableProGridCell"; + type Type = super::GridCell; + type ParentType = gtk::Widget; + + fn class_init(klass: &mut Self::Class) { + klass.set_layout_manager_type::(); + klass.set_css_name("tp-grid-cell"); + } + } + + impl ObjectImpl for GridCell { + fn dispose(&self) { + self.binding.take(); + self.edit.take(); + if let Some(editor) = self.inline.take() { + editor.widget().unparent(); + } + if let Some(field) = self.closed_field.take() { + field.unparent(); + } + if let Some(popover) = self.popover.take() { + popover.unparent(); + } + if let Some(content) = self.content.get() { + content.unparent(); + } + } + } + + impl WidgetImpl for GridCell { + fn unroot(&self) { + self.obj().leave_detached_row(); + self.parent_unroot(); + } + } +} + +glib::wrapper! { + /// One cell of the grid. It resolves its row through the column view + /// cell when an event fires, so the row is the one the cell shows + /// then, not the one it was bound to. + pub struct GridCell(ObjectSubclass) + @extends gtk::Widget, + @implements gtk::Accessible, gtk::Buildable, gtk::ConstraintTarget; +} + +impl Default for GridCell { + fn default() -> Self { + Self::new() + } +} + +impl GridCell { + pub fn new() -> Self { + glib::Object::new() + } + + pub fn attach(&self, cell: >k::ColumnViewCell, column: usize) { + self.imp().list_item.set(Some(cell)); + self.imp().column.set(column); + } + + pub fn set_content(&self, content: &impl IsA) { + let content = content.as_ref(); + if self.imp().content.set(content.clone()).is_ok() { + content.set_parent(self); + } + } + + pub fn content(&self) -> Option<>k::Widget> { + self.imp().content.get() + } + + /// The text display every cell but a check box draws with. + pub fn display(&self) -> Option { + self.content()?.clone().downcast().ok() + } + + pub fn check_button(&self) -> Option { + self.content()?.clone().downcast().ok() + } + + /// The one-line editor's text field while it is open. + pub fn inline_text(&self) -> Option { + self.imp() + .inline + .borrow() + .as_ref() + .map(|editor| editor.widget().clone()) + } + + pub fn row(&self) -> Option { + self.imp().list_item.upgrade()?.item().and_downcast() + } + + pub fn position(&self) -> Option { + let position = self.imp().list_item.upgrade()?.position(); + (position != gtk::INVALID_LIST_POSITION).then_some(position) + } + + pub fn column(&self) -> usize { + self.imp().column.get() + } + + pub fn is_editing(&self) -> bool { + let imp = self.imp(); + imp.edit.borrow().is_some() || imp.popover.borrow().is_some() || imp.inline.borrow().is_some() + } + + /// The cell that holds the keyboard focus. A focused column view + /// cell widget is this cell's parent, so its first child is tried + /// after its ancestors. + pub fn from_focus(focus: >k::Widget) -> Option { + if let Some(cell) = focus.ancestor(GridCell::static_type()) { + return cell.downcast().ok(); + } + focus.first_child()?.downcast().ok() + } + + pub fn lock(&self) -> Option { + self.imp().lock.get() + } + + pub(super) fn set_drawing(&self, drawing: Rc) { + let _ = self.imp().drawing.set(drawing); + } + + pub(super) fn drawing(&self) -> Option> { + self.imp().drawing.get().cloned() + } + + pub(super) fn set_sink(&self, sink: GridSink) { + let _ = self.imp().sink.set(sink); + } + + pub(super) fn sink(&self) -> Option { + self.imp().sink.get().cloned() + } + + pub(super) fn set_columns(&self, columns: Rc) { + let _ = self.imp().columns.set(columns); + } + + pub(super) fn columns(&self) -> Option> { + self.imp().columns.get().cloned() + } + + pub(super) fn column_view(&self) -> Option { + self.ancestor(gtk::ColumnView::static_type()).and_downcast() + } + + pub(super) fn bind(&self, row: &RowObject) { + let lock = match self.imp().drawing.get() { + Some(drawing) => drawing.lock(row.id()), + None => Some(CellLock::ResultRow), + }; + self.imp().lock.set(lock); + self.imp().binding.replace(Some(CellBinding::new(self, row))); + self.render(); + } + + /// An edit or popover still open is cancelled, since the row it was + /// for is no longer this cell's. + pub(super) fn unbind(&self) { + self.imp().binding.take(); + self.cancel_edit(); + self.reset_property(gtk::AccessibleProperty::Description); + } + + /// The snapshot goes first, so an editor closing sends nothing. + pub(crate) fn cancel_edit(&self) { + self.take_edit(); + if let Some(editor) = self.take_inline() { + editor.close(self); + } + if let Some(popover) = self.take_popover() { + popover.popdown(); + } + } + + pub(super) fn render(&self) { + #[cfg(test)] + self.imp().renders.set(self.imp().renders.get() + 1); + let row = self + .imp() + .binding + .borrow() + .as_ref() + .map(|binding| binding.row().clone()); + let (Some(row), Some(drawing)) = (row, self.drawing()) else { + return; + }; + let drawn = drawing.draw(self, &row); + // On the cell, since GtkColumnViewCell passes on no accessible + // property of its own. + match cell_description(drawn.state, drawn.null) { + Some(description) => self.update_property(&[gtk::accessible::Property::Description(&description)]), + None => self.reset_property(gtk::AccessibleProperty::Description), + } + } + + #[cfg(test)] + pub(super) fn render_count(&self) -> u32 { + self.imp().renders.get() + } + + pub(super) fn begin_edit(&self, snapshot: EditSnapshot) { + self.imp().edit.replace(Some(snapshot)); + } + + pub(super) fn take_edit(&self) -> Option { + self.imp().edit.take() + } + + /// Whether an open edit started from another value than `value`. + /// `default` is a draft's cell left to the server. + pub(super) fn edit_left_behind(&self, value: &Value, default: bool) -> bool { + match self.imp().edit.borrow().as_ref().map(|snapshot| &snapshot.original) { + None => false, + Some(CellInput::Default) => !default, + Some(CellInput::Value(original)) => default || original != value, + } + } + + /// The cell's text buffer. One grown past a short value is let go, + /// so a page of long values does not keep a long buffer per cell. + pub(super) fn with_scratch(&self, f: impl FnOnce(&mut String) -> R) -> R { + let mut scratch = self.imp().scratch.borrow_mut(); + let drawn = f(&mut scratch); + if scratch.capacity() > SCRATCH_KEPT_BYTES { + *scratch = String::new(); + } + drawn + } + + pub(super) fn set_inline(&self, editor: InlineEditor) { + self.imp().inline.replace(Some(editor)); + } + + pub(super) fn take_inline(&self) -> Option { + self.imp().inline.take() + } + + pub(super) fn keep_closed_field(&self, field: gtk::Text) { + if let Some(previous) = self.imp().closed_field.replace(Some(field)) { + previous.unparent(); + } + } + + pub(super) fn remove_closed_field(&self) { + if let Some(field) = self.imp().closed_field.take() { + field.unparent(); + } + } + + /// Parent `popover` to the cell and keep it there until it closes. + /// A popover the user closes ends its edit; one the editor took back + /// first has already dealt with it. + pub(super) fn present_popover(&self, popover: >k::Popover) { + popover.set_parent(self); + if let Some(previous) = self.imp().popover.replace(Some(popover.clone())) { + previous.popdown(); + } + // The cell holds the popover in its slot and as its child, so the + // popover holds the cell weakly and itself not at all. + let cell = self.downgrade(); + popover.connect_closed(move |popover| { + let cell = cell.upgrade(); + // GTK moves a focus left in a hidden popover to the popover's + // parent after the next frame, and by then it has none, so the + // focus would leave the grid. An unbound cell's row is gone. + if let Some(cell) = &cell + && cell.is_bound() + && holds_focus(popover) + { + return_focus(cell); + } + popover.unparent(); + if let Some(cell) = cell + && cell.release_popover(popover) + { + cell.take_edit(); + } + }); + popover.popup(); + } + + /// GTK detaches a removed row before it unbinds it, and moves a focus + /// left inside only after the next frame; a window focus-in between + /// the two crashes GTK 4.22 on the detached row. So the focus goes + /// before the edit ends, and the grid takes it back after the frame. + fn leave_detached_row(&self) { + let window = self.root().and_downcast::(); + let held = window + .as_ref() + .and_then(gtk::prelude::GtkWindowExt::focus) + .is_some_and(|focus| self.row_holds(&focus)); + if let Some(window) = window.as_ref().filter(|_| held) { + gtk::prelude::GtkWindowExt::set_focus(window, None::<>k::Widget>); + // The focus leaving commits a one-line edit, but a field that + // lost it to another window already left and hears nothing. + commit_inline(self); + } + self.cancel_edit(); + let (Some(window), true) = (window, held) else { + return; + }; + let (Some(view), Some(clock)) = (self.column_view(), window.frame_clock()) else { + return; + }; + let view = view.downgrade(); + let handler: Rc>> = Rc::default(); + let slot = handler.clone(); + let id = clock.connect_after_paint(move |clock| { + if let Some(id) = slot.borrow_mut().take() { + clock.disconnect(id); + } + if let Some(view) = view.upgrade() + && view.root().is_some() + { + view.grab_focus(); + } + }); + handler.replace(Some(id)); + } + + /// Whether `focus` is in this cell's row: in the cell or its editor, + /// or on the list's own cell or row widget above it, where the + /// keyboard sits while nothing is being edited. + fn row_holds(&self, focus: >k::Widget) -> bool { + if focus == self.upcast_ref::() || focus.is_ancestor(self) { + return true; + } + self.ancestor(gtk::ListView::static_type()) + .is_some_and(|list| self.is_ancestor(focus) && focus.is_ancestor(&list)) + } + + fn is_bound(&self) -> bool { + self.imp().binding.borrow().is_some() + } + + pub(super) fn take_popover(&self) -> Option { + self.imp().popover.take() + } + + /// Forget `popover` if it is still this cell's, leaving a newer one + /// in place. `true` when it was. + fn release_popover(&self, popover: >k::Popover) -> bool { + let mut slot = self.imp().popover.borrow_mut(); + if slot.as_ref() == Some(popover) { + slot.take(); + return true; + } + false + } + + pub(super) fn toggle_suppressed(&self) -> bool { + self.imp().suppress_toggle.get() + } + + pub(super) fn suppress_toggle(&self, suppressed: bool) { + self.imp().suppress_toggle.set(suppressed); + } +} + +fn holds_focus(widget: &impl IsA) -> bool { + widget + .root() + .and_downcast::() + .and_then(|window| gtk::prelude::GtkWindowExt::focus(&window)) + .is_some_and(|focus| focus.is_ancestor(widget)) +} + +#[cfg(test)] +mod tests { + use gtk4 as gtk; + use gtk4::prelude::*; + use tablepro_core::{ColumnInfo, Value}; + + use super::GridCell; + use crate::services::tracker_edit::TrackerEdit; + use crate::test_support::{FocusThief, after_next_frame, descendants, drain_main_context, wait_until}; + use crate::ui::grid::test_grid::{ + Grid, WAIT, edited_rows, flag_rows, focus_in_grid, key, named, page, recorder, text, two_text_rows, type_into, + }; + use crate::ui::grid::{RowObject, build_result_grid}; + + fn described(widget: &impl IsA) -> bool { + gtk::test_accessible_has_property(widget, gtk::AccessibleProperty::Description) + } + + #[gtk4::test] + fn pending_edit_sets_description_on_grid_cell() { + let grid = two_text_rows(); + let cell = grid.cell_of(1, 1); + assert!(!described(&cell)); + + grid.redraw(&grid.tracker.apply(TrackerEdit::Cell { + row_key: key(1), + col: 1, + original: Value::Text("A".into()), + value: Value::Text("edited".into()), + })); + assert!(described(&cell), "an edited cell said nothing"); + assert!(!described(&grid.cell_of(2, 1))); + + grid.redraw(&grid.tracker.undo().expect("an edit to undo")); + assert!(!described(&cell), "an undone edit still read as modified"); + } + + fn has_attribute(cell: &GridCell, kind: gtk::pango::AttrType) -> bool { + cell.display() + .and_then(|display| display.attributes()) + .is_some_and(|list| list.attributes().iter().any(|attr| attr.type_() == kind)) + } + + #[gtk4::test] + fn modified_cell_renders_bold() { + let grid = two_text_rows(); + let cell = grid.cell_of(1, 1); + assert!(!has_attribute(&cell, gtk::pango::AttrType::Weight)); + + grid.redraw(&grid.tracker.apply(TrackerEdit::Cell { + row_key: key(1), + col: 1, + original: Value::Text("A".into()), + value: Value::Text("edited".into()), + })); + assert!( + has_attribute(&cell, gtk::pango::AttrType::Weight), + "an edit showed only by its tint" + ); + assert!(!has_attribute(&grid.cell_of(1, 0), gtk::pango::AttrType::Weight)); + + grid.redraw(&grid.tracker.apply(TrackerEdit::DeleteRows { + persisted: vec![(key(1), vec![Value::Int(1), Value::Text("A".into())].into())], + drafts: Vec::new(), + })); + assert!(has_attribute(&cell, gtk::pango::AttrType::Strikethrough)); + assert!( + !has_attribute(&cell, gtk::pango::AttrType::Weight), + "a cell going away with its row read as an edit" + ); + } + + #[gtk4::test] + fn null_cell_has_description_text_null_does_not() { + let columns = [named("id", "integer", false), named("name", "text", false)]; + let rows = vec![RowObject::result(vec![Value::Null, Value::Text("NULL".into())].into())]; + let (sink, _messages) = recorder(); + let (view, _selection) = build_result_grid(columns.iter().map(ColumnInfo::result_column).collect(), rows, sink); + let window = gtk::Window::new(); + window.set_child(Some(&view)); + window.present(); + let cells = || -> Vec { + descendants(&view) + .into_iter() + .filter_map(|widget| widget.downcast::().ok()) + .filter(|cell| cell.row().is_some()) + .collect() + }; + wait_until(WAIT, || cells().len() == 2).expect("the cells are drawn"); + let cell = |column| { + cells() + .into_iter() + .find(|cell| cell.column() == column) + .expect("the cell") + }; + + assert!(described(&cell(0)), "a NULL read the same as the text NULL"); + assert!(!described(&cell(1)), "the text NULL read as a NULL"); + window.destroy(); + + let editable = Grid::open( + vec![named("id", "integer", true), named("name", "text", false)], + vec![ + vec![Value::Int(1), Value::Null], + vec![Value::Int(2), Value::Text("NULL".into())], + ], + ); + assert!(described(&editable.cell_of(1, 1))); + assert!(!described(&editable.cell_of(2, 1))); + } + + #[gtk4::test] + fn bool_cell_is_labelled() { + let grid = flag_rows(); + let check = grid.cell_of(1, 1).check_button().expect("a check button"); + + assert!(gtk::test_accessible_has_property( + &check, + gtk::AccessibleProperty::Label + )); + } + + #[gtk4::test] + fn from_focus_finds_the_cell_below_a_focused_cell_widget_or_above_its_editor() { + let cell_widget = gtk::Box::new(gtk::Orientation::Horizontal, 0); + let cell = GridCell::new(); + let editor = gtk::Text::new(); + editor.set_parent(&cell); + cell_widget.append(&cell); + + let below = GridCell::from_focus(cell_widget.upcast_ref()); + let above = GridCell::from_focus(editor.upcast_ref()); + + assert_eq!(below.as_ref(), Some(&cell)); + assert_eq!(above.as_ref(), Some(&cell)); + assert!(GridCell::from_focus(gtk::Label::new(None).upcast_ref()).is_none()); + editor.unparent(); + } + + /// The keyboard sits on the list's own cell widget, above the cell. + /// GTK moves a focus left in a removed row only after the next frame, + /// and a window focus-in before then crashed GTK 4.22 on the row. + #[gtk4::test] + fn a_focus_in_after_a_page_took_the_focused_row_keeps_the_focus_in_the_grid() { + let grid = two_text_rows(); + grid.focus(0, 1); + let thief = FocusThief::take_from(&grid.window).expect("another window takes the focus"); + + grid.store.splice(0, grid.store.n_items(), &page(3..5)); + thief.give_back(&grid.window); + drain_main_context(); + after_next_frame(&grid.window); + + assert!(focus_in_grid(&grid), "the focus left the grid"); + } + + /// The focus leaving commits an edit whose row a page takes out. A + /// field whose window lost the focus to another has already left, + /// and its edit is committed all the same. + #[gtk4::test] + fn a_page_that_takes_an_edited_row_from_an_inactive_window_commits_the_edit() { + let grid = two_text_rows(); + let cell = grid.focus(0, 1); + type_into(&cell, "typed"); + let _thief = FocusThief::take_from(&grid.window).expect("another window takes the focus"); + + grid.store.splice(0, grid.store.n_items(), &page(3..5)); + drain_main_context(); + + assert!(!cell.is_editing(), "the edit outlived its row"); + assert_eq!( + edited_rows(&grid.take_messages()), + vec![(Some(key(1)), 1, text("typed"))] + ); + } + + #[gtk4::test] + fn an_unattached_cell_has_no_row() { + let cell = GridCell::new(); + + assert!(cell.row().is_none()); + assert!(cell.position().is_none()); + assert!(!cell.is_editing()); + } +} diff --git a/linux/crates/app/src/ui/grid/grid_columns.rs b/linux/crates/app/src/ui/grid/grid_columns.rs new file mode 100644 index 0000000000..ec3085bdd0 --- /dev/null +++ b/linux/crates/app/src/ui/grid/grid_columns.rs @@ -0,0 +1,107 @@ +use std::cell::RefCell; +use std::rc::Rc; + +use gtk4::prelude::*; +use gtk4::{self as gtk, gio, glib}; + +use super::RowObject; +use super::cell_editor_kind::CellEditorKind; +use super::column_drawing::ColumnDrawing; + +/// The grid's columns, for finding a cell's neighbours. The user can +/// drag the columns into another order, so the order is read from the +/// view each time. +#[derive(Default)] +pub(super) struct GridColumns { + columns: RefCell>, +} + +struct GridColumn { + index: usize, + /// The view owns its columns, and their cells hold this. + view_column: glib::WeakRef, + drawing: Rc, + default_width: Option, +} + +#[derive(Clone)] +pub(super) struct ShownColumn { + pub(super) view_column: gtk::ColumnViewColumn, + /// The column's place in the result, which is what its cells hold. + pub(super) index: usize, + drawing: Rc, + /// The width it opens at with none saved, `None` for its natural one. + pub(super) default_width: Option, +} + +impl ShownColumn { + /// Whether the column's cell in `row` opens an editor. A check box + /// is edited where it stands, so it opens none. + pub(super) fn opens_editor(&self, row: &RowObject) -> bool { + let Some(column) = self.drawing.catalog.as_ref() else { + return false; + }; + let editability = self.drawing.lock(row.id()).map_or(Ok(()), Err); + matches!( + CellEditorKind::for_cell(column, editability), + CellEditorKind::Inline { .. } | CellEditorKind::Multiline { .. } | CellEditorKind::Calendar + ) + } +} + +impl GridColumns { + pub(super) fn push( + &self, + index: usize, + view_column: >k::ColumnViewColumn, + drawing: Rc, + default_width: Option, + ) { + self.columns.borrow_mut().push(GridColumn { + index, + view_column: view_column.downgrade(), + drawing, + default_width, + }); + } + + /// Measure the rows a change to `store` brought in. A change that + /// replaced every row, a new page, measures from scratch; adding or + /// removing a draft, which has no cells, measures nothing. + pub(super) fn measure_change(&self, store: &gio::ListStore, position: u32, added: u32) { + let replaced_all = position == 0 && store.n_items() == added; + if !replaced_all && added == 0 { + return; + } + let rows: Vec = (position..position.saturating_add(added)) + .filter_map(|position| store.item(position).and_downcast()) + .collect(); + for column in self.columns.borrow().iter() { + let drawing = &column.drawing; + match replaced_all { + true => drawing.figures.measure(&drawing.column_type, column.index, &rows), + false => drawing.figures.extend(&drawing.column_type, column.index, &rows), + } + } + } + + pub(super) fn shown(&self, view: >k::ColumnView) -> Vec { + let columns = self.columns.borrow(); + let model = view.columns(); + (0..model.n_items()) + .filter_map(|position| model.item(position).and_downcast::()) + .filter(gtk::ColumnViewColumn::is_visible) + .filter_map(|view_column| { + let column = columns + .iter() + .find(|column| column.view_column.upgrade().as_ref() == Some(&view_column))?; + Some(ShownColumn { + view_column, + index: column.index, + drawing: column.drawing.clone(), + default_width: column.default_width, + }) + }) + .collect() + } +} diff --git a/linux/crates/app/src/ui/grid/grid_content.rs b/linux/crates/app/src/ui/grid/grid_content.rs new file mode 100644 index 0000000000..4d1f0ccf2a --- /dev/null +++ b/linux/crates/app/src/ui/grid/grid_content.rs @@ -0,0 +1,13 @@ +use std::sync::Arc; + +use tablepro_core::column::ResultColumn; + +use super::RowObject; + +/// What a grid is built to show: the result's columns and its rows, +/// which the grid's store then owns. +#[derive(Debug, Clone)] +pub struct GridContent { + pub columns: Arc<[ResultColumn]>, + pub rows: Vec, +} diff --git a/linux/crates/app/src/ui/grid/grid_keyboard.rs b/linux/crates/app/src/ui/grid/grid_keyboard.rs new file mode 100644 index 0000000000..f62c3c8844 --- /dev/null +++ b/linux/crates/app/src/ui/grid/grid_keyboard.rs @@ -0,0 +1,498 @@ +use gtk4::prelude::*; +use gtk4::{self as gtk, gdk, glib}; + +use super::cell_edit::begin_edit; +use super::cell_focus::focused_cell; +use super::cell_preset::cell_presets; +use super::context_menu::prepare_menu; +use super::grid_menus::GridMenus; +use super::{CellPreset, GridCell, GridMsg, GridSink}; +use crate::application::{ShortcutOwner, component_trigger, controller_name}; + +pub(super) struct GridKeyboard; + +impl GridKeyboard { + /// One controller on the column view, run in the capture phase. + /// GtkListFactoryWidget class-binds Return, ISO_Enter and KP_Enter + /// to activate-keybinding and space to listitem.select, and a + /// G_TYPE_NONE signal action counts as handled, so on a focused cell + /// those keys never reach a bubble-phase ancestor. + /// + /// Each action proceeds while the cell is editing. GtkShortcutController + /// then tries its next shortcut and lets the key go on, so an open + /// editor keeps Return, space, Menu and Ctrl+Backspace (delete word). + pub(super) fn install(column_view: >k::ColumnView, menus: GridMenus, sink: GridSink) { + let controller = gtk::ShortcutController::new(); + controller.set_scope(gtk::ShortcutScope::Local); + controller.set_propagation_phase(gtk::PropagationPhase::Capture); + controller.set_name(Some(controller_name(ShortcutOwner::GridCell))); + let add = |action: &str, act: gtk::CallbackAction| { + controller.add_shortcut(gtk::Shortcut::new(component_trigger(action), Some(act))); + }; + add("cell.edit", on_focused_cell(|_, cell| edit_or_toggle(cell))); + add("cell.toggle", on_focused_cell(|_, cell| toggle(cell))); + add( + "cell.menu", + on_focused_cell(move |widget, cell| show_menu(widget, cell, &menus)), + ); + add("cell.set-null", on_focused_cell(move |_, cell| set_null(cell, &sink))); + column_view.add_controller(controller); + } +} + +fn on_focused_cell(act: impl Fn(>k::Widget, &GridCell) -> glib::Propagation + 'static) -> gtk::CallbackAction { + gtk::CallbackAction::new(move |widget, _| match focused_cell(widget) { + Some(cell) if !cell.is_editing() => act(widget, &cell), + _ => glib::Propagation::Proceed, + }) +} + +/// A locked cell lets the key go on, so the list's own activation runs. +fn edit_or_toggle(cell: &GridCell) -> glib::Propagation { + if cell.check_button().is_some() { + return toggle(cell); + } + if cell.lock().is_some() { + return glib::Propagation::Proceed; + } + begin_edit(cell); + glib::Propagation::Stop +} + +/// Space on any other cell goes on to select the row. +fn toggle(cell: &GridCell) -> glib::Propagation { + let (None, Some(check)) = (cell.lock(), cell.check_button()) else { + return glib::Propagation::Proceed; + }; + // The check button's toggled handler sends the edit. + check.set_active(!check.is_active()); + glib::Propagation::Stop +} + +fn show_menu(widget: >k::Widget, cell: &GridCell, menus: &GridMenus) -> glib::Propagation { + let Some(column_view) = widget.downcast_ref::() else { + return glib::Propagation::Proceed; + }; + let popover = prepare_menu(menus, cell, column_view); + // A key has no pointer position, so the menu points at the whole + // cell, in the coordinates of the view the menu is parented to. + let bounds = cell.compute_bounds(column_view).map(|bounds| { + gdk::Rectangle::new( + bounds.x() as i32, + bounds.y() as i32, + bounds.width() as i32, + bounds.height() as i32, + ) + }); + popover.set_pointing_to(bounds.as_ref()); + popover.popup(); + glib::Propagation::Stop +} + +fn set_null(cell: &GridCell, sink: &GridSink) -> glib::Propagation { + let (None, Some(row), Some(drawing)) = (cell.lock(), cell.row(), cell.drawing()) else { + return glib::Propagation::Proceed; + }; + let Some(column) = drawing.catalog.as_ref() else { + return glib::Propagation::Proceed; + }; + if !cell_presets(column, row.id()).null { + return glib::Propagation::Proceed; + } + sink(GridMsg::SetCellValue { + row, + column: cell.column(), + preset: CellPreset::Null, + }); + glib::Propagation::Stop +} + +#[cfg(test)] +mod tests { + use std::collections::HashSet; + + use gtk4::prelude::*; + use gtk4::{self as gtk, gdk}; + use tablepro_core::Value; + + use crate::application::{ACCELERATORS, AcceleratorScope, ShortcutOwner}; + use crate::test_support::shortcut_keys::{ + Keystroke, class_binding_keys, class_keys_on_focus_paths, keystroke, owner_controller, shortcuts, table_keys, + trigger_keys, + }; + use crate::test_support::{descendants, wait_until}; + use crate::ui::grid::test_grid::{Grid, WAIT, content, edited_rows, key, named, recorder, text, two_text_rows}; + use crate::ui::grid::{ + CellPreset, GridCell, GridMsg, GridSorting, RowObject, TabGridContext, begin_edit, build_column_view, + }; + + fn accels(shortcut: >k::Shortcut) -> Vec { + shortcut + .trigger() + .map(|trigger| trigger_keys(&trigger)) + .unwrap_or_default() + } + + fn keyboard(view: >k::ColumnView) -> gtk::ShortcutController { + owner_controller(view, ShortcutOwner::GridCell).expect("the grid's keyboard controller") + } + + /// Run what `key` does on the focused cell, as a key press reaching + /// the controller would. `true` when the grid took the key. + fn press(view: >k::ColumnView, key: gdk::Key, modifiers: gdk::ModifierType) -> bool { + let action = shortcuts(&keyboard(view)) + .into_iter() + .find(|shortcut| accels(shortcut).contains(&(key.to_lower(), modifiers))) + .and_then(|shortcut| shortcut.action()) + .expect("the grid binds the key"); + action.activate(gtk::ShortcutActionFlags::empty(), view, None) + } + + fn none() -> gdk::ModifierType { + gdk::ModifierType::empty() + } + + #[gtk4::test] + fn grid_keyboard_matches_component_entries() { + let grid = two_text_rows(); + let bound: HashSet = shortcuts(&keyboard(&grid.view)).iter().flat_map(accels).collect(); + + assert_eq!(bound, table_keys(ShortcutOwner::GridCell)); + assert!(bound.contains(&keystroke("BackSpace"))); + assert!( + bound.contains(&(gdk::Key::KP_Enter, none())), + "the keypad Enter was lost" + ); + } + + /// A bubble-phase key on the column view reaches it only when no + /// class on the way up from the focused cell binds it first. + #[gtk4::test] + fn browse_grid_keys_are_not_class_bindings_inside_the_grid() { + let grid = two_text_rows(); + grid.focus(0, 1); + let class_keys = class_keys_on_focus_paths(&grid.view, |_| false); + assert!( + class_keys.contains(&keystroke("a")), + "the list view's own bindings were not found, so the check reads nothing" + ); + + for entry in ACCELERATORS { + let AcceleratorScope::Local { + owner: ShortcutOwner::BrowseGrid, + phase, + } = entry.scope + else { + continue; + }; + assert_eq!(phase, gtk::PropagationPhase::Bubble, "{}", entry.action); + for accel in entry.triggers { + assert!( + !class_keys.contains(&keystroke(accel)), + "{accel} of {} never reaches the column view", + entry.action + ); + } + } + } + + /// The table lists these as GtkListBase's own, so the list must bind + /// them. + #[gtk4::test] + fn stock_list_keys_are_list_view_bindings() { + let grid = two_text_rows(); + let list_view = descendants(&grid.view) + .into_iter() + .find(|widget| widget.is::()) + .expect("the column view's list"); + let bound = class_binding_keys(&list_view); + + for entry in ACCELERATORS.iter().filter(|entry| { + entry.scope == AcceleratorScope::Stock + && entry.action.starts_with("list.") + && entry.triggers.iter().all(|accel| !accel.contains("Pointer")) + }) { + for accel in entry.triggers { + assert!(bound.contains(&keystroke(accel)), "the list does not bind {accel}"); + } + } + } + + #[gtk4::test] + fn grid_shortcut_controller_runs_in_capture_phase_above_focus() { + let grid = two_text_rows(); + let controller = keyboard(&grid.view); + + grid.focus(1, 1); + + assert_eq!(controller.propagation_phase(), gtk::PropagationPhase::Capture); + assert_eq!(controller.scope(), gtk::ShortcutScope::Local); + let focus = gtk::prelude::GtkWindowExt::focus(&grid.window).expect("a focused widget"); + let owner = controller.widget().expect("the controller is on a widget"); + assert!( + focus.is_ancestor(&owner), + "a key on the focused cell does not pass the controller" + ); + assert_eq!(focus.css_name(), "cell", "the focus is not on a cell"); + } + + #[gtk4::test] + fn grid_shortcuts_proceed_while_editing() { + let grid = two_text_rows(); + let cell = grid.focus(0, 1); + begin_edit(&cell); + assert!(cell.inline_text().is_some()); + + assert!(!press(&grid.view, gdk::Key::Return, none())); + assert!(!press(&grid.view, gdk::Key::space, none())); + assert!(!press(&grid.view, gdk::Key::BackSpace, gdk::ModifierType::CONTROL_MASK)); + assert!(!press(&grid.view, gdk::Key::Menu, none())); + + assert!(cell.inline_text().is_some(), "a grid key closed the open editor"); + assert!(grid.take_messages().is_empty()); + } + + #[gtk4::test] + fn f2_starts_editing_focused_text_cell() { + let grid = two_text_rows(); + let cell = grid.focus(1, 1); + + assert!(press(&grid.view, gdk::Key::F2, none())); + + let editor = cell.inline_text().expect("F2 opened no editor"); + assert_eq!(editor.text(), "B"); + } + + #[gtk4::test] + fn kp_enter_starts_editing_focused_int_cell() { + let grid = Grid::open( + vec![named("id", "integer", true), named("n", "integer", false)], + vec![vec![Value::Int(1), Value::Int(42)]], + ); + let cell = grid.focus(0, 1); + + assert!(press(&grid.view, gdk::Key::KP_Enter, none())); + + let editor = cell.inline_text().expect("KP_Enter opened no editor"); + assert_eq!(editor.text(), "42"); + assert_eq!(editor.input_purpose(), gtk::InputPurpose::Number); + } + + #[gtk4::test] + fn return_toggles_a_focused_boolean_cell() { + let grid = Grid::open( + vec![named("id", "integer", true), named("flag", "boolean", false)], + vec![vec![Value::Int(1), Value::Bool(false)]], + ); + let cell = grid.focus(0, 1); + + assert!(press(&grid.view, gdk::Key::Return, none())); + + assert_eq!( + edited_rows(&grid.take_messages()), + vec![( + Some(key(1)), + 1, + tablepro_core::edit::CellInput::Value(Value::Bool(true)) + )] + ); + assert!(!cell.is_editing()); + } + + #[gtk4::test] + fn space_toggles_a_focused_check_box_and_selects_from_any_other_cell() { + let grid = Grid::open( + vec![ + named("id", "integer", true), + named("name", "text", false), + named("flag", "boolean", false), + ], + vec![vec![Value::Int(1), Value::Text("a".into()), Value::Bool(false)]], + ); + + grid.focus(0, 2); + assert!(press(&grid.view, gdk::Key::space, none())); + grid.focus(0, 1); + assert!( + !press(&grid.view, gdk::Key::space, none()), + "Space on a text cell did not go on to select the row" + ); + + assert_eq!( + edited_rows(&grid.take_messages()), + vec![( + Some(key(1)), + 2, + tablepro_core::edit::CellInput::Value(Value::Bool(true)) + )] + ); + } + + #[gtk4::test] + fn return_commits_and_returns_focus_to_cell() { + let grid = two_text_rows(); + let cell = grid.focus(1, 1); + assert!(press(&grid.view, gdk::Key::Return, none())); + let editor = cell.inline_text().expect("Return opened no editor"); + editor.set_text("renamed"); + + editor.emit_activate(); + + assert_eq!( + edited_rows(&grid.take_messages()), + vec![(Some(key(2)), 1, text("renamed"))] + ); + assert!(!cell.is_editing()); + assert_eq!(grid.focused_cell().as_ref(), Some(&cell), "the focus did not come back"); + } + + #[gtk4::test] + fn shift_f10_opens_menu_anchored_to_focused_cell() { + let grid = two_text_rows(); + let cell = grid.focus(1, 1); + wait_until(WAIT, || { + cell.compute_bounds(&grid.view) + .is_some_and(|bounds| bounds.width() > 0.0 && bounds.y() > 0.0) + }) + .expect("the cell is laid out"); + + assert!(press(&grid.view, gdk::Key::F10, gdk::ModifierType::SHIFT_MASK)); + + let mut popover = None; + wait_until(WAIT, || { + popover = descendants(&grid.view) + .into_iter() + .filter_map(|widget| widget.downcast::().ok()) + .find(|popover| popover.is_visible()); + popover.is_some() + }) + .expect("the cell menu opens"); + let popover = popover.expect("an open menu"); + let bounds = cell.compute_bounds(&grid.view).expect("the cell's bounds"); + let (pointed, rect) = popover.pointing_to(); + assert!(pointed); + assert_eq!( + (rect.x(), rect.y(), rect.width(), rect.height()), + ( + bounds.x() as i32, + bounds.y() as i32, + bounds.width() as i32, + bounds.height() as i32 + ) + ); + grid.view + .activate_action("cell.set-null", None) + .expect("the menu's Set to NULL"); + popover.popdown(); + let set: Vec<_> = grid + .take_messages() + .into_iter() + .filter_map(|msg| match msg { + GridMsg::SetCellValue { row, column, preset } => Some((row.key().cloned(), column, preset)), + _ => None, + }) + .collect(); + assert_eq!( + set, + vec![(Some(key(2)), 1, CellPreset::Null)], + "the menu acts on another cell" + ); + } + + #[gtk4::test] + fn ctrl_backspace_sets_focused_nullable_cell_null() { + let mut required = named("code", "text", false); + required.nullable = false; + let grid = Grid::open( + vec![named("id", "integer", true), named("name", "text", false), required], + vec![vec![Value::Int(1), Value::Text("a".into()), Value::Text("b".into())]], + ); + let ctrl = gdk::ModifierType::CONTROL_MASK; + + grid.focus(0, 1); + assert!(press(&grid.view, gdk::Key::BackSpace, ctrl)); + grid.focus(0, 2); + assert!( + !press(&grid.view, gdk::Key::BackSpace, ctrl), + "a NOT NULL column took NULL" + ); + grid.focus(0, 0); + assert!( + !press(&grid.view, gdk::Key::BackSpace, ctrl), + "a saved row's key took NULL" + ); + + let set: Vec<_> = grid + .take_messages() + .into_iter() + .filter_map(|msg| match msg { + GridMsg::SetCellValue { row, column, preset } => Some((row.key().cloned(), column, preset)), + _ => None, + }) + .collect(); + assert_eq!(set, vec![(Some(key(1)), 1, CellPreset::Null)]); + } + + #[gtk4::test] + fn focused_read_only_cell_resolves_grid_cell() { + let grid = two_text_rows(); + + let cell = grid.focus(0, 0); + + assert!(cell.lock().is_some(), "a saved row's key was editable"); + assert_eq!(cell.row().and_then(|row| row.key().cloned()), Some(key(1))); + assert!(!press(&grid.view, gdk::Key::F2, none()), "F2 took a locked cell"); + assert!(!cell.is_editing()); + assert!(press(&grid.view, gdk::Key::Menu, none()), "a locked cell has no menu"); + } + + #[gtk4::test] + fn a_result_grid_opens_its_read_only_menu_from_the_keyboard() { + let columns = [named("id", "integer", true)]; + let (sink, _messages) = recorder(); + let (view, _selection) = build_column_view( + content(&columns, vec![RowObject::result(vec![Value::Int(7)].into())]), + &[], + sink, + GridSorting::Server { initial: None }, + TabGridContext::default(), + ); + let window = gtk::Window::new(); + window.set_child(Some(&view)); + window.present(); + view.grab_focus(); + let mut focused = None; + wait_until(WAIT, || { + focused = gtk::prelude::GtkWindowExt::focus(&window).and_then(|focus| GridCell::from_focus(&focus)); + focused.is_some() + }) + .expect("a result cell takes the focus"); + + assert!(!press(&view, gdk::Key::Return, none()), "Return edited a result cell"); + assert!(press(&view, gdk::Key::Menu, none())); + let open: Vec = descendants(&view) + .into_iter() + .filter_map(|widget| widget.downcast::().ok()) + .filter(|popover| popover.is_visible()) + .collect(); + assert_eq!(open.len(), 1); + assert!( + view.activate_action("cell.set-null", None).is_err(), + "a result grid offers Set to NULL" + ); + for popover in open { + popover.popdown(); + } + window.destroy(); + } + + #[gtk4::test] + fn grid_keys_proceed_with_no_focused_cell() { + let grid = two_text_rows(); + gtk::prelude::GtkWindowExt::set_focus(&grid.window, None::<>k::Widget>); + + assert!(!press(&grid.view, gdk::Key::F2, none())); + assert!(!press(&grid.view, gdk::Key::BackSpace, gdk::ModifierType::CONTROL_MASK)); + assert!(grid.take_messages().is_empty()); + } +} diff --git a/linux/crates/app/src/ui/grid/grid_menus.rs b/linux/crates/app/src/ui/grid/grid_menus.rs new file mode 100644 index 0000000000..3fb2784762 --- /dev/null +++ b/linux/crates/app/src/ui/grid/grid_menus.rs @@ -0,0 +1,583 @@ +use std::rc::Rc; +use std::sync::Arc; + +use gtk4::prelude::*; +use gtk4::{self as gtk, gio, glib}; + +use tablepro_core::column::ResultColumn; + +use super::cell_edit::begin_edit; +use super::cell_editor_kind::CellEditorKind; +use super::cell_preset::cell_presets; +use super::context_menu::{CellContext, MenuTarget}; +use super::grid_columns::ShownColumn; +use super::row_json_dialog::RowJsonRequest; +use super::selection::export_snapshot; +use super::{ + CellPreset, DeleteAvailability, GridCell, GridMsg, GridSink, RowObject, RowSnapshot, TabGridContext, + delete_availability, +}; +use crate::ui::clipboard::{CopyKind, CopyRequest, InClauseRows}; + +/// Enabled per right-clicked cell. `None` on a read-only grid, which +/// registers none of them. +#[derive(Clone, Default)] +pub(super) struct CellActions { + /// Hidden while disabled, so a cell they do not apply to shows no + /// dead entry. + edit: Option, + set_empty: Option, + set_null: Option, + set_default: Option, + /// Shown insensitive while disabled, since a menu item cannot carry + /// a tooltip to say why. + delete_row: Option, + /// On while the rows are sorted. + clear_sort: Option, +} + +impl CellActions { + /// Set for `cell` and `rows`, what the menu acts on, as it opens. + pub(super) fn arm(&self, cell: &GridCell, rows: &[RowObject]) { + let column = cell.drawing().and_then(|drawing| drawing.catalog.clone()); + let row = cell.row(); + let editor = column + .as_ref() + .map(|column| CellEditorKind::for_cell(column, cell.lock().map_or(Ok(()), Err))); + let presets = column + .as_ref() + .zip(row.as_ref()) + .map(|(column, row)| cell_presets(column, row.id())); + let enable = |action: &Option, enabled: bool| { + if let Some(action) = action { + action.set_enabled(enabled); + } + }; + enable( + &self.edit, + matches!( + editor, + Some(CellEditorKind::Inline { .. } | CellEditorKind::Multiline { .. } | CellEditorKind::Calendar) + ), + ); + enable(&self.set_empty, presets.is_some_and(|presets| presets.empty)); + enable(&self.set_null, presets.is_some_and(|presets| presets.null)); + enable(&self.set_default, presets.is_some_and(|presets| presets.default)); + if let Some(delete_row) = &self.delete_row { + let availability = delete_availability(rows.iter().map(RowObject::id), false); + delete_row.set_enabled(availability == DeleteAvailability::Available); + } + let sorted = cell + .column_view() + .and_then(|view| view.sorter()) + .and_downcast::() + .is_some_and(|sorter| sorter.primary_sort_column().is_some()); + enable(&self.clear_sort, sorted); + } +} + +#[derive(Clone)] +pub(super) struct GridMenus { + pub(super) context: Rc, + pub(super) editable_popover: gtk::PopoverMenu, + pub(super) readonly_popover: gtk::PopoverMenu, + pub(super) actions: CellActions, +} + +#[derive(Clone, Copy)] +struct MenuShape { + edit_cell: bool, + set_value: bool, + row_ops: bool, +} + +impl MenuShape { + const READ_ONLY: MenuShape = MenuShape { + edit_cell: false, + set_value: false, + row_ops: false, + }; + const ROW_OPS: MenuShape = MenuShape { + edit_cell: false, + set_value: false, + row_ops: true, + }; + const FULL: MenuShape = MenuShape { + edit_cell: true, + set_value: true, + row_ops: true, + }; +} + +fn build_cell_menu(shape: MenuShape) -> gio::Menu { + let menu = gio::Menu::new(); + let edit_section = gio::Menu::new(); + let hidden_while_disabled = |label: String, action: &str| { + let item = crate::application::menu_item(&label, action); + item.set_attribute_value("hidden-when", Some(&"action-disabled".to_variant())); + edit_section.append_item(&item); + }; + if shape.edit_cell { + hidden_while_disabled(crate::i18n::gettext("Edit Cell"), "cell.edit"); + } + if shape.set_value { + // Each is offered only where the column can hold it, so a menu + // never offers a value the save would refuse. + hidden_while_disabled(crate::i18n::gettext("Set to Empty"), "cell.set-empty"); + hidden_while_disabled(crate::i18n::gettext("Set to NULL"), "cell.set-null"); + hidden_while_disabled(crate::i18n::gettext("Set to DEFAULT"), "cell.set-default"); + } + if shape.edit_cell || shape.set_value { + menu.append_section(None, &edit_section); + } + + let copy_as = gio::Menu::new(); + copy_as.append(Some(&crate::i18n::gettext("Rows")), Some("cell.copy-rows")); + copy_as.append( + Some(&crate::i18n::gettext("With Headers")), + Some("cell.copy-rows-headers"), + ); + copy_as.append(Some(&crate::i18n::gettext("JSON")), Some("cell.copy-json")); + copy_as.append(Some(&crate::i18n::gettext("CSV")), Some("cell.copy-csv")); + copy_as.append( + Some(&crate::i18n::gettext("CSV With Headers")), + Some("cell.copy-csv-headers"), + ); + copy_as.append(Some(&crate::i18n::gettext("Markdown")), Some("cell.copy-markdown")); + copy_as.append(Some(&crate::i18n::gettext("IN Clause")), Some("cell.copy-in-clause")); + if shape.row_ops { + let sql_section = gio::Menu::new(); + sql_section.append( + Some(&crate::i18n::gettext("INSERT Statement")), + Some("cell.copy-row-insert"), + ); + copy_as.append_section(None, &sql_section); + } + let copy_section = gio::Menu::new(); + copy_section.append(Some(&crate::i18n::gettext("Copy")), Some("cell.copy")); + copy_section.append_submenu(Some(&crate::i18n::gettext("Copy As")), ©_as); + copy_section.append( + Some(&crate::i18n::gettext("Copy Column Name")), + Some("cell.copy-column-name"), + ); + menu.append_section(None, ©_section); + + let json_section = gio::Menu::new(); + json_section.append( + Some(&crate::i18n::gettext("Show Row as JSON")), + Some("cell.show-row-json"), + ); + menu.append_section(None, &json_section); + + // A header's click and drag, which no key reaches. + let column_section = gio::Menu::new(); + column_section.append( + Some(&crate::i18n::gettext("Sort Ascending")), + Some("cell.sort-ascending"), + ); + column_section.append( + Some(&crate::i18n::gettext("Sort Descending")), + Some("cell.sort-descending"), + ); + column_section.append(Some(&crate::i18n::gettext("Clear Sort")), Some("cell.clear-sort")); + column_section.append( + Some(&crate::i18n::gettext("Reset Column Width")), + Some("cell.reset-width"), + ); + menu.append_section(None, &column_section); + + let action_section = gio::Menu::new(); + action_section.append(Some(&crate::i18n::gettext("Export…")), Some("cell.export")); + if shape.row_ops { + action_section.append(Some(&crate::i18n::gettext("Insert Row")), Some("cell.insert-row")); + action_section.append(Some(&crate::i18n::gettext("Duplicate")), Some("cell.duplicate-row")); + action_section.append(Some(&crate::i18n::gettext("Delete")), Some("cell.delete-row")); + } + menu.append_section(None, &action_section); + menu +} + +pub(super) struct GridMenuInit<'a> { + pub(super) column_view: &'a gtk::ColumnView, + pub(super) sink: GridSink, + pub(super) columns: Arc<[ResultColumn]>, + pub(super) tab_ctx: TabGridContext, +} + +pub(super) fn install_grid_context_menus(init: GridMenuInit<'_>) -> GridMenus { + let GridMenuInit { + column_view, + sink, + columns, + tab_ctx, + } = init; + let editable = tab_ctx.editable; + let kit = CellActionKit { + context: Rc::new(CellContext::default()), + view: column_view.downgrade(), + sink, + columns, + tab: Rc::new(tab_ctx), + }; + + let group = gio::SimpleActionGroup::new(); + let mut entries = kit.reading_entries(); + // A read-only grid registers none of the mutating actions: it + // shows no menu item for them, and the editor throws their + // messages away at the far end. + if editable { + entries.extend(kit.editing_entries()); + } + group.add_action_entries(entries); + column_view.insert_action_group("cell", Some(&group)); + + let actions = CellActions { + edit: editable.then(|| simple_action(&group, "edit")).flatten(), + set_empty: editable.then(|| simple_action(&group, "set-empty")).flatten(), + set_null: editable.then(|| simple_action(&group, "set-null")).flatten(), + set_default: editable.then(|| simple_action(&group, "set-default")).flatten(), + delete_row: editable.then(|| simple_action(&group, "delete-row")).flatten(), + clear_sort: simple_action(&group, "clear-sort"), + }; + + let (editable_popover, readonly_popover) = cell_popovers(column_view, editable); + let mut popovers_for_destroy = vec![editable_popover.clone()]; + if readonly_popover != editable_popover { + popovers_for_destroy.push(readonly_popover.clone()); + } + if editable { + popovers_for_destroy.push(install_empty_space_menu(column_view)); + } + column_view.connect_destroy(move |_| { + for popover in &popovers_for_destroy { + popover.unparent(); + } + }); + + GridMenus { + context: kit.context, + editable_popover, + readonly_popover, + actions, + } +} + +type CellEntry = gio::ActionEntry; + +struct CellActionKit { + context: Rc, + /// Every action closure holds the view weakly. The action group + /// belongs to the ColumnView, so a strong clone in a closure is a + /// cycle: the grid, its selection model, its store and every row it + /// holds would outlive the page that built them. + view: glib::WeakRef, + sink: GridSink, + columns: Arc<[ResultColumn]>, + tab: Rc, +} + +impl CellActionKit { + /// An action on the cell the menu opened on, its row and the rows + /// the menu acts on. + fn on_target(&self, name: &str, act: impl Fn(&GridCell, RowObject, Vec) + 'static) -> CellEntry { + let ctx = self.context.clone(); + let view = self.view.clone(); + gio::ActionEntry::builder(name) + .activate(move |_, _, _| { + let Some(cv) = view.upgrade() else { return }; + let Some(MenuTarget { cell, row, rows }) = ctx.target(&cv) else { + return; + }; + act(&cell, row, rows); + }) + .build() + } + + /// The rows are taken as the grid shows them, sharing its cells, and + /// the owner renders the copy off the GTK thread. + fn copy(&self, name: &str, kind: impl Fn(&GridCell, &[RowObject]) -> CopyKind + 'static) -> CellEntry { + let s = self.sink.clone(); + let tab = self.tab.clone(); + let columns = self.columns.clone(); + self.on_target(name, move |cell, _row, rows| { + s(GridMsg::Copy(CopyRequest::Rows { + columns: columns.clone(), + rows: snapshots(&tab, &rows), + kind: kind(cell, &rows), + })); + }) + } + + fn send(&self, name: &str, msg: impl Fn(&GridCell, RowObject, Vec) -> GridMsg + 'static) -> CellEntry { + let s = self.sink.clone(); + self.on_target(name, move |cell, row, rows| s(msg(cell, row, rows))) + } + + /// The actions a read-only grid offers too. + fn reading_entries(&self) -> Vec { + let mut entries = self.copy_entries(); + entries.extend([self.copy_column_name(), self.show_row_json(), self.export()]); + entries.extend(self.column_entries()); + entries + } + + /// Sort by the cell's column, as a header click does, or reset the + /// width a header drag set. + fn column_entries(&self) -> Vec { + let sort = |name: &str, order: gtk::SortType| { + let view = self.view.clone(); + self.on_target(name, move |cell, _row, _rows| { + let Some(cv) = view.upgrade() else { return }; + if let Some(column) = shown_column(&cv, cell) { + cv.sort_by_column(Some(&column.view_column), order); + } + }) + }; + let clear_sort = { + let view = self.view.clone(); + let sink = self.sink.clone(); + self.on_target("clear-sort", move |_cell, _row, _rows| { + let Some(cv) = view.upgrade() else { return }; + cv.sort_by_column(None, gtk::SortType::Ascending); + // The server sorts a browse page, and an unsorted view + // tells it nothing by itself. + sink(GridMsg::SortCleared); + }) + }; + let reset_width = { + let view = self.view.clone(); + let tab = self.tab.clone(); + let columns = self.columns.clone(); + self.on_target("reset-width", move |cell, _row, _rows| { + let Some(column) = view.upgrade().and_then(|cv| shown_column(&cv, cell)) else { + return; + }; + column.view_column.set_fixed_width(column.default_width.unwrap_or(-1)); + if let (Some(widths), Some(info)) = (&tab.column_widths, columns.get(column.index)) { + widths.forget(&info.name); + } + }) + }; + vec![ + sort("sort-ascending", gtk::SortType::Ascending), + sort("sort-descending", gtk::SortType::Descending), + clear_sort, + reset_width, + ] + } + + fn copy_entries(&self) -> Vec { + vec![ + // Plain Copy reads the cell's value, never the widget's text: + // the widget carries the display form, which is the `` + // sentinel, the `(auto)` placeholder, `` for a blob and + // a value cut at the 10k display cap. + self.copy("copy", |cell, rows| match rows.len() { + 1 => CopyKind::Value(cell.column()), + _ => CopyKind::Rows, + }), + self.copy("copy-rows", |_, _| CopyKind::Rows), + self.copy("copy-rows-headers", |_, _| CopyKind::RowsWithHeaders), + self.copy("copy-json", |_, _| CopyKind::Json), + self.copy("copy-csv", |_, _| CopyKind::Csv), + self.copy("copy-csv-headers", |_, _| CopyKind::CsvWithHeaders), + self.copy("copy-markdown", |_, _| CopyKind::Markdown), + self.copy_in_clause(), + ] + } + + fn copy_in_clause(&self) -> CellEntry { + let tab = self.tab.clone(); + let columns = self.columns.clone(); + self.send("copy-in-clause", move |cell, _row, rows| { + GridMsg::CopyInClause(InClauseRows { + columns: columns.clone(), + rows: snapshots(&tab, &rows), + column: cell.column(), + }) + }) + } + + fn copy_column_name(&self) -> CellEntry { + let s = self.sink.clone(); + let cols = self.columns.clone(); + let view = self.view.clone(); + self.on_target("copy-column-name", move |cell, _row, _rows| { + let Some(cv) = view.upgrade() else { return }; + let name = cols + .get(cell.column()) + .map(|column| column.name.clone()) + .unwrap_or_default(); + crate::ui::clipboard::copy_text(&cv, name, |message| s(GridMsg::ShowToast(message.to_owned()))); + }) + } + + fn show_row_json(&self) -> CellEntry { + let cols = self.columns.clone(); + let tab = self.tab.clone(); + self.send("show-row-json", move |_cell, row, _rows| { + GridMsg::ShowRowJson(RowJsonRequest { + columns: cols.clone(), + row: tab.snapshot(&row), + }) + }) + } + + fn export(&self) -> CellEntry { + let s = self.sink.clone(); + let view = self.view.clone(); + let cols = self.columns.clone(); + let tab = self.tab.clone(); + gio::ActionEntry::builder("export") + .activate(move |_, _, _| { + let Some(cv) = view.upgrade() else { return }; + s(GridMsg::ExportResults(crate::ui::export::ExportSource::Rows { + columns: cols.clone(), + rows: export_snapshot(&cv, &tab), + })); + }) + .build() + } + + /// The actions that change rows, which only an editable grid has. + fn editing_entries(&self) -> Vec { + let set_value = |name: &str, preset: CellPreset| { + self.send(name, move |cell, row, _rows| GridMsg::SetCellValue { + row, + column: cell.column(), + preset, + }) + }; + let insert_row = { + let s = self.sink.clone(); + gio::ActionEntry::builder("insert-row") + .activate(move |_, _, _| s(GridMsg::InsertRow)) + .build() + }; + vec![ + // Editing opens on the cell, which must still show the row. + self.on_target("edit", |cell, row, _rows| { + if cell.row().as_ref() == Some(&row) { + begin_edit(cell); + } + }), + set_value("set-empty", CellPreset::Empty), + set_value("set-null", CellPreset::Null), + set_value("set-default", CellPreset::Default), + self.send("copy-row-insert", |_cell, _row, rows| GridMsg::CopyRowsAsInsert { + rows, + }), + insert_row, + self.send("delete-row", |_cell, _row, rows| GridMsg::DeleteRows { rows }), + self.send("duplicate-row", |_cell, _row, rows| GridMsg::DuplicateRows { rows }), + ] + } +} + +/// The menus a right click on a cell opens: one for a row that can be +/// edited and one for a row that can't. A read-only grid has a single +/// shape and both name it. +fn cell_popovers(column_view: >k::ColumnView, editable: bool) -> (gtk::PopoverMenu, gtk::PopoverMenu) { + // Popovers are parented eagerly so each PopoverMenu's action muxer + // snapshots the ColumnView's `cell` group at set_parent() time. + // Lazy parenting in the gesture handler drops every activation + // silently. + let make_popover = |shape: MenuShape| { + let popover = gtk::PopoverMenu::from_model_full(&build_cell_menu(shape), gtk::PopoverMenuFlags::NESTED); + popover.set_has_arrow(true); + popover.set_parent(column_view); + popover + }; + if editable { + (make_popover(MenuShape::FULL), make_popover(MenuShape::ROW_OPS)) + } else { + let readonly = make_popover(MenuShape::READ_ONLY); + (readonly.clone(), readonly) + } +} + +/// Insert Row on a right click below the last row. +fn install_empty_space_menu(column_view: >k::ColumnView) -> gtk::PopoverMenu { + let empty_menu = gio::Menu::new(); + empty_menu.append(Some(&crate::i18n::gettext("Insert Row")), Some("cell.insert-row")); + let empty_popover = gtk::PopoverMenu::from_model_full(&empty_menu, gtk::PopoverMenuFlags::NESTED); + empty_popover.set_has_arrow(true); + empty_popover.set_parent(column_view); + + let view = column_view.downgrade(); + let popover = empty_popover.clone(); + let empty_gesture = gtk::GestureClick::builder().button(3).build(); + empty_gesture.connect_pressed(move |g, _, x, y| { + let Some(cv) = view.upgrade() else { return }; + let cv_widget: gtk::Widget = cv.clone().upcast(); + if let Some(picked) = cv.pick(x, y, gtk::PickFlags::DEFAULT) + && picked != cv_widget + { + return; + } + g.set_state(gtk::EventSequenceState::Claimed); + popover.set_pointing_to(Some(>k::gdk::Rectangle::new(x as i32, y as i32, 1, 1))); + popover.popup(); + }); + column_view.add_controller(empty_gesture); + empty_popover +} + +/// The view's column that `cell` sits in. +fn shown_column(view: >k::ColumnView, cell: &GridCell) -> Option { + cell.columns()? + .shown(view) + .into_iter() + .find(|column| column.index == cell.column()) +} + +/// The rows a menu item copies, as the grid shows them. +fn snapshots(tab: &TabGridContext, rows: &[RowObject]) -> Vec { + rows.iter().map(|row| tab.snapshot(row)).collect() +} + +fn simple_action(group: &gio::SimpleActionGroup, name: &str) -> Option { + group + .lookup_action(name) + .and_then(|a| a.downcast::().ok()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn accel_of(menu: &gio::MenuModel, action: &str) -> Option> { + for position in 0..menu.n_items() { + let named = menu + .item_attribute_value(position, "action", None) + .and_then(|value| value.get::()); + if named.as_deref() == Some(action) { + return Some( + menu.item_attribute_value(position, "accel", None) + .and_then(|value| value.get::()), + ); + } + for link in ["section", "submenu"] { + if let Some(found) = menu + .item_link(position, link) + .and_then(|inner| accel_of(&inner, action)) + { + return Some(found); + } + } + } + None + } + + #[gtk4::test] + fn the_cell_menu_shows_the_cell_keys() { + let menu = build_cell_menu(MenuShape::FULL).upcast::(); + + assert_eq!( + accel_of(&menu, "cell.set-null"), + Some(Some("BackSpace".to_owned())) + ); + assert_eq!(accel_of(&menu, "cell.edit"), Some(Some("F2".to_owned()))); + assert_eq!(accel_of(&menu, "cell.set-empty"), Some(None)); + } +} diff --git a/linux/crates/app/src/ui/grid/grid_msg.rs b/linux/crates/app/src/ui/grid/grid_msg.rs new file mode 100644 index 0000000000..62eb484e8a --- /dev/null +++ b/linux/crates/app/src/ui/grid/grid_msg.rs @@ -0,0 +1,49 @@ +use std::rc::Rc; + +use super::{CellPreset, RowJsonRequest, RowObject}; +use crate::services::tracker_edit::TrackerEdit; +use crate::ui::clipboard::{CopyRequest, InClauseRows}; +use crate::ui::export::ExportSource; + +/// Where a grid sends its events. The owner holds the grid, so the +/// closure holds the owner weakly. +pub type GridSink = Rc; + +#[derive(Debug)] +pub enum GridMsg { + /// The sorter's state after the click, not a toggle hint: a click on + /// another column fires both sort notifies, and a hint would flip twice. + SortChanged(usize, bool), + /// Clear Sort: the rows go back to the order the server sends. + SortCleared, + /// An edit of `column` in `row`, already parsed for the column. + Edit { + row: RowObject, + column: usize, + edit: TrackerEdit, + }, + /// Rows to copy, which the owner renders off the GTK thread. + Copy(CopyRequest), + ShowRowJson(RowJsonRequest), + ShowToast(String), + CopyRowsAsInsert { + rows: Vec, + }, + /// The owner knows the engine whose literals the list is written in. + CopyInClause(InClauseRows), + /// An intent the tab resolves against the column, so the keyboard + /// path and the menu reach the same value. + SetCellValue { + row: RowObject, + column: usize, + preset: CellPreset, + }, + ExportResults(ExportSource), + DeleteRows { + rows: Vec, + }, + InsertRow, + DuplicateRows { + rows: Vec, + }, +} diff --git a/linux/crates/app/src/ui/grid/grid_row_id.rs b/linux/crates/app/src/ui/grid/grid_row_id.rs new file mode 100644 index 0000000000..f7c10011c1 --- /dev/null +++ b/linux/crates/app/src/ui/grid/grid_row_id.rs @@ -0,0 +1,61 @@ +use crate::services::change_tracker::RowKey; + +/// What a grid row is to the tab that tracks its edits. Fixed when the +/// row object is made, so an event that reaches the row after others +/// moved around it still names the same row. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum GridRowId { + /// An editor result row, which nothing tracks. + Result, + /// A table row with no usable key, which can be read but not edited. + Unkeyed, + Persisted(RowKey), + Draft(u64), +} + +impl GridRowId { + /// The key of a persisted row, which is what an UPDATE or DELETE + /// names it by. + pub fn key(&self) -> Option<&RowKey> { + match self { + Self::Persisted(key) => Some(key), + Self::Result | Self::Unkeyed | Self::Draft(_) => None, + } + } + + pub fn draft_id(&self) -> Option { + match self { + Self::Draft(draft_id) => Some(*draft_id), + Self::Result | Self::Unkeyed | Self::Persisted(_) => None, + } + } + + /// The key the tracker files this row's pending changes under. + pub fn tracker_key(&self) -> Option { + match self { + Self::Persisted(key) => Some(key.clone()), + Self::Draft(draft_id) => Some(RowKey::Draft(*draft_id)), + Self::Result | Self::Unkeyed => None, + } + } +} + +#[cfg(test)] +mod tests { + use tablepro_core::Value; + + use super::GridRowId; + use crate::services::change_tracker::RowKey; + + #[test] + fn grid_row_id_key_is_none_for_result_and_draft_rows() { + let key = RowKey::from_pk_values(&[Value::Int(1)]).expect("a key"); + + assert_eq!(GridRowId::Result.key(), None); + assert_eq!(GridRowId::Draft(3).key(), None); + assert_eq!(GridRowId::Unkeyed.key(), None); + assert_eq!(GridRowId::Persisted(key.clone()).key(), Some(&key)); + assert_eq!(GridRowId::Draft(3).tracker_key(), Some(RowKey::Draft(3))); + assert_eq!(GridRowId::Result.tracker_key(), None); + } +} diff --git a/linux/crates/app/src/ui/grid/grid_row_index.rs b/linux/crates/app/src/ui/grid/grid_row_index.rs new file mode 100644 index 0000000000..54b6019a00 --- /dev/null +++ b/linux/crates/app/src/ui/grid/grid_row_index.rs @@ -0,0 +1,111 @@ +use std::collections::HashMap; + +use gtk4::gio; +use gtk4::glib; +use gtk4::prelude::*; + +use super::RowObject; +use crate::services::change_tracker::RowKey; + +/// The grid's rows by the key the tracker files their changes under, +/// drafts included. The store owns the rows, so a row it drops is +/// forgotten here on the next lookup. +#[derive(Debug, Default)] +pub struct GridRowIndex(HashMap>); + +impl GridRowIndex { + pub fn rebuild(&mut self, store: &gio::ListStore) { + self.0.clear(); + for position in 0..store.n_items() { + if let Some(row) = store.item(position).and_downcast::() { + self.insert(&row); + } + } + } + + pub fn insert(&mut self, row: &RowObject) { + if let Some(key) = row.id().tracker_key() { + self.0.insert(key, row.downgrade()); + } + } + + pub fn remove(&mut self, key: &RowKey) { + self.0.remove(key); + } + + pub fn get(&mut self, key: &RowKey) -> Option { + let row = self.0.get(key)?.upgrade(); + if row.is_none() { + self.0.remove(key); + } + row + } + + pub fn position_in(&mut self, store: &gio::ListStore, key: &RowKey) -> Option { + store.find(&self.get(key)?) + } + + #[cfg(test)] + fn len(&self) -> usize { + self.0.len() + } +} + +#[cfg(test)] +mod tests { + use gtk4::gio; + use tablepro_core::Value; + + use super::GridRowIndex; + use crate::services::change_tracker::RowKey; + use crate::ui::grid::RowObject; + + fn key(id: i64) -> RowKey { + RowKey::from_pk_values(&[Value::Int(id)]).expect("a key") + } + + fn saved(id: i64) -> RowObject { + RowObject::persisted(Some(key(id)), vec![Value::Int(id)].into()) + } + + #[test] + fn grid_row_index_drops_dead_rows_on_get() { + let mut index = GridRowIndex::default(); + let kept = saved(1); + index.insert(&kept); + index.insert(&saved(2)); + + assert_eq!(index.get(&key(1)), Some(kept)); + assert_eq!(index.get(&key(2)), None, "a row nothing holds came back"); + assert_eq!(index.len(), 1, "the dead row stayed in the index"); + } + + #[test] + fn drafts_are_found_by_their_draft_key_and_unkeyed_rows_not_at_all() { + let store = gio::ListStore::new::(); + store.append(&RowObject::draft(7)); + store.append(&saved(1)); + store.append(&RowObject::persisted(None, vec![Value::Int(3)].into())); + let mut index = GridRowIndex::default(); + + index.rebuild(&store); + + assert_eq!(index.position_in(&store, &RowKey::Draft(7)), Some(0)); + assert_eq!(index.position_in(&store, &key(1)), Some(1)); + assert_eq!(index.len(), 2); + index.remove(&RowKey::Draft(7)); + assert_eq!(index.get(&RowKey::Draft(7)), None); + } + + #[test] + fn a_draft_is_never_taken_for_a_saved_row_with_the_same_number() { + let mut index = GridRowIndex::default(); + let draft = RowObject::draft(3); + let row = saved(3); + index.insert(&draft); + index.insert(&row); + + assert_eq!(index.get(&RowKey::Draft(3)), Some(draft)); + assert_eq!(index.get(&key(3)), Some(row)); + } +} diff --git a/linux/crates/app/src/ui/grid/inline_editor.rs b/linux/crates/app/src/ui/grid/inline_editor.rs new file mode 100644 index 0000000000..911d4b5faa --- /dev/null +++ b/linux/crates/app/src/ui/grid/inline_editor.rs @@ -0,0 +1,260 @@ +use std::cell::Cell; +use std::rc::Rc; + +use gtk4::prelude::*; +use gtk4::{self as gtk, glib}; + +use super::cell_accessibility::name_editor; +use super::cell_focus::{edit_next, return_focus}; +use super::edit_commit::{commit_edit, send_commit}; +use super::edit_navigation::EditDirection; +use super::{EditSnapshot, GridCell}; + +/// How the one-line editor treats what is typed into it. +#[derive(Debug, Clone, PartialEq)] +pub struct InlineOptions { + /// Off for a column a line break means nothing in: a pasted break + /// keeps the first line, as a one-line field does elsewhere. + pub accepts_line_breaks: bool, + pub purpose: gtk::InputPurpose, + /// What an empty commit means, shown while the field is empty. + pub placeholder: Option, +} + +/// A `gtk::Text` laid over the cell's display while an edit is open, and +/// removed when it commits or is cancelled. +#[derive(Debug)] +pub struct InlineEditor { + text: gtk::Text, +} + +impl InlineEditor { + pub(super) fn begin(cell: &GridCell, snapshot: EditSnapshot, initial: &str, options: InlineOptions) { + cell.cancel_edit(); + cell.remove_closed_field(); + let text = gtk::Text::new(); + text.set_hexpand(true); + text.set_margin_start(8); + text.set_margin_end(8); + text.add_css_class("tp-cell-text"); + text.set_truncate_multiline(!options.accepts_line_breaks); + text.set_input_purpose(options.purpose); + text.set_placeholder_text(options.placeholder.as_deref()); + text.set_text(initial); + name_editor(&text, cell); + connect_commit(cell, &text); + if let Some(display) = cell.content() { + display.set_visible(false); + } + text.set_parent(cell); + cell.begin_edit(snapshot); + cell.set_inline(Self { text: text.clone() }); + text.grab_focus(); + // The first keystroke replaces the value, as in a Files rename. + text.select_region(0, -1); + } + + pub(super) fn widget(&self) -> >k::Text { + &self.text + } + + /// Show the display again, with the text the field held. GTK ends a + /// focus change by walking up from the widget that lost the focus, and + /// GTK 4.22 loops forever when that widget has no parent by then, so a + /// field that lost it stays in the cell, hidden, until the next edit. + pub(super) fn close(self, cell: &GridCell) -> String { + let typed = self.text.text().to_string(); + if holds_window_focus(self.text.upcast_ref()) { + self.text.unparent(); + } else { + self.text.set_visible(false); + cell.keep_closed_field(self.text); + } + if let Some(display) = cell.content() { + display.set_visible(true); + } + typed + } +} + +/// Commit the cell's one-line edit, as its field losing the focus does. +pub(super) fn commit_inline(cell: &GridCell) { + finish_inline(cell, true); +} + +/// End the cell's one-line edit. The field closes before the commit is +/// sent, so the redraw the commit causes meets no open editor. +fn finish_inline(cell: &GridCell, commit: bool) { + let Some(editor) = cell.take_inline() else { + return; + }; + let snapshot = cell.take_edit(); + let typed = editor.close(cell); + if !commit { + return; + } + let (Some(snapshot), Some(drawing)) = (snapshot, cell.drawing()) else { + return; + }; + let Some(column) = drawing.catalog.as_ref() else { + return; + }; + let result = commit_edit(column, &snapshot, &typed); + send_commit(cell, snapshot, result); +} + +/// The field is the cell's child, so its handlers hold the cell weakly. +fn connect_commit(cell: &GridCell, text: >k::Text) { + let weak = cell.downgrade(); + text.connect_activate(move |_| { + if let Some(cell) = weak.upgrade() { + finish_inline(&cell, true); + return_focus(&cell); + } + }); + + // Focus moving to an IME popover ends the edit mid-composition, and + // committing then would send the text from before it, so the commit + // waits for the preedit to clear. + let composing = Rc::new(Cell::new(false)); + let commit_when_composed = Rc::new(Cell::new(false)); + + // Capture phase, so Escape cancels before the GtkText can take it to + // clear its own buffer, and Tab commits before the window moves the + // focus. While a preedit is open both keys are the input method's. + let keys = gtk::EventControllerKey::new(); + keys.set_propagation_phase(gtk::PropagationPhase::Capture); + let weak = cell.downgrade(); + let keys_composing = composing.clone(); + keys.connect_key_pressed(move |_, keyval, _, modifiers| { + let Some(cell) = weak.upgrade() else { + return glib::Propagation::Proceed; + }; + if keys_composing.get() { + return glib::Propagation::Proceed; + } + match edit_key(keyval, modifiers) { + Some(EditKey::Cancel) => { + finish_inline(&cell, false); + return_focus(&cell); + } + Some(EditKey::Move(direction)) => { + finish_inline(&cell, true); + edit_next(&cell, direction); + } + None => return glib::Propagation::Proceed, + } + glib::Propagation::Stop + }); + text.add_controller(keys); + + let weak = cell.downgrade(); + let preedit_composing = composing.clone(); + let preedit_commit = commit_when_composed.clone(); + text.connect_preedit_changed(move |_, preedit| { + preedit_composing.set(!preedit.is_empty()); + if preedit.is_empty() + && preedit_commit.replace(false) + && let Some(cell) = weak.upgrade() + { + finish_inline(&cell, true); + } + }); + + let focus = gtk::EventControllerFocus::new(); + let weak = cell.downgrade(); + focus.connect_leave(move |focus| { + let Some(cell) = weak.upgrade() else { + return; + }; + // Another window taking the focus leaves it in the field, so the + // edit stays open for when the window is back. A focus moving + // within the window is unset while the leave runs. + if focus.widget().is_some_and(|field| holds_window_focus(&field)) { + return; + } + if composing.get() { + commit_when_composed.set(true); + return; + } + finish_inline(&cell, true); + }); + text.add_controller(focus); +} + +fn holds_window_focus(widget: >k::Widget) -> bool { + widget + .root() + .and_downcast::() + .and_then(|window| gtk::prelude::GtkWindowExt::focus(&window)) + .is_some_and(|focus| focus == *widget || focus.is_ancestor(widget)) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum EditKey { + Cancel, + /// Commit, then edit the next cell that way. + Move(EditDirection), +} + +/// Ctrl+Tab and Alt+Tab belong to the window's tabs and switcher. +fn edit_key(keyval: gtk::gdk::Key, modifiers: gtk::gdk::ModifierType) -> Option { + use gtk::gdk::{Key, ModifierType}; + if modifiers.intersects(ModifierType::CONTROL_MASK | ModifierType::ALT_MASK) { + return None; + } + match keyval { + Key::Escape => Some(EditKey::Cancel), + Key::ISO_Left_Tab => Some(EditKey::Move(EditDirection::Backward)), + Key::Tab | Key::KP_Tab if modifiers.contains(ModifierType::SHIFT_MASK) => { + Some(EditKey::Move(EditDirection::Backward)) + } + Key::Tab | Key::KP_Tab => Some(EditKey::Move(EditDirection::Forward)), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use gtk4::gdk::{Key, ModifierType}; + + use super::{EditKey, edit_key}; + use crate::test_support::shortcut_keys::keystroke; + use crate::ui::grid::edit_navigation::EditDirection; + + #[test] + fn tab_moves_escape_cancels_and_window_keys_pass() { + let none = ModifierType::empty(); + let shift = ModifierType::SHIFT_MASK; + + assert_eq!(edit_key(Key::Escape, none), Some(EditKey::Cancel)); + assert_eq!(edit_key(Key::Tab, none), Some(EditKey::Move(EditDirection::Forward))); + assert_eq!( + edit_key(Key::ISO_Left_Tab, shift), + Some(EditKey::Move(EditDirection::Backward)) + ); + assert_eq!(edit_key(Key::Tab, shift), Some(EditKey::Move(EditDirection::Backward))); + assert_eq!(edit_key(Key::Tab, ModifierType::CONTROL_MASK), None); + assert_eq!(edit_key(Key::Tab, ModifierType::ALT_MASK), None); + assert_eq!(edit_key(Key::Return, none), None, "Return is the field's activate"); + } + + /// The editor reads keys itself, to leave them to an open preedit, so + /// the table's entries are checked against what it does. + #[gtk4::test] + fn edit_keys_match_cell_editor_entries() { + let expected = [ + ("cell-editor.cancel", EditKey::Cancel), + ("cell-editor.next", EditKey::Move(EditDirection::Forward)), + ("cell-editor.previous", EditKey::Move(EditDirection::Backward)), + ]; + for (action, outcome) in expected { + let triggers = crate::application::triggers_for(action); + assert!(!triggers.is_empty(), "{action} is not in the table"); + for accel in triggers { + let (key, modifiers) = keystroke(accel); + assert_eq!(edit_key(key, modifiers), Some(outcome), "{accel}"); + } + } + } +} diff --git a/linux/crates/app/src/ui/grid/menu_rows.rs b/linux/crates/app/src/ui/grid/menu_rows.rs new file mode 100644 index 0000000000..8175e8bd3b --- /dev/null +++ b/linux/crates/app/src/ui/grid/menu_rows.rs @@ -0,0 +1,80 @@ +use gtk4 as gtk; +use gtk4::prelude::*; + +use super::selection::{selected_positions, selection_of}; +use super::{GridCell, RowObject}; + +/// The rows a context menu item acts on: the whole selection when the +/// clicked row is in it, else the clicked row alone, so rows that moved +/// since the click never stand in for it. +pub fn menu_rows(selection: >k::MultiSelection, clicked: &RowObject, clicked_position: u32) -> Vec { + let clicked_is_selected = selection.is_selected(clicked_position) + && selection.item(clicked_position).and_downcast_ref::() == Some(clicked); + if !clicked_is_selected { + return vec![clicked.clone()]; + } + selected_positions(selection) + .into_iter() + .filter_map(|position| selection.item(position).and_downcast::()) + .collect() +} + +/// The rows a menu opened on `cell` acts on, read as it opens. +pub(super) fn menu_rows_of(column_view: >k::ColumnView, cell: &GridCell) -> Vec { + let Some(row) = cell.row() else { + return Vec::new(); + }; + match (selection_of(column_view), cell.position()) { + (Some(selection), Some(position)) => menu_rows(&selection, &row, position), + _ => vec![row], + } +} + +#[cfg(test)] +mod tests { + use gtk4 as gtk; + use gtk4::gio; + use gtk4::prelude::*; + use tablepro_core::Value; + + use super::menu_rows; + use crate::services::change_tracker::RowKey; + use crate::ui::grid::RowObject; + + fn rows(n: i64) -> (gtk::MultiSelection, Vec) { + let store = gio::ListStore::new::(); + let rows: Vec = (0..n) + .map(|id| RowObject::persisted(RowKey::from_pk_values(&[Value::Int(id)]), vec![Value::Int(id)].into())) + .collect(); + for row in &rows { + store.append(row); + } + (gtk::MultiSelection::new(Some(store)), rows) + } + + #[gtk4::test] + fn menu_rows_uses_selection_when_clicked_row_is_selected() { + let (selection, rows) = rows(5); + selection.select_item(3, false); + selection.select_item(1, false); + + assert_eq!( + menu_rows(&selection, &rows[3], 3), + vec![rows[1].clone(), rows[3].clone()], + "the rows are not the selection in position order" + ); + } + + #[gtk4::test] + fn menu_rows_uses_only_clicked_row_outside_selection() { + let (selection, rows) = rows(5); + selection.select_range(0, 2, true); + + assert_eq!(menu_rows(&selection, &rows[4], 4), vec![rows[4].clone()]); + assert_eq!( + menu_rows(&selection, &rows[4], 1), + vec![rows[4].clone()], + "a selected row at the clicked position stood in for the clicked one" + ); + } +} diff --git a/linux/crates/app/src/ui/grid/mod.rs b/linux/crates/app/src/ui/grid/mod.rs new file mode 100644 index 0000000000..cd039973fe --- /dev/null +++ b/linux/crates/app/src/ui/grid/mod.rs @@ -0,0 +1,73 @@ +mod calendar_editor; +mod cell_accessibility; +mod cell_binding; +mod cell_edit; +mod cell_editability; +mod cell_editor_kind; +mod cell_focus; +mod cell_lock; +mod cell_preset; +mod cell_tooltip; +mod column_drawing; +mod column_factory; +mod column_figures; +mod column_width_binding; +mod context_menu; +mod delete_availability; +mod display_text; +mod edit_commit; +mod edit_navigation; +mod edit_snapshot; +mod grid_attributes; +mod grid_cell; +mod grid_columns; +mod grid_content; +mod grid_keyboard; +mod grid_menus; +mod grid_msg; +mod grid_row_id; +mod grid_row_index; +mod inline_editor; +mod menu_rows; +mod multiline_editor; +mod result_grid; +mod result_sorters; +mod row_factory; +mod row_json_dialog; +mod row_key_builder; +mod row_object; +mod row_snapshot; +mod row_state_column; +mod row_state_icon; +mod row_watches; +mod selection; +mod tab_grid_context; +#[cfg(test)] +mod test_grid; +mod toggle_cell; +mod value_order; + +#[cfg(test)] +pub(crate) use self::cell_edit::begin_edit; +pub(crate) use self::cell_editability::cell_editability; +pub(crate) use self::cell_focus::{commit_open_edit, edit_row}; +pub(crate) use self::cell_lock::CellLock; +pub(crate) use self::cell_preset::{CellPreset, cell_presets}; +pub(crate) use self::column_factory::{GridSorting, build_column_view}; +pub(crate) use self::column_width_binding::ColumnWidthBinding; +pub(crate) use self::delete_availability::{DeleteAvailability, delete_availability}; +pub(crate) use self::edit_commit::edit_context; +pub(crate) use self::edit_snapshot::EditSnapshot; +pub(crate) use self::grid_cell::GridCell; +pub(crate) use self::grid_content::GridContent; +pub(crate) use self::grid_msg::{GridMsg, GridSink}; +pub(crate) use self::grid_row_id::GridRowId; +pub(crate) use self::grid_row_index::GridRowIndex; +pub(crate) use self::result_grid::build_result_grid; +pub(crate) use self::row_json_dialog::{RowJsonRequest, present_row_json}; +pub(crate) use self::row_key_builder::RowKeyBuilder; +pub(crate) use self::row_object::RowObject; +pub(crate) use self::row_snapshot::RowSnapshot; +pub(crate) use self::row_state_column::data_columns; +pub(crate) use self::selection::{export_snapshot, selected_positions}; +pub(crate) use self::tab_grid_context::TabGridContext; diff --git a/linux/crates/app/src/ui/grid/multiline_editor.rs b/linux/crates/app/src/ui/grid/multiline_editor.rs new file mode 100644 index 0000000000..c6128de9b1 --- /dev/null +++ b/linux/crates/app/src/ui/grid/multiline_editor.rs @@ -0,0 +1,109 @@ +use gtk4::prelude::*; +use gtk4::{self as gtk, glib}; +use sourceview5::prelude::*; + +use super::cell_accessibility::name_editor; +use super::edit_commit::{commit_edit, send_commit}; +use super::{EditSnapshot, GridCell}; +use crate::application::{ShortcutOwner, component_trigger, controller_name}; + +/// A popover with a source view, for text that spans lines or is too +/// long for a one-line field. Return adds a line, so Save is a button +/// and Ctrl+Return. +pub struct MultilineEditor; + +impl MultilineEditor { + pub(super) fn open(cell: &GridCell, snapshot: EditSnapshot, text: &str, language: Option<&'static str>) { + cell.cancel_edit(); + let buffer = sourceview5::Buffer::new(None); + if let Some(language) = language.and_then(|id| sourceview5::LanguageManager::default().language(id)) { + buffer.set_language(Some(&language)); + } + buffer.set_text(text); + let view = sourceview5::View::with_buffer(&buffer); + view.set_monospace(true); + view.set_wrap_mode(gtk::WrapMode::WordChar); + view.set_auto_indent(true); + crate::ui::style_scheme_resolver::apply(&view, "Adwaita"); + name_editor(&view, cell); + + let scrolled = gtk::ScrolledWindow::builder() + .child(&view) + .min_content_width(420) + .min_content_height(280) + .has_frame(true) + .build(); + let save = gtk::Button::with_label(&crate::i18n::gettext("Save")); + save.add_css_class("suggested-action"); + crate::ui::a11y::announce_key_shortcuts(&save, "cell-editor.save-text"); + let cancel = gtk::Button::with_label(&crate::i18n::gettext("Cancel")); + let buttons = gtk::Box::builder() + .orientation(gtk::Orientation::Horizontal) + .spacing(8) + .halign(gtk::Align::End) + .build(); + buttons.append(&cancel); + buttons.append(&save); + let content = gtk::Box::builder() + .orientation(gtk::Orientation::Vertical) + .spacing(8) + .margin_top(8) + .margin_bottom(8) + .margin_start(8) + .margin_end(8) + .build(); + content.append(&scrolled); + content.append(&buttons); + let popover = gtk::Popover::builder().child(&content).build(); + + let weak_popover = popover.downgrade(); + cancel.connect_clicked(move |_| { + if let Some(popover) = weak_popover.upgrade() { + popover.popdown(); + } + }); + let weak_cell = cell.downgrade(); + save.connect_clicked(move |_| { + if let Some(cell) = weak_cell.upgrade() { + save_text(&cell, &buffer); + } + }); + let save_shortcut = gtk::ShortcutController::new(); + save_shortcut.set_name(Some(controller_name(ShortcutOwner::CellEditor))); + let weak_save = save.downgrade(); + save_shortcut.add_shortcut(gtk::Shortcut::new( + component_trigger("cell-editor.save-text"), + Some(gtk::CallbackAction::new(move |_, _| { + if let Some(save) = weak_save.upgrade() { + save.emit_clicked(); + } + glib::Propagation::Stop + })), + )); + view.add_controller(save_shortcut); + + cell.begin_edit(snapshot); + cell.present_popover(&popover); + view.grab_focus(); + } +} + +/// The popover closes before the tab hears of the value, so an edit the +/// tab redraws the row for never meets an open popover. +fn save_text(cell: &GridCell, buffer: &sourceview5::Buffer) { + let Some(snapshot) = cell.take_edit() else { + return; + }; + let text = buffer.text(&buffer.start_iter(), &buffer.end_iter(), true).to_string(); + if let Some(popover) = cell.take_popover() { + popover.popdown(); + } + let Some(drawing) = cell.drawing() else { + return; + }; + let Some(column) = drawing.catalog.as_ref() else { + return; + }; + let result = commit_edit(column, &snapshot, &text); + send_commit(cell, snapshot, result); +} diff --git a/linux/crates/app/src/ui/grid/result_grid.rs b/linux/crates/app/src/ui/grid/result_grid.rs new file mode 100644 index 0000000000..a9531ac8c2 --- /dev/null +++ b/linux/crates/app/src/ui/grid/result_grid.rs @@ -0,0 +1,369 @@ +use std::sync::Arc; + +use gtk4 as gtk; +use tablepro_core::column::ResultColumn; + +use super::{GridContent, GridSink, GridSorting, RowObject, TabGridContext, build_column_view}; + +/// A grid of an editor result, which sorts the rows it holds when a +/// header is clicked. +/// +/// The rows reach the view through a `GtkSortListModel` on the view's +/// own sorter, so every selection, copy, menu and export reads the row +/// at its place on screen. +pub(crate) fn build_result_grid( + columns: Arc<[ResultColumn]>, + rows: Vec, + sink: GridSink, +) -> (gtk::ColumnView, gtk::MultiSelection) { + build_column_view( + GridContent { columns, rows }, + &[], + sink, + GridSorting::Client, + TabGridContext::default(), + ) +} + +#[cfg(test)] +mod tests { + use std::time::{Duration, Instant}; + + use gtk4::glib; + use gtk4::prelude::*; + use tablepro_core::Value; + use tablepro_core::column::ColumnInfo; + + use super::*; + use crate::test_support::{descendants, drain_main_context, wait_until}; + use crate::ui::clipboard::{CopyKind, CopyRequest}; + use crate::ui::grid::test_grid::{Grid, named, recorder}; + use crate::ui::grid::{GridCell, GridMsg}; + + const WAIT: Duration = Duration::from_secs(10); + + fn grid(columns: &[ColumnInfo], rows: Vec>, sink: GridSink) -> (gtk::ColumnView, gtk::MultiSelection) { + let rows = rows.into_iter().map(|cells| RowObject::result(cells.into())).collect(); + build_result_grid(columns.iter().map(ColumnInfo::result_column).collect(), rows, sink) + } + + fn sort(view: >k::ColumnView, column: u32, order: gtk::SortType) { + let column = view + .columns() + .item(column) + .and_downcast::() + .expect("the column"); + view.sort_by_column(Some(&column), order); + } + + /// Column `column` of each row, in the order the view shows them. + fn shown(selection: >k::MultiSelection, column: usize) -> Vec { + (0..selection.n_items()) + .filter_map(|position| selection.item(position).and_downcast::()) + .map(|row| row.cell(column).cloned().unwrap_or(Value::Null)) + .collect() + } + + fn text(value: &str) -> Value { + Value::Text(value.to_owned()) + } + + #[gtk4::test] + fn sort_by_column_descending_orders_result_rows() { + let columns = [named("id", "integer", false), named("name", "text", false)]; + let (view, selection) = grid( + &columns, + vec![ + vec![Value::Int(2), text("b")], + vec![Value::Int(10), text("j")], + vec![Value::Null, text("none")], + vec![Value::Int(-1), text("a")], + ], + recorder().0, + ); + + sort(&view, 0, gtk::SortType::Descending); + + assert_eq!( + shown(&selection, 0), + [Value::Int(10), Value::Int(2), Value::Int(-1), Value::Null] + ); + assert_eq!(shown(&selection, 1), [text("j"), text("b"), text("a"), text("none")]); + sort(&view, 0, gtk::SortType::Ascending); + assert_eq!( + shown(&selection, 0), + [Value::Null, Value::Int(-1), Value::Int(2), Value::Int(10)] + ); + } + + #[gtk4::test] + fn a_sort_keeps_the_selected_rows_selected() { + let columns = [named("id", "integer", false)]; + let (view, selection) = grid( + &columns, + vec![vec![Value::Int(3)], vec![Value::Int(1)], vec![Value::Int(2)]], + recorder().0, + ); + selection.select_item(0, true); + selection.select_item(1, false); + + sort(&view, 0, gtk::SortType::Ascending); + + let selected: Vec = (0..selection.n_items()) + .filter(|position| selection.is_selected(*position)) + .filter_map(|position| selection.item(position).and_downcast::()) + .map(|row| row.cell(0).cloned().unwrap_or(Value::Null)) + .collect(); + assert_eq!(selected, [Value::Int(1), Value::Int(3)]); + } + + #[gtk4::test] + fn text_column_sorts_with_unicode_collation() { + let columns = [named("name", "text", false)]; + let names = ["f", "é", "e", "E"]; + let mut rows: Vec> = names.iter().map(|name| vec![text(name)]).collect(); + rows.insert(2, vec![Value::Null]); + let (view, selection) = grid(&columns, rows, recorder().0); + + sort(&view, 0, gtk::SortType::Ascending); + + // The order is the locale's: whatever it is, it is the order of + // the collation keys GLib builds for it, with NULL first. The + // sorter ignores case, as GtkStringSorter does by default, so + // `e` and `E` tie and keep their row order. + let mut collated = names.to_vec(); + collated.sort_by_key(|name| glib::CollationKey::from(glib::casefold(name).as_str())); + let mut expected = vec![Value::Null]; + expected.extend(collated.iter().map(|name| text(name))); + assert_eq!(shown(&selection, 0), expected); + let key = |name: &str| glib::CollationKey::from(glib::casefold(name).as_str()); + if key("e") < key("é") && key("é") < key("f") { + let order: Vec = shown(&selection, 0) + .into_iter() + .filter(|value| *value != text("E")) + .collect(); + assert_eq!(order, [Value::Null, text("e"), text("é"), text("f")]); + } + sort(&view, 0, gtk::SortType::Descending); + assert_eq!( + shown(&selection, 0).last(), + Some(&Value::Null), + "NULL is not last descending" + ); + } + + #[gtk4::test] + fn a_text_column_orders_its_numbers_before_its_text() { + let mut column = named("tag", "nvarchar(20)", false); + column.column_type.dynamic_storage = false; + let (view, selection) = grid( + &[column], + vec![ + vec![Value::Int(10)], + vec![text("b")], + vec![Value::Int(9)], + vec![Value::Null], + vec![text("a")], + ], + recorder().0, + ); + + sort(&view, 0, gtk::SortType::Ascending); + + assert_eq!( + shown(&selection, 0), + [Value::Null, Value::Int(9), Value::Int(10), text("a"), text("b")] + ); + } + + #[gtk4::test] + fn text_column_sorter_is_string_sorter_with_unicode_collation() { + let (view, _selection) = grid(&[named("name", "varchar(20)", false)], Vec::new(), recorder().0); + let column = view + .columns() + .item(0) + .and_downcast::() + .expect("the column"); + + let sorter = column + .sorter() + .and_downcast::() + .expect("a text column sorts in steps"); + let collating = sorter + .item(sorter.n_items() - 1) + .and_downcast::() + .expect("the last step collates the text"); + assert_eq!(collating.collation(), gtk::Collation::Unicode); + assert!( + sorter.item(0).and_downcast::().is_some(), + "NULL is not placed first" + ); + } + + #[gtk4::test] + fn sorting_10k_text_rows_finishes_within_one_second() { + let rows = (0..10_000_u64) + .map(|row| vec![text(&format!("name {:05}", row * 7_919 % 10_000))]) + .collect(); + let (view, selection) = grid(&[named("name", "text", false)], rows, recorder().0); + + let started = Instant::now(); + sort(&view, 0, gtk::SortType::Ascending); + let count = selection.n_items(); + let elapsed = started.elapsed(); + + assert_eq!(count, 10_000); + assert!(elapsed < Duration::from_secs(1), "sorting took {elapsed:?}"); + assert_eq!(shown(&selection, 0)[0], text("name 00000")); + assert_eq!(shown(&selection, 0)[9_999], text("name 09999")); + } + + /// PostgreSQL's editor reads columns with no type, holding the + /// server's text. + #[gtk4::test] + fn untyped_numbers_line_up_as_the_browse_grid_lines_them_up() { + let untyped = |name: &str| ResultColumn::new(name, tablepro_core::column::ColumnType::untyped_text("text")); + let columns: Arc<[ResultColumn]> = ["id", "category", "products", "avg_price"].map(untyped).into(); + let rows = vec![ + RowObject::result(vec![text("1"), text("Coffee"), text("7"), text("23.79")].into()), + RowObject::result(vec![text("2"), text("Tea"), Value::Null, text("10.58")].into()), + ]; + let (view, _selection) = build_result_grid(columns, rows, recorder().0); + let window = gtk::Window::new(); + window.set_child(Some(&view)); + window.present(); + let result = |column: usize| { + descendants(&view) + .into_iter() + .filter_map(|widget| widget.downcast::().ok()) + .find(|cell| cell.position() == Some(0) && cell.column() == column && cell.row().is_some()) + .and_then(|cell| cell.display()) + }; + wait_until(WAIT, || (0..4).all(|column| result(column).is_some())).expect("the result is drawn"); + let browse = Grid::open( + vec![ + named("id", "integer", true), + named("category", "text", false), + named("products", "bigint", false), + named("avg_price", "numeric", false), + ], + vec![vec![ + Value::Int(1), + text("Coffee"), + Value::Int(7), + Value::Decimal("23.79".parse().expect("a decimal")), + ]], + ); + + for column in 0..4 { + let shown = result(column).expect("a result cell"); + let typed = browse.cell_of(1, column).display().expect("a browse cell"); + assert_eq!(shown.xalign(), typed.xalign(), "column {column}"); + assert_eq!( + shown.has_css_class("numeric"), + typed.has_css_class("numeric"), + "column {column}" + ); + } + assert_eq!(result(2).map(|display| display.xalign()), Some(1.0)); + assert_eq!(result(1).map(|display| display.xalign()), Some(0.0)); + window.close(); + } + + #[gtk4::test] + fn the_cell_menu_sorts_the_results_and_restores_their_order() { + let columns = [named("id", "integer", false), named("name", "text", false)]; + let (view, selection) = grid( + &columns, + vec![ + vec![Value::Int(2), text("b")], + vec![Value::Int(3), text("c")], + vec![Value::Int(1), text("a")], + ], + recorder().0, + ); + let window = gtk::Window::new(); + window.set_default_size(600, 400); + window.set_child(Some(&view)); + window.present(); + let cell = || { + descendants(&view) + .into_iter() + .filter_map(|widget| widget.downcast::().ok()) + .find(|cell| cell.position() == Some(0) && cell.column() == 0 && cell.row().is_some()) + }; + wait_until(WAIT, || cell().is_some()).expect("the cells are drawn"); + let open_menu = || { + let cell = cell().expect("the first row's cell"); + let controllers = cell.observe_controllers(); + let gesture = (0..controllers.n_items()) + .filter_map(|index| controllers.item(index).and_downcast::()) + .find(|gesture| gesture.button() == 3) + .expect("a cell answers a right-click"); + gesture.emit_by_name::<()>("pressed", &[&1i32, &1.0f64, &1.0f64]); + }; + + open_menu(); + view.activate_action("cell.sort-descending", None) + .expect("the menu's Sort Descending"); + assert_eq!(shown(&selection, 0), [Value::Int(3), Value::Int(2), Value::Int(1)]); + + open_menu(); + view.activate_action("cell.clear-sort", None) + .expect("the menu's Clear Sort"); + assert_eq!(shown(&selection, 0), [Value::Int(2), Value::Int(3), Value::Int(1)]); + window.close(); + } + + #[gtk4::test] + fn copy_after_sort_copies_displayed_row() { + let columns = [named("id", "integer", false), named("name", "text", false)]; + let (sink, messages) = recorder(); + let (view, _selection) = grid( + &columns, + vec![ + vec![Value::Int(3), text("c")], + vec![Value::Int(1), text("a")], + vec![Value::Int(2), text("b")], + ], + sink, + ); + let window = gtk::Window::new(); + window.set_default_size(600, 400); + window.set_child(Some(&view)); + window.present(); + sort(&view, 0, gtk::SortType::Ascending); + let first_cell = || { + descendants(&view) + .into_iter() + .filter_map(|widget| widget.downcast::().ok()) + .find(|cell| cell.position() == Some(0) && cell.column() == 1) + }; + wait_until(WAIT, || first_cell().is_some_and(|cell| cell.row().is_some())).expect("the cells are drawn"); + let cell = first_cell().expect("the first row's cell"); + let gesture = { + let controllers = cell.observe_controllers(); + (0..controllers.n_items()) + .filter_map(|index| controllers.item(index).and_downcast::()) + .find(|gesture| gesture.button() == 3) + .expect("a cell answers a right-click") + }; + + gesture.emit_by_name::<()>("pressed", &[&1i32, &1.0f64, &1.0f64]); + view.activate_action("cell.copy", None).expect("the menu's Copy"); + drain_main_context(); + + let copied: Vec<(Vec, CopyKind)> = messages + .borrow() + .iter() + .filter_map(|msg| match msg { + GridMsg::Copy(CopyRequest::Rows { rows, kind, .. }) => { + Some((rows.iter().flat_map(|row| row.to_values()).collect(), *kind)) + } + _ => None, + }) + .collect(); + assert_eq!(copied, [(vec![Value::Int(1), text("a")], CopyKind::Value(1))]); + window.close(); + } +} diff --git a/linux/crates/app/src/ui/grid/result_sorters.rs b/linux/crates/app/src/ui/grid/result_sorters.rs new file mode 100644 index 0000000000..8553ce35a0 --- /dev/null +++ b/linux/crates/app/src/ui/grid/result_sorters.rs @@ -0,0 +1,63 @@ +use std::cmp::Ordering; + +use gtk4::prelude::*; +use gtk4::{self as gtk, glib}; +use tablepro_core::Value; +use tablepro_core::column::{ColumnKind, ResultColumn}; + +use super::RowObject; +use super::value_order::{compare, sort_text, storage_class_order}; + +/// What a header click on column `index` sorts the rows by. +/// +/// Text is collated in the user's locale by `GtkStringSorter`, which +/// builds a `g_utf8_collate_key` once per row and compares the keys +/// with `strcmp`, so a sort of thousands of rows does not normalise a +/// string for every comparison. The sorter ahead of it places every +/// value that is not text, so the pair serves any column that can hold +/// text: a text kind, or one with `dynamic_storage`, which a column of +/// an ordinary SQLite table has because its rows choose their own +/// storage whatever the declared kind. +pub(crate) fn column_sorter(index: usize, column: &ResultColumn) -> gtk::Sorter { + let column_type = &column.column_type; + if column_type.dynamic_storage || holds_text(&column_type.kind) { + return collating(cells_by(index, storage_class_order), index); + } + cells_by(index, compare).upcast() +} + +fn holds_text(kind: &ColumnKind) -> bool { + kind.is_textual() || matches!(kind, ColumnKind::Json { .. } | ColumnKind::Network(_)) +} + +/// `first`, then the locale's collation of the column's text. +fn collating(first: gtk::CustomSorter, index: usize) -> gtk::Sorter { + let sorter = gtk::MultiSorter::new(); + sorter.append(first); + sorter.append(text_sorter(index)); + sorter.upcast() +} + +fn text_sorter(index: usize) -> gtk::StringSorter { + let text = gtk::ClosureExpression::with_callback(&[] as &[gtk::Expression], move |values: &[glib::Value]| { + let row = values.first()?.get::().ok()?; + row.cell(index).and_then(sort_text).map(str::to_owned) + }); + let sorter = gtk::StringSorter::new(Some(text)); + sorter.set_collation(gtk::Collation::Unicode); + sorter +} + +/// A sorter that orders two rows by their cells in column `index`. +fn cells_by(index: usize, order: fn(&Value, &Value) -> Ordering) -> gtk::CustomSorter { + gtk::CustomSorter::new(move |a, b| { + let (Some(a), Some(b)) = (a.downcast_ref::(), b.downcast_ref::()) else { + return gtk::Ordering::Equal; + }; + order( + a.cell(index).unwrap_or(&Value::Null), + b.cell(index).unwrap_or(&Value::Null), + ) + .into() + }) +} diff --git a/linux/crates/app/src/ui/grid/row_factory.rs b/linux/crates/app/src/ui/grid/row_factory.rs new file mode 100644 index 0000000000..61095da789 --- /dev/null +++ b/linux/crates/app/src/ui/grid/row_factory.rs @@ -0,0 +1,117 @@ +use std::rc::Rc; + +use gtk4 as gtk; +use gtk4::prelude::*; + +use super::cell_accessibility::row_description; +use super::row_watches::RowWatches; +use super::{RowObject, TabGridContext}; + +/// Rows never take the focus, so it always lands on a cell and the +/// arrow keys move between cells. Each row tells a screen reader when +/// it is new or marked for deletion. +pub(super) fn install_row_factory(column_view: >k::ColumnView, tab_ctx: TabGridContext) { + let factory = gtk::SignalListItemFactory::new(); + factory.connect_setup(|_, item| { + if let Some(list_row) = item.downcast_ref::() { + list_row.set_focusable(false); + } + }); + + let watches: Rc> = Rc::default(); + let tab_ctx = Rc::new(tab_ctx); + { + let watches = watches.clone(); + factory.connect_bind(move |_, item| { + let Some(list_row) = item.downcast_ref::() else { + return; + }; + let Some(row) = list_row.item().and_downcast::() else { + return; + }; + let tab_ctx = tab_ctx.clone(); + watches.bind(list_row, row, move |list_row, row| describe(list_row, &tab_ctx, row)); + }); + } + factory.connect_unbind(move |_, item| { + let Some(list_row) = item.downcast_ref::() else { + return; + }; + watches.unbind(list_row); + list_row.set_property("accessible-description", None::); + }); + column_view.set_row_factory(Some(&factory)); +} + +/// Through the property, since the setter takes no NULL to clear it. +fn describe(list_row: >k::ColumnViewRow, tab_ctx: &TabGridContext, row: &RowObject) { + list_row.set_property( + "accessible-description", + row_description(tab_ctx.row_pending_state(row)), + ); +} + +#[cfg(test)] +mod tests { + use gtk4 as gtk; + use gtk4::prelude::*; + use tablepro_core::Value; + use tablepro_core::edit::CellInput; + + use crate::services::tracker_edit::TrackerEdit; + use crate::test_support::wait_until; + use crate::ui::grid::test_grid::{WAIT, key, two_text_rows}; + + #[gtk4::test] + fn pending_delete_sets_row_widget_description() { + let grid = two_text_rows(); + let cell = grid.cell_of(1, 1); + let row = cell.parent().and_then(|cell| cell.parent()).expect("the row widget"); + let described = || gtk::test_accessible_has_property(&row, gtk::AccessibleProperty::Description); + assert!(gtk::test_accessible_has_role(&row, gtk::AccessibleRole::Row)); + assert!(!described()); + assert!(!row.is_focusable(), "a row took the focus a cell should"); + + grid.redraw(&grid.tracker.apply(TrackerEdit::DeleteRows { + persisted: vec![(key(1), vec![Value::Int(1), Value::Text("A".into())].into())], + drafts: Vec::new(), + })); + assert!(described(), "a row marked for deletion said nothing"); + + grid.redraw(&grid.tracker.undo().expect("a delete to undo")); + assert!(!described(), "a restored row still read as marked"); + } + + #[gtk4::test] + fn a_new_row_is_described_and_its_cells_are_not() { + let grid = two_text_rows(); + grid.insert_draft(); + let mut draft = None; + wait_until(WAIT, || { + draft = grid + .cells() + .into_iter() + .find(|cell| cell.row().is_some_and(|row| row.draft_id().is_some())); + draft.is_some() + }) + .expect("the draft is drawn"); + let draft = draft.expect("the draft's cell"); + let row = draft.parent().and_then(|cell| cell.parent()).expect("the row widget"); + // A NULL is named whatever the row, so the cell holds a value. + let draft_id = draft.row().and_then(|row| row.draft_id()).expect("a draft"); + grid.redraw(&grid.tracker.apply(TrackerEdit::DraftCell { + draft_id, + col: draft.column(), + input: CellInput::Value(Value::Text("typed".into())), + })); + + assert!(gtk::test_accessible_has_property( + &row, + gtk::AccessibleProperty::Description + )); + assert!(!gtk::test_accessible_has_property( + &draft, + gtk::AccessibleProperty::Description + )); + } +} diff --git a/linux/crates/app/src/ui/grid/row_json_dialog.rs b/linux/crates/app/src/ui/grid/row_json_dialog.rs new file mode 100644 index 0000000000..9f60d40cb7 --- /dev/null +++ b/linux/crates/app/src/ui/grid/row_json_dialog.rs @@ -0,0 +1,397 @@ +use std::sync::Arc; + +use gtk4 as gtk; +use gtk4::prelude::*; +use libadwaita as adw; +use libadwaita::prelude::*; +use sourceview5::prelude::*; +use tablepro_core::Value; +use tablepro_core::column::ResultColumn; +use tablepro_core::export::EncodeError; +use tablepro_session::runtime::Tasks; + +use super::RowSnapshot; +use crate::ui::clipboard::{ClipboardFormat, ClipboardPayload}; +use crate::ui::format::{format_thousands, plural_count}; + +/// Longer text is cut in the dialog: the view lays out every character +/// it holds, and a value of megabytes would stall it. +const SHOWN_CHARS: usize = 10_000; +/// Binary shows as hex, two characters a byte. +const SHOWN_BYTES: usize = SHOWN_CHARS / 2; +/// Each item of an array is a line of its own. +const SHOWN_ITEMS: usize = 1_000; + +/// One row to show as JSON, under the grid's visible columns. +#[derive(Debug)] +pub struct RowJsonRequest { + pub columns: Arc<[ResultColumn]>, + pub row: RowSnapshot, +} + +/// The row's JSON in full, for a copy, and as the dialog shows it. +struct RowJson { + full: String, + shown: String, + shortened: bool, +} + +fn render(request: RowJsonRequest) -> Result { + let values = request.row.to_values(); + let full = tablepro_core::export::row_to_json(&request.columns, &values)?; + let mut shortened = false; + let shown_values: Vec = values.into_iter().map(|value| shown(value, &mut shortened)).collect(); + let shown = match shortened { + true => tablepro_core::export::row_to_json(&request.columns, &shown_values)?, + false => full.clone(), + }; + Ok(RowJson { full, shown, shortened }) +} + +/// `value` as the dialog shows it, with anything long cut short and +/// saying how much is left out. A document cut short is no longer JSON, +/// so it shows as text. +fn shown(value: Value, shortened: &mut bool) -> Value { + let cut = match &value { + Value::Text(text) => shorten(text), + Value::Json(json) => shorten(json.as_str()), + Value::Other(other) => shorten(&other.text), + Value::Bits(bits) => shorten(&bits.to_string()), + Value::Decimal(decimal) => shorten(&decimal.to_string()), + Value::Bytes(bytes) => shorten_bytes(bytes), + _ => None, + }; + if let Some(cut) = cut { + *shortened = true; + return Value::Text(cut); + } + let Value::Array(items) = value else { + return value; + }; + let more = items.len().saturating_sub(SHOWN_ITEMS) as u64; + let mut kept: Vec = items + .into_iter() + .take(SHOWN_ITEMS) + .map(|item| shown(item, shortened)) + .collect(); + if more > 0 { + *shortened = true; + kept.push(Value::Text(crate::i18n::ngettext_f( + "… ({count} more item)", + "… ({count} more items)", + plural_count(more), + &[("count", &format_thousands(more))], + ))); + } + Value::Array(kept) +} + +/// `text` cut at a character boundary after `SHOWN_CHARS` characters, +/// saying how much is left out, or `None` when it fits. +fn shorten(text: &str) -> Option { + let (end, _) = text.char_indices().nth(SHOWN_CHARS)?; + Some(format!("{}{}", &text[..end], more_bytes((text.len() - end) as u64))) +} + +/// The hex the grid writes for binary, cut after `SHOWN_BYTES` bytes. +fn shorten_bytes(bytes: &[u8]) -> Option { + let more = bytes.len().checked_sub(SHOWN_BYTES).filter(|more| *more > 0)?; + let head = bytes.get(..SHOWN_BYTES)?; + Some(format!( + "0x{}{}", + tablepro_core::hex::encode_lower(head), + more_bytes(more as u64) + )) +} + +fn more_bytes(more: u64) -> String { + crate::i18n::ngettext_f( + "… ({count} more byte)", + "… ({count} more bytes)", + plural_count(more), + &[("count", &format_thousands(more))], + ) +} + +/// Render the row off the GTK thread, then show it over `parent`. +pub(crate) async fn present_row_json( + parent: gtk::Widget, + tasks: Tasks, + request: RowJsonRequest, +) -> Option { + let dialog = match tasks.spawn_blocking_task(move || render(request)).await { + Ok(Ok(rendered)) => row_json_dialog(rendered), + Ok(Err(refused)) => refused_alert(&refused).upcast(), + Err(failure) => { + tracing::warn!(%failure, "a row could not be rendered as JSON"); + return None; + } + }; + dialog.present(Some(&parent)); + Some(dialog) +} + +/// Why the row has no JSON to show. Both refusals name the column the +/// grid shows as unreadable. +fn refused_alert(refused: &EncodeError) -> adw::AlertDialog { + let (EncodeError::Undecodable { column } | EncodeError::NotXmlText { column }) = refused; + let alert = adw::AlertDialog::new( + Some(&crate::i18n::gettext("Can't Show Row as JSON")), + Some(&crate::i18n::gettext_f( + "The column “{column}” holds a value TablePro couldn't read, so the row has no JSON to show.", + &[("column", column)], + )), + ); + alert.add_response("close", &crate::i18n::gettext("_Close")); + alert +} + +fn row_json_dialog(json: RowJson) -> adw::Dialog { + let RowJson { full, shown, shortened } = json; + let buffer = sourceview5::Buffer::new(None); + if let Some(lang) = sourceview5::LanguageManager::default().language("json") { + buffer.set_language(Some(&lang)); + } + let scheme_name = if adw::StyleManager::default().is_dark() { + "Adwaita-dark" + } else { + "Adwaita" + }; + buffer.set_style_scheme(sourceview5::StyleSchemeManager::default().scheme(scheme_name).as_ref()); + buffer.set_text(&shown); + let view = sourceview5::View::with_buffer(&buffer); + view.set_editable(false); + view.set_monospace(true); + view.set_show_line_numbers(true); + view.set_top_margin(8); + view.set_left_margin(8); + let scrolled = gtk::ScrolledWindow::builder().child(&view).vexpand(true).build(); + + let copy_button = gtk::Button::from_icon_name(crate::ui::icons::EDIT_COPY); + crate::ui::a11y::describe_icon_button(©_button, &crate::i18n::gettext("Copy"), None); + let payload = ClipboardPayload { + text: full, + format: ClipboardFormat::Json, + rows: 1, + skipped: 0, + }; + let toasts = adw::ToastOverlay::new(); + toasts.set_child(Some(&scrolled)); + let shown_on = toasts.downgrade(); + copy_button.connect_clicked(move |button| { + let shown_on = shown_on.clone(); + crate::ui::clipboard::copy_payload(button, payload.clone(), move |message| { + if let Some(toasts) = shown_on.upgrade() { + toasts.add_toast(crate::ui::plain_toast(message)); + } + }); + }); + let header = adw::HeaderBar::new(); + header.pack_end(©_button); + let banner = adw::Banner::new(&crate::i18n::gettext( + "Long values are shortened here. Copy includes them in full.", + )); + banner.set_revealed(shortened); + let toolbar = adw::ToolbarView::new(); + toolbar.add_top_bar(&header); + toolbar.add_top_bar(&banner); + toolbar.set_content(Some(&toasts)); + + let dialog = adw::Dialog::builder() + .title(crate::i18n::gettext("Row as JSON")) + .content_width(560) + .content_height(480) + .child(&toolbar) + .build(); + // GtkSourceView before 5.21 never frees a view that has drawn its line + // numbers, so the closed dialog takes the row's text back. + let shown_in = view.downgrade(); + dialog.connect_closed(move |_| { + if let Some(view) = shown_in.upgrade() { + view.set_buffer(Some(&sourceview5::Buffer::new(None))); + } + }); + dialog +} + +#[cfg(test)] +mod tests { + use std::time::Duration; + + use tablepro_core::column::ColumnType; + + use super::*; + use crate::test_support::{ + OwnedWindow, after_next_frame, descendants, finish_on_main, first_descendant_of_type, wait_until, + }; + + fn local_text(clipboard: >k::gdk::Clipboard) -> Option { + clipboard.content()?.value(String::static_type()).ok()?.get().ok() + } + + #[gtk4::test] + fn row_json_dialog_caps_display_but_copies_full_value() { + adw::init().expect("libadwaita initialises under the test backend"); + let window = OwnedWindow::presented(adw::Window::new()); + let runtime = crate::test_support::test_runtime(); + let long = "x".repeat(25_000); + let request = RowJsonRequest { + columns: vec![ + ResultColumn::new("id", ColumnType::untyped_text("text")), + ResultColumn::new("body", ColumnType::untyped_text("text")), + ] + .into(), + row: RowSnapshot::new(vec![Value::Int(1), Value::Text(long.clone())].into(), Vec::new()), + }; + + let dialog = finish_on_main( + Duration::from_secs(10), + present_row_json(window.clone().upcast(), runtime.tasks(), request), + ) + .expect("the render to end") + .expect("the dialog"); + + let view = first_descendant_of_type::(&dialog).expect("the text"); + let buffer = view.buffer(); + let shown = buffer.text(&buffer.start_iter(), &buffer.end_iter(), false).to_string(); + assert!(!shown.contains(&long), "the whole value was laid out"); + assert!( + shown.contains("… (15,000 more bytes)"), + "{}", + &shown[shown.len() - 60..] + ); + let banner = first_descendant_of_type::(&dialog).expect("the banner"); + assert!(banner.is_revealed()); + + let copy = descendants(&dialog) + .into_iter() + .filter_map(|widget| widget.downcast::().ok()) + .find(|button| button.tooltip_text().as_deref() == Some("Copy")) + .expect("the copy button"); + copy.emit_clicked(); + + assert!( + descendants(&dialog) + .iter() + .filter_map(|widget| widget.downcast_ref::()) + .any(|label| label.label() == "Copied to clipboard"), + "no toast said the copy landed" + ); + let clipboard = copy.clipboard(); + assert!(clipboard.formats().contain_mime_type("application/json")); + let copied = local_text(&clipboard).expect("the copied text"); + assert!(copied.contains(&long), "the copy was shortened too"); + let parsed: serde_json::Value = serde_json::from_str(&copied).expect("the copy is JSON"); + assert_eq!(parsed["body"], serde_json::json!(long)); + dialog.close(); + runtime.shutdown(); + } + + #[test] + fn every_kind_of_long_value_is_cut_for_display_and_kept_for_copy() { + const LONG: usize = 50_000; + let column = |name: &str, kind: &str| ResultColumn::new(name, ColumnType::untyped_text(kind)); + let document = format!("[{}]", vec!["1"; LONG].join(",")); + let shape = "x".repeat(LONG); + let request = RowJsonRequest { + columns: vec![ + column("doc", "jsonb"), + column("blob", "bytea"), + column("shape", "geometry"), + column("tags", "text[]"), + ] + .into(), + row: RowSnapshot::new( + vec![ + Value::Json(tablepro_core::value::JsonText::parse(document.clone()).expect("JSON")), + Value::Bytes(vec![0xab; LONG]), + Value::Other(Box::new(tablepro_core::value::OtherValue { + type_name: "geometry".to_owned(), + text: shape.clone(), + })), + Value::Array(vec![Value::Text("t".to_owned()); LONG]), + ] + .into(), + Vec::new(), + ), + }; + + let rendered = render(request).expect("a readable row"); + + assert!(rendered.shortened); + let longest = rendered.shown.lines().map(str::len).max().unwrap_or_default(); + assert!(longest < SHOWN_CHARS + 100, "a line of {longest} bytes is laid out"); + assert!(rendered.shown.len() < 100_000, "{} bytes shown", rendered.shown.len()); + assert!( + rendered.shown.contains("… (45,000 more bytes)"), + "the binary does not say what is left" + ); + assert!( + rendered.shown.contains("… (49,000 more items)"), + "the array does not say what is left" + ); + let full: serde_json::Value = serde_json::from_str(&rendered.full).expect("the copy is JSON"); + assert_eq!(full["doc"].as_array().map(Vec::len), Some(LONG)); + assert_eq!(full["blob"].as_str().map(str::len), Some(2 + 2 * LONG)); + assert_eq!(full["shape"], serde_json::json!(shape)); + assert_eq!(full["tags"].as_array().map(Vec::len), Some(LONG)); + } + + #[gtk4::test] + fn an_unreadable_value_is_refused_by_name_rather_than_shown_as_null() { + adw::init().expect("libadwaita initialises under the test backend"); + let window = OwnedWindow::presented(adw::Window::new()); + let runtime = crate::test_support::test_runtime(); + let unreadable = Value::Undecodable(Box::new(tablepro_core::value::UndecodedValue { + type_name: "text".to_owned(), + reason: tablepro_core::value::UndecodableReason::InvalidEncoding, + })); + let request = RowJsonRequest { + columns: vec![ + ResultColumn::new("id", ColumnType::untyped_text("text")), + ResultColumn::new("body", ColumnType::untyped_text("text")), + ] + .into(), + row: RowSnapshot::new(vec![Value::Int(1), unreadable].into(), Vec::new()), + }; + + let dialog = finish_on_main( + Duration::from_secs(10), + present_row_json(window.clone().upcast(), runtime.tasks(), request), + ) + .expect("the render to end") + .expect("a dialog"); + + let alert = dialog + .downcast::() + .expect("an alert, not the JSON view"); + assert_eq!( + alert.body().as_str(), + "The column “body” holds a value TablePro couldn't read, so the row has no JSON to show." + ); + alert.force_close(); + runtime.shutdown(); + } + + #[gtk4::test] + fn a_closed_dialog_lets_go_of_the_row() { + adw::init().expect("libadwaita initialises under the test backend"); + let window = OwnedWindow::presented(adw::Window::new()); + let dialog = row_json_dialog(RowJson { + full: r#"{"id": 1}"#.to_owned(), + shown: r#"{"id": 1}"#.to_owned(), + shortened: false, + }); + dialog.present(Some(&*window)); + let view = first_descendant_of_type::(&dialog).expect("the text"); + wait_until(Duration::from_secs(10), || view.is_mapped()).expect("the dialog on screen"); + after_next_frame(&view); + let row = view.buffer().downgrade(); + + drop(view); + dialog.force_close(); + drop(dialog); + + wait_until(Duration::from_secs(10), || row.upgrade().is_none()).expect("the row's text outlived its dialog"); + } +} diff --git a/linux/crates/app/src/ui/grid/row_key_builder.rs b/linux/crates/app/src/ui/grid/row_key_builder.rs new file mode 100644 index 0000000000..6613f22c14 --- /dev/null +++ b/linux/crates/app/src/ui/grid/row_key_builder.rs @@ -0,0 +1,232 @@ +use std::ops::Range; + +use tablepro_core::browse::PageLayout; +use tablepro_core::meta::RowIdentity; +use tablepro_core::{ColumnInfo, Value}; + +use crate::services::change_tracker::{KeyValue, RowKey}; + +/// Names each row of a page by every cell of the identity it was keyed +/// on, so two rows that share part of it never share a key. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RowKeyBuilder { + source: KeySource, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum KeySource { + /// Positions among the visible cells, in key order. + Visible(Box<[usize]>), + /// Positions among the identity cells the page projected after the + /// visible ones. + Identity(Range), +} + +impl RowKeyBuilder { + /// `None` when nothing on the page names a row, so its rows are + /// read-only: a sorting key can match several rows. + pub fn for_page(identity: &RowIdentity, columns: &[ColumnInfo], layout: &PageLayout) -> Option { + let source = match identity { + RowIdentity::UniqueKey { columns: key } if !key.is_empty() => { + let positions = key + .iter() + .map(|name| { + columns + .iter() + .position(|column| column.name == *name) + .filter(|position| layout.visible.contains(position)) + }) + .collect::>>()?; + KeySource::Visible(positions) + } + RowIdentity::EngineRowId(_) if !layout.identity.is_empty() => KeySource::Identity(0..layout.identity.len()), + RowIdentity::UniqueKey { .. } + | RowIdentity::EngineRowId(_) + | RowIdentity::SortingKey { .. } + | RowIdentity::Unordered => return None, + }; + Some(Self { source }) + } + + /// The row's key, `None` when a cell of it could not be read and so + /// cannot name the row to the server again. + pub fn key_for(&self, visible: &[Value], identity: &[Value]) -> Option { + let parts = match &self.source { + KeySource::Visible(positions) => positions + .iter() + .map(|position| visible.get(*position).filter(|value| readable(value))) + .map(|value| value.cloned().map(KeyValue::new)) + .collect::>>()?, + // A null row address names nothing. + KeySource::Identity(range) => identity + .get(range.clone())? + .iter() + .map(|value| (readable(value) && !value.is_null()).then(|| KeyValue::new(value.clone()))) + .collect::>>()?, + }; + Some(RowKey::Persisted(parts)) + } +} + +fn readable(value: &Value) -> bool { + !matches!(value, Value::Undecodable(_)) +} + +#[cfg(test)] +mod tests { + use tablepro_core::Value; + use tablepro_core::browse::{PageLayout, PageRequest, build_page}; + use tablepro_core::column::{ColumnDefault, ColumnType}; + use tablepro_core::meta::{EngineRowId, PagePosition, RowIdentity, TableRef}; + use tablepro_core::value::{UndecodableReason, UndecodedValue}; + use tablepro_core::{ColumnInfo, FilterSet}; + + use super::RowKeyBuilder; + use crate::services::change_tracker::RowKey; + + fn column(name: &str) -> ColumnInfo { + ColumnInfo { + name: name.to_owned(), + column_type: ColumnType::untyped_text("text"), + nullable: true, + primary_key: false, + is_auto_increment: false, + is_generated: false, + default: ColumnDefault::None, + comment: None, + } + } + + fn columns(names: &[&str]) -> Vec { + names.iter().map(|name| column(name)).collect() + } + + /// The layout the page builder gives `identity` over `columns`. + fn layout(identity: &RowIdentity, columns: &[ColumnInfo]) -> PageLayout { + let dialect = + tablepro_driver_postgres::PgDialect::new(&tablepro_core::ServerInfo::new("PostgreSQL", "18.0", 18, 0, 0)); + build_page( + &dialect, + &PageRequest { + table: &TableRef::unqualified("t"), + columns, + filter: &FilterSet::default(), + sort: &[], + identity, + position: &PagePosition::First, + limit: 100, + }, + ) + .expect("a page") + .layout + } + + fn key(values: &[Value]) -> Option { + RowKey::from_pk_values(values) + } + + fn text(value: &str) -> Value { + Value::Text(value.to_owned()) + } + + #[test] + fn row_key_builder_unique_key_uses_visible_key_columns() { + let columns = columns(&["tenant", "name", "id"]); + let identity = RowIdentity::UniqueKey { + columns: vec!["id".to_owned(), "tenant".to_owned()], + }; + let layout = layout(&identity, &columns); + let builder = RowKeyBuilder::for_page(&identity, &columns, &layout).expect("a keyed page"); + + let row = [text("eu"), text("Ada"), Value::Int(7)]; + + assert!(layout.identity.is_empty(), "a unique key needs no hidden column"); + assert_eq!(builder.key_for(&row, &[]), key(&[Value::Int(7), text("eu")])); + } + + #[test] + fn row_key_builder_tableoid_ctid_keys_on_both_identity_columns() { + let columns = columns(&["name"]); + let identity = RowIdentity::EngineRowId(EngineRowId::PostgresTableoidCtid); + let layout = layout(&identity, &columns); + let builder = RowKeyBuilder::for_page(&identity, &columns, &layout).expect("a keyed page"); + let ctid = text("(0,1)"); + let xmin = text("812"); + + let first = builder.key_for(&[text("a")], &[Value::Int(16_401), ctid.clone(), xmin.clone()]); + let second = builder.key_for(&[text("a")], &[Value::Int(16_402), ctid, xmin]); + + assert_eq!(layout.identity.len(), 3, "tableoid, ctid and xmin"); + assert!(first.is_some()); + assert_ne!(first, second, "a ctid from another partition named the same row"); + } + + #[test] + fn row_key_builder_rowid_uses_single_identity_column() { + let columns = columns(&["name", "note"]); + let identity = RowIdentity::EngineRowId(EngineRowId::SqliteRowid); + let layout = tablepro_core::browse::PageLayout { + visible: 0..2, + identity: 2..3, + }; + let builder = RowKeyBuilder::for_page(&identity, &columns, &layout).expect("a keyed page"); + + assert_eq!( + builder.key_for(&[text("a"), text("b")], &[Value::Int(11)]), + key(&[Value::Int(11)]) + ); + assert_eq!( + builder.key_for(&[text("a"), text("b")], &[Value::Null]), + None, + "a null row address named a row" + ); + } + + #[test] + fn row_key_builder_returns_none_for_undecodable_key() { + let unreadable = Value::Undecodable(Box::new(UndecodedValue { + type_name: "geography".to_owned(), + reason: UndecodableReason::UnsupportedType, + })); + let columns = columns(&["id", "name"]); + let identity = RowIdentity::UniqueKey { + columns: vec!["id".to_owned()], + }; + let layout = layout(&identity, &columns); + let builder = RowKeyBuilder::for_page(&identity, &columns, &layout).expect("a keyed page"); + + assert_eq!(builder.key_for(&[unreadable, text("a")], &[]), None); + assert_eq!( + builder.key_for(&[Value::Null, text("a")], &[]), + key(&[Value::Null]), + "a NULL key cell still names its row, as SQLite allows" + ); + } + + #[test] + fn a_page_nothing_names_has_no_builder() { + let columns = columns(&["day", "hits"]); + let layout = PageLayout { + visible: 0..2, + identity: 2..2, + }; + let sorting = RowIdentity::SortingKey { + expressions: vec!["day".to_owned()], + }; + let missing = RowIdentity::UniqueKey { + columns: vec!["gone".to_owned()], + }; + + assert_eq!(RowKeyBuilder::for_page(&sorting, &columns, &layout), None); + assert_eq!( + RowKeyBuilder::for_page(&RowIdentity::Unordered, &columns, &layout), + None + ); + assert_eq!(RowKeyBuilder::for_page(&missing, &columns, &layout), None); + assert_eq!( + RowKeyBuilder::for_page(&RowIdentity::UniqueKey { columns: Vec::new() }, &columns, &layout), + None, + "a table with no primary key" + ); + } +} diff --git a/linux/crates/app/src/ui/grid/row_object.rs b/linux/crates/app/src/ui/grid/row_object.rs new file mode 100644 index 0000000000..8d89294aff --- /dev/null +++ b/linux/crates/app/src/ui/grid/row_object.rs @@ -0,0 +1,145 @@ +use std::cell::{Cell, OnceCell}; +use std::sync::Arc; + +use gtk4::glib; +use gtk4::prelude::*; +use gtk4::subclass::prelude::*; + +use tablepro_core::Value; + +use super::GridRowId; +use crate::services::change_tracker::RowKey; + +mod imp { + use super::*; + + #[derive(Default, glib::Properties)] + #[properties(wrapper_type = super::RowObject)] + pub struct RowObject { + pub id: OnceCell, + /// The row as it was read. A draft has none: its cells are the + /// tracker's. + pub cells: OnceCell>, + /// Moves on when the row's pending state changes, so the cells + /// bound to it draw again without the store swapping the row. + #[property(get)] + pub revision: Cell, + } + + #[glib::object_subclass] + impl ObjectSubclass for RowObject { + const NAME: &'static str = "TableProRowObject"; + type Type = super::RowObject; + } + + #[glib::derived_properties] + impl ObjectImpl for RowObject {} +} + +glib::wrapper! { + pub struct RowObject(ObjectSubclass); +} + +/// What a row made by `glib::Object::new` rather than a constructor +/// here reads as: nothing to edit. +static UNNAMED: GridRowId = GridRowId::Result; + +impl RowObject { + fn with(id: GridRowId, cells: Option>) -> Self { + let row: Self = glib::Object::new(); + let _ = row.imp().id.set(id); + if let Some(cells) = cells { + let _ = row.imp().cells.set(cells); + } + row + } + + /// A row read from a table. `None` for a row with no usable key. + pub fn persisted(key: Option, cells: Arc<[Value]>) -> Self { + let id = match key { + Some(key) => GridRowId::Persisted(key), + None => GridRowId::Unkeyed, + }; + Self::with(id, Some(cells)) + } + + pub fn draft(draft_id: u64) -> Self { + Self::with(GridRowId::Draft(draft_id), None) + } + + pub fn result(cells: Arc<[Value]>) -> Self { + Self::with(GridRowId::Result, Some(cells)) + } + + pub fn id(&self) -> &GridRowId { + self.imp().id.get().unwrap_or(&UNNAMED) + } + + pub fn key(&self) -> Option<&RowKey> { + self.id().key() + } + + pub fn draft_id(&self) -> Option { + self.id().draft_id() + } + + pub fn cells(&self) -> Option> { + self.imp().cells.get().cloned() + } + + pub fn cell(&self, column: usize) -> Option<&Value> { + self.imp().cells.get()?.get(column) + } + + /// The row's own hold on its cells, to count who else holds them. + #[cfg(test)] + pub(crate) fn shared_cells(&self) -> Option<&Arc<[Value]>> { + self.imp().cells.get() + } + + pub(crate) fn bump_revision(&self) { + let revision = &self.imp().revision; + revision.set(revision.get().wrapping_add(1)); + self.notify_revision(); + } +} + +#[cfg(test)] +mod tests { + use std::cell::Cell; + use std::rc::Rc; + + use tablepro_core::Value; + + use super::RowObject; + use crate::services::change_tracker::RowKey; + use crate::ui::grid::GridRowId; + + #[test] + fn a_row_keeps_the_identity_it_was_made_with() { + let key = RowKey::from_pk_values(&[Value::Int(2)]).expect("a key"); + let persisted = RowObject::persisted(Some(key.clone()), vec![Value::Int(2)].into()); + let unkeyed = RowObject::persisted(None, vec![Value::Int(2)].into()); + let draft = RowObject::draft(5); + + assert_eq!(persisted.key(), Some(&key)); + assert_eq!(persisted.cell(0), Some(&Value::Int(2))); + assert_eq!(unkeyed.id(), &GridRowId::Unkeyed); + assert_eq!(draft.draft_id(), Some(5)); + assert!(draft.cells().is_none(), "a draft's cells are the tracker's"); + } + + #[test] + fn bump_revision_notifies_each_listener_once() { + let row = RowObject::draft(1); + let heard = Rc::new(Cell::new(0)); + let counter = heard.clone(); + row.connect_revision_notify(move |_| counter.set(counter.get() + 1)); + + row.bump_revision(); + row.bump_revision(); + + assert_eq!(row.revision(), 2); + assert_eq!(heard.get(), 2); + } +} diff --git a/linux/crates/app/src/ui/grid/row_snapshot.rs b/linux/crates/app/src/ui/grid/row_snapshot.rs new file mode 100644 index 0000000000..fbe6092d86 --- /dev/null +++ b/linux/crates/app/src/ui/grid/row_snapshot.rs @@ -0,0 +1,119 @@ +use std::sync::Arc; + +use tablepro_core::Value; + +static NULL: Value = Value::Null; + +/// A row as the grid shows it, pending edits included, for a copy or an +/// export. It shares the cells the page read and holds its own copy of +/// only the cells the user changed, and it can leave the GTK thread. +#[derive(Debug, Clone)] +pub struct RowSnapshot { + cells: Arc<[Value]>, + /// Pending values by column, in column order. + overrides: Box<[(usize, Value)]>, +} + +impl RowSnapshot { + pub fn new(cells: Arc<[Value]>, overrides: Vec<(usize, Value)>) -> Self { + Self { + cells, + overrides: overrides.into_boxed_slice(), + } + } + + /// NULL for a column past the row's end. + pub fn value(&self, column: usize) -> &Value { + match self.overrides.binary_search_by_key(&column, |(col, _)| *col) { + Ok(index) => &self.overrides[index].1, + Err(_) => self.cells.get(column).unwrap_or(&NULL), + } + } + + pub fn len(&self) -> usize { + self.cells.len() + } + + /// Each cell as the grid shows it, borrowed, for an encoder that + /// takes a row at a time. + pub fn values(&self) -> impl Iterator { + (0..self.len()).map(|column| self.value(column)) + } + + /// The row as owned cells, for a renderer that takes whole rows. + pub fn to_values(&self) -> Vec { + self.values().cloned().collect() + } + + #[cfg(test)] + pub(crate) fn shares_cells_with(&self, cells: &Arc<[Value]>) -> bool { + Arc::ptr_eq(&self.cells, cells) + } +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use tablepro_core::Value; + + use super::RowSnapshot; + use crate::services::change_tracker::RowKey; + use crate::services::tab_tracker_handle::TabTrackerHandle; + use crate::services::tracker_edit::TrackerEdit; + use crate::ui::grid::{RowObject, TabGridContext}; + + #[test] + fn row_snapshot_shares_unmodified_cells() { + let tracker = TabTrackerHandle::default(); + let key = RowKey::from_pk_values(&[Value::Int(1)]).expect("a key"); + let _ = tracker.apply(TrackerEdit::Cell { + row_key: key.clone(), + col: 2, + original: Value::Text("old".into()), + value: Value::Text("new".into()), + }); + let cells: Arc<[Value]> = vec![Value::Int(1), Value::Text("Ada".into()), Value::Text("old".into())].into(); + let row = RowObject::persisted(Some(key), cells.clone()); + let context = TabGridContext { + tracker: Some(tracker), + ..TabGridContext::default() + }; + + let snapshot = context.snapshot(&row); + + assert!(Arc::ptr_eq(&snapshot.cells, &cells), "the row's cells were copied"); + assert_eq!(snapshot.overrides.len(), 1, "an unedited cell was copied"); + assert_eq!(snapshot.overrides[0].0, 2); + assert_eq!( + snapshot.to_values(), + vec![Value::Int(1), Value::Text("Ada".into()), Value::Text("new".into())] + ); + assert_eq!(snapshot.value(9), &Value::Null); + } + + #[test] + fn a_draft_snapshot_reads_a_default_as_null() { + let tracker = TabTrackerHandle::default(); + let delta = tracker.apply(TrackerEdit::InsertDrafts { + rows: vec![vec![ + tablepro_core::edit::CellInput::Default, + tablepro_core::edit::CellInput::Value(Value::Int(3)), + ]], + }); + let draft = RowObject::draft(delta.inserted_drafts[0]); + let context = TabGridContext { + tracker: Some(tracker), + ..TabGridContext::default() + }; + + assert_eq!(context.snapshot(&draft).to_values(), vec![Value::Null, Value::Int(3)]); + } + + #[test] + fn a_snapshot_can_leave_the_gtk_thread() { + fn sendable() {} + + sendable::(); + } +} diff --git a/linux/crates/app/src/ui/grid/row_state_column.rs b/linux/crates/app/src/ui/grid/row_state_column.rs new file mode 100644 index 0000000000..0fa81768a5 --- /dev/null +++ b/linux/crates/app/src/ui/grid/row_state_column.rs @@ -0,0 +1,263 @@ +use std::rc::Rc; + +use gtk4 as gtk; +use gtk4::prelude::*; + +use super::row_state_icon::row_state_icon; +use super::row_watches::RowWatches; +use super::{RowObject, TabGridContext}; + +/// Not a result column, so it has no name in the table, no saved width +/// and no place in a copy or an export. +pub(crate) const ROW_STATE_COLUMN_ID: &str = "tp-row-state"; +/// One symbolic icon and its padding. +const ROW_STATE_COLUMN_WIDTH: i32 = 28; + +/// A narrow first column that shows each row's pending change as an +/// icon, so the change reads without its tint, as a diff gutter marks a +/// line. +pub(super) fn row_state_column(tab_ctx: TabGridContext) -> gtk::ColumnViewColumn { + let factory = gtk::SignalListItemFactory::new(); + factory.connect_setup(|_, item| { + let Some(cell) = item.downcast_ref::() else { + return; + }; + // The row's own description names the state, so the icon would + // say it twice. + let image = gtk::Image::builder() + .accessible_role(gtk::AccessibleRole::Presentation) + .build(); + cell.set_property("focusable", false); + cell.set_child(Some(&image)); + }); + + let watches: Rc> = Rc::default(); + let tab_ctx = Rc::new(tab_ctx); + { + let watches = watches.clone(); + factory.connect_bind(move |_, item| { + let Some(cell) = item.downcast_ref::() else { + return; + }; + let Some(row) = cell.item().and_downcast::() else { + return; + }; + let tab_ctx = tab_ctx.clone(); + watches.bind(cell, row, move |cell, row| { + if let Some(image) = cell.child().and_downcast::() { + show_state(&image, &tab_ctx, row); + } + }); + }); + } + factory.connect_unbind(move |_, item| { + if let Some(cell) = item.downcast_ref::() { + watches.unbind(cell); + } + }); + + gtk::ColumnViewColumn::builder() + .id(ROW_STATE_COLUMN_ID) + .factory(&factory) + .fixed_width(ROW_STATE_COLUMN_WIDTH) + .resizable(false) + .build() +} + +fn show_state(image: >k::Image, tab_ctx: &TabGridContext, row: &RowObject) { + let shown = row_state_icon(tab_ctx.row_pending_state(row)); + image.set_icon_name(shown.as_ref().map(|(icon, _)| *icon)); + image.set_tooltip_text(shown.as_ref().map(|(_, text)| text.as_str())); +} + +/// The view's columns that show the result's, in the view's order. That +/// is the order of a row's cells only until a header drag moves one, so +/// read it as the view is built. +pub(crate) fn data_columns(view: >k::ColumnView) -> Vec { + let columns = view.columns(); + (0..columns.n_items()) + .filter_map(|position| columns.item(position).and_downcast::()) + .filter(|column| column.id().is_none_or(|id| id.as_str() != ROW_STATE_COLUMN_ID)) + .collect() +} + +#[cfg(test)] +mod tests { + use gtk4 as gtk; + use gtk4::prelude::*; + use tablepro_core::Value; + + use super::{ROW_STATE_COLUMN_ID, data_columns}; + use crate::services::tab_tracker_handle::TabTrackerHandle; + use crate::services::tracker_edit::TrackerEdit; + use crate::test_support::{first_descendant_of_type, wait_until}; + use crate::ui::grid::test_grid::{WAIT, content, key, named, persisted, recorder, tracked, two_text_rows}; + use crate::ui::grid::{ColumnWidthBinding, GridCell, GridMsg, GridSorting, RowObject, build_column_view}; + + /// The state icon in the row `cell` is in. + fn state_icon(cell: &GridCell) -> gtk::Image { + let row = cell.parent().and_then(|cell| cell.parent()).expect("the row widget"); + first_descendant_of_type::(&row).expect("the row's state icon") + } + + fn shown(image: >k::Image) -> (Option, Option) { + ( + image.icon_name().map(|name| name.to_string()), + image.tooltip_text().map(|text| text.to_string()), + ) + } + + fn state(icon: &str, text: &str) -> (Option, Option) { + (Some(icon.to_owned()), Some(text.to_owned())) + } + + #[gtk4::test] + fn row_state_column_reflects_each_state() { + let grid = two_text_rows(); + let first = state_icon(&grid.cell_of(1, 1)); + let first_row = grid.cell_of(1, 1).row().expect("a bound row"); + assert_eq!(shown(&first), (None, None)); + assert!(gtk::test_accessible_has_role(&first, gtk::AccessibleRole::Presentation)); + assert!( + !first.parent().is_some_and(|cell| cell.is_focusable()), + "the state cell took the focus a data cell should" + ); + + grid.redraw(&grid.tracker.apply(TrackerEdit::Cell { + row_key: key(1), + col: 1, + original: Value::Text("A".into()), + value: Value::Text("edited".into()), + })); + assert_eq!(shown(&first), state("document-edit-symbolic", "Modified")); + + let generation = grid.tracker.flash_error_row(key(1)).generation; + first_row.bump_revision(); + assert_eq!(shown(&first), state("dialog-error-symbolic", "Not saved")); + assert!(grid.tracker.clear_error_row_if(generation)); + first_row.bump_revision(); + assert_eq!(shown(&first), state("document-edit-symbolic", "Modified")); + + grid.redraw(&grid.tracker.apply(TrackerEdit::DeleteRows { + persisted: vec![(key(2), vec![Value::Int(2), Value::Text("B".into())].into())], + drafts: Vec::new(), + })); + assert_eq!( + shown(&state_icon(&grid.cell_of(2, 1))), + state("edit-delete-symbolic", "Marked for deletion") + ); + + grid.insert_draft(); + let mut draft = None; + wait_until(WAIT, || { + draft = grid + .cells() + .into_iter() + .find(|cell| cell.row().is_some_and(|row| row.draft_id().is_some())); + draft.is_some() + }) + .expect("the draft is drawn"); + let draft = draft.expect("the draft's cell"); + assert_eq!(shown(&state_icon(&draft)), state("list-add-symbolic", "New row")); + + grid.redraw(&grid.tracker.discard()); + assert_eq!(shown(&state_icon(&grid.cell_of(1, 1))), (None, None)); + } + + /// The second row, since the first holds the focus, and a focused + /// row taken out of the list trips GTK 4.22. + #[gtk4::test] + fn an_unbound_state_icon_stops_following_its_row() { + let grid = two_text_rows(); + let icon = state_icon(&grid.cell_of(2, 1)); + let row: RowObject = grid.cell_of(2, 1).row().expect("a bound row"); + + grid.store.remove(1); + crate::test_support::drain_main_context(); + let _ = grid.tracker.apply(TrackerEdit::Cell { + row_key: key(2), + col: 1, + original: Value::Text("B".into()), + value: Value::Text("edited".into()), + }); + row.bump_revision(); + + assert_eq!(icon.icon_name(), None, "a recycled icon drew a row it no longer shows"); + } + + #[gtk4::test] + fn row_state_column_is_excluded_from_widths_and_sorting() { + let runtime = crate::test_support::test_runtime(); + let root = tempfile::tempdir().expect("tempdir"); + let widths = ColumnWidthBinding { + store: crate::persistence::ColumnWidthStore::load(root.path().join("column-widths.json"), &runtime.tasks()), + connection_id: uuid::Uuid::new_v4(), + schema: None, + table: "t".to_owned(), + }; + let columns = vec![named("id", "integer", true), named("name", "text", false)]; + let rows = vec![vec![Value::Int(1), Value::Text("A".into())]]; + let (sink, messages) = recorder(); + let tracker = TabTrackerHandle::default(); + let (view, _selection) = build_column_view( + content(&columns, persisted(&rows)), + &columns, + sink, + GridSorting::Server { initial: None }, + crate::ui::grid::TabGridContext { + column_widths: Some(widths.clone()), + ..tracked(&tracker) + }, + ); + let state = view + .columns() + .item(0) + .and_downcast::() + .expect("the state column"); + let data = data_columns(&view); + + assert_eq!( + state.id().map(|id| id.to_string()).as_deref(), + Some(ROW_STATE_COLUMN_ID) + ); + assert!(!state.is_resizable()); + assert!(state.sorter().is_none()); + assert_eq!(state.fixed_width(), 28); + assert_eq!(state.title(), None); + let titles: Vec = data + .iter() + .filter_map(|column| column.title().map(|title| title.to_string())) + .collect(); + assert_eq!(titles, ["id", "name"]); + + state.set_fixed_width(60); + data[1].set_fixed_width(200); + assert_eq!(widths.width(ROW_STATE_COLUMN_ID), None); + assert_eq!(widths.width("name"), Some(200)); + + view.sort_by_column(Some(&data[0]), gtk::SortType::Descending); + assert!( + messages + .borrow() + .iter() + .any(|msg| matches!(msg, GridMsg::SortChanged(0, false))), + "a sort by the first data column named another" + ); + } + + #[gtk4::test] + fn a_grid_without_edits_has_no_state_column() { + let columns = vec![named("id", "integer", true)]; + let (sink, _messages) = recorder(); + let (view, _selection) = build_column_view( + content(&columns, persisted(&[vec![Value::Int(1)]])), + &columns, + sink, + GridSorting::Client, + crate::ui::grid::TabGridContext::default(), + ); + + assert_eq!(view.columns().n_items(), 1); + assert_eq!(data_columns(&view).len(), 1); + } +} diff --git a/linux/crates/app/src/ui/grid/row_state_icon.rs b/linux/crates/app/src/ui/grid/row_state_icon.rs new file mode 100644 index 0000000000..ca991d8a95 --- /dev/null +++ b/linux/crates/app/src/ui/grid/row_state_icon.rs @@ -0,0 +1,90 @@ +use super::cell_accessibility::pending_state_text; +use crate::services::change_tracker::RowState; +use crate::ui::icons; + +/// A row's pending change, as the grid shows it. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RowPendingState { + Unchanged, + Modified, + Inserted, + MarkedForDeletion, + /// The row produced the statement a save just failed on. + SaveFailed, +} + +impl RowPendingState { + pub fn new(state: RowState, save_failed: bool) -> Self { + if save_failed { + return Self::SaveFailed; + } + match state { + RowState::Clean => Self::Unchanged, + RowState::Modified => Self::Modified, + RowState::InsertDraft => Self::Inserted, + RowState::PendingDelete => Self::MarkedForDeletion, + } + } +} + +/// The icon and its tooltip, so the state reads without its tint. +pub fn row_state_icon(state: RowPendingState) -> Option<(&'static str, String)> { + let icon = match state { + RowPendingState::Unchanged => return None, + RowPendingState::Modified => icons::DOCUMENT_EDIT, + RowPendingState::Inserted => icons::LIST_ADD, + RowPendingState::MarkedForDeletion => icons::EDIT_DELETE, + RowPendingState::SaveFailed => icons::DIALOG_ERROR, + }; + Some((icon, pending_state_text(state)?)) +} + +#[cfg(test)] +mod tests { + use std::collections::HashSet; + + use super::{RowPendingState, row_state_icon}; + use crate::services::change_tracker::RowState; + + const PENDING: [RowPendingState; 4] = [ + RowPendingState::Modified, + RowPendingState::Inserted, + RowPendingState::MarkedForDeletion, + RowPendingState::SaveFailed, + ]; + + #[test] + fn row_state_icons_are_distinct_and_described() { + let shown: Vec<(&str, String)> = PENDING.into_iter().filter_map(row_state_icon).collect(); + + assert_eq!( + shown, + [ + ("document-edit-symbolic", "Modified".to_owned()), + ("list-add-symbolic", "New row".to_owned()), + ("edit-delete-symbolic", "Marked for deletion".to_owned()), + ("dialog-error-symbolic", "Not saved".to_owned()), + ] + ); + let icons: HashSet<&str> = shown.iter().map(|(icon, _)| *icon).collect(); + assert_eq!(icons.len(), PENDING.len()); + assert_eq!(row_state_icon(RowPendingState::Unchanged), None); + } + + #[test] + fn a_failed_save_outranks_the_rows_change() { + assert_eq!( + RowPendingState::new(RowState::Modified, true), + RowPendingState::SaveFailed + ); + assert_eq!( + RowPendingState::new(RowState::InsertDraft, false), + RowPendingState::Inserted + ); + assert_eq!( + RowPendingState::new(RowState::PendingDelete, false), + RowPendingState::MarkedForDeletion + ); + assert_eq!(RowPendingState::new(RowState::Clean, false), RowPendingState::Unchanged); + } +} diff --git a/linux/crates/app/src/ui/grid/row_watches.rs b/linux/crates/app/src/ui/grid/row_watches.rs new file mode 100644 index 0000000000..4bbf75bceb --- /dev/null +++ b/linux/crates/app/src/ui/grid/row_watches.rs @@ -0,0 +1,90 @@ +use std::cell::RefCell; +use std::collections::HashMap; + +use gtk4::glib; +use gtk4::prelude::*; + +use super::RowObject; + +struct RowWatch { + row: RowObject, + handler: glib::SignalHandlerId, +} + +/// The rows a factory's bound items show. An item is redrawn each time +/// its row's revision moves, until it is unbound. +pub(super) struct RowWatches { + watches: RefCell>, +} + +impl Default for RowWatches { + fn default() -> Self { + Self { + watches: RefCell::default(), + } + } +} + +impl RowWatches +where + W: IsA, +{ + /// Show `row` on `item` now and after each of the row's changes. + pub fn bind(&self, item: &W, row: RowObject, show: impl Fn(&W, &RowObject) + 'static) { + show(item, &row); + // The row outlives the item it is bound to, so the handler holds + // the item weakly. + let weak = item.downgrade(); + let handler = row.connect_revision_notify(move |row| { + if let Some(item) = weak.upgrade() { + show(&item, row); + } + }); + if let Some(stale) = self + .watches + .borrow_mut() + .insert(item.clone(), RowWatch { row, handler }) + { + stale.row.disconnect(stale.handler); + } + } + + pub fn unbind(&self, item: &W) { + // `W` also borrows as a plain object, so the key type is named. + if let Some(watch) = self.watches.borrow_mut().remove::(item) { + watch.row.disconnect(watch.handler); + } + } +} + +#[cfg(test)] +mod tests { + use std::cell::RefCell; + use std::rc::Rc; + + use gtk4 as gtk; + + use super::RowWatches; + use crate::ui::grid::RowObject; + + #[gtk4::test] + fn an_item_follows_only_the_row_it_is_bound_to() { + let watches = RowWatches::default(); + let item = gtk::Label::new(None); + let (first, second) = (RowObject::draft(1), RowObject::draft(2)); + let shown = Rc::new(RefCell::new(Vec::new())); + let record = || { + let shown = shown.clone(); + move |_: >k::Label, row: &RowObject| shown.borrow_mut().push(row.draft_id()) + }; + + watches.bind(&item, first.clone(), record()); + watches.bind(&item, second.clone(), record()); + first.bump_revision(); + second.bump_revision(); + watches.unbind(&item); + second.bump_revision(); + + assert_eq!(*shown.borrow(), [Some(1), Some(2), Some(2)]); + } +} diff --git a/linux/crates/app/src/ui/grid/selection.rs b/linux/crates/app/src/ui/grid/selection.rs new file mode 100644 index 0000000000..d3fb70b014 --- /dev/null +++ b/linux/crates/app/src/ui/grid/selection.rs @@ -0,0 +1,47 @@ +use gtk4 as gtk; +use gtk4::prelude::*; + +use super::{RowObject, RowSnapshot, TabGridContext}; + +pub(crate) fn selected_positions(selection: >k::MultiSelection) -> Vec { + let bitset = selection.selection(); + let mut out = Vec::with_capacity(bitset.size() as usize); + for i in 0..bitset.size() { + out.push(bitset.nth(i as u32)); + } + out.sort_unstable(); + out +} + +pub(super) fn row_at(column_view: >k::ColumnView, position: u32) -> Option { + column_view.model()?.item(position)?.downcast::().ok() +} + +pub(super) fn selection_of(column_view: >k::ColumnView) -> Option { + column_view.model()?.downcast::().ok() +} + +/// A right-click on a selected row keeps the selection, so a block +/// still copies whole; any other row becomes the selection. That keeps +/// Copy and Delete in one menu pointing at the same rows. +pub(super) fn select_row_for_menu(column_view: >k::ColumnView, position: u32) { + let Some(selection) = selection_of(column_view) else { + return; + }; + if position >= selection.n_items() || selection.is_selected(position) { + return; + } + selection.select_item(position, true); +} + +/// The paginator's Export button and the menu's Export both read the +/// rows here, so one label cannot mean two files. +pub(crate) fn export_snapshot(column_view: >k::ColumnView, ctx: &TabGridContext) -> Vec { + let Some(model) = column_view.model() else { + return Vec::new(); + }; + (0..model.n_items()) + .filter_map(|position| row_at(column_view, position)) + .map(|row| ctx.snapshot(&row)) + .collect() +} diff --git a/linux/crates/app/src/ui/grid/tab_grid_context.rs b/linux/crates/app/src/ui/grid/tab_grid_context.rs new file mode 100644 index 0000000000..1843d93f89 --- /dev/null +++ b/linux/crates/app/src/ui/grid/tab_grid_context.rs @@ -0,0 +1,251 @@ +use std::sync::Arc; + +use tablepro_core::Value; +use tablepro_core::edit::CellInput; +use tablepro_core::meta::RowIdentity; + +use super::row_state_icon::RowPendingState; +use super::{ColumnWidthBinding, GridRowId, RowObject, RowSnapshot}; +use crate::services::cell_overlay::CellMarks; +use crate::services::change_tracker::{CellState, RowKey}; +use crate::services::tab_tracker_handle::TabTrackerHandle; + +/// `tracker` and `column_widths` are `None` for a grid nothing tracks, +/// such as editor results. +#[derive(Debug, Clone)] +pub struct TabGridContext { + pub tracker: Option, + /// Whether the grid offers edits at all. A read-only session's grid + /// does not. + pub editable: bool, + /// What names the grid's rows, which decides the columns a saved + /// row cannot change. + pub identity: RowIdentity, + pub column_widths: Option, +} + +impl Default for TabGridContext { + fn default() -> Self { + Self { + tracker: None, + editable: false, + identity: RowIdentity::Unordered, + column_widths: None, + } + } +} + +impl TabGridContext { + /// `None` when nothing tracks the grid or the row has no key. + fn tracked_key(&self, row: &RowObject) -> Option<(&TabTrackerHandle, RowKey)> { + Some((self.tracker.as_ref()?, row.id().tracker_key()?)) + } + + /// `Unchanged` for a row nothing tracks. + pub(super) fn row_pending_state(&self, row: &RowObject) -> RowPendingState { + self.tracked_key(row) + .map_or(RowPendingState::Unchanged, |(tracker, key)| { + RowPendingState::new(tracker.row_state(&key), tracker.is_error_row(&key)) + }) + } + + /// Whether the grid's rows can carry pending changes, which is when + /// it gives them a state column. + pub(super) fn tracks_changes(&self) -> bool { + self.editable && self.tracker.is_some() + } + + /// The row as the grid shows it, for every copy and export path: + /// `RowObject::cells` holds a saved row as read and a draft not at + /// all. A draft's cell left to the server reads as NULL. + pub(crate) fn snapshot(&self, row: &RowObject) -> RowSnapshot { + if let (GridRowId::Draft(draft_id), Some(tracker)) = (row.id(), self.tracker.as_ref()) { + return RowSnapshot::new(tracker.draft_values(*draft_id).unwrap_or_default().into(), Vec::new()); + } + let cells = row.cells().unwrap_or_else(|| Arc::from([])); + let overrides = self + .tracked_key(row) + .map(|(tracker, key)| tracker.pending_values(&key, cells.len())) + .unwrap_or_default(); + RowSnapshot::new(cells, overrides) + } + + /// Like `snapshot`, except a draft's cell left to the server stays + /// `Default` rather than reading as NULL. + pub(crate) fn effective_inputs(&self, row: &RowObject) -> Vec { + if let (GridRowId::Draft(draft_id), Some(tracker)) = (row.id(), self.tracker.as_ref()) { + return tracker.draft_inputs(*draft_id).unwrap_or_default(); + } + self.snapshot(row) + .to_values() + .into_iter() + .map(CellInput::Value) + .collect() + } + + /// What an editor opens on: a draft's cell as the tracker holds it, + /// a saved row's pending value, else the value as it was read. + pub(crate) fn edit_input(&self, row: &RowObject, col: usize) -> CellInput { + self.with_effective_value(row, col, |value, default| match default { + true => CellInput::Default, + false => CellInput::Value(value.clone()), + }) + } + + /// Calls `f` with the value the cell shows, borrowed from the row + /// unless the tracker holds a pending one. `default` is set for a + /// draft's cell left to the server, whose value then reads as NULL. + pub(crate) fn with_effective_value(&self, row: &RowObject, col: usize, f: impl FnOnce(&Value, bool) -> R) -> R { + let pending = self + .tracked_key(row) + .and_then(|(tracker, key)| tracker.cell_overlay(&key, col).pending); + match (pending, row.cell(col)) { + (Some(CellInput::Value(value)), _) => f(&value, false), + (Some(CellInput::Default), _) => f(&Value::Null, true), + (None, Some(value)) => f(value, false), + (None, None) => f(&Value::Null, false), + } + } + + /// The classes that tint the cell for its row's pending change, and + /// the cell's own state. + pub(super) fn pending_marks(&self, row: &RowObject, col: usize) -> (Vec<&'static str>, CellState) { + let Some((tracker, key)) = self.tracked_key(row) else { + return (Vec::new(), CellState::Clean); + }; + let marks = tracker.cell_marks(&key, col); + (pending_classes(&marks, col), marks.state) + } +} + +fn pending_classes(marks: &CellMarks, col: usize) -> Vec<&'static str> { + let mut classes = Vec::new(); + match (marks.row_deleted, marks.state) { + (true, _) => classes.push("tp-row-pending-delete"), + (false, CellState::InsertDraft) => classes.push("tp-row-pending-insert"), + (false, CellState::Modified) => classes.push("tp-cell-modified"), + (false, CellState::Clean) => {} + } + if col == 0 && marks.error_flash { + classes.push("tp-row-leftmost-error-flash"); + } + classes +} + +#[cfg(test)] +mod tests { + use tablepro_core::Value; + use tablepro_core::edit::CellInput; + + use super::{RowObject, TabGridContext}; + use crate::services::change_tracker::RowKey; + use crate::services::tab_tracker_handle::TabTrackerHandle; + use crate::services::tracker_edit::TrackerEdit; + + fn context(tracker: &TabTrackerHandle) -> TabGridContext { + TabGridContext { + tracker: Some(tracker.clone()), + ..TabGridContext::default() + } + } + + fn insert_draft(tracker: &TabTrackerHandle, values: Vec) -> u64 { + let delta = tracker.apply(TrackerEdit::InsertDrafts { + rows: vec![values.into_iter().map(CellInput::Value).collect()], + }); + let Some(draft_id) = delta.inserted_drafts.into_iter().next() else { + panic!("an insert adds a draft row"); + }; + draft_id + } + + #[test] + fn a_tracked_draft_carries_its_insert_tint_and_error_flash() { + let tracker = TabTrackerHandle::default(); + let draft_id = insert_draft(&tracker, vec![Value::Int(4)]); + let _ = tracker.flash_error_row(RowKey::Draft(draft_id)); + let draft = RowObject::draft(draft_id); + + let (classes, _) = context(&tracker).pending_marks(&draft, 0); + + assert_eq!( + context(&tracker).edit_input(&draft, 0), + CellInput::Value(Value::Int(4)), + "a draft's cells are read from the tracker" + ); + assert_eq!(classes, vec!["tp-row-pending-insert", "tp-row-leftmost-error-flash"]); + } + + #[test] + fn an_untracked_grid_draws_the_fetched_value_plainly() { + let row = RowObject::result(vec![Value::Int(1)].into()); + + let (classes, _) = TabGridContext::default().pending_marks(&row, 0); + + assert_eq!( + TabGridContext::default().edit_input(&row, 0), + CellInput::Value(Value::Int(1)) + ); + assert!(classes.is_empty()); + } + + #[test] + fn a_clean_cell_is_drawn_from_the_row_itself() { + let tracker = TabTrackerHandle::default(); + let key = RowKey::from_pk_values(&[Value::Int(1)]).expect("a key"); + let _ = tracker.apply(TrackerEdit::Cell { + row_key: key.clone(), + col: 1, + original: Value::Text("old".into()), + value: Value::Text("new".into()), + }); + let row = RowObject::persisted(Some(key), vec![Value::Int(1), Value::Text("old".into())].into()); + let cells = row.cells().expect("a saved row's cells"); + + let borrowed = context(&tracker).with_effective_value(&row, 0, |value, _| std::ptr::eq(value, &cells[0])); + let pending = context(&tracker).with_effective_value(&row, 1, |value, default| (value.clone(), default)); + + assert!(borrowed, "an unedited cell was copied out of its row"); + assert_eq!(pending, (Value::Text("new".into()), false)); + } + + #[test] + fn snapshots_include_pending_edits_and_draft_cells() { + let tracker = TabTrackerHandle::default(); + let key = RowKey::from_pk_values(&[Value::Int(1)]).expect("a key"); + let _ = tracker.apply(TrackerEdit::Cell { + row_key: key.clone(), + col: 1, + original: Value::Text("old".into()), + value: Value::Text("new".into()), + }); + let draft_id = insert_draft(&tracker, vec![Value::Null, Value::Text("draft".into())]); + let saved = RowObject::persisted(Some(key), vec![Value::Int(1), Value::Text("old".into())].into()); + + assert_eq!( + context(&tracker).snapshot(&saved).to_values(), + vec![Value::Int(1), Value::Text("new".into())] + ); + assert_eq!( + context(&tracker).snapshot(&RowObject::draft(draft_id)).to_values(), + vec![Value::Null, Value::Text("draft".into())] + ); + } + + #[test] + fn a_draft_cell_left_to_the_server_opens_as_the_default() { + let tracker = TabTrackerHandle::default(); + let delta = tracker.apply(TrackerEdit::InsertDrafts { + rows: vec![vec![CellInput::Default, CellInput::Value(Value::Null)]], + }); + let draft = RowObject::draft(delta.inserted_drafts[0]); + + assert_eq!(context(&tracker).edit_input(&draft, 0), CellInput::Default); + assert_eq!(context(&tracker).edit_input(&draft, 1), CellInput::Value(Value::Null)); + assert_eq!( + context(&tracker).snapshot(&draft).to_values(), + vec![Value::Null, Value::Null], + "a copy has no value to give a default" + ); + } +} diff --git a/linux/crates/app/src/ui/grid/test_grid.rs b/linux/crates/app/src/ui/grid/test_grid.rs new file mode 100644 index 0000000000..ee98a3b8b8 --- /dev/null +++ b/linux/crates/app/src/ui/grid/test_grid.rs @@ -0,0 +1,311 @@ +use std::cell::RefCell; +use std::rc::Rc; +use std::time::Duration; + +use gtk4::prelude::*; +use gtk4::{self as gtk, gio}; + +use tablepro_core::edit::CellInput; +use tablepro_core::meta::RowIdentity; +use tablepro_core::{ColumnInfo, Value}; + +use super::{ + ColumnWidthBinding, GridCell, GridContent, GridMsg, GridSink, GridSorting, RowObject, TabGridContext, begin_edit, + build_column_view, data_columns, +}; +use crate::services::change_tracker::RowKey; +use crate::services::tab_tracker_handle::TabTrackerHandle; +use crate::services::tracker_delta::TrackerDelta; +use crate::services::tracker_edit::TrackerEdit; +use crate::test_support::{descendants, drain_main_context, wait_until}; + +pub(super) const WAIT: Duration = Duration::from_secs(5); + +#[expect( + clippy::disallowed_methods, + reason = "a test fixture stands in for the catalogue, which is where this text comes from in production" +)] +pub(super) fn col(data_type: &str, primary_key: bool) -> ColumnInfo { + let kind = tablepro_core::column::classify_type_name(data_type); + ColumnInfo { + name: "x".into(), + column_type: tablepro_core::column::ColumnType { + name: data_type.to_owned(), + dynamic_storage: tablepro_core::column::has_dynamic_storage(&kind), + kind, + catalog: Some(tablepro_core::column::CatalogType { + declaration: tablepro_core::column::SqlTypeExpr::from_catalog_text(data_type), + cast_target: tablepro_core::column::SqlTypeExpr::from_catalog_text(data_type), + }), + read_form: tablepro_core::column::ReadForm::Native, + }, + nullable: true, + primary_key, + is_auto_increment: false, + is_generated: false, + default: tablepro_core::column::ColumnDefault::None, + comment: None, + } +} + +pub(super) fn named(name: &str, data_type: &str, primary_key: bool) -> ColumnInfo { + let mut column = col(data_type, primary_key); + column.name = name.to_owned(); + column +} + +pub(super) fn key(id: i64) -> RowKey { + RowKey::from_pk_values(&[Value::Int(id)]).expect("a key") +} + +pub(super) fn keyed_by_id() -> RowIdentity { + RowIdentity::UniqueKey { + columns: vec!["id".to_owned()], + } +} + +pub(super) fn tracked(tracker: &TabTrackerHandle) -> TabGridContext { + TabGridContext { + tracker: Some(tracker.clone()), + editable: true, + identity: keyed_by_id(), + column_widths: None, + } +} + +/// Persisted rows keyed by their first cell, as a browse page is. +pub(super) fn persisted(rows: &[Vec]) -> Vec { + rows.iter() + .map(|cells| { + let id = cells.first().cloned().unwrap_or(Value::Null); + RowObject::persisted(RowKey::from_pk_values(&[id]), cells.clone().into()) + }) + .collect() +} + +/// `rows` under the result columns of `columns`. +pub(super) fn content(columns: &[ColumnInfo], rows: Vec) -> GridContent { + GridContent { + columns: columns.iter().map(ColumnInfo::result_column).collect(), + rows, + } +} + +pub(super) fn recorder() -> (GridSink, Rc>>) { + let messages = Rc::new(RefCell::new(Vec::new())); + let log = messages.clone(); + (Rc::new(move |msg| log.borrow_mut().push(msg)), messages) +} + +pub(super) struct Grid { + pub(super) window: gtk::Window, + pub(super) view: gtk::ColumnView, + pub(super) store: gio::ListStore, + pub(super) tracker: TabTrackerHandle, + messages: Rc>>, + width: usize, +} + +impl Grid { + /// `rows` keyed by their first cell, in a grid that edits them. + pub(super) fn open(columns: Vec, rows: Vec>) -> Self { + Self::open_in(columns, rows, None) + } + + /// As `open`, with the column widths saved through `widths`. + pub(super) fn open_with_widths( + columns: Vec, + rows: Vec>, + widths: ColumnWidthBinding, + ) -> Self { + Self::open_in(columns, rows, Some(widths)) + } + + fn open_in(columns: Vec, rows: Vec>, widths: Option) -> Self { + let tracker = TabTrackerHandle::default(); + let (sink, messages) = recorder(); + let (view, selection) = build_column_view( + content(&columns, persisted(&rows)), + &columns, + sink, + GridSorting::Server { initial: None }, + TabGridContext { + column_widths: widths, + ..tracked(&tracker) + }, + ); + let store = selection + .model() + .and_downcast::() + .expect("a list store"); + let window = gtk::Window::new(); + window.set_default_size(600, 400); + window.set_child(Some(&view)); + window.present(); + let grid = Self { + window, + view, + store, + tracker, + messages, + width: columns.len(), + }; + wait_until(WAIT, || grid.cells().len() == columns.len() * rows.len()).expect("the cells are drawn"); + grid + } + + pub(super) fn cells(&self) -> Vec { + descendants(&self.view) + .into_iter() + .filter_map(|widget| widget.downcast::().ok()) + .filter(|cell| cell.row().is_some()) + .collect() + } + + /// The cell that shows `column` of the row named `id`. + pub(super) fn cell_of(&self, id: i64, column: usize) -> GridCell { + self.cells() + .into_iter() + .find(|cell| cell.column() == column && cell.row().is_some_and(|row| row.key() == Some(&key(id)))) + .expect("the row's cell is drawn") + } + + pub(super) fn insert_draft(&self) { + let delta = self.tracker.apply(TrackerEdit::InsertDrafts { + rows: vec![vec![CellInput::Value(Value::Null); self.width]], + }); + let Some(draft_id) = delta.inserted_drafts.into_iter().next() else { + panic!("an insert adds a draft row"); + }; + self.store.insert(0, &RowObject::draft(draft_id)); + drain_main_context(); + } + + /// The view's `index`th column of the result, past the state column. + pub(super) fn column(&self, index: u32) -> gtk::ColumnViewColumn { + let index = usize::try_from(index).expect("a column index"); + data_columns(&self.view).into_iter().nth(index).expect("a column") + } + + pub(super) fn focused_cell(&self) -> Option { + let focus = gtk::prelude::GtkWindowExt::focus(&self.window)?; + GridCell::from_focus(&focus) + } + + /// Focus the cell of the `column`th shown column at `position`, as + /// the arrow keys do. `scroll_to` moves the focus only within a + /// focused list, so the view's grab carries it there. The view shows + /// the columns in result order. + pub(super) fn focus(&self, position: u32, column: u32) -> GridCell { + self.view + .scroll_to(position, Some(&self.column(column)), gtk::ListScrollFlags::FOCUS, None); + self.view.grab_focus(); + let mut focused = None; + wait_until(WAIT, || { + focused = self + .focused_cell() + .filter(|cell| cell.position() == Some(position) && u32::try_from(cell.column()) == Ok(column)); + focused.is_some() + }) + .expect("the cell takes the focus"); + focused.expect("a focused cell") + } + + pub(super) fn take_messages(&self) -> Vec { + self.messages.take() + } + + /// What the tab does with a tracker change. + pub(super) fn redraw(&self, delta: &TrackerDelta) { + for position in 0..self.store.n_items() { + if let Some(row) = self.store.item(position).and_downcast::() + && row.id().tracker_key().is_some_and(|key| delta.changed.contains(&key)) + { + row.bump_revision(); + } + } + } +} + +impl Drop for Grid { + fn drop(&mut self) { + self.window.destroy(); + drain_main_context(); + } +} + +/// Saved rows keyed `ids`, as a page read from the table is. +pub(super) fn page(ids: std::ops::Range) -> Vec { + let rows: Vec> = ids + .map(|id| vec![Value::Int(id), Value::Text(format!("row {id}"))]) + .collect(); + persisted(&rows) +} + +/// Whether the window's focus is the grid or inside it. +pub(super) fn focus_in_grid(grid: &Grid) -> bool { + gtk::prelude::GtkWindowExt::focus(&grid.window) + .is_some_and(|focus| focus == *grid.view.upcast_ref::() || focus.is_ancestor(&grid.view)) +} + +pub(super) fn two_text_rows() -> Grid { + Grid::open(two_text_columns(), two_text_values()) +} + +pub(super) fn two_text_columns() -> Vec { + vec![named("id", "integer", true), named("name", "text", false)] +} + +pub(super) fn two_text_values() -> Vec> { + vec![ + vec![Value::Int(1), Value::Text("A".into())], + vec![Value::Int(2), Value::Text("B".into())], + ] +} + +/// Open the cell's one-line editor and type `text` over its value. +pub(super) fn type_into(cell: &GridCell, text: &str) -> gtk::Text { + begin_edit(cell); + let editor = cell.inline_text().expect("the cell did not open for editing"); + editor.set_text(text); + editor +} + +pub(super) fn display_text(cell: &GridCell) -> String { + cell.display() + .and_then(|display| display.text()) + .map(|text| text.to_string()) + .unwrap_or_default() +} + +/// Each edit the grid sent, as the row, the column and the value. +pub(super) fn edited_rows(messages: &[GridMsg]) -> Vec<(Option, usize, CellInput)> { + messages + .iter() + .filter_map(|msg| match msg { + GridMsg::Edit { row, column, edit } => { + let input = match edit { + TrackerEdit::Cell { value, .. } => CellInput::Value(value.clone()), + TrackerEdit::DraftCell { input, .. } => input.clone(), + other => panic!("a cell commit made {other:?}"), + }; + Some((row.key().cloned(), *column, input)) + } + _ => None, + }) + .collect() +} + +pub(super) fn text(value: &str) -> CellInput { + CellInput::Value(Value::Text(value.to_owned())) +} + +pub(super) fn flag_rows() -> Grid { + Grid::open( + vec![named("id", "integer", true), named("flag", "boolean", false)], + vec![ + vec![Value::Int(1), Value::Bool(false)], + vec![Value::Int(2), Value::Bool(false)], + ], + ) +} diff --git a/linux/crates/app/src/ui/grid/toggle_cell.rs b/linux/crates/app/src/ui/grid/toggle_cell.rs new file mode 100644 index 0000000000..6a975ddb3e --- /dev/null +++ b/linux/crates/app/src/ui/grid/toggle_cell.rs @@ -0,0 +1,153 @@ +use gtk4::prelude::*; +use gtk4::{self as gtk}; +use tablepro_core::Value; +use tablepro_core::column::{BoolStorage, ColumnKind, ColumnType}; +use tablepro_core::edit::CellInput; + +use super::display_text::display_text; +use super::edit_commit::{commit_input, send_commit}; +use super::{EditSnapshot, GridCell}; + +pub struct ToggleCell; + +/// How a check button shows a cell's value. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ToggleState { + pub active: bool, + /// A value that is neither true nor false shows as neither, with the + /// value itself in the tooltip, rather than as a confidently wrong + /// box. + pub inconsistent: bool, + pub tooltip: Option, +} + +impl ToggleCell { + /// `column` names the check box, as written in the table, since a + /// box with no label reads as nothing but its state. + pub(super) fn setup(cell: &GridCell, column: &str) { + let check = gtk::CheckButton::builder() + .halign(gtk::Align::Start) + .valign(gtk::Align::Center) + .margin_start(8) + .margin_end(8) + // The cell takes the focus, and toggles on F2, Return or Space, + // so Tab leaves the grid rather than stopping inside the cell. + .focusable(false) + .focus_on_click(false) + .build(); + check.update_property(&[gtk::accessible::Property::Label(column)]); + cell.set_content(&check); + // The cell owns the check button, so the handler finds the cell + // through its parent rather than holding it. + check.connect_toggled(|check| { + let Some(cell) = check.parent().and_downcast::() else { + return; + }; + if !cell.toggle_suppressed() && cell.lock().is_none() { + commit_toggle(&cell, check.is_active()); + } + }); + } + + pub(super) fn draw(cell: &GridCell, check: >k::CheckButton, state: &ToggleState) { + // Suppressed, so drawing the value does not echo back as an edit. + cell.suppress_toggle(true); + check.set_inconsistent(state.inconsistent); + check.set_active(state.active); + cell.suppress_toggle(false); + check.set_tooltip_text(state.tooltip.as_deref()); + } +} + +/// `default` is a draft's cell left to the server. +pub fn toggle_state(value: &Value, default: bool, column: &ColumnType, storage: BoolStorage) -> ToggleState { + let neither = |tooltip: &str| ToggleState { + active: false, + inconsistent: true, + tooltip: Some(tooltip.to_owned()), + }; + let shown = |active| ToggleState { + active, + inconsistent: false, + tooltip: None, + }; + match (default, value, storage) { + (true, ..) => neither("DEFAULT"), + (false, Value::Bool(flag), _) => shown(*flag), + (false, Value::Int(0), BoolStorage::Integer) => shown(false), + (false, Value::Int(1), BoolStorage::Integer) => shown(true), + (false, other, _) => neither(display_text(other, column, &mut String::new()).text), + } +} + +/// `tinyint(1)` keeps any integer, so a toggle writes 0 or 1 as one. +pub fn toggled_value(storage: BoolStorage, active: bool) -> Value { + match storage { + BoolStorage::Native => Value::Bool(active), + BoolStorage::Integer => Value::Int(i64::from(active)), + } +} + +fn commit_toggle(cell: &GridCell, active: bool) { + let (Some(row), Some(drawing)) = (cell.row(), cell.drawing()) else { + return; + }; + let Some(column) = drawing.catalog.as_ref() else { + return; + }; + let ColumnKind::Bool(storage) = column.column_type.kind else { + return; + }; + let snapshot = EditSnapshot { + original: drawing.tab_ctx.edit_input(&row, cell.column()), + row, + column: cell.column(), + }; + let result = commit_input(column, &snapshot, CellInput::Value(toggled_value(storage, active))); + send_commit(cell, snapshot, result); +} + +#[cfg(test)] +mod tests { + use tablepro_core::Value; + use tablepro_core::column::{BoolStorage, ColumnType}; + + use super::{ToggleState, toggle_state, toggled_value}; + + fn state(active: bool, inconsistent: bool, tooltip: Option<&str>) -> ToggleState { + ToggleState { + active, + inconsistent, + tooltip: tooltip.map(str::to_owned), + } + } + + #[test] + fn a_toggle_shows_only_true_and_false_as_checked_or_not() { + let column = ColumnType::untyped_text("boolean"); + let value = |value: Value, storage| toggle_state(&value, false, &column, storage); + + assert_eq!(value(Value::Bool(true), BoolStorage::Native), state(true, false, None)); + assert_eq!(value(Value::Int(1), BoolStorage::Integer), state(true, false, None)); + assert_eq!(value(Value::Int(0), BoolStorage::Integer), state(false, false, None)); + assert_eq!( + value(Value::Int(2), BoolStorage::Integer), + state(false, true, Some("2")) + ); + assert_eq!( + value(Value::Null, BoolStorage::Native), + state(false, true, Some("NULL")) + ); + assert_eq!( + toggle_state(&Value::Null, true, &column, BoolStorage::Integer), + state(false, true, Some("DEFAULT")) + ); + } + + #[test] + fn a_toggle_writes_the_columns_own_form() { + assert_eq!(toggled_value(BoolStorage::Native, true), Value::Bool(true)); + assert_eq!(toggled_value(BoolStorage::Integer, true), Value::Int(1)); + assert_eq!(toggled_value(BoolStorage::Integer, false), Value::Int(0)); + } +} diff --git a/linux/crates/app/src/ui/grid/value_order.rs b/linux/crates/app/src/ui/grid/value_order.rs new file mode 100644 index 0000000000..0fc932aaef --- /dev/null +++ b/linux/crates/app/src/ui/grid/value_order.rs @@ -0,0 +1,519 @@ +use std::borrow::Cow; +use std::cmp::Ordering; +use std::str::FromStr; + +use tablepro_core::Value; +use tablepro_core::value::{SqlDecimal, SqlInterval, Temporal, TimeWithOffset}; + +/// The order a header click puts a column's values in, for every kind +/// but text, which `GtkStringSorter` collates in the user's locale. +/// +/// NULL comes first. Numbers compare by value across their kinds, so +/// an integer, a float and a decimal in one column interleave as they +/// would on a number line. Each value is ordered by what it holds +/// rather than by its column's declared type, since a SQLite column +/// can hold any kind. +pub(crate) fn compare(a: &Value, b: &Value) -> Ordering { + match (a, b) { + (Value::Null, Value::Null) => Ordering::Equal, + (Value::Null, _) => Ordering::Less, + (_, Value::Null) => Ordering::Greater, + _ => match (rank(a), rank(b)) { + (left, right) if left != right => left.cmp(&right), + _ => same_rank(a, b), + }, + } +} + +/// The order of a column that can hold text: NULL, then numbers by +/// value, then text, then blobs, then the rest as `compare` orders +/// them. Two texts are `Equal` here, so the collating sorter after this +/// one orders them. +pub(crate) fn storage_class_order(a: &Value, b: &Value) -> Ordering { + let class = storage_class(a); + match class.cmp(&storage_class(b)) { + Ordering::Equal => match class { + StorageClass::Null | StorageClass::Text => Ordering::Equal, + StorageClass::Numeric => numeric_order(a, b), + StorageClass::Blob | StorageClass::Other => compare(a, b), + }, + unequal => unequal, + } +} + +/// The text a collating sorter orders `value` by. `None` for a value +/// that is not text, which some other sorter has already placed. +pub(crate) fn sort_text(value: &Value) -> Option<&str> { + let text = match value { + Value::Text(text) => text, + Value::Json(json) => json.as_str(), + Value::Other(other) => &other.text, + _ => return None, + }; + Some(sort_head(text)) +} + +/// A collation key is built from the whole text it is given, so a sort +/// of large documents would build keys as large as they are. The head +/// decides any order a person reads. +const SORT_TEXT_MAX_CHARS: usize = 4_096; + +/// A text of no more bytes than the cap has no more chars either, so it +/// is not walked. +fn sort_head(text: &str) -> &str { + if text.len() <= SORT_TEXT_MAX_CHARS { + return text; + } + text.char_indices() + .nth(SORT_TEXT_MAX_CHARS) + .map_or(text, |(index, _)| &text[..index]) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +enum StorageClass { + Null, + Numeric, + Text, + Blob, + Other, +} + +fn storage_class(value: &Value) -> StorageClass { + match value { + Value::Null => StorageClass::Null, + _ if is_number(value) => StorageClass::Numeric, + _ if sort_text(value).is_some() => StorageClass::Text, + Value::Bytes(_) => StorageClass::Blob, + _ => StorageClass::Other, + } +} + +/// Where a kind of value sorts among the others, for a column that +/// mixes them. +fn rank(value: &Value) -> u8 { + match value { + Value::Null => 0, + Value::Bool(_) => 1, + Value::Int(_) + | Value::UInt(_) + | Value::WideInt(_) + | Value::Float32(_) + | Value::Float64(_) + | Value::Decimal(_) => 2, + Value::Date(_) | Value::Timestamp(_) | Value::TimestampTz(_) => 3, + Value::Time(_) | Value::TimeTz(_) => 4, + Value::Interval(_) => 5, + Value::Text(_) | Value::Json(_) | Value::Other(_) => 6, + Value::Uuid(_) => 7, + Value::Bits(_) => 8, + Value::Bytes(_) => 9, + Value::Array(_) => 10, + Value::Undecodable(_) => 11, + } +} + +fn same_rank(a: &Value, b: &Value) -> Ordering { + match (a, b) { + (Value::Bool(a), Value::Bool(b)) => a.cmp(b), + _ if is_number(a) && is_number(b) => numeric_order(a, b), + (Value::Date(_) | Value::Timestamp(_) | Value::TimestampTz(_), _) => instant_order(a, b), + (Value::Time(a), Value::Time(b)) => a.total_nanos().cmp(&b.total_nanos()), + (Value::TimeTz(a), Value::TimeTz(b)) => { + let utc = |time: &TimeWithOffset| { + time.time.total_nanos() - i128::from(time.offset.local_minus_utc()) * 1_000_000_000 + }; + utc(a) + .cmp(&utc(b)) + .then_with(|| a.offset.local_minus_utc().cmp(&b.offset.local_minus_utc())) + } + (Value::Time(_), Value::TimeTz(_)) => Ordering::Less, + (Value::TimeTz(_), Value::Time(_)) => Ordering::Greater, + (Value::Interval(a), Value::Interval(b)) => interval_span(a).cmp(&interval_span(b)), + _ if sort_text(a).is_some() => sort_text(a).cmp(&sort_text(b)), + (Value::Uuid(a), Value::Uuid(b)) => a.as_bytes().cmp(b.as_bytes()), + (Value::Bits(a), Value::Bits(b)) => a + .bit_len() + .cmp(&b.bit_len()) + .then_with(|| a.as_bytes().cmp(b.as_bytes())), + (Value::Bytes(a), Value::Bytes(b)) => a.cmp(b), + (Value::Array(a), Value::Array(b)) => a + .iter() + .zip(b.iter()) + .map(|(a, b)| compare(a, b)) + .find(|order| order.is_ne()) + .unwrap_or_else(|| a.len().cmp(&b.len())), + _ => Ordering::Equal, + } +} + +/// An interval's length the way PostgreSQL compares two: a month is 30 +/// days and a day 24 hours, so `1 mon` equals `30 days` and sorts +/// before `40 days`. +fn interval_span(interval: &SqlInterval) -> i128 { + const MICROS_PER_DAY: i128 = 86_400_000_000; + let days = i128::from(interval.months) * 30 + i128::from(interval.days); + days * MICROS_PER_DAY + i128::from(interval.microseconds) +} + +fn is_number(value: &Value) -> bool { + matches!( + value, + Value::Int(_) | Value::UInt(_) | Value::WideInt(_) | Value::Float32(_) | Value::Float64(_) | Value::Decimal(_) + ) +} + +/// Two numbers by value. NaN sorts after every number, as PostgreSQL +/// sorts it. +fn numeric_order(a: &Value, b: &Value) -> Ordering { + match (float_of(a), float_of(b)) { + (Some(a), Some(b)) => return float_order(a, b), + (Some(a), None) if a.is_nan() => return Ordering::Greater, + (None, Some(b)) if b.is_nan() => return Ordering::Less, + _ => {} + } + if let (Some(a), Some(b)) = (integer_of(a), integer_of(b)) { + return a.cmp(&b); + } + match (decimal_of(a), decimal_of(b)) { + (Some(a), Some(b)) => a.cmp_numeric(b.as_ref()), + _ => Ordering::Equal, + } +} + +fn float_order(a: f64, b: f64) -> Ordering { + match (a.is_nan(), b.is_nan()) { + (true, true) => Ordering::Equal, + (true, false) => Ordering::Greater, + (false, true) => Ordering::Less, + (false, false) => a.total_cmp(&b), + } +} + +fn float_of(value: &Value) -> Option { + match value { + Value::Float32(float) => Some(f64::from(*float)), + Value::Float64(float) => Some(*float), + _ => None, + } +} + +fn integer_of(value: &Value) -> Option { + match value { + Value::Int(int) => Some(i128::from(*int)), + Value::UInt(int) => Some(i128::from(*int)), + Value::WideInt(wide) => wide.to_i128(), + _ => None, + } +} + +/// The value as an exact decimal, for comparing numbers of different +/// kinds. A float is taken at the decimal its shortest form spells. A +/// decimal is borrowed, since a sort compares each one many times. +fn decimal_of(value: &Value) -> Option> { + let owned = match value { + Value::Decimal(decimal) => return Some(Cow::Borrowed(decimal)), + Value::Float32(_) | Value::Float64(_) => { + let float = float_of(value)?; + if float.is_nan() { + SqlDecimal::nan() + } else if float.is_infinite() { + SqlDecimal::infinity(float < 0.0) + } else { + SqlDecimal::from_str(&format_float(value)).ok()? + } + } + _ => match integer_of(value) { + Some(int) => SqlDecimal::from_scaled_i128(int, 0), + None => match value { + Value::WideInt(wide) => SqlDecimal::from_str(&wide.to_string()).ok()?, + _ => return None, + }, + }, + }; + Some(Cow::Owned(owned)) +} + +/// A float's shortest decimal spelling at its own width, so `0.1f32` +/// is `1e-1` rather than the double it widens to. +fn format_float(value: &Value) -> String { + match value { + Value::Float32(float) => format!("{float:e}"), + Value::Float64(float) => format!("{float:e}"), + _ => String::new(), + } +} + +/// Dates and timestamps, with the infinities at either end. A timestamp +/// with an offset orders by its instant, then by its offset, so two +/// spellings of one instant keep a stable order. +fn instant_order(a: &Value, b: &Value) -> Ordering { + let (Some(a_key), Some(b_key)) = (instant(a), instant(b)) else { + return rank(a).cmp(&rank(b)); + }; + a_key.cmp(&b_key) +} + +/// `(end, instant, offset)`: `end` puts -infinity first and infinity +/// last. +fn instant(value: &Value) -> Option<(i8, Option, i32)> { + let midnight = |date: &chrono::NaiveDate| date.and_hms_opt(0, 0, 0); + match value { + Value::Date(date) => Some(temporal_key(date, |date| (midnight(date), 0))), + Value::Timestamp(stamp) => Some(temporal_key(stamp, |stamp| (Some(*stamp), 0))), + Value::TimestampTz(stamp) => Some(temporal_key(stamp, |stamp| { + (Some(stamp.utc()), stamp.offset().local_minus_utc()) + })), + _ => None, + } +} + +fn temporal_key( + temporal: &Temporal, + finite: impl Fn(&T) -> (Option, i32), +) -> (i8, Option, i32) { + match temporal { + Temporal::NegInfinity => (-1, None, 0), + Temporal::Finite(value) => { + let (instant, offset) = finite(value); + (0, instant, offset) + } + Temporal::Infinity => (1, None, 0), + } +} + +#[cfg(test)] +mod tests { + use chrono::{DateTime, NaiveDate}; + use tablepro_core::value::{BitString, OffsetTimestamp, WideInteger}; + + use super::*; + + fn order(a: &Value, b: &Value) -> Ordering { + compare(a, b) + } + + fn sorted(mut values: Vec) -> Vec { + values.sort_by(order); + values + } + + fn decimal(text: &str) -> Value { + Value::Decimal(SqlDecimal::from_str(text).expect("a decimal")) + } + + #[test] + fn nulls_sort_first() { + assert_eq!( + sorted(vec![Value::Int(1), Value::Null, Value::Bool(false), Value::Null]), + [Value::Null, Value::Null, Value::Bool(false), Value::Int(1)] + ); + assert_eq!(order(&Value::Null, &Value::Float64(f64::NAN)), Ordering::Less); + } + + #[test] + fn int_decimal_and_float_compare_numerically() { + let values = vec![ + decimal("2.5"), + Value::Int(-3), + Value::Float64(2.25), + Value::UInt(u64::MAX), + Value::Float32(0.1), + decimal("0.09"), + Value::Int(2), + Value::WideInt(WideInteger::from_i128(i128::from(u64::MAX) + 1)), + Value::WideInt(WideInteger::from_u128(u128::MAX)), + ]; + + assert_eq!( + sorted(values), + [ + Value::Int(-3), + decimal("0.09"), + Value::Float32(0.1), + Value::Int(2), + Value::Float64(2.25), + decimal("2.5"), + Value::UInt(u64::MAX), + Value::WideInt(WideInteger::from_i128(i128::from(u64::MAX) + 1)), + Value::WideInt(WideInteger::from_u128(u128::MAX)), + ] + ); + assert_eq!(order(&Value::Int(2), &decimal("2.000")), Ordering::Equal); + assert_eq!(order(&Value::Float64(-0.0), &Value::Float64(0.0)), Ordering::Less); + } + + #[test] + fn nan_sorts_after_numbers() { + let nan = Value::Float64(f64::NAN); + + assert_eq!(order(&nan, &Value::Float64(f64::INFINITY)), Ordering::Greater); + assert_eq!(order(&nan, &decimal("1e300")), Ordering::Greater); + assert_eq!(order(&Value::Int(i64::MAX), &nan), Ordering::Less); + assert_eq!(order(&nan, &Value::Float32(f32::NAN)), Ordering::Equal); + assert_eq!( + order(&Value::Decimal(SqlDecimal::nan()), &Value::Float64(1.0)), + Ordering::Greater + ); + } + + #[test] + fn temporal_infinities_order() { + let day = |d| Value::Date(Temporal::Finite(NaiveDate::from_ymd_opt(2024, 1, d).expect("a date"))); + + assert_eq!( + sorted(vec![ + Value::Date(Temporal::Infinity), + day(2), + Value::Date(Temporal::NegInfinity), + day(1), + ]), + [ + Value::Date(Temporal::NegInfinity), + day(1), + day(2), + Value::Date(Temporal::Infinity), + ] + ); + } + + #[test] + fn offset_timestamps_compare_by_instant_then_offset() { + let at = |text: &str| { + Value::TimestampTz(Temporal::Finite(OffsetTimestamp::from_datetime( + DateTime::parse_from_rfc3339(text).expect("a timestamp"), + ))) + }; + let noon_utc = at("2024-06-15T12:00:00+00:00"); + let same_in_hanoi = at("2024-06-15T19:00:00+07:00"); + let earlier_in_hanoi = at("2024-06-15T18:00:00+07:00"); + + assert_eq!(order(&earlier_in_hanoi, &noon_utc), Ordering::Less); + assert_eq!( + order(&noon_utc, &same_in_hanoi), + Ordering::Less, + "a tie broke on the wall clock" + ); + assert_eq!(order(&same_in_hanoi, &noon_utc), Ordering::Greater); + assert_eq!( + order(&Value::TimestampTz(Temporal::Infinity), &noon_utc), + Ordering::Greater + ); + } + + #[test] + fn intervals_compare_by_their_length() { + let interval = |months, days, microseconds| { + Value::Interval(SqlInterval { + months, + days, + microseconds, + }) + }; + let hour = 3_600_000_000; + + assert_eq!(order(&interval(1, 0, 0), &interval(0, 40, 0)), Ordering::Less); + assert_eq!(order(&interval(1, 0, 0), &interval(0, 30, 0)), Ordering::Equal); + assert_eq!(order(&interval(0, 1, 0), &interval(0, 0, 25 * hour)), Ordering::Less); + assert_eq!(order(&interval(0, -1, 0), &interval(0, 0, -hour)), Ordering::Less); + } + + #[test] + fn bytes_bits_uuids_and_arrays_order_by_content() { + let bits = |value, len| Value::Bits(BitString::from_u64(value, len)); + + assert_eq!( + order(&Value::Bytes(vec![1, 2]), &Value::Bytes(vec![1, 3])), + Ordering::Less + ); + assert_eq!( + order(&bits(0b1, 1), &bits(0b0, 2)), + Ordering::Less, + "a shorter bit string sorts first" + ); + assert_eq!(order(&bits(0b01, 2), &bits(0b10, 2)), Ordering::Less); + assert_eq!( + order( + &Value::Uuid(uuid::Uuid::from_u128(1)), + &Value::Uuid(uuid::Uuid::from_u128(2)) + ), + Ordering::Less + ); + assert_eq!( + order( + &Value::Array(vec![Value::Int(1), Value::Int(2)]), + &Value::Array(vec![Value::Int(1), Value::Int(2), Value::Int(0)]) + ), + Ordering::Less + ); + assert_eq!( + order( + &Value::Array(vec![Value::Int(10)]), + &Value::Array(vec![Value::Int(9), Value::Int(9)]) + ), + Ordering::Greater + ); + } + + #[test] + fn dynamic_storage_orders_by_storage_class() { + let mut values = vec![ + Value::Bytes(vec![0]), + Value::Text("apple".to_owned()), + Value::Float64(2.5), + Value::Null, + Value::Int(3), + Value::Int(-1), + ]; + values.sort_by(storage_class_order); + + assert_eq!( + values, + [ + Value::Null, + Value::Int(-1), + Value::Float64(2.5), + Value::Int(3), + Value::Text("apple".to_owned()), + Value::Bytes(vec![0]), + ] + ); + assert_eq!( + storage_class_order(&Value::Text("b".to_owned()), &Value::Text("a".to_owned())), + Ordering::Equal, + "text is the collating sorter's to order" + ); + } + + #[test] + fn a_long_text_sorts_by_its_head() { + let long = format!("{}tail", "é".repeat(SORT_TEXT_MAX_CHARS)); + + assert_eq!( + sort_text(&Value::Text(long)), + Some("é".repeat(SORT_TEXT_MAX_CHARS).as_str()) + ); + assert_eq!(sort_text(&Value::Text("short".to_owned())), Some("short")); + } + + #[test] + fn sort_text_covers_text_kinds_only() { + let json = tablepro_core::value::JsonText::parse("{\"a\":1}".to_owned()).expect("json"); + let other = Value::Other(Box::new(tablepro_core::value::OtherValue { + type_name: "inet".to_owned(), + text: "10.0.0.1".to_owned(), + })); + + assert_eq!(sort_text(&Value::Text("plain".to_owned())), Some("plain")); + assert_eq!(sort_text(&Value::Json(json)), Some("{\"a\":1}")); + assert_eq!(sort_text(&other), Some("10.0.0.1")); + for value in [ + Value::Null, + Value::Int(1), + Value::Bytes(b"text".to_vec()), + Value::Uuid(uuid::Uuid::nil()), + Value::Bool(true), + ] { + assert_eq!(sort_text(&value), None, "{value:?}"); + } + } +} diff --git a/linux/crates/app/src/ui/handler_guard.rs b/linux/crates/app/src/ui/handler_guard.rs new file mode 100644 index 0000000000..f9befd868c --- /dev/null +++ b/linux/crates/app/src/ui/handler_guard.rs @@ -0,0 +1,66 @@ +use gtk4::glib; +use gtk4::glib::prelude::*; + +/// A handler on an object that outlives the widget that connected it, +/// such as the style manager or the settings. The object never drops +/// the handler on its own, so the widget holds this and the handler +/// goes with it. +pub(crate) struct HandlerGuard { + object: glib::WeakRef, + handler: Option, +} + +impl HandlerGuard { + pub(crate) fn new(object: &impl IsA, handler: glib::SignalHandlerId) -> Self { + Self { + object: object.upcast_ref::().downgrade(), + handler: Some(handler), + } + } +} + +impl Drop for HandlerGuard { + fn drop(&mut self) { + if let (Some(object), Some(handler)) = (self.object.upgrade(), self.handler.take()) { + object.disconnect(handler); + } + } +} + +#[cfg(test)] +mod tests { + use std::cell::Cell; + use std::rc::Rc; + + use gtk4::gio; + use gtk4::gio::prelude::*; + + use super::*; + + #[test] + fn dropping_the_guard_disconnects_the_handler() { + let action = gio::SimpleAction::new("probe", None); + let calls = Rc::new(Cell::new(0)); + let counter = calls.clone(); + let guard = HandlerGuard::new( + &action, + action.connect_enabled_notify(move |_| counter.set(counter.get() + 1)), + ); + action.set_enabled(false); + assert_eq!(calls.get(), 1); + + drop(guard); + action.set_enabled(true); + + assert_eq!(calls.get(), 1, "the handler ran after its guard dropped"); + } + + #[test] + fn a_guard_outliving_its_object_drops_quietly() { + let action = gio::SimpleAction::new("probe", None); + let guard = HandlerGuard::new(&action, action.connect_enabled_notify(|_| {})); + + drop(action); + drop(guard); + } +} diff --git a/linux/crates/app/src/ui/history_dialog/connection_filter.rs b/linux/crates/app/src/ui/history_dialog/connection_filter.rs new file mode 100644 index 0000000000..f0d5de59b2 --- /dev/null +++ b/linux/crates/app/src/ui/history_dialog/connection_filter.rs @@ -0,0 +1,158 @@ +use std::cell::RefCell; +use std::rc::Rc; + +use gtk4 as gtk; +use gtk4::prelude::*; +use tablepro_storage::SavedConnection; +use tablepro_storage::query_history::HistoryConnection; +use uuid::Uuid; + +use crate::i18n::gettext; + +/// The connection filter's choices: All Connections, then each +/// connection the history has entries for. A saved connection shows the +/// name it has now; one deleted or never saved shows the name its latest +/// run had. A clone shares the same choices. +#[derive(Clone)] +pub(crate) struct ConnectionFilterModel { + ids: Rc>>>, + labels: gtk::StringList, +} + +impl ConnectionFilterModel { + pub(crate) fn new() -> Self { + Self { + ids: Rc::new(RefCell::new(vec![None])), + labels: gtk::StringList::new(&[gettext("All Connections").as_str()]), + } + } + + /// One label per choice, in the order of the ids. + pub(crate) fn labels(&self) -> >k::StringList { + &self.labels + } + + pub(crate) fn fill(&self, connections: &[HistoryConnection], saved: &[SavedConnection]) { + let mut listed: Vec<(String, Uuid)> = connections + .iter() + .map(|connection| { + let name = saved + .iter() + .find(|saved| saved.id == connection.id) + .map_or(&connection.name, |saved| &saved.name); + (name.clone(), connection.id) + }) + .collect(); + listed.sort_by_cached_key(|(name, id)| (name.to_lowercase(), *id)); + let ids: Vec> = std::iter::once(None) + .chain(listed.iter().map(|(_, id)| Some(*id))) + .collect(); + let unchanged = *self.ids.borrow() == ids + && listed + .iter() + .zip(1..) + .all(|((name, _), position)| self.labels.string(position).is_some_and(|label| label == name.as_str())); + // A splice drops the drop-down's selection, so the filter would + // move for a list that did not change. + if unchanged { + return; + } + // The ids go first: the splice notifies the drop-down, and what + // it reads then has to match the labels. + self.ids.replace(ids); + let names: Vec<&str> = listed.iter().map(|(name, _)| name.as_str()).collect(); + self.labels.splice(1, self.labels.n_items().saturating_sub(1), &names); + } + + /// `None` for All Connections, and for a position past the end. + pub(crate) fn id_at(&self, position: u32) -> Option { + self.ids.borrow().get(position as usize).copied().flatten() + } + + pub(crate) fn position_of(&self, id: Uuid) -> Option { + self.ids + .borrow() + .iter() + .position(|listed| *listed == Some(id)) + .and_then(|position| u32::try_from(position).ok()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn saved(id: Uuid, name: &str) -> SavedConnection { + SavedConnection { + id, + name: name.to_owned(), + driver_id: "postgres".to_owned(), + host: "db".to_owned(), + port: 5432, + database: "app".to_owned(), + username: "postgres".to_owned(), + tls: tablepro_core::TlsConfig::verify_full(), + read_only: false, + auth_mode: tablepro_core::AuthMode::Password, + ssh: None, + last_opened_at: None, + color: None, + group: None, + } + } + + fn labels(model: &ConnectionFilterModel) -> Vec { + (0..model.labels().n_items()) + .filter_map(|position| model.labels().string(position)) + .map(String::from) + .collect() + } + + #[gtk4::test] + fn saved_connections_show_their_current_name_in_order() { + let (renamed, unsaved) = (Uuid::new_v4(), Uuid::new_v4()); + let model = ConnectionFilterModel::new(); + + model.fill( + &[ + HistoryConnection { + id: renamed, + name: "zeta".to_owned(), + }, + HistoryConnection { + id: unsaved, + name: "Scratch".to_owned(), + }, + ], + &[saved(renamed, "alpha"), saved(Uuid::new_v4(), "No history")], + ); + + assert_eq!(labels(&model), ["All Connections", "alpha", "Scratch"]); + assert_eq!(model.id_at(0), None); + assert_eq!(model.id_at(1), Some(renamed)); + assert_eq!(model.position_of(unsaved), Some(2)); + assert_eq!(model.id_at(3), None); + } + + #[gtk4::test] + fn filling_with_the_same_connections_leaves_the_labels_alone() { + let model = ConnectionFilterModel::new(); + let listed = [HistoryConnection { + id: Uuid::new_v4(), + name: "alpha".to_owned(), + }]; + model.fill(&listed, &[]); + let changes = std::rc::Rc::new(std::cell::Cell::new(0)); + let counted = changes.clone(); + model + .labels() + .connect_items_changed(move |_, _, _, _| counted.set(counted.get() + 1)); + + model.fill(&listed, &[]); + assert_eq!(changes.get(), 0, "an unchanged list was spliced"); + + model.fill(&listed, &[saved(listed[0].id, "renamed")]); + assert_eq!(changes.get(), 1); + assert_eq!(labels(&model), ["All Connections", "renamed"]); + } +} diff --git a/linux/crates/app/src/ui/history_dialog/history_actions.rs b/linux/crates/app/src/ui/history_dialog/history_actions.rs new file mode 100644 index 0000000000..33fafaf388 --- /dev/null +++ b/linux/crates/app/src/ui/history_dialog/history_actions.rs @@ -0,0 +1,46 @@ +use gtk4::glib; +use gtk4::subclass::prelude::*; + +use super::history_export::ExportKind; +use super::{HistoryDialog, QueryDestination, imp}; + +/// The dialog's actions. The ones for an entry take its id, so the row +/// buttons and the one row menu name the entry they act on. +pub(super) fn install(klass: &mut ::Class) { + let id = Some(glib::VariantTy::INT64); + klass.install_action("history.open", id, |dialog, _, target| { + with_id(dialog, target, |dialog, id| { + dialog.send_query(id, QueryDestination::NewTab) + }); + }); + klass.install_action("history.replace", id, |dialog, _, target| { + with_id(dialog, target, |dialog, id| { + dialog.send_query(id, QueryDestination::CurrentTab) + }); + }); + klass.install_action("history.pin", id, |dialog, _, target| { + with_id(dialog, target, HistoryDialog::toggle_pin); + }); + klass.install_action("history.copy", id, |dialog, _, target| { + with_id(dialog, target, HistoryDialog::copy_query); + }); + klass.install_action("history.delete", id, |dialog, _, target| { + with_id(dialog, target, |dialog, id| dialog.delete_entries(vec![id])); + }); + klass.install_action("history.delete-selected", None, |dialog, _, _| dialog.delete_selected()); + klass.install_action("history.export-sql", None, |dialog, _, _| { + dialog.export_selected(ExportKind::Sql); + }); + klass.install_action("history.export-csv", None, |dialog, _, _| { + dialog.export_selected(ExportKind::Csv); + }); + klass.install_action("history.clear-all", None, |dialog, _, _| dialog.confirm_clear_all()); + klass.install_action("history.show-in-files", None, |dialog, _, _| dialog.show_in_files()); + klass.install_action("history-row.menu", None, |dialog, _, _| dialog.popup_focused_row_menu()); +} + +fn with_id(dialog: &HistoryDialog, target: Option<&glib::Variant>, act: impl FnOnce(&HistoryDialog, i64)) { + if let Some(id) = target.and_then(glib::Variant::get::) { + act(dialog, id); + } +} diff --git a/linux/crates/app/src/ui/history_dialog/history_entry_object.rs b/linux/crates/app/src/ui/history_dialog/history_entry_object.rs new file mode 100644 index 0000000000..06450b46ec --- /dev/null +++ b/linux/crates/app/src/ui/history_dialog/history_entry_object.rs @@ -0,0 +1,37 @@ +use gtk4::glib; +use gtk4::subclass::prelude::*; +use tablepro_storage::query_history::Entry; + +use super::history_entry_object_imp as imp; + +glib::wrapper! { + /// One history entry as the dialog's list model holds it. An entry + /// never changes in place: a pin puts a new one where the history + /// lists it now. + pub struct HistoryEntryObject(ObjectSubclass); +} + +impl HistoryEntryObject { + pub(crate) fn new(entry: Entry) -> Self { + let object: Self = glib::Object::new(); + let _ = object.imp().entry.set(entry); + object + } + + /// Always set: `new` is the only way to build one. + pub(crate) fn entry(&self) -> Option<&Entry> { + self.imp().entry.get() + } + + pub(crate) fn id(&self) -> Option { + self.entry().map(|entry| entry.id) + } + + pub(crate) fn is_pinned(&self) -> bool { + self.entry().is_some_and(|entry| entry.pinned) + } + + pub(crate) fn query(&self) -> Option { + self.entry().map(|entry| entry.query.clone()) + } +} diff --git a/linux/crates/app/src/ui/history_dialog/history_entry_object_imp.rs b/linux/crates/app/src/ui/history_dialog/history_entry_object_imp.rs new file mode 100644 index 0000000000..cf6ee724ea --- /dev/null +++ b/linux/crates/app/src/ui/history_dialog/history_entry_object_imp.rs @@ -0,0 +1,18 @@ +use std::cell::OnceCell; + +use gtk4::glib; +use gtk4::subclass::prelude::*; +use tablepro_storage::query_history::Entry; + +#[derive(Default)] +pub struct HistoryEntryObject { + pub(super) entry: OnceCell, +} + +#[glib::object_subclass] +impl ObjectSubclass for HistoryEntryObject { + const NAME: &'static str = "TableProHistoryEntryObject"; + type Type = super::HistoryEntryObject; +} + +impl ObjectImpl for HistoryEntryObject {} diff --git a/linux/crates/app/src/ui/history_dialog/history_export.rs b/linux/crates/app/src/ui/history_dialog/history_export.rs new file mode 100644 index 0000000000..dfeab73a46 --- /dev/null +++ b/linux/crates/app/src/ui/history_dialog/history_export.rs @@ -0,0 +1,160 @@ +use gtk4 as gtk; +use gtk4::prelude::*; +use gtk4::{gio, glib}; +use libadwaita as adw; +use libadwaita::prelude::*; +use tablepro_storage::{QueryHistory, StorageError}; + +use crate::i18n::gettext; +use crate::ui::export::{ExportFailure, FinishError, ReplaceFileWriter, failure_text}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum ExportKind { + Sql, + Csv, +} + +impl ExportKind { + pub(super) async fn render(self, history: QueryHistory, ids: Vec) -> Result { + match self { + Self::Sql => history.export_sql(&ids).await, + Self::Csv => history.export_csv(&ids).await, + } + } + + fn suggested_name(self) -> &'static str { + match self { + Self::Sql => "tablepro-history.sql", + Self::Csv => "tablepro-history.csv", + } + } + + fn filter(self) -> gtk::FileFilter { + let filter = gtk::FileFilter::new(); + match self { + Self::Sql => { + filter.set_name(Some(&gettext("SQL files"))); + filter.add_mime_type("text/x-sql"); + filter.add_suffix("sql"); + } + Self::Csv => { + filter.set_name(Some(&gettext("CSV files"))); + filter.add_mime_type("text/csv"); + filter.add_suffix("csv"); + } + } + filter + } +} + +/// Ask where to keep `content`, then write it there. +pub(super) fn save_export(parent: &adw::Dialog, kind: ExportKind, content: String) { + let filter = kind.filter(); + let filters = gio::ListStore::new::(); + filters.append(&filter); + let chooser = gtk::FileDialog::builder() + .title(gettext("Export Query History")) + .modal(true) + .initial_name(kind.suggested_name()) + .default_filter(&filter) + .filters(&filters) + .build(); + let window = parent.root().and_downcast::(); + let parent = parent.clone().upcast::(); + glib::spawn_future_local(async move { + // Cancelling the chooser is the only way it fails without a file. + let Ok(file) = chooser.save_future(window.as_ref()).await else { + return; + }; + write_history_export(&parent, &file, content).await; + }); +} + +/// Write the exported history to `file`, and say over `parent` when it +/// fails and what it left of the file. +pub(super) async fn write_history_export(parent: >k::Widget, file: &gio::File, content: String) { + let written = async { + let writer = ReplaceFileWriter::open(file) + .await + .map_err(|error| (ExportFailure::from(error), None))?; + if let Err(error) = writer.write(content.into_bytes()).await { + return Err((error.into(), Some(writer.abort().await))); + } + writer + .finish() + .await + .map_err(|FinishError { error, file }| (error.into(), Some(file))) + } + .await; + if let Err((failure, left)) = written { + tracing::warn!(%failure, path = %file.parse_name(), "history export write failed"); + let name = file.basename().map_or_else( + || file.parse_name().to_string(), + |name| name.to_string_lossy().into_owned(), + ); + show_export_failure(parent, &failure_text(&failure, left, &name)); + } +} + +/// Why the entries could not be read for an export. The storage error +/// is logged; its text is not translated. +pub(super) fn history_export_failure_text(error: &StorageError) -> String { + match error { + StorageError::Encode(_) => gettext("The selected entries couldn't be written as CSV."), + _ => gettext("The selected entries couldn't be read from the query history."), + } +} + +pub(super) fn show_export_failure(parent: >k::Widget, body: &str) { + let alert = adw::AlertDialog::new(Some(&gettext("Couldn't Export")), Some(body)); + alert.add_response("close", &gettext("_Close")); + alert.set_default_response(Some("close")); + alert.set_close_response("close"); + alert.present(Some(parent)); +} + +#[cfg(test)] +mod tests { + use std::time::Duration; + + use super::*; + use crate::test_support::{OwnedWindow, finish_on_main}; + use crate::workspace::visible_alert; + + #[gtk4::test] + fn history_export_failure_presents_alert() { + adw::init().expect("libadwaita initialises under the test backend"); + let window = OwnedWindow::presented(adw::Window::new()); + let dir = tempfile::tempdir().expect("tempdir"); + let file = gio::File::for_path(dir.path().join("missing").join("history.csv")); + let parent = window.clone().upcast::(); + + finish_on_main(Duration::from_secs(10), async move { + write_history_export(&parent, &file, "query\n".to_owned()).await; + }) + .expect("the write to end"); + + let alert = visible_alert(&window).expect("an alert"); + assert_eq!(alert.heading().as_deref(), Some("Couldn't Export")); + assert!( + alert.body().contains("history.csv"), + "the alert does not name the file: {}", + alert.body() + ); + assert!(!alert.body().contains("incomplete"), "{}", alert.body()); + } + + #[test] + fn history_export_failure_text_is_not_the_storage_error() { + let errors = [ + StorageError::NotInitialised, + StorageError::Encode(tablepro_core::export::RenderError::Flush(std::io::Error::other("full"))), + ]; + + for error in errors { + let text = history_export_failure_text(&error); + assert!(!text.contains(&error.to_string()), "{text}"); + assert!(text.starts_with("The selected entries couldn't"), "{text}"); + } + } +} diff --git a/linux/crates/app/src/ui/history_dialog/history_filter_state.rs b/linux/crates/app/src/ui/history_dialog/history_filter_state.rs new file mode 100644 index 0000000000..64b8171d64 --- /dev/null +++ b/linux/crates/app/src/ui/history_dialog/history_filter_state.rs @@ -0,0 +1,71 @@ +use std::time::{Duration, SystemTime}; + +use chrono::{DateTime, Local, NaiveTime, TimeDelta}; +use tablepro_storage::query_history::StatusFilter; +use uuid::Uuid; + +const DAY: Duration = Duration::from_secs(86_400); + +/// What the history filters are set to. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub(crate) struct HistoryFilterState { + pub connection: Option, + pub status: StatusFilter, + pub window: TimeWindow, +} + +/// How far back the history looks. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub(crate) enum TimeWindow { + #[default] + AnyTime, + Today, + Last7Days, + Last30Days, +} + +impl TimeWindow { + /// In the order the drop-down lists them. + pub(crate) const ALL: [Self; 4] = [Self::AnyTime, Self::Today, Self::Last7Days, Self::Last30Days]; + + /// The earliest run the window keeps. Today starts at the local + /// midnight, not 24 hours back. + pub(crate) fn executed_after(self, now: SystemTime) -> Option { + match self { + Self::AnyTime => None, + Self::Today => Some(start_of_local_day(now)), + Self::Last7Days => now.checked_sub(7 * DAY), + Self::Last30Days => now.checked_sub(30 * DAY), + } + } +} + +/// A zone that moves its clocks at midnight skips it on that day, which +/// then starts at the first hour that exists. +fn start_of_local_day(now: SystemTime) -> SystemTime { + let midnight = DateTime::::from(now).date_naive().and_time(NaiveTime::MIN); + (0..=24) + .filter_map(|hours| midnight.checked_add_signed(TimeDelta::hours(hours))) + .find_map(|moment| moment.and_local_timezone(Local).earliest()) + .map_or(now, SystemTime::from) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn time_window_executed_after() { + let now = SystemTime::UNIX_EPOCH + Duration::from_secs(1_800_000_000); + + assert_eq!(TimeWindow::AnyTime.executed_after(now), None); + assert_eq!(TimeWindow::Last7Days.executed_after(now), Some(now - 7 * DAY)); + assert_eq!(TimeWindow::Last30Days.executed_after(now), Some(now - 30 * DAY)); + + let today = TimeWindow::Today.executed_after(now).expect("a start"); + let local = DateTime::::from(today); + assert!(today <= now); + assert_eq!(local.date_naive(), DateTime::::from(now).date_naive()); + assert_eq!(local.time(), NaiveTime::MIN); + } +} diff --git a/linux/crates/app/src/ui/history_dialog/history_filters.rs b/linux/crates/app/src/ui/history_dialog/history_filters.rs new file mode 100644 index 0000000000..0bb3be85d0 --- /dev/null +++ b/linux/crates/app/src/ui/history_dialog/history_filters.rs @@ -0,0 +1,201 @@ +use std::rc::Rc; + +use gtk4 as gtk; +use gtk4::prelude::*; +use libadwaita as adw; +use tablepro_storage::query_history::StatusFilter; +use uuid::Uuid; + +use super::connection_filter::ConnectionFilterModel; +use super::history_filter_state::{HistoryFilterState, TimeWindow}; +use crate::i18n::gettext; + +const STATUSES: [(&str, StatusFilter); 4] = [ + ("any", StatusFilter::Any), + ("succeeded", StatusFilter::Succeeded), + ("failed", StatusFilter::Failed), + ("cancelled", StatusFilter::Cancelled), +]; + +/// The filters under the search bar, in a box that wraps them onto more +/// lines when the dialog is narrow. +pub(crate) struct HistoryFilters { + root: adw::WrapBox, + connection: gtk::DropDown, + status: adw::ToggleGroup, + window: gtk::DropDown, + connections: ConnectionFilterModel, +} + +impl HistoryFilters { + pub(crate) fn new(connections: &ConnectionFilterModel) -> Self { + let connection = gtk::DropDown::new(Some(connections.labels().clone()), None::); + // Search matches the label the list shows for each connection. + connection.set_expression(Some(gtk::PropertyExpression::new( + gtk::StringObject::static_type(), + None::, + "string", + ))); + connection.set_enable_search(true); + label(&connection, &gettext("Connection")); + + let status = adw::ToggleGroup::new(); + for (name, text) in STATUSES.iter().map(|(name, _)| *name).zip([ + gettext("All"), + gettext("Succeeded"), + gettext("Failed"), + gettext("Cancelled"), + ]) { + status.add(adw::Toggle::builder().name(name).label(text).build()); + } + status.set_active_name(Some("any")); + label(&status, &gettext("Status")); + + let window_labels = [ + gettext("Any Time"), + gettext("Today"), + gettext("Last 7 Days"), + gettext("Last 30 Days"), + ]; + let window_labels: Vec<&str> = window_labels.iter().map(String::as_str).collect(); + let window = gtk::DropDown::from_strings(&window_labels); + label(&window, &gettext("Time window")); + + let root = adw::WrapBox::builder() + .child_spacing(6) + .line_spacing(6) + .margin_top(6) + .margin_bottom(6) + .margin_start(12) + .margin_end(12) + .build(); + root.append(&connection); + root.append(&status); + root.append(&window); + Self { + root, + connection, + status, + window, + connections: connections.clone(), + } + } + + pub(crate) fn root(&self) -> &adw::WrapBox { + &self.root + } + + /// The connection drop-down's choices. + pub(crate) fn connections(&self) -> &ConnectionFilterModel { + &self.connections + } + + pub(crate) fn state(&self) -> HistoryFilterState { + read_state(&self.connection, &self.status, &self.window, &self.connections) + } + + /// `f` runs with the new state whenever any filter changes. + pub(crate) fn connect_changed(&self, f: impl Fn(HistoryFilterState) + 'static) { + let f = Rc::new(f); + // Weak, since each handler lives on one of the widgets it reads. + let (connection, status, window) = ( + self.connection.downgrade(), + self.status.downgrade(), + self.window.downgrade(), + ); + let connections = self.connections.clone(); + let changed = Rc::new(move || { + if let (Some(connection), Some(status), Some(window)) = + (connection.upgrade(), status.upgrade(), window.upgrade()) + { + f(read_state(&connection, &status, &window, &connections)); + } + }); + let notify = changed.clone(); + self.connection.connect_selected_notify(move |_| notify()); + let notify = changed.clone(); + self.status.connect_active_name_notify(move |_| notify()); + self.window.connect_selected_notify(move |_| changed()); + } + + /// Put the connection filter on `id`, or on All Connections when the + /// history has nothing for it. + pub(crate) fn select_connection(&self, id: Option) { + let position = id.and_then(|id| self.connections.position_of(id)).unwrap_or(0); + self.connection.set_selected(position); + } + + #[cfg(test)] + pub(crate) fn controls(&self) -> (gtk::DropDown, adw::ToggleGroup, gtk::DropDown) { + (self.connection.clone(), self.status.clone(), self.window.clone()) + } +} + +fn read_state( + connection: >k::DropDown, + status: &adw::ToggleGroup, + window: >k::DropDown, + connections: &ConnectionFilterModel, +) -> HistoryFilterState { + let active = status.active_name(); + HistoryFilterState { + connection: connections.id_at(connection.selected()), + status: STATUSES + .iter() + .find(|(name, _)| active.as_deref() == Some(*name)) + .map_or(StatusFilter::Any, |(_, status)| *status), + window: TimeWindow::ALL + .get(window.selected() as usize) + .copied() + .unwrap_or_default(), + } +} + +/// The controls have no visible label of their own, so a screen reader +/// is told what each one filters by. +fn label(widget: &impl IsA, text: &str) { + let widget = widget.upcast_ref::(); + widget.update_property(&[gtk::accessible::Property::Label(text)]); + widget.set_tooltip_text(Some(text)); +} + +#[cfg(test)] +mod tests { + use std::cell::RefCell; + + use super::*; + + #[gtk4::test] + fn toggle_emits_failed_status() { + let filters = HistoryFilters::new(&ConnectionFilterModel::new()); + let seen: Rc>> = Rc::default(); + let sink = seen.clone(); + filters.connect_changed(move |state| sink.borrow_mut().push(state)); + let (_, status, _) = filters.controls(); + + status.set_active_name(Some("failed")); + + assert_eq!( + seen.borrow().last().map(|state| state.status), + Some(StatusFilter::Failed) + ); + assert_eq!(filters.state().status, StatusFilter::Failed); + } + + #[gtk4::test] + fn the_time_window_drop_down_names_each_window() { + let filters = HistoryFilters::new(&ConnectionFilterModel::new()); + let (_, _, window) = filters.controls(); + + window.set_selected(1); + + assert_eq!(filters.state().window, TimeWindow::Today); + assert_eq!( + filters.state(), + HistoryFilterState { + window: TimeWindow::Today, + ..HistoryFilterState::default() + } + ); + } +} diff --git a/linux/crates/app/src/ui/history_dialog/history_formatting.rs b/linux/crates/app/src/ui/history_dialog/history_formatting.rs new file mode 100644 index 0000000000..730771b05c --- /dev/null +++ b/linux/crates/app/src/ui/history_dialog/history_formatting.rs @@ -0,0 +1,166 @@ +use std::time::SystemTime; + +use tablepro_storage::query_history::Entry; + +use crate::i18n::{gettext, ngettext_f}; +use crate::ui::format::{format_thousands, plural_count}; + +const TITLE_CHARS: usize = 80; +const ERROR_CHARS: usize = 48; + +/// The first line that says something, cut to fit one row. +pub(super) fn preview_title(query: &str) -> String { + let first_line = query + .lines() + .map(str::trim) + .find(|line| !line.is_empty() && !line.starts_with("--")) + .unwrap_or(""); + if first_line.is_empty() { + return gettext("Empty Query"); + } + truncated(first_line, TITLE_CHARS) +} + +pub(super) fn subtitle(entry: &Entry, now: SystemTime) -> String { + let mut parts = vec![ + entry.connection_name.clone(), + entry.driver_id.clone(), + relative_time(entry.executed_at, now), + ]; + if let Some(ms) = entry.duration_ms { + parts.push(format!("{} ms", format_thousands(u64::try_from(ms).unwrap_or(0)))); + } + if entry.cancelled { + parts.push(gettext("cancelled")); + } else if let Some(error) = &entry.error { + parts.push(truncated(error, ERROR_CHARS)); + } else if let Some(rows) = entry.rows_affected { + let rows = u64::try_from(rows).unwrap_or(0); + parts.push(ngettext_f( + "{n} row", + "{n} rows", + plural_count(rows), + &[("n", &format_thousands(rows))], + )); + } + parts.join(" · ") +} + +/// What the status icon stands for, for a screen reader. +pub(super) fn outcome_label(entry: &Entry) -> String { + if entry.cancelled { + gettext("Cancelled") + } else if entry.success { + gettext("Succeeded") + } else { + gettext("Failed") + } +} + +pub(super) fn outcome_icon(entry: &Entry) -> &'static str { + if entry.cancelled { + crate::ui::icons::PROCESS_STOP + } else if entry.success { + crate::ui::icons::SUCCESS + } else { + crate::ui::icons::DIALOG_ERROR + } +} + +fn relative_time(when: SystemTime, now: SystemTime) -> String { + let secs = now.duration_since(when).unwrap_or_default().as_secs(); + let count = |n: u64| (plural_count(n), n.to_string()); + if secs < 60 { + gettext("just now") + } else if secs < 3_600 { + let (n, text) = count(secs / 60); + ngettext_f("{n} minute ago", "{n} minutes ago", n, &[("n", &text)]) + } else if secs < 86_400 { + let (n, text) = count(secs / 3_600); + ngettext_f("{n} hour ago", "{n} hours ago", n, &[("n", &text)]) + } else if secs < 30 * 86_400 { + let (n, text) = count(secs / 86_400); + ngettext_f("{n} day ago", "{n} days ago", n, &[("n", &text)]) + } else { + chrono::DateTime::::from(when) + .format("%Y-%m-%d") + .to_string() + } +} + +fn truncated(text: &str, limit: usize) -> String { + let mut chars = text.chars(); + let kept: String = chars.by_ref().take(limit).collect(); + if chars.next().is_some() { + format!("{kept}…") + } else { + kept + } +} + +#[cfg(test)] +mod tests { + use std::time::Duration; + + use uuid::Uuid; + + use super::*; + + fn entry(executed_at: SystemTime) -> Entry { + Entry { + id: 1, + query: "SELECT 1".to_owned(), + driver_id: "postgres".to_owned(), + connection_id: Uuid::nil(), + connection_name: "local".to_owned(), + executed_at, + duration_ms: Some(12), + rows_affected: Some(2), + success: true, + cancelled: false, + pinned: false, + error: None, + } + } + + #[test] + fn the_title_skips_blank_and_comment_lines() { + assert_eq!(preview_title("\n-- yesterday\n SELECT 1\nFROM t"), "SELECT 1"); + } + + #[test] + fn a_long_title_is_cut_on_a_character_boundary() { + let title = preview_title(&"é".repeat(100)); + + assert_eq!(title.chars().count(), TITLE_CHARS + 1); + assert!(title.ends_with('…')); + } + + #[test] + fn a_query_of_only_comments_is_called_empty() { + assert_eq!(preview_title("-- nothing\n\n"), "Empty Query"); + } + + #[test] + fn the_subtitle_names_where_when_and_what_came_back() { + let now = SystemTime::UNIX_EPOCH + Duration::from_secs(1_800_000_000); + + let text = subtitle(&entry(now - Duration::from_secs(120)), now); + + assert_eq!(text, "local · postgres · 2 minutes ago · 12 ms · 2 rows"); + } + + #[test] + fn a_failed_run_shows_its_error_cut_short() { + let now = SystemTime::UNIX_EPOCH + Duration::from_secs(1_800_000_000); + let mut failed = entry(now); + failed.success = false; + failed.error = Some("x".repeat(60)); + + let text = subtitle(&failed, now); + + assert!(text.ends_with(&format!("{}…", "x".repeat(ERROR_CHARS))), "{text}"); + assert_eq!(outcome_icon(&failed), crate::ui::icons::DIALOG_ERROR); + assert_eq!(outcome_label(&failed), "Failed"); + } +} diff --git a/linux/crates/app/src/ui/history_dialog/history_paging.rs b/linux/crates/app/src/ui/history_dialog/history_paging.rs new file mode 100644 index 0000000000..223974b895 --- /dev/null +++ b/linux/crates/app/src/ui/history_dialog/history_paging.rs @@ -0,0 +1,181 @@ +use tablepro_storage::query_history::HistoryCursor; + +/// Which page reads the dialog is waiting on. A refresh starts a new +/// generation, so a page that was still on its way for the old search +/// is dropped when it lands, and only one next-page read runs at once. +#[derive(Debug, Default)] +pub(super) struct HistoryPaging { + generation: u64, + next: Option, + loading_more: bool, + loaded: bool, +} + +impl HistoryPaging { + pub(super) fn start_refresh(&mut self) -> u64 { + self.generation += 1; + self.loading_more = false; + // The cursor is the old search's: a next page read with it under + // the new search would skip rows or show some twice. + self.next = None; + self.generation + } + + /// Whether the first page belongs to the latest refresh. + pub(super) fn first_page_arrived(&mut self, generation: u64, next: Option) -> bool { + if generation != self.generation { + return false; + } + self.next = next; + self.loaded = true; + true + } + + /// Whether a first page ever arrived, so a failed refresh knows if + /// there are rows to keep. + pub(super) fn has_loaded(&self) -> bool { + self.loaded + } + + pub(super) fn is_current(&self, generation: u64) -> bool { + generation == self.generation + } + + pub(super) fn is_loading_more(&self) -> bool { + self.loading_more + } + + /// Whether an entry that sorts at `cursor` falls in the pages read + /// so far, rather than in one still to come. + pub(super) fn covers(&self, cursor: HistoryCursor) -> bool { + self.next.is_none_or(|next| cursor > next) + } + + /// The generation and cursor for the next page, unless one is on its + /// way or the last page is in. + pub(super) fn start_more(&mut self) -> Option<(u64, HistoryCursor)> { + if self.loading_more { + return None; + } + let next = self.next?; + self.loading_more = true; + Some((self.generation, next)) + } + + pub(super) fn more_arrived(&mut self, generation: u64, next: Option) -> bool { + if generation != self.generation { + return false; + } + self.loading_more = false; + self.next = next; + true + } + + /// The read failed; the next edge reached tries it again. + pub(super) fn more_failed(&mut self, generation: u64) { + if generation == self.generation { + self.loading_more = false; + } + } +} + +#[cfg(test)] +mod tests { + use std::time::SystemTime; + + use tablepro_storage::query_history::Entry; + use uuid::Uuid; + + use super::*; + + fn cursor(id: i64) -> HistoryCursor { + HistoryCursor::after(&Entry { + id, + query: String::new(), + driver_id: String::new(), + connection_id: Uuid::nil(), + connection_name: String::new(), + executed_at: SystemTime::UNIX_EPOCH, + duration_ms: None, + rows_affected: None, + success: true, + cancelled: false, + pinned: false, + error: None, + }) + } + + #[test] + fn one_next_page_read_at_a_time() { + let mut paging = HistoryPaging::default(); + let generation = paging.start_refresh(); + assert!(paging.first_page_arrived(generation, Some(cursor(10)))); + + assert_eq!(paging.start_more(), Some((generation, cursor(10)))); + assert_eq!( + paging.start_more(), + None, + "a second read started while one was on its way" + ); + + assert!(paging.more_arrived(generation, None)); + assert_eq!(paging.start_more(), None, "a read started past the last page"); + } + + #[test] + fn a_page_for_an_older_search_is_dropped() { + let mut paging = HistoryPaging::default(); + let old = paging.start_refresh(); + assert!(paging.first_page_arrived(old, Some(cursor(10)))); + let (more_for, _) = paging.start_more().expect("a next page"); + + let new = paging.start_refresh(); + + assert!(!paging.more_arrived(more_for, None)); + assert!(!paging.first_page_arrived(old, None)); + assert!(paging.first_page_arrived(new, Some(cursor(5)))); + assert_eq!( + paging.start_more(), + Some((new, cursor(5))), + "the old read kept the slot" + ); + } + + #[test] + fn a_refresh_drops_the_old_searchs_cursor() { + let mut paging = HistoryPaging::default(); + let old = paging.start_refresh(); + assert!(paging.first_page_arrived(old, Some(cursor(10)))); + + let new = paging.start_refresh(); + + assert_eq!(paging.start_more(), None, "a next page read with the old cursor"); + assert!(paging.first_page_arrived(new, Some(cursor(5)))); + assert_eq!(paging.start_more(), Some((new, cursor(5)))); + } + + #[test] + fn covers_what_sorts_above_the_next_page() { + let mut paging = HistoryPaging::default(); + let generation = paging.start_refresh(); + assert!(paging.first_page_arrived(generation, Some(cursor(10)))); + + assert!(paging.covers(cursor(11))); + assert!(!paging.covers(cursor(9))); + + assert!(paging.more_arrived(generation, None)); + assert!(paging.covers(cursor(9)), "the last page is in"); + } + + #[test] + fn a_failed_next_page_can_be_tried_again() { + let mut paging = HistoryPaging::default(); + let generation = paging.start_refresh(); + assert!(paging.first_page_arrived(generation, Some(cursor(10)))); + let _ = paging.start_more(); + + paging.more_failed(generation); + + assert_eq!(paging.start_more(), Some((generation, cursor(10)))); + } +} diff --git a/linux/crates/app/src/ui/history_dialog/history_row.rs b/linux/crates/app/src/ui/history_dialog/history_row.rs new file mode 100644 index 0000000000..dcc44b75fa --- /dev/null +++ b/linux/crates/app/src/ui/history_dialog/history_row.rs @@ -0,0 +1,126 @@ +use std::cell::RefCell; +use std::time::SystemTime; + +use gtk4 as gtk; +use gtk4::glib; +use gtk4::prelude::*; +use gtk4::subclass::prelude::*; + +use super::HistoryEntryObject; +use super::history_formatting::{outcome_icon, outcome_label, preview_title, subtitle}; +use crate::i18n::gettext; +use crate::ui::a11y::describe_icon_button; + +mod imp { + use super::*; + + #[derive(Default, gtk::CompositeTemplate)] + #[template(resource = "/app/tablepro/TablePro/ui/history-row.ui")] + pub struct HistoryRow { + #[template_child] + pub check: TemplateChild, + #[template_child] + pub status_icon: TemplateChild, + #[template_child] + pub title_label: TemplateChild, + #[template_child] + pub subtitle_label: TemplateChild, + #[template_child] + pub pin_button: TemplateChild, + #[template_child] + pub delete_button: TemplateChild, + pub entry: RefCell>, + } + + #[glib::object_subclass] + impl ObjectSubclass for HistoryRow { + const NAME: &'static str = "TableProHistoryRow"; + type Type = super::HistoryRow; + type ParentType = gtk::Box; + + fn class_init(klass: &mut Self::Class) { + klass.bind_template(); + } + + fn instance_init(object: &glib::subclass::InitializingObject) { + object.init_template(); + } + } + + impl ObjectImpl for HistoryRow { + fn constructed(&self) { + self.parent_constructed(); + self.obj().show_pinned(false); + } + } + impl WidgetImpl for HistoryRow {} + impl BoxImpl for HistoryRow {} +} + +glib::wrapper! { + /// A row of the history list. The list view builds a few and fills + /// them again as the user scrolls, so nothing here belongs to one + /// entry for longer than a bind. + pub struct HistoryRow(ObjectSubclass) + @extends gtk::Box, gtk::Widget, + @implements gtk::Accessible, gtk::Buildable, gtk::ConstraintTarget, gtk::Orientable; +} + +impl Default for HistoryRow { + fn default() -> Self { + glib::Object::new() + } +} + +impl HistoryRow { + pub(super) fn bind(&self, object: &HistoryEntryObject, now: SystemTime) { + let imp = self.imp(); + imp.entry.replace(Some(object.clone())); + let Some(entry) = object.entry() else { + return; + }; + imp.title_label.set_label(&preview_title(&entry.query)); + imp.subtitle_label.set_label(&subtitle(entry, now)); + imp.status_icon.set_icon_name(Some(outcome_icon(entry))); + imp.status_icon + .update_property(&[gtk::accessible::Property::Label(&outcome_label(entry))]); + + self.show_pinned(entry.pinned); + let id = entry.id.to_variant(); + // The target goes first: an action that takes an id, named on a + // button without one, warns that it cannot be activated. + imp.pin_button.set_action_target_value(Some(&id)); + imp.pin_button.set_action_name(Some("history.pin")); + imp.delete_button.set_action_target_value(Some(&id)); + imp.delete_button.set_action_name(Some("history.delete")); + } + + fn show_pinned(&self, pinned: bool) { + let button = self.imp().pin_button.get(); + let label = if pinned { gettext("Unpin") } else { gettext("Pin") }; + describe_icon_button(&button, &label, None); + if pinned { + button.add_css_class("accent"); + } else { + button.remove_css_class("accent"); + } + } + + pub(super) fn unbind(&self) { + self.imp().entry.replace(None); + } + + /// The entry the row shows now, if it is bound. + pub(super) fn entry(&self) -> Option { + self.imp().entry.borrow().clone() + } + + pub(super) fn check(&self) -> gtk::CheckButton { + self.imp().check.get() + } + + #[cfg(test)] + pub(super) fn title(&self) -> String { + self.imp().title_label.label().to_string() + } +} diff --git a/linux/crates/app/src/ui/history_dialog/imp.rs b/linux/crates/app/src/ui/history_dialog/imp.rs new file mode 100644 index 0000000000..1c57a73937 --- /dev/null +++ b/linux/crates/app/src/ui/history_dialog/imp.rs @@ -0,0 +1,102 @@ +use std::cell::{Cell, OnceCell, RefCell}; +use std::sync::OnceLock; + +use glib::subclass::Signal; +use gtk4 as gtk; +use gtk4::glib; +use gtk4::prelude::*; +use gtk4::subclass::prelude::*; +use libadwaita as adw; +use libadwaita::subclass::prelude::*; +use tablepro_storage::query_history::HistoryQuery; + +use super::DialogParts; +use super::history_paging::HistoryPaging; + +#[derive(Default, gtk::CompositeTemplate, glib::Properties)] +#[template(resource = "/app/tablepro/TablePro/ui/history-dialog.ui")] +#[properties(wrapper_type = super::HistoryDialog)] +pub struct HistoryDialog { + #[template_child] + pub toast_overlay: TemplateChild, + #[template_child] + pub toolbar_view: TemplateChild, + #[template_child] + pub search_bar: TemplateChild, + #[template_child] + pub search_entry: TemplateChild, + #[template_child] + pub stack: TemplateChild, + #[template_child] + pub scroll: TemplateChild, + #[template_child] + pub list_view: TemplateChild, + #[template_child] + pub status_page: TemplateChild, + #[template_child] + pub selection_bar: TemplateChild, + #[template_child] + pub selection_label: TemplateChild, + + /// Select mode: rows show a check and the list takes a multiple + /// selection instead of opening what is clicked. + #[property(get, set = Self::set_selecting)] + pub selecting: Cell, + + pub(super) parts: OnceCell, + /// The search the rows on screen answer. + pub(super) query: RefCell, + pub(super) paging: RefCell, +} + +impl HistoryDialog { + fn set_selecting(&self, selecting: bool) { + if self.selecting.replace(selecting) != selecting { + self.obj().selection_mode_changed(); + } + } +} + +#[glib::object_subclass] +impl ObjectSubclass for HistoryDialog { + const NAME: &'static str = "TableProHistoryDialog"; + type Type = super::HistoryDialog; + type ParentType = adw::Dialog; + + fn class_init(klass: &mut Self::Class) { + klass.bind_template(); + super::history_actions::install(klass); + } + + fn instance_init(object: &glib::subclass::InitializingObject) { + object.init_template(); + } +} + +#[glib::derived_properties] +impl ObjectImpl for HistoryDialog { + fn signals() -> &'static [Signal] { + static SIGNALS: OnceLock> = OnceLock::new(); + SIGNALS.get_or_init(|| { + vec![ + Signal::builder("open-in-new-tab") + .param_types([String::static_type()]) + .build(), + Signal::builder("replace-current-tab") + .param_types([String::static_type()]) + .build(), + ] + }) + } + + /// The row menu is parented to the list view, which would otherwise + /// be finalized with a child it does not know about. + fn dispose(&self) { + if let Some(parts) = self.parts.get() { + parts.menu.unparent(); + } + } +} + +impl WidgetImpl for HistoryDialog {} +impl AdwDialogImpl for HistoryDialog {} diff --git a/linux/crates/app/src/ui/history_dialog/mod.rs b/linux/crates/app/src/ui/history_dialog/mod.rs new file mode 100644 index 0000000000..c625690fa0 --- /dev/null +++ b/linux/crates/app/src/ui/history_dialog/mod.rs @@ -0,0 +1,1110 @@ +//! The query history: a searchable list of what ran, read a page at a +//! time off the GTK thread. + +mod connection_filter; +mod history_actions; +mod history_entry_object; +mod history_entry_object_imp; +mod history_export; +mod history_filter_state; +mod history_filters; +mod history_formatting; +mod history_paging; +mod history_row; +mod imp; +#[cfg(test)] +mod tests; + +use std::collections::HashSet; +use std::rc::Rc; +use std::sync::Arc; +use std::time::SystemTime; + +use gtk4 as gtk; +use gtk4::prelude::*; +use gtk4::subclass::prelude::*; +use gtk4::{gdk, gio, glib, graphene}; +use libadwaita as adw; +use libadwaita::prelude::*; +use tablepro_session::runtime::{TaskFailure, Tasks}; +use tablepro_storage::query_history::{ + Entry, HistoryConnection, HistoryCursor, HistoryPage, HistoryPageRequest, HistoryQuery, StatusFilter, +}; + +use tablepro_storage::{ConnectionStore, QueryHistory, SavedConnection, StorageError}; +use uuid::Uuid; + +use crate::application::{ShortcutOwner, local_shortcut_controller}; +use crate::i18n::{gettext, ngettext_f}; +use crate::runtime::spawn_and_deliver; +use crate::services::history_service::HistoryService; +use crate::ui::file_reveal::{FileRevealer, reveal_failure_toast}; +use crate::ui::format::plural_count; +use history_export::{ExportKind, history_export_failure_text, save_export, show_export_failure}; + +use connection_filter::ConnectionFilterModel; +pub(crate) use history_entry_object::HistoryEntryObject; +use history_filters::HistoryFilters; +pub(super) use history_row::HistoryRow; + +/// Several screens of rows, so the edge is reached rarely, and few +/// enough that the first page is quick on a large history. +const PAGE_SIZE: usize = 100; + +glib::wrapper! { + pub struct HistoryDialog(ObjectSubclass) + @extends adw::Dialog, gtk::Widget, + @implements gtk::Accessible, gtk::Buildable, gtk::ConstraintTarget; +} + +pub(crate) struct HistoryDialogInit { + pub history: HistoryService, + /// Where the filter reads the connections' current names. + pub connections: ConnectionStore, + /// The presenting window's connection, which the filter starts on. + pub preferred_connection: Option, + pub revealer: Rc, + pub tasks: Tasks, +} + +/// Where a query the user picked goes. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum QueryDestination { + NewTab, + CurrentTab, +} + +impl QueryDestination { + fn signal(self) -> &'static str { + match self { + Self::NewTab => "open-in-new-tab", + Self::CurrentTab => "replace-current-tab", + } + } +} + +/// What the dialog is built with and keeps for its life. +pub(super) struct DialogParts { + history: Option, + connections: ConnectionStore, + preferred_connection: Option, + filters: HistoryFilters, + revealer: Rc, + tasks: Tasks, + entries: gio::ListStore, + plain: gtk::NoSelection, + multiple: gtk::MultiSelection, + menu: gtk::PopoverMenu, +} + +impl HistoryDialog { + pub(crate) fn new(init: HistoryDialogInit) -> Self { + let dialog: Self = glib::Object::new(); + let imp = dialog.imp(); + let entries = gio::ListStore::new::(); + let sorted = gtk::SortListModel::new(Some(entries.clone()), None::); + sorted.set_section_sorter(Some(&pinned_first())); + let menu = gtk::PopoverMenu::from_model(None::<&gio::MenuModel>); + menu.set_has_arrow(true); + // Parented now: a popover menu finds its actions through the + // parent it had when it was set up. + menu.set_parent(&imp.list_view.get()); + let filters = HistoryFilters::new(&ConnectionFilterModel::new()); + // After the search bar, so the filters sit under it. + imp.toolbar_view.add_top_bar(filters.root()); + let _ = imp.parts.set(DialogParts { + history: init.history.store(), + connections: init.connections, + preferred_connection: init.preferred_connection, + filters, + revealer: init.revealer, + tasks: init.tasks, + entries, + plain: gtk::NoSelection::new(Some(sorted.clone())), + multiple: gtk::MultiSelection::new(Some(sorted)), + menu, + }); + dialog.setup_list(); + dialog.load_connections(); + dialog + } + + pub(crate) fn connect_query_chosen(&self, f: F) { + let f = Rc::new(f); + for destination in [QueryDestination::NewTab, QueryDestination::CurrentTab] { + let f = f.clone(); + self.connect_closure( + destination.signal(), + false, + glib::closure_local!(move |dialog: &Self, query: String| f(dialog, destination, query)), + ); + } + } + + fn setup_list(&self) { + let imp = self.imp(); + let Some(parts) = imp.parts.get() else { + return; + }; + let list_view = imp.list_view.get(); + list_view.set_model(Some(&parts.plain)); + list_view.set_factory(Some(&self.row_factory())); + list_view.set_header_factory(Some(§ion_header_factory())); + // The Menu key reaches the list view, which has the focused row. + list_view.add_controller(local_shortcut_controller(ShortcutOwner::HistoryRow)); + let dialog = self.downgrade(); + list_view.connect_activate(move |_, position| { + if let Some(dialog) = dialog.upgrade() { + dialog.open_at(position); + } + }); + let dialog = self.downgrade(); + parts.multiple.connect_selection_changed(move |_, _, _| { + if let Some(dialog) = dialog.upgrade() { + dialog.selection_changed(); + } + }); + let dialog = self.downgrade(); + imp.scroll.connect_edge_reached(move |_, edge| { + if edge == gtk::PositionType::Bottom + && let Some(dialog) = dialog.upgrade() + { + dialog.load_more(); + } + }); + imp.search_bar.set_key_capture_widget(Some(&imp.stack.get())); + } + + fn connect_filters(&self) { + let imp = self.imp(); + let dialog = self.downgrade(); + imp.search_entry.connect_search_changed(move |_| { + if let Some(dialog) = dialog.upgrade() { + dialog.refresh(); + } + }); + let Some(parts) = imp.parts.get() else { + return; + }; + let dialog = self.downgrade(); + parts.filters.connect_changed(move |_| { + if let Some(dialog) = dialog.upgrade() { + dialog.refresh(); + } + }); + } + + fn row_factory(&self) -> gtk::SignalListItemFactory { + let factory = gtk::SignalListItemFactory::new(); + let dialog = self.downgrade(); + factory.connect_setup(move |_, object| { + let (Some(item), Some(dialog)) = (object.downcast_ref::(), dialog.upgrade()) else { + return; + }; + let row = HistoryRow::default(); + dialog + .bind_property("selecting", &row.check(), "visible") + .sync_create() + .build(); + dialog.follow_selection(item, &row); + dialog.toggle_on_click(item, &row); + dialog.attach_row_menu(&row); + item.set_child(Some(&row)); + }); + factory.connect_bind(|_, object| { + let Some(item) = object.downcast_ref::() else { + return; + }; + let (Some(row), Some(entry)) = ( + item.child().and_downcast::(), + item.item().and_downcast::(), + ) else { + return; + }; + row.bind(&entry, SystemTime::now()); + row.check().set_active(item.is_selected()); + }); + factory.connect_unbind(|_, object| { + if let Some(row) = object + .downcast_ref::() + .and_then(|item| item.child().and_downcast::()) + { + row.unbind(); + } + }); + factory + } + + /// The row's check and the list's selection say the same thing, + /// whichever of them the user changes. + fn follow_selection(&self, item: >k::ListItem, row: &HistoryRow) { + let check = row.check(); + let shown = check.downgrade(); + item.connect_selected_notify(move |item| { + if let Some(check) = shown.upgrade() { + check.set_active(item.is_selected()); + } + }); + let (dialog, item) = (self.downgrade(), item.downgrade()); + check.connect_toggled(move |check| { + let (Some(dialog), Some(item)) = (dialog.upgrade(), item.upgrade()) else { + return; + }; + if item.is_selected() == check.is_active() { + return; + } + let Some(parts) = dialog.imp().parts.get() else { + return; + }; + let position = item.position(); + if check.is_active() { + parts.multiple.select_item(position, false); + } else { + parts.multiple.unselect_item(position); + } + }); + } + + /// In select mode a click on a row toggles it, as its check does. + /// The list view's own click would select that row alone, and drop + /// the others the user ticked. + fn toggle_on_click(&self, item: >k::ListItem, row: &HistoryRow) { + let gesture = gtk::GestureClick::new(); + gesture.set_button(gdk::BUTTON_PRIMARY); + let (dialog, item) = (self.downgrade(), item.downgrade()); + gesture.connect_released(move |gesture, _, _, _| { + let (Some(dialog), Some(item), Some(row)) = (dialog.upgrade(), item.upgrade(), gesture.widget()) else { + return; + }; + // Shift and Ctrl clicks keep the list view's range and toggle. + let modified = gesture + .current_event_state() + .intersects(gdk::ModifierType::SHIFT_MASK | gdk::ModifierType::CONTROL_MASK); + if !dialog.selecting() || modified { + return; + } + gesture.set_state(gtk::EventSequenceState::Claimed); + let toggle = (item.position(), true, false).to_variant(); + if let Err(error) = row.activate_action("list.select-item", Some(&toggle)) { + tracing::warn!(%error, "could not toggle a history row"); + } + }); + row.add_controller(gesture); + } + + fn attach_row_menu(&self, row: &HistoryRow) { + let gesture = gtk::GestureClick::new(); + gesture.set_button(gdk::BUTTON_SECONDARY); + let dialog = self.downgrade(); + gesture.connect_pressed(move |gesture, _, x, y| { + let (Some(dialog), Some(row)) = (dialog.upgrade(), gesture.widget().and_downcast::()) else { + return; + }; + gesture.set_state(gtk::EventSequenceState::Claimed); + dialog.popup_row_menu(&row, Some(graphene::Point::new(x as f32, y as f32))); + }); + row.add_controller(gesture); + } + + /// Open the menu for `row`'s entry, at `at` in the row, or over the + /// whole row for the Menu key. + fn popup_row_menu(&self, row: &HistoryRow, at: Option) { + let imp = self.imp(); + let (Some(parts), Some(entry)) = (imp.parts.get(), row.entry()) else { + return; + }; + let list_view = imp.list_view.get(); + // The menu is parented to the list view, so it points in the + // view's coordinates, not the row's. + let pointing = match at { + Some(point) => row + .compute_point(&list_view, &point) + .map(|point| gdk::Rectangle::new(point.x() as i32, point.y() as i32, 1, 1)), + None => row.compute_bounds(&list_view).map(|bounds| { + gdk::Rectangle::new( + bounds.x() as i32, + bounds.y() as i32, + bounds.width() as i32, + bounds.height() as i32, + ) + }), + }; + parts.menu.set_menu_model(Some(&row_menu(&entry))); + parts.menu.set_pointing_to(pointing.as_ref()); + parts.menu.popup(); + } + + fn popup_focused_row_menu(&self) { + let Some(item) = self.imp().list_view.focus_child() else { + return; + }; + let mut child = item.first_child(); + while let Some(widget) = child { + if let Some(row) = widget.downcast_ref::() { + self.popup_row_menu(row, None); + return; + } + child = widget.next_sibling(); + } + } + + fn selection_mode_changed(&self) { + let imp = self.imp(); + let Some(parts) = imp.parts.get() else { + return; + }; + let selecting = self.selecting(); + if selecting { + imp.list_view.set_model(Some(&parts.multiple)); + } else { + parts.multiple.unselect_all(); + imp.list_view.set_model(Some(&parts.plain)); + } + // A click picks a row in select mode rather than opening it. + imp.list_view.set_single_click_activate(!selecting); + self.selection_changed(); + } + + fn selection_changed(&self) { + let imp = self.imp(); + let count = if self.selecting() { self.selected_ids().len() } else { 0 }; + if count == 0 { + imp.selection_bar.set_reveal_child(false); + return; + } + let n = count as u64; + imp.selection_label.set_label(&ngettext_f( + "{n} selected", + "{n} selected", + plural_count(n), + &[("n", &n.to_string())], + )); + imp.selection_bar.set_reveal_child(true); + } + + fn selected_ids(&self) -> Vec { + let Some(parts) = self.imp().parts.get() else { + return Vec::new(); + }; + let selection = &parts.multiple; + (0..selection.n_items()) + .filter(|position| selection.is_selected(*position)) + .filter_map(|position| selection.item(position).and_downcast::()) + .filter_map(|entry| entry.id()) + .collect() + } + + fn reselect(&self, ids: &HashSet) { + let Some(parts) = self.imp().parts.get() else { + return; + }; + if ids.is_empty() { + return; + } + let selection = &parts.multiple; + for position in 0..selection.n_items() { + let keep = selection + .item(position) + .and_downcast::() + .and_then(|entry| entry.id()) + .is_some_and(|id| ids.contains(&id)); + if keep { + selection.select_item(position, false); + } + } + } + + fn entry_by_id(&self, id: i64) -> Option { + let parts = self.imp().parts.get()?; + parts + .entries + .iter::() + .filter_map(Result::ok) + .find(|entry| entry.id() == Some(id)) + } + + fn open_at(&self, position: u32) { + if self.selecting() { + return; + } + let entry = self + .imp() + .list_view + .model() + .and_then(|model| model.item(position)) + .and_downcast::(); + if let Some(id) = entry.and_then(|entry| entry.id()) { + self.send_query(id, QueryDestination::NewTab); + } + } + + fn send_query(&self, id: i64, destination: QueryDestination) { + let Some(query) = self.entry_by_id(id).and_then(|entry| entry.query()) else { + return; + }; + self.emit_by_name::<()>(destination.signal(), &[&query]); + self.close(); + } + + fn copy_query(&self, id: i64) { + if let Some(query) = self.entry_by_id(id).and_then(|entry| entry.query()) { + crate::ui::clipboard::copy_text(self, query, |message| self.add_toast(message)); + } + } + + fn storage(&self) -> Option<(QueryHistory, Tasks)> { + let parts = self.imp().parts.get()?; + Some((parts.history.clone()?, parts.tasks.clone())) + } + + /// Run `work` against the history off the GTK thread, then read the + /// list and the connections it holds again once it has landed. + /// `failed` is what a toast says when the change does not land. + fn change_then_refresh( + &self, + what: &'static str, + failed: String, + work: impl FnOnce(QueryHistory) -> W + Send + 'static, + ) where + W: Future> + Send + 'static, + { + let Some((history, tasks)) = self.storage() else { + return; + }; + let dialog = self.downgrade(); + drop(spawn_and_deliver( + &tasks, + async move { work(history).await }, + move |done| { + let Some(dialog) = dialog.upgrade() else { + return; + }; + match done { + Ok(Ok(())) => {} + Ok(Err(error)) => { + tracing::warn!(%error, what, "a query history change failed"); + dialog.add_toast(&failed); + } + Err(failure) => { + tracing::warn!(%failure, what, "a query history task failed"); + dialog.add_toast(&failed); + } + } + dialog.refresh(); + dialog.reload_connections(); + }, + )); + } + + /// The row moves to where the history lists it now, and the pages + /// read so far stay, with the scroll position and the selection. + fn toggle_pin(&self, id: i64) { + let Some(entry) = self.entry_by_id(id) else { + return; + }; + let Some((history, tasks)) = self.storage() else { + return; + }; + let pinned = !entry.is_pinned(); + let dialog = self.downgrade(); + drop(spawn_and_deliver( + &tasks, + async move { history.set_pinned(id, pinned).await }, + move |done| { + let Some(dialog) = dialog.upgrade() else { + return; + }; + let failure = match done { + Ok(Ok(())) => { + dialog.pin_changed(id, pinned); + return; + } + Ok(Err(error)) => error.to_string(), + Err(failure) => failure.to_string(), + }; + tracing::warn!(%failure, pinned, "could not change a query history pin"); + dialog.add_toast(&if pinned { + gettext("The query could not be pinned.") + } else { + gettext("The query could not be unpinned.") + }); + }, + )); + } + + fn pin_changed(&self, id: i64, pinned: bool) { + let imp = self.imp(); + let Some(parts) = imp.parts.get() else { + return; + }; + let Some(position) = parts + .entries + .iter::() + .position(|object| object.is_ok_and(|object| object.id() == Some(id))) + else { + return; + }; + let Some(mut entry) = parts + .entries + .item(position as u32) + .and_downcast::() + .and_then(|object| object.entry().cloned()) + else { + return; + }; + // A refresh that read the row after the change already shows it. + if entry.pinned == pinned { + return; + } + entry.pinned = pinned; + let selected = self.selected_ids().contains(&id); + parts.entries.remove(position as u32); + let key = HistoryCursor::after(&entry); + // One that now sorts past the pages read comes back with its page. + if imp.paging.borrow().covers(key) { + let at = parts + .entries + .iter::() + .filter_map(Result::ok) + .position(|other| other.entry().is_some_and(|other| HistoryCursor::after(other) < key)) + .unwrap_or(parts.entries.n_items() as usize); + parts.entries.insert(at as u32, &HistoryEntryObject::new(entry)); + if selected { + self.reselect(&HashSet::from([id])); + } + } + self.selection_changed(); + self.show_results(); + } + + /// Delete at once and offer Undo, rather than ask first: the entries + /// come back as they were. + fn delete_entries(&self, ids: Vec) { + if ids.is_empty() { + return; + } + let Some((history, tasks)) = self.storage() else { + return; + }; + let dialog = self.downgrade(); + drop(spawn_and_deliver( + &tasks, + async move { + let deleted = history.delete_returning(&ids).await; + (ids, deleted) + }, + move |done| { + let Some(dialog) = dialog.upgrade() else { + return; + }; + match done { + Ok((ids, Ok(entries))) => dialog.entries_deleted(&ids, entries), + Ok((_, Err(error))) => { + tracing::warn!(%error, "could not delete from the query history"); + dialog.add_toast(&gettext("The queries could not be deleted.")); + } + Err(failure) => tracing::warn!(%failure, "the query history delete failed"), + } + }, + )); + } + + /// Every row asked for goes, including one the history no longer + /// had; only what was deleted here can be undone. + fn entries_deleted(&self, requested: &[i64], entries: Vec) { + let imp = self.imp(); + let Some(parts) = imp.parts.get() else { + return; + }; + let gone: HashSet = requested.iter().copied().collect(); + parts.entries.retain(|object| { + object + .downcast_ref::() + .and_then(HistoryEntryObject::id) + .is_none_or(|id| !gone.contains(&id)) + }); + // The next page loads when the list is scrolled to its end, which + // rows too few to fill it never are. + if parts.entries.n_items() < PAGE_SIZE as u32 { + self.load_more(); + } + self.selection_changed(); + if !imp.paging.borrow().is_loading_more() { + self.show_results(); + } + self.reload_connections(); + if entries.is_empty() { + return; + } + + let count = entries.len() as u64; + let toast = crate::ui::plain_toast(&ngettext_f( + "{count} query deleted", + "{count} queries deleted", + plural_count(count), + &[("count", &count.to_string())], + )); + toast.set_button_label(Some(&gettext("_Undo"))); + let dialog = self.downgrade(); + toast.connect_button_clicked(move |_| { + if let Some(dialog) = dialog.upgrade() { + let entries = entries.clone(); + dialog.change_then_refresh( + "restore", + gettext("The queries could not be restored."), + move |history| async move { history.restore(&entries).await }, + ); + } + }); + imp.toast_overlay.add_toast(toast); + } + + fn delete_selected(&self) { + self.delete_entries(self.selected_ids()); + } + + fn confirm_clear_all(&self) { + let alert = adw::AlertDialog::new( + Some(&gettext("Clear All Query History?")), + Some(&gettext( + "This permanently deletes every history entry, including pinned ones. Saved queries are kept.", + )), + ); + alert.add_response("cancel", &gettext("Cancel")); + alert.add_response("clear", &gettext("Clear")); + alert.set_response_appearance("clear", adw::ResponseAppearance::Destructive); + alert.set_default_response(Some("cancel")); + alert.set_close_response("cancel"); + let dialog = self.downgrade(); + alert.connect_response(None, move |_, response| { + if response == "clear" + && let Some(dialog) = dialog.upgrade() + { + dialog.change_then_refresh( + "clear", + gettext("The query history could not be cleared."), + |history| async move { history.clear_all().await.map(drop) }, + ); + } + }); + alert.present(Some(self)); + } + + /// Show the database file selected in its folder; a failure says so + /// in the dialog. + fn show_in_files(&self) { + let Some(parts) = self.imp().parts.get() else { + return; + }; + let Some(history) = parts.history.as_ref() else { + return; + }; + let revealing = parts + .revealer + .reveal(gio::File::for_path(history.path()), self.root().and_downcast()); + let dialog = self.downgrade(); + glib::spawn_future_local(async move { + let revealed = revealing.await; + if let (Some(toast), Some(dialog)) = (reveal_failure_toast(&revealed), dialog.upgrade()) { + dialog.imp().toast_overlay.add_toast(toast); + } + }); + } + + fn export_selected(&self, kind: ExportKind) { + let ids = self.selected_ids(); + if ids.is_empty() { + return; + } + let Some((history, tasks)) = self.storage() else { + return; + }; + let dialog = self.downgrade(); + drop(spawn_and_deliver(&tasks, kind.render(history, ids), move |rendered| { + let Some(dialog) = dialog.upgrade() else { + return; + }; + match rendered { + Ok(Ok(content)) => save_export(dialog.upcast_ref(), kind, content), + Ok(Err(error)) => { + tracing::warn!(%error, "history export failed"); + show_export_failure(dialog.upcast_ref(), &history_export_failure_text(&error)); + } + Err(failure) => tracing::warn!(%failure, "the history export task failed"), + } + })); + } + + /// List every connection the history holds entries for, so one that + /// is not open can still be picked, then read the first page with + /// the filter on the presenting window's connection. + fn load_connections(&self) { + let Some(parts) = self.imp().parts.get() else { + return; + }; + if parts.history.is_none() { + self.connect_filters(); + self.show_unavailable(); + return; + } + let preferred = parts.preferred_connection; + self.read_connections(move |dialog, connections| { + if let Some((listed, saved)) = connections { + dialog.fill_connections(&listed, &saved, preferred); + } + dialog.connect_filters(); + dialog.refresh(); + }); + } + + /// After a change that can add a connection's first entry or drop + /// its last. The filter stays on its connection while that one + /// still has history. + fn reload_connections(&self) { + self.read_connections(|dialog, connections| { + let (Some((listed, saved)), Some(parts)) = (connections, dialog.imp().parts.get()) else { + return; + }; + let current = parts.filters.state().connection; + dialog.fill_connections(&listed, &saved, current); + }); + } + + /// `then` gets the connections with history and the saved ones, or + /// `None` when the history could not list them. + fn read_connections( + &self, + then: impl FnOnce(&Self, Option<(Vec, Arc<[SavedConnection]>)>) + 'static, + ) { + let Some(parts) = self.imp().parts.get() else { + return; + }; + let Some(history) = parts.history.clone() else { + return; + }; + let (store, tasks) = (parts.connections.clone(), parts.tasks.clone()); + let blocking = tasks.clone(); + let dialog = self.downgrade(); + drop(spawn_and_deliver( + &tasks, + async move { + let listed = history.connections_with_history().await; + let saved = blocking.spawn_blocking_task(move || store.load_blocking()).await; + (listed, saved) + }, + move |loaded| { + let Some(dialog) = dialog.upgrade() else { + return; + }; + let connections = match loaded { + Ok((listed, saved)) => connections_read(listed, saved), + Err(failure) => { + tracing::warn!(%failure, "the history connections task failed"); + None + } + }; + then(&dialog, connections); + }, + )); + } + + fn fill_connections(&self, listed: &[HistoryConnection], saved: &[SavedConnection], select: Option) { + let Some(parts) = self.imp().parts.get() else { + return; + }; + parts.filters.connections().fill(listed, saved); + parts.filters.select_connection(select); + } + + fn query_from_filters(&self) -> HistoryQuery { + let imp = self.imp(); + let mut query = HistoryQuery::from_input(&imp.search_entry.text()); + if let Some(parts) = imp.parts.get() { + let state = parts.filters.state(); + query.connection = state.connection; + query.status = state.status; + query.executed_after = state.window.executed_after(SystemTime::now()); + } + query + } + + /// Read the first page for the current search. The rows on screen + /// stay until it arrives, so typing never blanks the list. + fn refresh(&self) { + let imp = self.imp(); + let query = self.query_from_filters(); + imp.query.replace(query.clone()); + let generation = imp.paging.borrow_mut().start_refresh(); + let Some((history, tasks)) = self.storage() else { + self.show_unavailable(); + return; + }; + let dialog = self.downgrade(); + drop(spawn_and_deliver( + &tasks, + async move { history.search_page(&query, HistoryPageRequest::first(PAGE_SIZE)).await }, + move |page| { + if let Some(dialog) = dialog.upgrade() { + dialog.first_page_arrived(generation, page); + } + }, + )); + } + + fn first_page_arrived(&self, generation: u64, page: Result, TaskFailure>) { + let imp = self.imp(); + let Some(parts) = imp.parts.get() else { + return; + }; + let page = match page { + Ok(Ok(page)) => page, + Ok(Err(error)) => { + tracing::warn!(%error, "could not read the query history"); + self.first_page_failed(generation); + return; + } + Err(failure) => { + tracing::warn!(%failure, "the query history read failed"); + self.first_page_failed(generation); + return; + } + }; + if !imp.paging.borrow_mut().first_page_arrived(generation, page.next) { + return; + } + let selected: HashSet = self.selected_ids().into_iter().collect(); + let objects: Vec = page.entries.into_iter().map(HistoryEntryObject::new).collect(); + parts.entries.splice(0, parts.entries.n_items(), &objects); + self.reselect(&selected); + // Rows that went with the splice leave the selection without a + // selection-changed, so the count is read again here. + self.selection_changed(); + self.show_results(); + } + + /// A refresh that fails keeps the rows the last one showed. + fn first_page_failed(&self, generation: u64) { + let imp = self.imp(); + let paging = imp.paging.borrow(); + if !paging.is_current(generation) { + return; + } + if paging.has_loaded() { + drop(paging); + self.add_toast(&gettext("The query history could not be read.")); + } else { + drop(paging); + self.show_unavailable(); + } + } + + fn load_more(&self) { + let imp = self.imp(); + let Some((history, tasks)) = self.storage() else { + return; + }; + let Some((generation, after)) = imp.paging.borrow_mut().start_more() else { + return; + }; + let query = imp.query.borrow().clone(); + let dialog = self.downgrade(); + let request = HistoryPageRequest { + after: Some(after), + limit: PAGE_SIZE, + }; + drop(spawn_and_deliver( + &tasks, + async move { history.search_page(&query, request).await }, + move |page| { + if let Some(dialog) = dialog.upgrade() { + dialog.more_arrived(generation, page); + } + }, + )); + } + + fn more_arrived(&self, generation: u64, page: Result, TaskFailure>) { + let imp = self.imp(); + let Some(parts) = imp.parts.get() else { + return; + }; + let page = match page { + Ok(Ok(page)) => page, + Ok(Err(error)) => { + tracing::warn!(%error, "could not read the next page of the query history"); + self.more_failed(generation); + return; + } + Err(failure) => { + tracing::warn!(%failure, "the next query history page failed"); + self.more_failed(generation); + return; + } + }; + if !imp.paging.borrow_mut().more_arrived(generation, page.next) { + return; + } + let objects: Vec = page.entries.into_iter().map(HistoryEntryObject::new).collect(); + parts.entries.splice(parts.entries.n_items(), 0, &objects); + // A delete that emptied the list waits for this page to say + // whether anything is left. + self.show_results(); + } + + /// The next edge reached tries again. With no rows left on screen + /// there is no edge to reach, so the list says it could not read. + fn more_failed(&self, generation: u64) { + let imp = self.imp(); + imp.paging.borrow_mut().more_failed(generation); + let empty = imp.parts.get().is_some_and(|parts| parts.entries.n_items() == 0); + if empty && imp.paging.borrow().is_current(generation) { + self.show_unavailable(); + } + } + + fn show_results(&self) { + let imp = self.imp(); + let Some(parts) = imp.parts.get() else { + return; + }; + if parts.entries.n_items() > 0 { + imp.stack.set_visible_child_name("list"); + return; + } + let query = imp.query.borrow(); + let narrowed = !query.terms().is_empty() + || query.connection.is_some() + || query.status != StatusFilter::Any + || query.executed_after.is_some(); + let page = &imp.status_page; + if narrowed { + page.set_icon_name(Some(crate::ui::icons::SYSTEM_SEARCH)); + page.set_title(&gettext("No Matches")); + page.set_description(Some(&gettext("Try a different search term or change the filters."))); + } else { + page.set_icon_name(Some(crate::ui::icons::DOCUMENT_OPEN_RECENT)); + page.set_title(&gettext("No Queries Yet")); + page.set_description(Some(&gettext("Run a query in the SQL editor and it will appear here."))); + } + imp.stack.set_visible_child_name("empty"); + } + + fn show_unavailable(&self) { + let imp = self.imp(); + imp.status_page.set_icon_name(Some(crate::ui::icons::DIALOG_ERROR)); + imp.status_page.set_title(&gettext("History Unavailable")); + imp.status_page + .set_description(Some(&gettext("The query history could not be read."))); + imp.stack.set_visible_child_name("empty"); + } + + fn add_toast(&self, message: &str) { + self.imp().toast_overlay.add_toast(crate::ui::plain_toast(message)); + } +} + +/// Without the saved list the history's own names still serve. +fn connections_read( + listed: Result, StorageError>, + saved: Result, StorageError>, TaskFailure>, +) -> Option<(Vec, Arc<[SavedConnection]>)> { + let listed = match listed { + Ok(listed) => listed, + Err(error) => { + tracing::warn!(%error, "could not list the connections in the history"); + return None; + } + }; + let saved = match saved { + Ok(Ok(saved)) => saved, + Ok(Err(error)) => { + tracing::warn!(%error, "could not read the saved connections for the history filter"); + Arc::from([]) + } + Err(failure) => { + tracing::warn!(%failure, "the saved connections read failed"); + Arc::from([]) + } + }; + Some((listed, saved)) +} + +/// Pinned entries form the first section. Every entry within a section +/// compares equal, so the order the history returned holds inside it. +fn pinned_first() -> gtk::CustomSorter { + gtk::CustomSorter::new(|a, b| { + let pinned = |object: &glib::Object| { + object + .downcast_ref::() + .is_some_and(HistoryEntryObject::is_pinned) + }; + pinned(b).cmp(&pinned(a)).into() + }) +} + +fn section_header_factory() -> gtk::SignalListItemFactory { + let factory = gtk::SignalListItemFactory::new(); + factory.connect_setup(|_, object| { + let Some(header) = object.downcast_ref::() else { + return; + }; + let label = gtk::Label::builder() + .xalign(0.0) + .margin_top(12) + .margin_bottom(6) + .margin_start(6) + .build(); + label.add_css_class("heading"); + header.set_child(Some(&label)); + }); + factory.connect_bind(|_, object| { + let Some(header) = object.downcast_ref::() else { + return; + }; + let Some(label) = header.child().and_downcast::() else { + return; + }; + let pinned = header + .item() + .and_downcast::() + .is_some_and(|entry| entry.is_pinned()); + label.set_label(&if pinned { + gettext("Pinned") + } else { + gettext("All Queries") + }); + }); + factory +} + +/// The row menu for one entry. Each item carries the entry's id, so the +/// one popover serves every row. +fn row_menu(entry: &HistoryEntryObject) -> gio::Menu { + let Some(id) = entry.id() else { + return gio::Menu::new(); + }; + let target = id.to_variant(); + let item = |label: String, action: &str| { + let item = gio::MenuItem::new(Some(&label), None); + item.set_action_and_target_value(Some(action), Some(&target)); + item + }; + let open = gio::Menu::new(); + open.append_item(&item(gettext("Open in New Tab"), "history.open")); + open.append_item(&item(gettext("Replace Current Tab"), "history.replace")); + let keep = gio::Menu::new(); + let pin = if entry.is_pinned() { + gettext("Unpin") + } else { + gettext("Pin") + }; + keep.append_item(&item(pin, "history.pin")); + keep.append_item(&item(gettext("Copy SQL"), "history.copy")); + let remove = gio::Menu::new(); + remove.append_item(&item(gettext("Delete"), "history.delete")); + let menu = gio::Menu::new(); + menu.append_section(None, &open); + menu.append_section(None, &keep); + menu.append_section(None, &remove); + menu +} diff --git a/linux/crates/app/src/ui/history_dialog/tests.rs b/linux/crates/app/src/ui/history_dialog/tests.rs new file mode 100644 index 0000000000..669ad73736 --- /dev/null +++ b/linux/crates/app/src/ui/history_dialog/tests.rs @@ -0,0 +1,912 @@ +use std::cell::RefCell; +use std::rc::Rc; +use std::time::{Duration, SystemTime}; + +use gtk4 as gtk; +use gtk4::prelude::*; +use gtk4::subclass::prelude::*; +use gtk4::{gio, glib}; +use libadwaita as adw; +use libadwaita::prelude::*; +use tablepro_storage::query_history::{NewEntry, Outcome}; +use tablepro_storage::{ConnectionStore, QueryHistory, SavedConnection, StoragePaths}; +use tokio::sync::watch; +use uuid::Uuid; + +use super::*; +use crate::runtime::AppRuntime; +use crate::services::history_preferences::HistoryPreferences; +use crate::test_support::{descendants, drain_main_context, test_runtime, wait_until}; +use crate::ui::file_reveal::RecordingRevealer; + +/// Long enough that a loaded machine cannot decide the outcome; a +/// passing test returns as soon as its condition holds. +pub(super) const WAIT: Duration = Duration::from_secs(30); + +pub(super) struct Harness { + pub runtime: AppRuntime, + pub service: HistoryService, + pub store: QueryHistory, + pub connections: ConnectionStore, + pub revealer: Rc, + pub window: adw::Window, + _root: tempfile::TempDir, +} + +impl Harness { + pub fn new() -> Self { + Self::revealing_with(RecordingRevealer::succeeding()) + } + + pub fn revealing_with(revealer: RecordingRevealer) -> Self { + crate::register_test_resources(); + let runtime = test_runtime(); + let root = tempfile::tempdir().expect("tempdir"); + let paths = StoragePaths::under(root.path(), "tablepro-test", "app.tablepro.TablePro.Devel"); + let preferences = HistoryPreferences { + retention_days: watch::channel(0).1, + save: watch::channel(true).1, + }; + let connections = ConnectionStore::new(&paths); + let service = HistoryService::start(paths, preferences, &runtime.tasks()); + let availability = service.availability(); + wait_until(WAIT, || availability.borrow().is_ready()).expect("the history to open"); + let store = service.store().expect("the history"); + let window = adw::Window::new(); + window.set_default_size(640, 640); + window.present(); + Self { + runtime, + service, + store, + connections, + revealer: Rc::new(revealer), + window, + _root: root, + } + } + + pub fn record(&self, query: &str) -> i64 { + self.record_on(query, Uuid::nil(), "local") + } + + pub fn record_on(&self, query: &str, connection: Uuid, name: &str) -> i64 { + block_on( + &self.runtime, + self.store.record(NewEntry { + query: query.to_owned(), + driver_id: "postgres".to_owned(), + connection_id: connection, + connection_name: name.to_owned(), + executed_at: SystemTime::now(), + duration_ms: Some(1), + rows_affected: Some(1), + outcome: Outcome::Success, + }), + ) + .expect("record") + } + + pub fn pin(&self, id: i64) { + block_on(&self.runtime, self.store.set_pinned(id, true)).expect("pin"); + } + + pub fn save_connection(&self, id: Uuid, name: &str) { + self.connections + .upsert_blocking(SavedConnection { + id, + name: name.to_owned(), + driver_id: "postgres".to_owned(), + host: "db".to_owned(), + port: 5432, + database: "app".to_owned(), + username: "postgres".to_owned(), + tls: tablepro_core::TlsConfig::verify_full(), + read_only: false, + auth_mode: tablepro_core::AuthMode::Password, + ssh: None, + last_opened_at: None, + color: None, + group: None, + }) + .expect("save the connection"); + } + + pub fn dialog(&self) -> HistoryDialog { + self.dialog_for(None) + } + + pub fn dialog_for(&self, preferred_connection: Option) -> HistoryDialog { + HistoryDialog::new(HistoryDialogInit { + history: self.service.clone(), + connections: self.connections.clone(), + preferred_connection, + revealer: self.revealer.clone(), + tasks: self.runtime.tasks(), + }) + } + + /// A dialog on the test window, so its list view lays out rows. + pub fn presented_dialog(&self) -> HistoryDialog { + self.present(self.dialog()) + } + + pub fn present(&self, dialog: HistoryDialog) -> HistoryDialog { + dialog.present(Some(&self.window)); + dialog + } + + pub fn stored_queries(&self) -> Vec { + block_on( + &self.runtime, + self.store + .search_page(&HistoryQuery::default(), HistoryPageRequest::first(100)), + ) + .expect("search") + .entries + .into_iter() + .map(|entry| entry.query) + .collect() + } +} + +impl Drop for Harness { + fn drop(&mut self) { + crate::test_support::drop_focus(&self.window); + self.window.destroy(); + drain_main_context(); + } +} + +/// The test drives the runtime from outside it, which is the one place +/// blocking on it cannot deadlock. +#[expect( + clippy::disallowed_methods, + reason = "a GTK test has no runtime of its own, so it drives the app's from outside" +)] +pub(super) fn block_on(runtime: &AppRuntime, future: F) -> F::Output { + runtime.tasks().handle().block_on(future) +} + +pub(super) fn entries(dialog: &HistoryDialog) -> Vec { + let Some(parts) = dialog.imp().parts.get() else { + return Vec::new(); + }; + parts + .entries + .iter::() + .filter_map(Result::ok) + .filter_map(|entry| entry.query()) + .collect() +} + +pub(super) fn rows(dialog: &HistoryDialog) -> Vec { + descendants(&dialog.imp().list_view.get()) + .into_iter() + .filter_map(|widget| widget.downcast::().ok()) + .filter(|row| row.entry().is_some()) + .collect() +} + +pub(super) fn row_titled(dialog: &HistoryDialog, title: &str) -> Option { + rows(dialog).into_iter().find(|row| row.title() == title) +} + +/// What the menu's item called `label` activates, and with what target. +pub(super) fn menu_item(menu: &gio::MenuModel, label: &str) -> Option<(String, Option)> { + for index in 0..menu.n_items() { + if let Some(section) = menu.item_link(index, gio::MENU_LINK_SECTION) + && let Some(found) = menu_item(§ion, label) + { + return Some(found); + } + let text = menu.item_attribute_value(index, gio::MENU_ATTRIBUTE_LABEL, Some(glib::VariantTy::STRING)); + if text.and_then(|text| text.get::()).as_deref() == Some(label) { + let action = menu + .item_attribute_value(index, gio::MENU_ATTRIBUTE_ACTION, Some(glib::VariantTy::STRING))? + .get::()?; + let target = menu.item_attribute_value(index, gio::MENU_ATTRIBUTE_TARGET, None); + return Some((action, target)); + } + } + None +} + +/// Every row has the same check box, Pin and Delete, so each says which +/// query it acts on. +#[gtk4::test] +fn each_rows_controls_name_their_query() { + let harness = Harness::new(); + harness.record("SELECT 1"); + let dialog = harness.presented_dialog(); + wait_until(WAIT, || !rows(&dialog).is_empty()).expect("the row"); + let row = rows(&dialog).remove(0); + + let controls: Vec = descendants(&row) + .into_iter() + .filter(|widget| widget.is::() || widget.is::()) + .collect(); + assert_eq!(controls.len(), 3, "the check box, Pin and Delete"); + for control in controls { + assert!( + gtk::test_accessible_has_relation(&control, gtk::AccessibleRelation::DescribedBy), + "{} does not name its query", + control.type_().name() + ); + } +} + +#[gtk4::test] +fn list_view_shows_pinned_section_header() { + let harness = Harness::new(); + let pinned = harness.record("SELECT pinned"); + harness.pin(pinned); + harness.record("SELECT plain"); + + let dialog = harness.presented_dialog(); + let list_view = dialog.imp().list_view.get(); + let header = |text: &str| { + descendants(&list_view) + .into_iter() + .filter_map(|widget| widget.downcast::().ok()) + .find(|label| label.has_css_class("heading") && label.label() == text) + }; + wait_until(WAIT, || header("Pinned").is_some() && header("All Queries").is_some()) + .expect("a header for each section"); + + let top_of = |widget: Option| { + widget + .filter(|widget| widget.height() > 0) + .and_then(|widget| widget.compute_point(&list_view, &graphene::Point::new(0.0, 0.0))) + .map(|point| point.y()) + }; + let order = || { + [ + top_of(header("Pinned").map(Cast::upcast)), + top_of(row_titled(&dialog, "SELECT pinned").map(Cast::upcast)), + top_of(header("All Queries").map(Cast::upcast)), + top_of(row_titled(&dialog, "SELECT plain").map(Cast::upcast)), + ] + }; + let in_order = || { + let tops = order(); + tops.iter().all(Option::is_some) && tops.windows(2).all(|pair| pair[0] < pair[1]) + }; + let laid_out = wait_until(WAIT, in_order); + assert!( + laid_out.is_ok(), + "headers and rows are not in section order: {:?}", + order() + ); + dialog.close(); +} + +#[gtk4::test] +fn edge_reached_appends_next_page() { + let harness = Harness::new(); + let total = PAGE_SIZE + 30; + for index in 0..total { + harness.record(&format!("SELECT {index}")); + } + + let dialog = harness.dialog(); + wait_until(WAIT, || entries(&dialog).len() == PAGE_SIZE).expect("the first page"); + + let scroll = dialog.imp().scroll.get(); + scroll.emit_by_name::<()>("edge-reached", &[>k::PositionType::Bottom]); + // A second edge while the first read is out must not read the same + // page again. + scroll.emit_by_name::<()>("edge-reached", &[>k::PositionType::Bottom]); + wait_until(WAIT, || entries(&dialog).len() >= total).expect("the next page"); + scroll.emit_by_name::<()>("edge-reached", &[>k::PositionType::Bottom]); + crate::test_support::drain_main_context(); + std::thread::sleep(Duration::from_millis(50)); + crate::test_support::drain_main_context(); + + let shown = entries(&dialog); + assert_eq!(shown.len(), total, "a page was read twice or past the end"); + let mut distinct = shown.clone(); + distinct.sort(); + distinct.dedup(); + assert_eq!(distinct.len(), total); + assert_eq!( + shown.first().map(String::as_str), + Some(format!("SELECT {}", total - 1).as_str()) + ); +} + +#[gtk4::test] +fn refresh_keeps_rows_until_results_arrive() { + let harness = Harness::new(); + harness.record("SELECT alpha"); + harness.record("SELECT beta"); + let dialog = harness.presented_dialog(); + wait_until(WAIT, || entries(&dialog).len() == 2).expect("the first page"); + + dialog.imp().search_entry.set_text("alpha"); + dialog.refresh(); + + assert_eq!(entries(&dialog).len(), 2, "the rows went before the new ones came"); + assert_eq!(dialog.imp().stack.visible_child_name().as_deref(), Some("list")); + wait_until(WAIT, || entries(&dialog) == ["SELECT alpha"]).expect("the search result"); + dialog.close(); +} + +#[gtk4::test] +fn row_context_menu_open_emits_output_for_clicked_id() { + let harness = Harness::new(); + harness.record("SELECT first"); + harness.record("SELECT second"); + let dialog = harness.presented_dialog(); + let chosen: Rc>> = Rc::default(); + let sink = chosen.clone(); + dialog.connect_query_chosen(move |_, destination, query| sink.borrow_mut().push((destination, query))); + wait_until(WAIT, || row_titled(&dialog, "SELECT first").is_some()).expect("the rows"); + let row = row_titled(&dialog, "SELECT first").expect("the row"); + + let controllers = row.observe_controllers(); + let gesture = (0..controllers.n_items()) + .filter_map(|index| controllers.item(index).and_downcast::()) + .find(|gesture| gesture.button() == gtk::gdk::BUTTON_SECONDARY) + .expect("a row answers a right-click"); + gesture.emit_by_name::<()>("pressed", &[&1i32, &4.0f64, &4.0f64]); + + let menu = dialog.imp().parts.get().expect("parts").menu.clone(); + assert!(menu.is_visible(), "the menu did not open"); + let model = menu.menu_model().expect("a menu model"); + let (action, target) = menu_item(&model, "Open in New Tab").expect("an Open item"); + menu.popdown(); + dialog.activate_action(&action, target.as_ref()).expect("the action"); + + assert_eq!( + *chosen.borrow(), + [(QueryDestination::NewTab, "SELECT first".to_owned())] + ); +} + +pub(super) fn filter_labels(dialog: &HistoryDialog) -> Vec { + let Some(parts) = dialog.imp().parts.get() else { + return Vec::new(); + }; + let labels = parts.filters.connections().labels(); + (0..labels.n_items()) + .filter_map(|position| labels.string(position)) + .map(String::from) + .collect() +} + +/// The text of the toast on screen, and its button. +pub(super) fn toast(window: &adw::Window, text: &str) -> Option { + let toast = descendants(window) + .into_iter() + .find(|widget| widget.type_().name() == "AdwToastWidget" && has_label(widget, text))?; + descendants(&toast) + .into_iter() + .filter_map(|widget| widget.downcast::().ok()) + .find(|button| button.label().is_some_and(|label| label.contains("Undo"))) +} + +fn has_label(root: >k::Widget, text: &str) -> bool { + descendants(root) + .into_iter() + .filter_map(|widget| widget.downcast::().ok()) + .any(|label| label.label() == text) +} + +#[gtk4::test] +fn filter_lists_saved_connection_without_live_session() { + let harness = Harness::new(); + let (saved, unsaved) = (Uuid::new_v4(), Uuid::new_v4()); + harness.record_on("SELECT 1", saved, "Warehouse (old name)"); + harness.record_on("SELECT 2", unsaved, "Scratch"); + harness.save_connection(saved, "Warehouse"); + + let dialog = harness.dialog(); + + wait_until(WAIT, || filter_labels(&dialog).len() == 3).expect("the connections"); + assert_eq!(filter_labels(&dialog), ["All Connections", "Scratch", "Warehouse"]); + connection_drop_down(&dialog).set_selected(2); + wait_until(WAIT, || entries(&dialog) == ["SELECT 1"]).expect("the saved connection's queries"); +} + +#[gtk4::test] +fn filter_defaults_to_presenting_window_connection() { + let harness = Harness::new(); + let (other, presenting) = (Uuid::new_v4(), Uuid::new_v4()); + harness.record_on("SELECT other", other, "Other"); + harness.record_on("SELECT presenting", presenting, "Presenting"); + + let dialog = harness.dialog_for(Some(presenting)); + + wait_until(WAIT, || entries(&dialog) == ["SELECT presenting"]).expect("the presenting connection's queries"); + let selected = connection_drop_down(&dialog).selected(); + assert_eq!(filter_labels(&dialog)[selected as usize], "Presenting"); + + let without_history = harness.dialog_for(Some(Uuid::new_v4())); + wait_until(WAIT, || entries(&without_history).len() == 2).expect("every query"); + assert_eq!(connection_drop_down(&without_history).selected(), 0); +} + +#[gtk4::test] +fn delete_undo_restores_row() { + let harness = Harness::new(); + harness.record("SELECT kept"); + let gone = harness.record("SELECT gone"); + let dialog = harness.presented_dialog(); + wait_until(WAIT, || entries(&dialog).len() == 2).expect("the first page"); + + dialog + .activate_action("history.delete", Some(&gone.to_variant())) + .expect("the action"); + + wait_until(WAIT, || entries(&dialog) == ["SELECT kept"]).expect("the row to go"); + assert_eq!(harness.stored_queries(), ["SELECT kept"]); + wait_until(WAIT, || toast(&harness.window, "1 query deleted").is_some()).expect("a toast"); + toast(&harness.window, "1 query deleted").expect("Undo").emit_clicked(); + + wait_until(WAIT, || entries(&dialog) == ["SELECT gone", "SELECT kept"]).expect("the row back"); + assert_eq!(harness.stored_queries(), ["SELECT gone", "SELECT kept"]); + let found = block_on( + &harness.runtime, + harness + .store + .search_page(&HistoryQuery::from_input("gone"), HistoryPageRequest::first(10)), + ) + .expect("search"); + assert_eq!(found.entries.len(), 1, "the restored row is not searchable"); + assert_eq!(found.entries[0].id, gone); +} + +#[gtk4::test] +fn bulk_delete_shows_toast_not_alert() { + let harness = Harness::new(); + for query in ["SELECT 1", "SELECT 2", "SELECT 3"] { + harness.record(query); + } + let dialog = harness.presented_dialog(); + wait_until(WAIT, || entries(&dialog).len() == 3).expect("the first page"); + dialog.set_selecting(true); + let parts = dialog.imp().parts.get().expect("parts"); + parts.multiple.select_item(0, false); + parts.multiple.select_item(2, false); + + dialog + .activate_action("history.delete-selected", None) + .expect("the action"); + + wait_until(WAIT, || entries(&dialog) == ["SELECT 2"]).expect("the selected rows to go"); + assert!( + crate::workspace::visible_alert(&harness.window).is_none(), + "a bulk delete asked first" + ); + wait_until(WAIT, || toast(&harness.window, "2 queries deleted").is_some()).expect("a toast"); +} + +#[gtk4::test] +fn connection_named_with_ampersand_renders_in_filter() { + let harness = Harness::new(); + let connection = Uuid::new_v4(); + let name = "R&D "; + harness.record_on("SELECT 1", connection, name); + + let dialog = harness.present(harness.dialog_for(Some(connection))); + + wait_until(WAIT, || filter_labels(&dialog).len() == 2).expect("the connections"); + assert_eq!(filter_labels(&dialog)[1], name); + let drop_down = connection_drop_down(&dialog); + let shown = || { + descendants(&drop_down) + .into_iter() + .filter_map(|widget| widget.downcast::().ok()) + .find(|label| label.is_mapped() && label.label() == name) + }; + wait_until(WAIT, || shown().is_some()).expect("the name on the drop-down"); + assert!( + shown().is_some_and(|label| !label.uses_markup()), + "the name was read as markup" + ); +} + +fn connection_drop_down(dialog: &HistoryDialog) -> gtk::DropDown { + dialog.imp().parts.get().expect("parts").filters.controls().0 +} + +#[gtk4::test] +fn history_filters_use_dropdowns_and_toggle_group() { + let harness = Harness::new(); + + let dialog = harness.presented_dialog(); + + let header = crate::test_support::first_descendant_of_type::(&dialog).expect("a header bar"); + let popovers: Vec = descendants(&header) + .into_iter() + .filter(|widget| widget.is::() && !widget.is::()) + .collect(); + assert!(popovers.is_empty(), "the header still has a filter popover"); + assert!(crate::test_support::first_descendant_of_type::(&dialog).is_none()); + let root = dialog.imp().parts.get().expect("parts").filters.root().clone(); + let children: Vec = std::iter::successors(root.first_child(), gtk::Widget::next_sibling).collect(); + assert_eq!(children.iter().filter(|child| child.is::()).count(), 2); + assert_eq!( + children.iter().filter(|child| child.is::()).count(), + 1 + ); + let search_bar = dialog.imp().search_bar.get().upcast::(); + assert_eq!( + root.prev_sibling(), + Some(search_bar), + "the filters are not under the search bar" + ); +} + +#[gtk4::test] +fn connection_dropdown_follows_model_and_preferred_connection() { + let harness = Harness::new(); + let (alpha, beta) = (Uuid::new_v4(), Uuid::new_v4()); + harness.record_on("SELECT a", alpha, "Alpha"); + harness.record_on("SELECT b", beta, "Beta"); + + let dialog = harness.dialog_for(Some(beta)); + let drop_down = connection_drop_down(&dialog); + let labels = dialog + .imp() + .parts + .get() + .expect("parts") + .filters + .connections() + .labels() + .clone(); + + assert_eq!(drop_down.model().and_downcast::(), Some(labels)); + wait_until(WAIT, || drop_down.model().map(|model| model.n_items()) == Some(3)).expect("the connections"); + assert_eq!(drop_down.selected(), 2); + wait_until(WAIT, || entries(&dialog) == ["SELECT b"]).expect("Beta's queries"); + drop_down.set_selected(1); + wait_until(WAIT, || entries(&dialog) == ["SELECT a"]).expect("Alpha's queries"); +} + +#[gtk4::test] +fn filters_are_labelled() { + let filters = HistoryFilters::new(&ConnectionFilterModel::new()); + let (connection, status, window) = filters.controls(); + + for control in [connection.upcast::(), status.upcast(), window.upcast()] { + assert!( + gtk::test_accessible_has_property(&control, gtk::AccessibleProperty::Label), + "{} has no accessible label", + control.type_().name() + ); + } +} + +#[gtk4::test] +fn the_open_search_bar_is_labelled() { + let harness = Harness::new(); + let dialog = harness.presented_dialog(); + let imp = dialog.imp(); + + imp.search_bar.set_search_mode(true); + crate::test_support::drain_main_context(); + + crate::test_support::assert_labelled(&imp.search_entry.get()).unwrap_or_else(|problem| panic!("{problem}")); + assert_eq!( + crate::test_support::unlabelled_icon_buttons(&dialog), + Vec::::new() + ); +} + +#[gtk4::test] +fn history_dialog_show_in_files_reveals_the_database_file() { + let harness = Harness::new(); + let dialog = harness.presented_dialog(); + let header = crate::test_support::first_descendant_of_type::(&dialog).expect("a header bar"); + let menu = descendants(&header) + .into_iter() + .filter_map(|widget| widget.downcast::().ok()) + .find_map(|button| button.menu_model()) + .expect("the dialog's menu"); + let (action, target) = menu_item(&menu, "Show in Files").expect("a Show in Files item"); + + dialog.activate_action(&action, target.as_ref()).expect("the action"); + + assert_eq!(harness.revealer.revealed(), [harness.store.path().to_path_buf()]); + assert!(harness.store.path().ends_with("history.db")); +} + +#[gtk4::test] +fn history_dialog_reveal_failure_shows_a_toast() { + let harness = Harness::revealing_with(RecordingRevealer::failing()); + let dialog = harness.presented_dialog(); + + dialog + .activate_action("history.show-in-files", None) + .expect("the action"); + + let window: gtk::Widget = harness.window.clone().upcast(); + wait_until(WAIT, || has_label(&window, "Could not show the file in Files")).expect("a toast"); +} + +#[gtk4::test] +fn menu_key_opens_the_menu_of_the_focused_row() { + let harness = Harness::new(); + let first = harness.record("SELECT first"); + harness.record("SELECT second"); + let dialog = harness.presented_dialog(); + wait_until(WAIT, || row_titled(&dialog, "SELECT first").is_some()).expect("the rows"); + let list_view = dialog.imp().list_view.get(); + // Newest first, so the older entry is the second row. The scroll + // marks the row the list focuses next; the grab carries it there. + list_view.scroll_to(1, gtk::ListScrollFlags::FOCUS, None); + list_view.grab_focus(); + let focused_title = || { + list_view + .focus_child() + .and_then(|item| item.first_child()) + .and_downcast::() + .map(|row| row.title()) + }; + wait_until(WAIT, || focused_title().as_deref() == Some("SELECT first")).expect("the second row focused"); + + dialog.activate_action("history-row.menu", None).expect("the action"); + + let menu = dialog.imp().parts.get().expect("parts").menu.clone(); + assert!(menu.is_visible(), "the menu did not open"); + let model = menu.menu_model().expect("a menu model"); + let (_, target) = menu_item(&model, "Delete").expect("a Delete item"); + assert_eq!(target.and_then(|target| target.get::()), Some(first)); + menu.popdown(); +} + +fn select_entry(dialog: &HistoryDialog, id: i64) { + let selection = &dialog.imp().parts.get().expect("parts").multiple; + let position = (0..selection.n_items()) + .find(|position| { + selection + .item(*position) + .and_downcast::() + .and_then(|entry| entry.id()) + == Some(id) + }) + .expect("the entry on screen"); + selection.select_item(position, false); +} + +fn scroll_to_end(dialog: &HistoryDialog) { + dialog + .imp() + .scroll + .emit_by_name::<()>("edge-reached", &[>k::PositionType::Bottom]); +} + +#[gtk4::test] +fn deleting_every_loaded_row_loads_the_rest() { + let harness = Harness::new(); + for index in 0..PAGE_SIZE + 5 { + harness.record(&format!("SELECT {index}")); + } + let dialog = harness.dialog(); + wait_until(WAIT, || entries(&dialog).len() == PAGE_SIZE).expect("the first page"); + dialog.set_selecting(true); + dialog.imp().parts.get().expect("parts").multiple.select_all(); + + dialog + .activate_action("history.delete-selected", None) + .expect("the action"); + + wait_until(WAIT, || entries(&dialog).len() == 5).expect("the rest of the history"); + assert_eq!( + entries(&dialog), + ["SELECT 4", "SELECT 3", "SELECT 2", "SELECT 1", "SELECT 0"] + ); + assert_eq!(dialog.imp().stack.visible_child_name().as_deref(), Some("list")); +} + +#[gtk4::test] +fn deleting_a_row_already_gone_removes_it_without_undo() { + let harness = Harness::new(); + harness.record("SELECT kept"); + let gone = harness.record("SELECT gone"); + let dialog = harness.presented_dialog(); + wait_until(WAIT, || entries(&dialog).len() == 2).expect("the first page"); + block_on(&harness.runtime, harness.store.delete_returning(&[gone])).expect("a delete elsewhere"); + + dialog + .activate_action("history.delete", Some(&gone.to_variant())) + .expect("the action"); + + wait_until(WAIT, || entries(&dialog) == ["SELECT kept"]).expect("the row to go"); + assert!( + toast(&harness.window, "1 query deleted").is_none(), + "Undo offered for nothing deleted" + ); +} + +#[gtk4::test] +fn clear_all_says_saved_queries_are_kept_and_keeps_them() { + use crate::workspace::{answer, visible_alert}; + use tablepro_storage::saved_queries::NewSavedQuery; + + let harness = Harness::new(); + harness.record("SELECT 1"); + let saved = harness.store.saved_queries(); + block_on( + &harness.runtime, + saved.save(NewSavedQuery { + name: "daily".to_owned(), + query: "SELECT 1".to_owned(), + connection_id: Uuid::new_v4(), + connection_name: "local".to_owned(), + }), + ) + .expect("a saved query"); + let dialog = harness.presented_dialog(); + wait_until(WAIT, || entries(&dialog).len() == 1).expect("the first page"); + + dialog.activate_action("history.clear-all", None).expect("the action"); + wait_until(WAIT, || visible_alert(&harness.window).is_some()).expect("the confirmation"); + let body = visible_alert(&harness.window) + .map(|alert| alert.body().to_string()) + .unwrap_or_default(); + assert_eq!( + body, + "This permanently deletes every history entry, including pinned ones. Saved queries are kept." + ); + assert!(answer(&harness.window, "clear")); + + wait_until(WAIT, || harness.stored_queries().is_empty()).expect("the history cleared"); + let kept = block_on(&harness.runtime, saved.list(None)).expect("the saved queries"); + assert_eq!(kept.len(), 1, "clearing the history took the saved queries too"); +} + +#[gtk4::test] +fn undo_that_fails_says_so() { + let harness = Harness::new(); + let gone = harness.record("SELECT gone"); + let dialog = harness.presented_dialog(); + wait_until(WAIT, || entries(&dialog).len() == 1).expect("the first page"); + let entry = block_on( + &harness.runtime, + harness + .store + .search_page(&HistoryQuery::default(), HistoryPageRequest::first(10)), + ) + .expect("search") + .entries + .remove(0); + dialog + .activate_action("history.delete", Some(&gone.to_variant())) + .expect("the action"); + wait_until(WAIT, || toast(&harness.window, "1 query deleted").is_some()).expect("a toast"); + // Back under its id before Undo runs, so the restore collides. + block_on(&harness.runtime, harness.store.restore(&[entry])).expect("a restore elsewhere"); + + toast(&harness.window, "1 query deleted").expect("Undo").emit_clicked(); + + let window: gtk::Widget = harness.window.clone().upcast(); + wait_until(WAIT, || has_label(&window, "The queries could not be restored.")).expect("a toast"); +} + +#[gtk4::test] +fn click_in_select_mode_toggles_the_row() { + let harness = Harness::new(); + let first = harness.record("SELECT 1"); + harness.record("SELECT 2"); + let third = harness.record("SELECT 3"); + let dialog = harness.presented_dialog(); + dialog.set_selecting(true); + wait_until(WAIT, || row_titled(&dialog, "SELECT 1").is_some()).expect("the rows"); + select_entry(&dialog, third); + let click = || { + let row = row_titled(&dialog, "SELECT 1").expect("the row"); + let controllers = row.observe_controllers(); + let gesture = (0..controllers.n_items()) + .filter_map(|index| controllers.item(index).and_downcast::()) + .find(|gesture| gesture.button() == gtk::gdk::BUTTON_PRIMARY) + .expect("a row answers a click"); + gesture.emit_by_name::<()>("released", &[&1i32, &4.0f64, &4.0f64]); + }; + + click(); + let mut selected = dialog.selected_ids(); + selected.sort_unstable(); + assert_eq!(selected, [first, third], "the click replaced the selection"); + + click(); + assert_eq!(dialog.selected_ids(), [third]); +} + +#[gtk4::test] +fn pin_keeps_the_pages_read_and_the_selection() { + let harness = Harness::new(); + let ids: Vec = (0..PAGE_SIZE + 30) + .map(|index| harness.record(&format!("SELECT {index}"))) + .collect(); + let dialog = harness.dialog(); + wait_until(WAIT, || entries(&dialog).len() == PAGE_SIZE).expect("the first page"); + scroll_to_end(&dialog); + wait_until(WAIT, || entries(&dialog).len() == ids.len()).expect("the next page"); + dialog.set_selecting(true); + select_entry(&dialog, ids[5]); + + dialog + .activate_action("history.pin", Some(&ids[10].to_variant())) + .expect("the action"); + + wait_until(WAIT, || { + entries(&dialog).first().map(String::as_str) == Some("SELECT 10") + }) + .expect("the pinned row first"); + assert_eq!(entries(&dialog).len(), ids.len(), "the pages read were dropped"); + assert_eq!(dialog.selected_ids(), [ids[5]]); +} + +#[gtk4::test] +fn unpinned_row_past_the_pages_read_comes_back_with_its_page() { + let harness = Harness::new(); + let oldest = harness.record("SELECT 0"); + for index in 1..PAGE_SIZE + 30 { + harness.record(&format!("SELECT {index}")); + } + harness.pin(oldest); + let dialog = harness.dialog(); + wait_until(WAIT, || entries(&dialog).len() == PAGE_SIZE).expect("the first page"); + assert_eq!(entries(&dialog).first().map(String::as_str), Some("SELECT 0")); + + dialog + .activate_action("history.pin", Some(&oldest.to_variant())) + .expect("the action"); + + wait_until(WAIT, || entries(&dialog).len() == PAGE_SIZE - 1).expect("the row to leave"); + assert!(!entries(&dialog).iter().any(|query| query == "SELECT 0")); + scroll_to_end(&dialog); + wait_until(WAIT, || entries(&dialog).len() == PAGE_SIZE + 30).expect("the next page"); + let shown = entries(&dialog); + let mut distinct = shown.clone(); + distinct.sort(); + distinct.dedup(); + assert_eq!(distinct.len(), shown.len(), "a row is shown twice"); + assert_eq!(shown.last().map(String::as_str), Some("SELECT 0")); +} + +#[gtk4::test] +fn deleting_a_connections_last_query_drops_it_from_the_filter() { + let harness = Harness::new(); + let connection = Uuid::new_v4(); + let only = harness.record_on("SELECT 1", connection, "Warehouse"); + let dialog = harness.dialog_for(Some(connection)); + wait_until(WAIT, || entries(&dialog) == ["SELECT 1"]).expect("the connection's queries"); + assert_eq!(connection_drop_down(&dialog).selected(), 1); + + dialog + .activate_action("history.delete", Some(&only.to_variant())) + .expect("the action"); + + wait_until(WAIT, || filter_labels(&dialog) == ["All Connections"]).expect("the connection to go"); + assert_eq!(connection_drop_down(&dialog).selected(), 0); + let imp = dialog.imp(); + wait_until(WAIT, || { + imp.stack.visible_child_name().as_deref() == Some("empty") && imp.status_page.title() == "No Queries Yet" + }) + .expect("the empty history, not an empty search"); +} + +#[gtk4::test] +fn copy_query_puts_it_on_the_clipboard_and_says_so() { + let harness = Harness::new(); + let id = harness.record("SELECT copied"); + let dialog = harness.presented_dialog(); + wait_until(WAIT, || entries(&dialog).len() == 1).expect("the entry"); + + dialog + .activate_action("history.copy", Some(&id.to_variant())) + .expect("the action"); + + let text = dialog + .clipboard() + .content() + .and_then(|content| content.value(String::static_type()).ok()) + .and_then(|value| value.get::().ok()); + assert_eq!(text.as_deref(), Some("SELECT copied")); + wait_until(WAIT, || has_label(harness.window.upcast_ref(), "Copied to clipboard")).expect("a toast"); +} diff --git a/linux/crates/app/src/ui/host_key_failure.rs b/linux/crates/app/src/ui/host_key_failure.rs new file mode 100644 index 0000000000..4b80e5d6b0 --- /dev/null +++ b/linux/crates/app/src/ui/host_key_failure.rs @@ -0,0 +1,158 @@ +//! A host key ssh refused, in the terms the review dialog shows it. + +use std::path::PathBuf; + +use tablepro_core::{DriverError, SshFailure, TransportError}; +use tablepro_session::ConnectError; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct HostKeyFailure { + /// The name ssh looks the host up by: `host`, `[host]:port` or an + /// alias, as ssh printed it. + pub host: String, + pub algorithm: String, + /// Empty when ssh refused without showing the key. + pub fingerprint: String, + pub kind: HostKeyFailureKind, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum HostKeyFailureKind { + /// `trust_allowed` is false when the user's ssh configuration takes + /// only keys already in known_hosts, so ssh never asks. + Unknown { + trust_allowed: bool, + }, + /// The old key is at `line` of `known_hosts`. `trust_allowed` is as + /// for `Unknown`, for the new key once the old one is removed. + Changed { + known_hosts: PathBuf, + line: usize, + trust_allowed: bool, + }, + Revoked { + detail: String, + }, +} + +impl HostKeyFailure { + pub(crate) fn from_connect_error(error: &ConnectError) -> Option { + let ConnectError::Driver(DriverError::Transport(TransportError::Ssh(failure))) = error else { + return None; + }; + Self::from_ssh_failure(failure) + } + + fn from_ssh_failure(failure: &SshFailure) -> Option { + match failure { + SshFailure::HostKeyUnknown { + host, + algorithm, + sha256_fingerprint, + trust_allowed, + .. + } => Some(Self { + host: host.clone(), + algorithm: algorithm.clone(), + fingerprint: sha256_fingerprint.clone(), + kind: HostKeyFailureKind::Unknown { + trust_allowed: *trust_allowed, + }, + }), + SshFailure::HostKeyChanged { + host, + algorithm, + sha256_fingerprint, + known_hosts, + line, + trust_allowed, + } => Some(Self { + host: host.clone(), + algorithm: algorithm.clone(), + fingerprint: sha256_fingerprint.clone(), + kind: HostKeyFailureKind::Changed { + known_hosts: known_hosts.clone(), + line: *line, + trust_allowed: *trust_allowed, + }, + }), + SshFailure::HostKeyRevoked { host, detail } => Some(Self { + host: host.clone(), + algorithm: String::new(), + fingerprint: String::new(), + kind: HostKeyFailureKind::Revoked { detail: detail.clone() }, + }), + _ => None, + } + } +} + +#[cfg(test)] +mod tests { + use tablepro_core::LossPhase; + + use super::*; + + fn ssh(failure: SshFailure) -> ConnectError { + ConnectError::Driver(DriverError::Transport(TransportError::Ssh(failure))) + } + + #[test] + fn each_host_key_refusal_becomes_a_failure_to_review() { + let unknown = ssh(SshFailure::HostKeyUnknown { + host: "bastion".to_owned(), + algorithm: "ED25519".to_owned(), + sha256_fingerprint: "SHA256:new".to_owned(), + trust_allowed: true, + declined: true, + }); + let changed = ssh(SshFailure::HostKeyChanged { + host: "[db.example.com]:2222".to_owned(), + algorithm: "ED25519".to_owned(), + sha256_fingerprint: "SHA256:new".to_owned(), + known_hosts: PathBuf::from("/home/u/.ssh/known_hosts"), + line: 3, + trust_allowed: false, + }); + let revoked = ssh(SshFailure::HostKeyRevoked { + host: "bastion".to_owned(), + detail: "revoked by file /etc/ssh/krl".to_owned(), + }); + + assert_eq!( + HostKeyFailure::from_connect_error(&unknown).map(|failure| failure.kind), + Some(HostKeyFailureKind::Unknown { trust_allowed: true }) + ); + let changed = HostKeyFailure::from_connect_error(&changed).unwrap(); + assert_eq!(changed.host, "[db.example.com]:2222"); + assert_eq!(changed.fingerprint, "SHA256:new"); + assert_eq!( + changed.kind, + HostKeyFailureKind::Changed { + known_hosts: PathBuf::from("/home/u/.ssh/known_hosts"), + line: 3, + trust_allowed: false, + } + ); + assert!(matches!( + HostKeyFailure::from_connect_error(&revoked).map(|failure| failure.kind), + Some(HostKeyFailureKind::Revoked { .. }) + )); + } + + #[test] + fn other_failures_have_no_key_to_review() { + let errors = [ + ssh(SshFailure::InteractionCancelled), + ConnectError::Driver(DriverError::auth(None)), + ConnectError::Driver(DriverError::ConnectionLost { + during: LossPhase::Connect, + }), + ConnectError::Cancelled, + ]; + + for error in errors { + assert_eq!(HostKeyFailure::from_connect_error(&error), None, "{error:?}"); + } + } +} diff --git a/linux/crates/app/src/ui/host_key_review.rs b/linux/crates/app/src/ui/host_key_review.rs new file mode 100644 index 0000000000..071e5e9b33 --- /dev/null +++ b/linux/crates/app/src/ui/host_key_review.rs @@ -0,0 +1,365 @@ +//! The dialog that shows a refused SSH host key and asks what to do. + +use gtk4 as gtk; +use gtk4::prelude::*; +use libadwaita as adw; +use libadwaita::prelude::*; + +use super::host_key_failure::{HostKeyFailure, HostKeyFailureKind}; + +const CANCEL: &str = "cancel"; +const CLOSE: &str = "close"; +const TRUST: &str = "trust"; +const REPLACE: &str = "replace"; +const RETRY: &str = "retry"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum HostKeyReviewOutcome { + /// Connect, trusting the key shown. + Trust, + /// Remove the old key from known_hosts, then connect trusting the + /// new one. + Replace, + /// Connect again answering nothing, for a key the user fixed outside + /// TablePro. + Retry, + Dismissed, +} + +/// What the dialog says and offers, worked out before any widget exists. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct HostKeyDialogSpec { + pub heading: String, + pub body: String, + pub responses: Vec, + pub default: &'static str, + pub close: &'static str, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct HostKeyResponse { + pub id: &'static str, + pub label: String, + pub appearance: adw::ResponseAppearance, +} + +impl HostKeyResponse { + fn new(id: &'static str, label: String, appearance: adw::ResponseAppearance) -> Self { + Self { id, label, appearance } + } + + fn cancel() -> Self { + Self::new(CANCEL, crate::i18n::gettext("Cancel"), adw::ResponseAppearance::Default) + } + + fn close() -> Self { + Self::new(CLOSE, crate::i18n::gettext("Close"), adw::ResponseAppearance::Default) + } + + fn retry() -> Self { + Self::new(RETRY, crate::i18n::gettext("Retry"), adw::ResponseAppearance::Default) + } +} + +/// Cancel is the default wherever there is something to accept, so +/// Enter never trusts a key or deletes one. +pub(crate) fn host_key_dialog_spec(failure: &HostKeyFailure) -> HostKeyDialogSpec { + let host = failure.host.as_str(); + match &failure.kind { + HostKeyFailureKind::Unknown { trust_allowed: true } => { + let (body, accept) = match failure.fingerprint.is_empty() { + false => ( + crate::i18n::gettext_f( + "TablePro has not connected to “{host}” before and cannot confirm its identity. Compare the fingerprint with the one from your server administrator.", + &[("host", host)], + ), + crate::i18n::gettext("Trust and Connect"), + ), + true => ( + crate::i18n::gettext_f( + "TablePro has not connected to “{host}” before. Connect to see its key’s fingerprint and decide whether to trust it.", + &[("host", host)], + ), + crate::i18n::gettext("Connect"), + ), + }; + HostKeyDialogSpec { + heading: crate::i18n::gettext("Unknown SSH Host"), + body, + responses: vec![ + HostKeyResponse::cancel(), + HostKeyResponse::new(TRUST, accept, adw::ResponseAppearance::Suggested), + ], + default: CANCEL, + close: CANCEL, + } + } + HostKeyFailureKind::Unknown { trust_allowed: false } => fixed_elsewhere( + crate::i18n::gettext("Unknown SSH Host"), + crate::i18n::gettext_f( + "The SSH configuration for “{host}” accepts only keys already in known_hosts. Add the server’s key there, then retry.", + &[("host", host)], + ), + ), + // ssh would refuse the new key without asking once the old one is + // gone, so a replace would only lose the old key. + HostKeyFailureKind::Changed { + known_hosts, + line, + trust_allowed: false, + } => fixed_elsewhere( + crate::i18n::gettext("SSH Host Key Changed"), + match known_hosts.as_os_str().is_empty() { + true => crate::i18n::gettext_f( + "The key “{host}” sent is not the one trusted in known_hosts. Someone may be intercepting the connection, or the server was reinstalled. The SSH configuration for this host accepts only keys already in known_hosts, so if your server administrator confirms the new fingerprint, replace the key there yourself, then retry.", + &[("host", host)], + ), + false => crate::i18n::gettext_f( + "The key “{host}” sent is not the one trusted in {file}, line {line}. Someone may be intercepting the connection, or the server was reinstalled. The SSH configuration for this host accepts only keys already in known_hosts, so if your server administrator confirms the new fingerprint, replace the key in that file yourself, then retry.", + &[ + ("host", host), + ("file", &known_hosts.display().to_string()), + ("line", &line.to_string()), + ], + ), + }, + ), + // Without the file ssh named, there is no key to replace. + HostKeyFailureKind::Changed { known_hosts, .. } if known_hosts.as_os_str().is_empty() => fixed_elsewhere( + crate::i18n::gettext("SSH Host Key Changed"), + crate::i18n::gettext_f( + "The key “{host}” sent is not the one trusted in known_hosts. Someone may be intercepting the connection, or the server was reinstalled. If your server administrator confirms the new fingerprint, remove the old key from known_hosts, then retry.", + &[("host", host)], + ), + ), + HostKeyFailureKind::Changed { known_hosts, line, .. } => HostKeyDialogSpec { + heading: crate::i18n::gettext("SSH Host Key Changed"), + body: crate::i18n::gettext_f( + "The key “{host}” sent is not the one trusted in {file}, line {line}. Someone may be intercepting the connection, or the server was reinstalled. Replace the key only if your server administrator confirms the new fingerprint.", + &[ + ("host", host), + ("file", &known_hosts.display().to_string()), + ("line", &line.to_string()), + ], + ), + responses: vec![ + HostKeyResponse::cancel(), + HostKeyResponse::new( + REPLACE, + crate::i18n::gettext("Replace Key and Connect"), + adw::ResponseAppearance::Destructive, + ), + ], + default: CANCEL, + close: CANCEL, + }, + HostKeyFailureKind::Revoked { detail } => fixed_elsewhere( + crate::i18n::gettext("SSH Host Key Revoked"), + crate::i18n::gettext_f( + "The key “{host}” sent is revoked, so TablePro will not connect to it. Ask your server administrator for its current key. ssh reported: {detail}", + &[("host", host), ("detail", detail)], + ), + ), + } +} + +/// Only a change outside TablePro clears these, so the dialog offers the +/// retry the banner would have. +fn fixed_elsewhere(heading: String, body: String) -> HostKeyDialogSpec { + HostKeyDialogSpec { + heading, + body, + responses: vec![HostKeyResponse::close(), HostKeyResponse::retry()], + default: CLOSE, + close: CLOSE, + } +} + +/// Show the key over `parent` and wait for the answer. Closing the +/// dialog any other way dismisses it. +pub(crate) async fn review_host_key(parent: >k::Widget, failure: &HostKeyFailure) -> HostKeyReviewOutcome { + let dialog = build_dialog(failure); + let response = crate::workspace::dialog_response::respond(&dialog, parent).await; + match response.as_str() { + TRUST => HostKeyReviewOutcome::Trust, + REPLACE => HostKeyReviewOutcome::Replace, + RETRY => HostKeyReviewOutcome::Retry, + _ => HostKeyReviewOutcome::Dismissed, + } +} + +fn build_dialog(failure: &HostKeyFailure) -> adw::AlertDialog { + let spec = host_key_dialog_spec(failure); + let dialog = adw::AlertDialog::new(Some(&spec.heading), Some(&spec.body)); + for response in &spec.responses { + dialog.add_response(response.id, &response.label); + dialog.set_response_appearance(response.id, response.appearance); + } + dialog.set_default_response(Some(spec.default)); + dialog.set_close_response(spec.close); + if !failure.fingerprint.is_empty() { + dialog.set_extra_child(Some(&fingerprint_box(failure))); + } + dialog +} + +/// Selectable, so the fingerprint can be copied and compared. +fn fingerprint_box(failure: &HostKeyFailure) -> gtk::Box { + let column = gtk::Box::new(gtk::Orientation::Vertical, 6); + let caption = match failure.algorithm.is_empty() { + true => crate::i18n::gettext("Key fingerprint"), + false => crate::i18n::gettext_f("{algorithm} key fingerprint", &[("algorithm", &failure.algorithm)]), + }; + let caption = gtk::Label::new(Some(&caption)); + caption.add_css_class("dimmed"); + caption.add_css_class("caption"); + let fingerprint = gtk::Label::new(Some(&failure.fingerprint)); + fingerprint.add_css_class("monospace"); + fingerprint.set_selectable(true); + fingerprint.set_wrap(true); + fingerprint.set_wrap_mode(gtk::pango::WrapMode::Char); + fingerprint.update_relation(&[gtk::accessible::Relation::LabelledBy(&[caption.upcast_ref()])]); + column.append(&caption); + column.append(&fingerprint); + column +} + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + use std::time::Duration; + + use gtk4::glib; + + use super::*; + use crate::test_support::{OwnedWindow, wait_until}; + use crate::workspace::dialog_response::visible_alert; + + fn failure(kind: HostKeyFailureKind, fingerprint: &str) -> HostKeyFailure { + HostKeyFailure { + host: "bastion".to_owned(), + algorithm: "ED25519".to_owned(), + fingerprint: fingerprint.to_owned(), + kind, + } + } + + fn ids(spec: &HostKeyDialogSpec) -> Vec<&'static str> { + spec.responses.iter().map(|response| response.id).collect() + } + + #[test] + fn host_key_dialog_spec_matrix() { + let unknown = host_key_dialog_spec(&failure( + HostKeyFailureKind::Unknown { trust_allowed: true }, + "SHA256:new", + )); + let unseen = host_key_dialog_spec(&failure(HostKeyFailureKind::Unknown { trust_allowed: true }, "")); + let strict = host_key_dialog_spec(&failure(HostKeyFailureKind::Unknown { trust_allowed: false }, "")); + let changed = host_key_dialog_spec(&failure( + HostKeyFailureKind::Changed { + known_hosts: PathBuf::from("/home/u/.ssh/known_hosts"), + line: 3, + trust_allowed: true, + }, + "SHA256:new", + )); + let strict_changed = host_key_dialog_spec(&failure( + HostKeyFailureKind::Changed { + known_hosts: PathBuf::from("/home/u/.ssh/known_hosts"), + line: 3, + trust_allowed: false, + }, + "SHA256:new", + )); + let strict_unplaced = host_key_dialog_spec(&failure( + HostKeyFailureKind::Changed { + known_hosts: PathBuf::new(), + line: 0, + trust_allowed: false, + }, + "SHA256:new", + )); + let revoked = host_key_dialog_spec(&failure( + HostKeyFailureKind::Revoked { + detail: "revoked by file /etc/ssh/krl".to_owned(), + }, + "", + )); + let unplaced = host_key_dialog_spec(&failure( + HostKeyFailureKind::Changed { + known_hosts: PathBuf::new(), + line: 0, + trust_allowed: true, + }, + "SHA256:new", + )); + + assert_eq!(ids(&unknown), [CANCEL, TRUST]); + assert_eq!(ids(&unseen), [CANCEL, TRUST]); + assert_eq!(unknown.responses[1].appearance, adw::ResponseAppearance::Suggested); + assert_eq!(ids(&strict), [CLOSE, RETRY]); + assert_eq!(ids(&changed), [CANCEL, REPLACE]); + assert_eq!(changed.responses[1].appearance, adw::ResponseAppearance::Destructive); + assert!(changed.body.contains("/home/u/.ssh/known_hosts"), "{}", changed.body); + assert!(changed.body.contains('3'), "{}", changed.body); + assert_eq!(ids(&revoked), [CLOSE, RETRY]); + assert!(revoked.body.contains("/etc/ssh/krl"), "{}", revoked.body); + assert_eq!(ids(&unplaced), [CLOSE, RETRY]); + assert!(!unplaced.body.contains(", line 0"), "{}", unplaced.body); + assert_eq!(ids(&strict_changed), [CLOSE, RETRY]); + assert!( + strict_changed.body.contains("/home/u/.ssh/known_hosts, line 3"), + "{}", + strict_changed.body + ); + assert_eq!(ids(&strict_unplaced), [CLOSE, RETRY]); + assert!(!strict_unplaced.body.contains(", line 0"), "{}", strict_unplaced.body); + for spec in [ + &unknown, + &unseen, + &strict, + &changed, + &revoked, + &unplaced, + &strict_changed, + &strict_unplaced, + ] { + assert!(spec.body.contains("bastion"), "{}", spec.body); + assert_eq!(spec.default, spec.close, "{spec:?}"); + let default = spec.responses.iter().find(|response| response.id == spec.default); + assert_eq!( + default.map(|response| response.appearance), + Some(adw::ResponseAppearance::Default), + "{spec:?}" + ); + } + } + + #[gtk4::test] + fn review_dialog_built_from_spec() { + adw::init().expect("libadwaita initialises under the test backend"); + let window = OwnedWindow::presented(adw::Window::new()); + let failure = failure(HostKeyFailureKind::Unknown { trust_allowed: true }, "SHA256:new"); + let outcome = std::rc::Rc::new(std::cell::Cell::new(None)); + let sink = outcome.clone(); + let parent = window.clone().upcast::(); + glib::spawn_future_local(async move { + sink.set(Some(review_host_key(&parent, &failure).await)); + }); + wait_until(Duration::from_secs(10), || visible_alert(&window).is_some()).expect("the dialog"); + + let dialog = visible_alert(&window).unwrap(); + let fingerprint = crate::test_support::descendants(&dialog) + .into_iter() + .filter_map(|widget| widget.downcast::().ok()) + .find(|label| label.label() == "SHA256:new") + .expect("the fingerprint label"); + assert!(fingerprint.is_selectable()); + assert!(fingerprint.has_css_class("monospace")); + dialog.force_close(); + + wait_until(Duration::from_secs(10), || outcome.get().is_some()).expect("an outcome"); + assert_eq!(outcome.get(), Some(HostKeyReviewOutcome::Dismissed)); + } +} diff --git a/linux/crates/app/src/ui/icons.rs b/linux/crates/app/src/ui/icons.rs new file mode 100644 index 0000000000..50a9b04a20 --- /dev/null +++ b/linux/crates/app/src/ui/icons.rs @@ -0,0 +1,135 @@ +//! Every icon name the app asks the icon theme for. Keeping them in +//! one place is what lets `all_icons_resolve` prove the set is real. + +pub const DIALOG_ERROR: &str = "dialog-error-symbolic"; +pub const DIALOG_WARNING: &str = "dialog-warning-symbolic"; +pub const DOCUMENT_EDIT: &str = "document-edit-symbolic"; +/// Not in Adwaita; the app's resources carry it, as GNOME Text Editor's do. +pub const DOCUMENT_MODIFIED: &str = "document-modified-symbolic"; +pub const DOCUMENT_OPEN: &str = "document-open-symbolic"; +pub const DOCUMENT_OPEN_RECENT: &str = "document-open-recent-symbolic"; +pub const DOCUMENT_SAVE: &str = "document-save-symbolic"; +pub const EDIT_CLEAR: &str = "edit-clear-symbolic"; +pub const EDIT_COPY: &str = "edit-copy-symbolic"; +pub const EDIT_DELETE: &str = "edit-delete-symbolic"; +pub const EDIT_FIND_REPLACE: &str = "edit-find-replace-symbolic"; +pub const FOLDER: &str = "folder-symbolic"; +pub const GO_DOWN: &str = "go-down-symbolic"; +pub const GO_FIRST: &str = "go-first-symbolic"; +pub const GO_LAST: &str = "go-last-symbolic"; +pub const GO_NEXT: &str = "go-next-symbolic"; +pub const GO_PREVIOUS: &str = "go-previous-symbolic"; +pub const GO_UP: &str = "go-up-symbolic"; +pub const LIST_ADD: &str = "list-add-symbolic"; +pub const LIST_REMOVE: &str = "list-remove-symbolic"; +pub const MEDIA_PLAYBACK_STOP: &str = "media-playback-stop-symbolic"; +pub const NETWORK_ERROR: &str = "network-error-symbolic"; +pub const NETWORK_SERVER: &str = "network-server-symbolic"; +pub const OPEN_MENU: &str = "open-menu-symbolic"; +pub const PAN_DOWN: &str = "pan-down-symbolic"; +pub const PREFERENCES_SYSTEM: &str = "preferences-system-symbolic"; +pub const PROCESS_STOP: &str = "process-stop-symbolic"; +pub const STATEMENT_PENDING: &str = "statement-pending-symbolic"; +pub const SIDEBAR_SHOW: &str = "sidebar-show-symbolic"; +pub const SUCCESS: &str = "object-select-symbolic"; +pub const SYSTEM_RUN: &str = "system-run-symbolic"; +pub const SYSTEM_SEARCH: &str = "system-search-symbolic"; +pub const TABLE_RELATION: &str = "table-relation-symbolic"; +pub const TAB_NEW: &str = "tab-new-symbolic"; +pub const TEXT_EDITOR: &str = "text-editor-symbolic"; +pub const TEXT_X_GENERIC: &str = "text-x-generic-symbolic"; +pub const USER_TRASH: &str = "user-trash-symbolic"; +pub const VIEW_GRID: &str = "view-grid-symbolic"; +pub const VIEW_LIST: &str = "view-list-symbolic"; +pub const VIEW_MORE: &str = "view-more-symbolic"; +pub const VIEW_PAGED: &str = "view-paged-symbolic"; +pub const VIEW_PIN: &str = "view-pin-symbolic"; +pub const VIEW_REFRESH: &str = "view-refresh-symbolic"; +pub const VIEW_REVEAL: &str = "view-reveal-symbolic"; +pub const VIEW_SORT_ASCENDING: &str = "view-sort-ascending-symbolic"; +pub const WINDOW_CLOSE: &str = "window-close-symbolic"; + +#[cfg_attr( + not(test), + expect( + dead_code, + reason = "the app uses the individual constants; ALL exists so all_icons_resolve can check the whole set" + ) +)] +pub const ALL: &[&str] = &[ + DIALOG_ERROR, + DIALOG_WARNING, + DOCUMENT_EDIT, + DOCUMENT_MODIFIED, + DOCUMENT_OPEN, + DOCUMENT_OPEN_RECENT, + DOCUMENT_SAVE, + EDIT_CLEAR, + EDIT_COPY, + EDIT_DELETE, + EDIT_FIND_REPLACE, + FOLDER, + GO_DOWN, + GO_FIRST, + GO_LAST, + GO_NEXT, + GO_PREVIOUS, + GO_UP, + LIST_ADD, + LIST_REMOVE, + MEDIA_PLAYBACK_STOP, + NETWORK_ERROR, + NETWORK_SERVER, + OPEN_MENU, + PAN_DOWN, + PREFERENCES_SYSTEM, + PROCESS_STOP, + SIDEBAR_SHOW, + STATEMENT_PENDING, + SUCCESS, + SYSTEM_RUN, + SYSTEM_SEARCH, + TABLE_RELATION, + TAB_NEW, + TEXT_EDITOR, + TEXT_X_GENERIC, + USER_TRASH, + VIEW_GRID, + VIEW_LIST, + VIEW_MORE, + VIEW_PAGED, + VIEW_PIN, + VIEW_REFRESH, + VIEW_REVEAL, + VIEW_SORT_ASCENDING, + WINDOW_CLOSE, +]; + +#[cfg(test)] +mod tests { + use super::*; + + /// A name the icon theme cannot resolve renders as a blank square at + /// runtime with no warning, so the whole set is checked here. + #[gtk4::test] + fn all_icons_resolve() { + crate::register_test_resources(); + let display = gtk4::gdk::Display::default().expect("a display under the test backend"); + let theme = gtk4::IconTheme::for_display(&display); + theme.add_resource_path(&format!("{}/icons", crate::config::RESOURCE_BASE_PATH)); + + let missing: Vec<&str> = ALL.iter().copied().filter(|name| !theme.has_icon(name)).collect(); + + assert!(missing.is_empty(), "the icon theme cannot resolve {missing:?}"); + } + + #[test] + fn no_removed_emblem_icons_remain() { + for name in ALL { + assert!( + !name.starts_with("emblem-"), + "{name} was removed from adwaita-icon-theme" + ); + } + } +} diff --git a/linux/crates/app/src/ui/import_dialog.rs b/linux/crates/app/src/ui/import_dialog.rs new file mode 100644 index 0000000000..c8f7ca31b2 --- /dev/null +++ b/linux/crates/app/src/ui/import_dialog.rs @@ -0,0 +1,606 @@ +use std::cell::RefCell; +use std::rc::Rc; + +use relm4::adw::prelude::*; +use relm4::prelude::*; +use relm4::{adw, gtk}; + +use tablepro_core::export::CsvDelimiter; +use tablepro_core::import::{ + CsvImportOptions, CsvSheet, ImportColumn, ImportError, MAX_PREVIEW_ROWS, read_csv, suggest_mapping, +}; + +use crate::runtime::DeliverInput; + +/// The most a CSV may be before the import refuses it. +/// +/// Every row becomes an INSERT in one transaction, so the file's size +/// decides how much the app holds at once. Refusing is better than a +/// window that stops painting halfway through a file nobody meant to +/// open. +pub const MAX_CSV_FILE_BYTES: usize = 64 * 1024 * 1024; + +/// How many values from the file each mapping row shows, so the user +/// can see they picked the field they meant. +const SAMPLE_VALUES: usize = 3; + +pub struct ImportCsvDialog { + root: adw::Dialog, + schema: Option, + table: String, + bytes: Vec, + columns: Vec, + sheet: CsvSheet, + /// Why the file could not be read under the options as they stand. + read_error: Option, + options: CsvImportOptions, + /// One row per table column, in column order. The selected index is + /// 0 for "Skip" and the field number plus one otherwise. + mapping_rows: Rc>>, + mapping_group: adw::PreferencesGroup, + summary: gtk::Label, + delimiter_row: adw::ComboRow, + header_row: adw::SwitchRow, + null_row: adw::EntryRow, + import_button: gtk::Button, +} + +pub struct ImportCsvDialogInit { + pub schema: Option, + pub table: String, + pub columns: Vec, + pub bytes: Vec, +} + +#[derive(Debug)] +pub enum ImportCsvDialogInput { + /// The delimiter, header switch or NULL marker moved, so the file + /// has to be read again. + Reread, + /// A mapping combo moved, so the samples have to be redrawn. + MappingChanged, + Import, +} + +/// Everything an import needs, once the mapping dialog has settled it. +/// +/// It travels as one value because every part of it is a decision the +/// dialog made together with the others: the mapping only means +/// anything against these columns, read under these options. +#[derive(Debug, Clone)] +pub struct CsvImport { + pub schema: Option, + pub table: String, + pub columns: Vec, + /// One entry per table column: the CSV field it takes, or `None` + /// for a column the user set to Skip or the server fills. + pub mapping: Vec>, + pub options: CsvImportOptions, + pub bytes: Vec, +} + +#[derive(Debug)] +pub enum ImportCsvDialogOutput { + Import(Box), +} + +impl Component for ImportCsvDialog { + type Init = ImportCsvDialogInit; + type Input = ImportCsvDialogInput; + type Output = ImportCsvDialogOutput; + type CommandOutput = (); + type Root = adw::Dialog; + type Widgets = (); + + fn init_root() -> Self::Root { + adw::Dialog::builder() + .title(crate::i18n::gettext("Import CSV")) + .content_width(560) + .content_height(680) + .build() + } + + fn init(init: Self::Init, root: Self::Root, sender: ComponentSender) -> ComponentParts { + let toolbar = adw::ToolbarView::new(); + let header = adw::HeaderBar::builder().show_end_title_buttons(true).build(); + let subtitle = qualified(init.schema.as_deref(), &init.table); + header.set_title_widget(Some(&adw::WindowTitle::new( + &crate::i18n::gettext("Import CSV"), + &subtitle, + ))); + let import_button = gtk::Button::builder().label(crate::i18n::gettext("Import")).build(); + import_button.add_css_class("suggested-action"); + let sender_for_import = sender.clone(); + import_button.connect_clicked(move |_| sender_for_import.deliver(ImportCsvDialogInput::Import)); + header.pack_end(&import_button); + toolbar.add_top_bar(&header); + + let page = adw::PreferencesPage::new(); + + let file_group = adw::PreferencesGroup::builder() + .title(crate::i18n::gettext("File")) + .build(); + let delimiter_row = adw::ComboRow::builder() + .title(crate::i18n::gettext("Delimiter")) + .model(>k::StringList::new(&[ + &crate::i18n::gettext("Comma"), + &crate::i18n::gettext("Semicolon"), + &crate::i18n::gettext("Tab"), + &crate::i18n::gettext("Pipe"), + ])) + .build(); + let header_row = adw::SwitchRow::builder() + .title(crate::i18n::gettext("First Row Is a Header")) + .subtitle(crate::i18n::gettext( + "Its values name the fields instead of being imported.", + )) + .active(true) + .build(); + let null_row = adw::EntryRow::builder() + .title(crate::i18n::gettext("Text that means NULL")) + .build(); + file_group.add(&delimiter_row); + file_group.add(&header_row); + file_group.add(&null_row); + page.add(&file_group); + + let mapping_group = adw::PreferencesGroup::builder() + .title(crate::i18n::gettext("Columns")) + .description(crate::i18n::gettext( + "Each table column takes one field from the file. A column set to Skip keeps its own default.", + )) + .build(); + page.add(&mapping_group); + + // Problems with the file are told here, inside the dialog that + // covers the window a toast would go to, and long enough to read. + let summary = gtk::Label::builder() + .xalign(0.0) + .wrap(true) + .wrap_mode(gtk::pango::WrapMode::WordChar) + .selectable(true) + .build(); + let summary_group = adw::PreferencesGroup::new(); + summary_group.add(&summary); + page.add(&summary_group); + + toolbar.set_content(Some(&page)); + root.set_child(Some(&toolbar)); + + let sender_for_header = sender.clone(); + header_row.connect_active_notify(move |_| sender_for_header.deliver(ImportCsvDialogInput::Reread)); + let sender_for_delimiter = sender.clone(); + delimiter_row.connect_selected_notify(move |_| sender_for_delimiter.deliver(ImportCsvDialogInput::Reread)); + let sender_for_null = sender.clone(); + null_row.connect_changed(move |_| sender_for_null.deliver(ImportCsvDialogInput::MappingChanged)); + + let mut model = ImportCsvDialog { + root: root.clone(), + schema: init.schema, + table: init.table, + bytes: init.bytes, + columns: init.columns, + sheet: CsvSheet::default(), + read_error: None, + options: CsvImportOptions::default(), + mapping_rows: Rc::new(RefCell::new(Vec::new())), + mapping_group, + summary, + delimiter_row, + header_row, + null_row, + import_button, + }; + model.reread(&sender); + + ComponentParts { model, widgets: () } + } + + fn update(&mut self, msg: Self::Input, sender: ComponentSender, _root: &Self::Root) { + match msg { + ImportCsvDialogInput::Reread => self.reread(&sender), + ImportCsvDialogInput::MappingChanged => { + self.options = self.read_options(); + self.show_samples(); + self.show_summary(); + } + ImportCsvDialogInput::Import => { + let mapping = self.mapping(); + if self.blocker().is_some() { + return; + } + let _ = sender.output(ImportCsvDialogOutput::Import(Box::new(CsvImport { + schema: self.schema.clone(), + table: self.table.clone(), + columns: self.columns.clone(), + mapping, + options: self.read_options(), + bytes: std::mem::take(&mut self.bytes), + }))); + self.root.close(); + } + } + } +} + +impl ImportCsvDialog { + pub fn dialog(&self) -> &adw::Dialog { + &self.root + } + + fn read_options(&self) -> CsvImportOptions { + CsvImportOptions { + delimiter: match self.delimiter_row.selected() { + 1 => CsvDelimiter::Semicolon, + 2 => CsvDelimiter::Tab, + 3 => CsvDelimiter::Pipe, + _ => CsvDelimiter::Comma, + }, + has_header: self.header_row.is_active(), + null_marker: self.null_row.text().to_string(), + } + } + + /// Read the file again under the options as they stand and rebuild + /// the mapping rows, which are named after the file's own fields. + fn reread(&mut self, sender: &ComponentSender) { + self.options = self.read_options(); + match read_csv(&self.bytes, &self.options, Some(MAX_PREVIEW_ROWS)) { + Ok(sheet) => { + self.sheet = sheet; + self.read_error = None; + } + Err(error) => { + self.sheet = CsvSheet::default(); + self.read_error = Some(import_error_text(&error)); + } + } + self.rebuild_mapping(sender); + self.show_summary(); + } + + fn rebuild_mapping(&mut self, sender: &ComponentSender) { + for row in self.mapping_rows.borrow_mut().drain(..) { + self.mapping_group.remove(&row); + } + + let choices: Vec = std::iter::once(crate::i18n::gettext("Skip")) + .chain(self.sheet.headers.iter().cloned()) + .collect(); + let choice_refs: Vec<&str> = choices.iter().map(String::as_str).collect(); + // One list for every row: each row keeps its own selection, and + // a wide file is held once rather than once per column. + let choices = gtk::StringList::new(&choice_refs); + let suggested = suggest_mapping(&self.sheet.headers, &self.columns); + + for (column, field) in self.columns.iter().zip(&suggested) { + let row = adw::ComboRow::builder() + .title(glib::markup_escape_text(&column.info.name)) + .model(&choices) + .sensitive(!column.server_filled) + .build(); + row.set_selected(field.map_or(0, |index| index as u32 + 1)); + let sender = sender.clone(); + row.connect_selected_notify(move |_| sender.deliver(ImportCsvDialogInput::MappingChanged)); + self.mapping_group.add(&row); + self.mapping_rows.borrow_mut().push(row); + } + self.show_samples(); + } + + /// Show what the mapped field actually holds, under each column. + fn show_samples(&self) { + let rows = self.mapping_rows.borrow(); + for ((row, field), column) in rows.iter().zip(self.mapping()).zip(&self.columns) { + row.set_subtitle(&match field { + _ if column.server_filled => crate::i18n::gettext("The database fills this column"), + Some(index) => sample_text(&self.sheet, index, &self.options), + None => crate::i18n::gettext("Uses the column's own default"), + }); + } + } + + /// What stands in the way of importing, if anything. + fn blocker(&self) -> Option { + if let Some(error) = &self.read_error { + return Some(error.clone()); + } + if self.sheet.rows.is_empty() { + return Some(crate::i18n::gettext("The file has no rows to import.")); + } + if self.mapping().iter().all(Option::is_none) { + return Some(crate::i18n::gettext( + "Every column is set to Skip. Pick a field for at least one column to import.", + )); + } + None + } + + fn show_summary(&self) { + let blocker = self.blocker(); + self.import_button.set_sensitive(blocker.is_none()); + let text = match blocker { + Some(blocker) => { + self.summary.remove_css_class("dim-label"); + self.summary.add_css_class("error"); + self.summary.set_label(&blocker); + return; + } + None if self.sheet.truncated => crate::i18n::gettext_f( + "{fields} fields. Showing the first {rows} rows; the whole file is imported.", + &[ + ("fields", &self.sheet.headers.len().to_string()), + ("rows", &self.sheet.rows.len().to_string()), + ], + ), + None => crate::i18n::ngettext_f( + "{fields} fields, {rows} row.", + "{fields} fields, {rows} rows.", + self.sheet.rows.len() as u32, + &[ + ("fields", &self.sheet.headers.len().to_string()), + ("rows", &self.sheet.rows.len().to_string()), + ], + ), + }; + self.summary.remove_css_class("error"); + self.summary.add_css_class("dim-label"); + self.summary.set_label(&text); + } + + fn mapping(&self) -> Vec> { + self.mapping_rows + .borrow() + .iter() + .zip(&self.columns) + .map(|(row, column)| match row.selected() { + _ if column.server_filled => None, + 0 => None, + index => Some(index as usize - 1), + }) + .collect() + } +} + +/// What went wrong reading the file, in words the user can act on. +pub(crate) fn import_error_text(error: &ImportError) -> String { + match error { + ImportError::NotUtf8 { offset } => crate::i18n::gettext_f( + "The file isn't UTF-8 text from byte {offset} on. Save it as UTF-8 and import it again.", + &[("offset", &offset.to_string())], + ), + ImportError::Malformed(detail) => { + crate::i18n::gettext_f("The file couldn't be read as CSV: {error}", &[("error", detail)]) + } + ImportError::NoColumns => crate::i18n::gettext("The file has no columns."), + ImportError::TooManyFields { line, limit } => crate::i18n::gettext_f( + "Line {line} has more than {limit} fields, more than any table holds. Check the delimiter.", + &[("line", &line.to_string()), ("limit", &limit.to_string())], + ), + } +} + +/// The first few values of one field, as one line. +fn sample_text(sheet: &CsvSheet, field: usize, options: &CsvImportOptions) -> String { + let values: Vec = sheet + .rows + .iter() + .filter_map(|row| row.get(field)) + .take(SAMPLE_VALUES) + .map(|text| match text == &options.null_marker { + true => crate::i18n::gettext("NULL"), + false => text.clone(), + }) + .collect(); + match values.is_empty() { + true => crate::i18n::gettext("No values in the file"), + false => values.join(" · "), + } +} + +fn qualified(schema: Option<&str>, table: &str) -> String { + match schema { + Some(schema) => format!("{schema}.{table}"), + None => table.to_owned(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sheet() -> CsvSheet { + CsvSheet { + headers: vec!["id".to_owned(), "name".to_owned()], + rows: vec![ + vec!["1".to_owned(), "ada".to_owned()], + vec!["2".to_owned(), String::new()], + vec!["3".to_owned(), "grace".to_owned()], + vec!["4".to_owned(), "alan".to_owned()], + ], + truncated: false, + } + } + + #[test] + fn a_sample_shows_the_first_few_values_of_its_field() { + assert_eq!( + sample_text(&sheet(), 0, &CsvImportOptions::default()), + "1 · 2 · 3", + "a sample past {SAMPLE_VALUES} values would not fit a subtitle" + ); + } + + #[test] + fn a_sample_names_the_values_that_would_import_as_null() { + assert_eq!( + sample_text(&sheet(), 1, &CsvImportOptions::default()), + "ada · NULL · grace" + ); + } + + #[test] + fn a_field_the_file_never_fills_says_so_rather_than_showing_nothing() { + assert_eq!( + sample_text(&CsvSheet::default(), 0, &CsvImportOptions::default()), + "No values in the file" + ); + } + + #[test] + fn a_table_in_a_schema_is_named_with_it() { + assert_eq!(qualified(Some("public"), "orders"), "public.orders"); + assert_eq!(qualified(None, "orders"), "orders"); + } +} + +#[cfg(test)] +mod dialog_tests { + use super::*; + use tablepro_core::ColumnInfo; + use tablepro_core::column::{ColumnDefault, ColumnType}; + + /// The dialog only reads a column's name: the type decides what a + /// value parses into, which happens after the dialog is gone. + fn column(name: &str) -> ImportColumn { + ImportColumn { + info: ColumnInfo { + name: name.to_owned(), + column_type: ColumnType::unknown(), + nullable: true, + primary_key: false, + is_auto_increment: false, + is_generated: false, + default: ColumnDefault::None, + comment: None, + }, + server_filled: false, + } + } + + fn launch(csv: &str) -> relm4::component::Connector { + ImportCsvDialog::builder().launch(ImportCsvDialogInit { + schema: Some("public".to_owned()), + table: "people".to_owned(), + columns: vec![column("id"), column("name"), column("nickname")], + bytes: csv.as_bytes().to_vec(), + }) + } + + #[gtk4::test] + fn a_column_named_in_the_file_starts_mapped_to_its_field() { + let dialog = launch("name,id\nada,1\n"); + + crate::test_support::drain_main_context(); + + // id is the file's second field, name its first, and nothing in + // the file is called nickname. + assert_eq!(dialog.model().mapping(), vec![Some(1), Some(0), None]); + } + + #[gtk4::test] + fn an_unmapped_column_says_it_keeps_its_default() { + let dialog = launch("name,id\nada,1\n"); + crate::test_support::drain_main_context(); + + let subtitles: Vec = dialog + .model() + .mapping_rows + .borrow() + .iter() + .map(|row| row.subtitle().unwrap_or_default().to_string()) + .collect(); + + assert_eq!(subtitles[0], "1"); + assert_eq!(subtitles[1], "ada"); + assert_eq!(subtitles[2], "Uses the column's own default"); + } + + #[gtk4::test] + fn turning_the_header_switch_off_remaps_against_positional_names() { + let dialog = launch("name,id\nada,1\n"); + crate::test_support::drain_main_context(); + assert_eq!(dialog.model().mapping(), vec![Some(1), Some(0), None]); + + dialog.model().header_row.set_active(false); + crate::test_support::drain_main_context(); + + // "Column 1" and "Column 2" match no table column, so every row + // falls back to Skip and the header line reads as data. + assert_eq!(dialog.model().mapping(), vec![None, None, None]); + } + + #[gtk4::test] + fn a_column_the_server_fills_cannot_take_a_field() { + let mut id = column("id"); + id.server_filled = true; + let dialog = ImportCsvDialog::builder().launch(ImportCsvDialogInit { + schema: None, + table: "people".to_owned(), + columns: vec![id, column("name")], + bytes: b"id,name\n1,ada\n".to_vec(), + }); + crate::test_support::drain_main_context(); + + let rows = dialog.model().mapping_rows.borrow().clone(); + + assert_eq!(dialog.model().mapping(), vec![None, Some(1)]); + assert!( + !rows[0].is_sensitive(), + "a field could be picked for a column the server fills" + ); + assert_eq!( + rows[0].subtitle().unwrap_or_default().as_str(), + "The database fills this column" + ); + } + + #[gtk4::test] + fn a_file_that_is_not_csv_leaves_the_import_button_off_and_says_why_in_the_dialog() { + let dialog = ImportCsvDialog::builder().launch(ImportCsvDialogInit { + schema: None, + table: "people".to_owned(), + columns: vec![column("id")], + bytes: b"id\n\xff\n".to_vec(), + }); + + crate::test_support::drain_main_context(); + + assert!(!dialog.model().import_button.is_sensitive()); + assert_eq!( + dialog.model().summary.label().as_str(), + "The file isn't UTF-8 text from byte 3 on. Save it as UTF-8 and import it again." + ); + assert!(dialog.model().summary.has_css_class("error")); + } + + #[gtk4::test] + fn every_column_set_to_skip_turns_the_import_button_off() { + let dialog = launch("name,id\nada,1\n"); + crate::test_support::drain_main_context(); + assert!(dialog.model().import_button.is_sensitive()); + + for row in dialog.model().mapping_rows.borrow().iter() { + row.set_selected(0); + } + crate::test_support::drain_main_context(); + + assert!(!dialog.model().import_button.is_sensitive()); + assert_eq!( + dialog.model().summary.label().as_str(), + "Every column is set to Skip. Pick a field for at least one column to import." + ); + } + + #[gtk4::test] + fn a_file_with_only_a_header_has_nothing_to_import() { + let dialog = launch("name,id\n"); + crate::test_support::drain_main_context(); + + assert!(!dialog.model().import_button.is_sensitive()); + assert_eq!( + dialog.model().summary.label().as_str(), + "The file has no rows to import." + ); + } +} diff --git a/linux/crates/app/src/ui/mod.rs b/linux/crates/app/src/ui/mod.rs new file mode 100644 index 0000000000..6100b54ab5 --- /dev/null +++ b/linux/crates/app/src/ui/mod.rs @@ -0,0 +1,47 @@ +mod a11y; +mod browse_tab; +pub(crate) mod clipboard; +mod combo_value; +mod connect_dialog; +mod connection_list_banner; +pub(crate) mod credential_prompt; +mod delete_connection_dialog; +mod discard_alert; +mod editor; +mod editor_font_provider; +pub(crate) mod error_text; +mod export; +mod file_read; +pub(crate) mod file_reveal; +mod file_write; +mod filter_strip; +mod format; +mod grid; +mod handler_guard; +mod history_dialog; +mod host_key_failure; +mod host_key_review; +pub(crate) mod icons; +mod import_dialog; +mod name_entry; +mod picker; +mod plain_toast; +mod preferences_dialog; +mod result_pane; +mod saved_queries_dialog; +mod session_window; +mod shell; +mod ssh_inputs; +mod ssh_section; +mod structure_tab; +mod style_scheme_grid; +mod style_scheme_resolver; +mod tab_load; +mod welcome_view; +mod window_geometry; +mod workspace; + +pub(crate) use plain_toast::plain_toast; +pub(crate) use preferences_dialog::PreferencesDialog; +pub(crate) use session_window::{SessionWindow, SessionWindowInit, SessionWindowInput}; +pub(crate) use structure_tab::review_dialog; diff --git a/linux/crates/app/src/ui/name_entry.rs b/linux/crates/app/src/ui/name_entry.rs new file mode 100644 index 0000000000..a360353dcf --- /dev/null +++ b/linux/crates/app/src/ui/name_entry.rs @@ -0,0 +1,39 @@ +use libadwaita as adw; +use libadwaita::prelude::*; + +/// Keep `response` off while `entry` holds no name, so Enter in the row +/// and the button both wait for one instead of closing on nothing. +pub(crate) fn require_name(dialog: &adw::AlertDialog, response: &str, entry: &adw::EntryRow) { + let named = |entry: &adw::EntryRow| !entry.text().trim().is_empty(); + dialog.set_response_enabled(response, named(entry)); + let dialog = dialog.downgrade(); + let response = response.to_owned(); + entry.connect_changed(move |entry| { + if let Some(dialog) = dialog.upgrade() { + dialog.set_response_enabled(&response, named(entry)); + } + }); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[gtk4::test] + fn the_response_waits_for_a_name() { + adw::init().expect("libadwaita initialises under the test backend"); + let dialog = adw::AlertDialog::new(Some("Save Query"), None); + dialog.add_response("save", "Save"); + let entry = adw::EntryRow::new(); + entry.set_text(" "); + + require_name(&dialog, "save", &entry); + assert!(!dialog.is_response_enabled("save"), "a blank name can be saved"); + + entry.set_text("daily"); + assert!(dialog.is_response_enabled("save")); + + entry.set_text(""); + assert!(!dialog.is_response_enabled("save")); + } +} diff --git a/linux/crates/app/src/ui/picker/connection_menu.rs b/linux/crates/app/src/ui/picker/connection_menu.rs new file mode 100644 index 0000000000..1f5cdbfeff --- /dev/null +++ b/linux/crates/app/src/ui/picker/connection_menu.rs @@ -0,0 +1,242 @@ +use gtk4::prelude::*; +use gtk4::{gio, glib}; +use tablepro_storage::{ConnectionColor, ConnectionGroupName}; +use uuid::Uuid; + +pub(super) const PICKER: &str = "picker"; +pub(super) const CONNECT: &str = "connect"; +pub(super) const EDIT: &str = "edit"; +pub(super) const DELETE: &str = "delete"; +pub(super) const DUPLICATE: &str = "duplicate"; +pub(super) const NEW_GROUP: &str = "new-group"; +pub(super) const SET_COLOR: &str = "set-color"; +pub(super) const MOVE_TO_GROUP: &str = "move-to-group"; + +/// The row's own actions: its menu, and the colour and group radios, +/// whose state is that row's connection. +pub(super) const ROW: &str = "picker-row"; +pub(super) const ROW_MENU: &str = "menu"; +pub(super) const ROW_COLOR: &str = "color"; +pub(super) const ROW_GROUP: &str = "group"; + +/// The radio state standing for "no colour" or "no group". An empty +/// string rather than a missing state, because the action's parameter +/// type is a string and a radio group needs every entry to carry one. +pub(super) const NONE: &str = ""; + +/// One connection's menu. Every item names the connection by id, so the +/// menu acts on the row it opened on whatever the list does meanwhile. +/// Delete sits in its own section so a destructive action is never the +/// neighbour of an ordinary one. +pub(crate) fn connection_menu(id: Uuid, groups: &[ConnectionGroupName]) -> gio::Menu { + let target = id.to_string().to_variant(); + let menu = gio::Menu::new(); + let open = gio::Menu::new(); + open.append_item(&item(&crate::i18n::gettext("Connect"), PICKER, CONNECT, &target)); + menu.append_section(None, &open); + let change = gio::Menu::new(); + change.append_item(&item(&crate::i18n::gettext("Edit…"), PICKER, EDIT, &target)); + change.append_item(&item(&crate::i18n::gettext("Duplicate"), PICKER, DUPLICATE, &target)); + menu.append_section(None, &change); + let file = gio::Menu::new(); + file.append_submenu(Some(&crate::i18n::gettext("Colour")), &color_menu()); + file.append_submenu( + Some(&crate::i18n::gettext("Move to Group")), + &group_menu(&target, groups), + ); + menu.append_section(None, &file); + let danger = gio::Menu::new(); + // Named as the dialog it opens names it. + danger.append_item(&item(&crate::i18n::gettext("Delete…"), PICKER, DELETE, &target)); + menu.append_section(None, &danger); + menu +} + +fn item(label: &str, group: &str, action: &str, target: &glib::Variant) -> gio::MenuItem { + let item = gio::MenuItem::new(Some(label), None); + item.set_action_and_target_value(Some(&format!("{group}.{action}")), Some(target)); + item +} + +/// The colour choices, as a radio group. "No Colour" leads because it +/// is the state every connection starts in. +fn color_menu() -> gio::Menu { + let menu = gio::Menu::new(); + menu.append_item(&item( + &crate::i18n::gettext("No Colour"), + ROW, + ROW_COLOR, + &NONE.to_variant(), + )); + let colors = gio::Menu::new(); + for color in ConnectionColor::ALL { + colors.append_item(&item(&color_name(color), ROW, ROW_COLOR, &color.id().to_variant())); + } + menu.append_section(None, &colors); + menu +} + +/// The groups already in the list, as a radio group ticked at the one +/// the connection is in, with a way to start a new one at the end. +fn group_menu(target: &glib::Variant, groups: &[ConnectionGroupName]) -> gio::Menu { + let menu = gio::Menu::new(); + menu.append_item(&item( + &crate::i18n::gettext("No Group"), + ROW, + ROW_GROUP, + &NONE.to_variant(), + )); + if !groups.is_empty() { + let existing = gio::Menu::new(); + for group in groups { + existing.append_item(&item( + &menu_label(group.as_str()), + ROW, + ROW_GROUP, + &group.as_str().to_variant(), + )); + } + menu.append_section(None, &existing); + } + let new = gio::Menu::new(); + new.append_item(&item(&crate::i18n::gettext("New Group…"), PICKER, NEW_GROUP, target)); + menu.append_section(None, &new); + menu +} + +/// A GtkPopoverMenu reads an underscore in a label as a mnemonic, so a +/// group the user named keeps its underscores by doubling them. +fn menu_label(text: &str) -> String { + text.replace('_', "__") +} + +pub(super) fn color_name(color: ConnectionColor) -> String { + match color { + ConnectionColor::Blue => crate::i18n::gettext("Blue"), + ConnectionColor::Teal => crate::i18n::gettext("Teal"), + ConnectionColor::Green => crate::i18n::gettext("Green"), + ConnectionColor::Yellow => crate::i18n::gettext("Yellow"), + ConnectionColor::Orange => crate::i18n::gettext("Orange"), + ConnectionColor::Red => crate::i18n::gettext("Red"), + ConnectionColor::Pink => crate::i18n::gettext("Pink"), + ConnectionColor::Purple => crate::i18n::gettext("Purple"), + ConnectionColor::Slate => crate::i18n::gettext("Slate"), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Each item's label, action and target, in order, sections and + /// submenus flattened. + fn items(menu: &gio::MenuModel) -> Vec<(String, Option, Option)> { + let mut found = Vec::new(); + for index in 0..menu.n_items() { + if let Some(label) = menu + .item_attribute_value(index, "label", None) + .and_then(|value| value.get::()) + { + let action = menu + .item_attribute_value(index, "action", None) + .and_then(|value| value.get::()); + found.push((label, action, menu.item_attribute_value(index, "target", None))); + } + for link in ["section", "submenu"] { + if let Some(linked) = menu.item_link(index, link) { + found.extend(items(&linked)); + } + } + } + found + } + + fn labels(menu: &gio::Menu) -> Vec { + items(menu.upcast_ref()).into_iter().map(|(label, ..)| label).collect() + } + + #[test] + fn connect_leads_and_delete_closes_the_menu() { + let id = Uuid::new_v4(); + + let found = items(connection_menu(id, &[]).upcast_ref()); + + let first = found.first().expect("an item"); + assert_eq!(first.0, "Connect"); + assert_eq!(first.1.as_deref(), Some("picker.connect")); + let last = found.last().expect("an item"); + assert_eq!(last.0, "Delete…"); + assert_eq!(last.1.as_deref(), Some("picker.delete")); + for (label, action, target) in &found { + if action.as_deref().is_some_and(|action| action.starts_with("picker.")) { + assert_eq!( + target.as_ref().and_then(|target| target.get::()), + Some(id.to_string()), + "{label} does not name the row's connection" + ); + } + } + } + + fn group(name: &str) -> ConnectionGroupName { + ConnectionGroupName::new(name).expect("a group name") + } + + #[test] + fn the_menu_reads_connect_edit_duplicate_colour_group_delete() { + let labels = labels(&connection_menu(Uuid::new_v4(), &[])); + let shown = |label: &str| { + labels + .iter() + .position(|candidate| candidate == label) + .unwrap_or_else(|| panic!("{label} is missing: {labels:?}")) + }; + + let order = [ + shown("Connect"), + shown("Edit…"), + shown("Duplicate"), + shown("Colour"), + shown("Move to Group"), + shown("Delete…"), + ]; + + assert!(order.windows(2).all(|pair| pair[0] < pair[1]), "{labels:?}"); + } + + #[test] + fn the_group_menu_offers_every_group_the_list_already_has() { + let labels = labels(&connection_menu(Uuid::new_v4(), &[group("Archive"), group("Work")])); + + assert!(labels.contains(&"Archive".to_owned()), "{labels:?}"); + assert!(labels.contains(&"Work".to_owned()), "{labels:?}"); + assert!(labels.contains(&"No Group".to_owned()), "{labels:?}"); + assert!(labels.contains(&"New Group…".to_owned()), "{labels:?}"); + } + + #[test] + fn a_group_name_keeps_its_underscores_in_the_menu() { + let found = items(connection_menu(Uuid::new_v4(), &[group("dev_db")]).upcast_ref()); + + let group = found + .iter() + .find(|(_, _, target)| { + target.as_ref().and_then(|target| target.get::()).as_deref() == Some("dev_db") + }) + .expect("the group's item"); + assert_eq!(group.0, "dev__db"); + } + + #[test] + fn the_radio_target_tells_no_colour_apart_from_a_colour() { + let found = items(color_menu().upcast_ref()); + + let targets: Vec = found + .iter() + .filter_map(|(_, _, target)| target.as_ref().and_then(|target| target.get::())) + .collect(); + assert_eq!(targets.first().map(String::as_str), Some(NONE)); + assert_eq!(targets.len(), ConnectionColor::ALL.len() + 1); + assert!(targets.iter().skip(1).all(|id| ConnectionColor::from_id(id).is_some())); + } +} diff --git a/linux/crates/app/src/ui/picker/connection_row.rs b/linux/crates/app/src/ui/picker/connection_row.rs new file mode 100644 index 0000000000..65fdf39156 --- /dev/null +++ b/linux/crates/app/src/ui/picker/connection_row.rs @@ -0,0 +1,198 @@ +use std::path::PathBuf; + +use gtk4 as gtk; +use gtk4::prelude::*; +use gtk4::{gdk, gio, glib}; +use libadwaita as adw; +use libadwaita::prelude::*; +use tablepro_session::runtime::Tasks; +use tablepro_storage::{ConnectionColor, ConnectionGroupName, SavedConnection}; + +use super::connection_menu::{ + CONNECT, MOVE_TO_GROUP, NONE, PICKER, ROW, ROW_COLOR, ROW_GROUP, ROW_MENU, SET_COLOR, color_name, connection_menu, +}; +use crate::application::{ShortcutOwner, local_shortcut_controller}; +use crate::services::file_location; +use crate::services::saved_connection_object::{SavedConnectionObject, is_sqlite, subtitle_for}; +use crate::ui::a11y::describe_icon_button; + +/// The row for one saved connection: activating it connects, and its +/// menu holds the rest. `groups` are the list's groups, which the menu +/// offers. `tasks` reads the file a portal path stands for; `None` +/// names the file alone. +pub(crate) fn connection_row( + object: &glib::Object, + groups: &[ConnectionGroupName], + tasks: Option<&Tasks>, +) -> gtk::Widget { + let Some(saved) = object + .downcast_ref::() + .and_then(SavedConnectionObject::saved) + .cloned() + else { + return adw::ActionRow::new().upcast(); + }; + let row = adw::ActionRow::builder() + .title(&saved.name) + .subtitle(subtitle_for(&saved, None)) + // A name is the user's text, never markup. + .use_markup(false) + .activatable(true) + .build(); + row.set_action_target_value(Some(&saved.id.to_string().to_variant())); + row.set_action_name(Some(&format!("{PICKER}.{CONNECT}"))); + row.add_prefix(&color_tag(saved.color)); + // One menu rather than a button per action: the row gains actions as + // the app does, and a row of icons stops reading as a row. + let menu_button = gtk::MenuButton::builder() + .icon_name(crate::ui::icons::VIEW_MORE) + .valign(gtk::Align::Center) + .menu_model(&connection_menu(saved.id, groups)) + .build(); + menu_button.add_css_class("flat"); + describe_icon_button( + &menu_button, + &crate::i18n::gettext("Connection Options"), + Some(&format!("{ROW}.{ROW_MENU}")), + ); + row.add_suffix(&menu_button); + row.insert_action_group(ROW, Some(&row_actions(&row, &saved, &menu_button))); + row.add_controller(local_shortcut_controller(ShortcutOwner::PickerRow)); + row.add_controller(secondary_click(&menu_button)); + if let Some(tasks) = tasks { + show_host_path(&row, &saved, tasks); + } + row.upcast() +} + +/// The row's menu, and the colour and group radios ticked at what the +/// connection has. A pick goes to the picker's action with the id. +fn row_actions(row: &adw::ActionRow, saved: &SavedConnection, menu_button: >k::MenuButton) -> gio::SimpleActionGroup { + let actions = gio::SimpleActionGroup::new(); + let menu = gio::SimpleAction::new(ROW_MENU, None); + let button = menu_button.downgrade(); + menu.connect_activate(move |_, _| { + if let Some(button) = button.upgrade() { + button.popup(); + } + }); + actions.add_action(&menu); + let current_color = saved.color.map_or(NONE, |color| color.id()); + let current_group = saved.group.as_ref().map_or(NONE, ConnectionGroupName::as_str); + for (name, state, forward) in [ + (ROW_COLOR, current_color, SET_COLOR), + (ROW_GROUP, current_group, MOVE_TO_GROUP), + ] { + let radio = gio::SimpleAction::new_stateful(name, Some(&String::static_variant_type()), &state.to_variant()); + let (row, id) = (row.downgrade(), saved.id.to_string()); + radio.connect_activate(move |radio, picked| { + let (Some(row), Some(picked)) = (row.upgrade(), picked.and_then(|value| value.get::())) else { + return; + }; + radio.set_state(&picked.to_variant()); + let target = (id.clone(), picked).to_variant(); + if let Err(error) = row.activate_action(&format!("{PICKER}.{forward}"), Some(&target)) { + tracing::warn!(%error, "a connection row's pick reached no picker"); + } + }); + actions.add_action(&radio); + } + actions +} + +fn secondary_click(menu_button: >k::MenuButton) -> gtk::GestureClick { + let gesture = gtk::GestureClick::new(); + gesture.set_button(gdk::BUTTON_SECONDARY); + let button = menu_button.downgrade(); + gesture.connect_pressed(move |gesture, _, _, _| { + gesture.set_state(gtk::EventSequenceState::Claimed); + if let Some(button) = button.upgrade() { + button.popup(); + } + }); + gesture +} + +/// The colour the user put on this connection, as a dot ahead of the +/// name. Absent rather than grey when there is none, so an untagged list +/// has no decoration in it. +fn color_tag(color: Option) -> gtk::Box { + let tag = gtk::Box::builder().valign(gtk::Align::Center).build(); + tag.set_css_classes(&tag_classes(color)); + tag.set_visible(color.is_some()); + if let Some(color) = color { + tag.set_tooltip_text(Some(&color_name(color))); + } + tag +} + +/// The dot's classes. Set as a whole list rather than added one by one +/// so a colour change never leaves the previous colour's class behind. +pub(super) fn tag_classes(color: Option) -> Vec<&'static str> { + let mut classes = vec!["tp-connection-tag"]; + if let Some(color) = color { + classes.push(match color { + ConnectionColor::Blue => "tp-tag-blue", + ConnectionColor::Teal => "tp-tag-teal", + ConnectionColor::Green => "tp-tag-green", + ConnectionColor::Yellow => "tp-tag-yellow", + ConnectionColor::Orange => "tp-tag-orange", + ConnectionColor::Red => "tp-tag-red", + ConnectionColor::Pink => "tp-tag-pink", + ConnectionColor::Purple => "tp-tag-purple", + ConnectionColor::Slate => "tp-tag-slate", + }); + } + classes +} + +/// A portal path's folder is an id, so the row names the file the +/// portal stands for once it is read. +fn show_host_path(row: &adw::ActionRow, saved: &SavedConnection, tasks: &Tasks) { + let path = PathBuf::from(&saved.database); + if !is_sqlite(saved) || !file_location::is_portal_path(&path) { + return; + } + let looking = tasks.spawn_blocking_task(move || file_location::portal_host_path_blocking(&path)); + let (saved, row) = (saved.clone(), row.downgrade()); + glib::spawn_future_local(async move { + if let (Ok(Some(host)), Some(row)) = (looking.await, row.upgrade()) { + row.set_subtitle(&subtitle_for(&saved, Some(&host))); + } + }); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_connection_with_no_colour_carries_no_colour_class() { + assert_eq!(tag_classes(None), vec!["tp-connection-tag"]); + } + + /// A class with no rule behind it paints nothing, and nothing is + /// exactly what a missing tag looks like. + #[test] + fn every_colour_class_has_a_rule_in_the_stylesheet() { + let css = include_str!("../../../../../data/resources/style.css"); + + for color in ConnectionColor::ALL { + let class = tag_classes(Some(color))[1]; + assert!( + css.contains(&format!(".tp-connection-tag.{class} {{")), + "{class} has no rule in style.css" + ); + } + } + + #[test] + fn every_colour_has_a_class_of_its_own() { + let classes: std::collections::HashSet<&str> = ConnectionColor::ALL + .into_iter() + .map(|color| tag_classes(Some(color))[1]) + .collect(); + + assert_eq!(classes.len(), ConnectionColor::ALL.len()); + } +} diff --git a/linux/crates/app/src/ui/picker/mod.rs b/linux/crates/app/src/ui/picker/mod.rs new file mode 100644 index 0000000000..1524c0172b --- /dev/null +++ b/linux/crates/app/src/ui/picker/mod.rs @@ -0,0 +1,11 @@ +//! The connection picker's rows, their menu and the actions they reach. + +mod connection_menu; +mod connection_row; +mod new_group_dialog; +mod picker_actions; +#[cfg(test)] +mod tests; + +pub(crate) use connection_row::connection_row; +pub(crate) use picker_actions::install_picker_actions; diff --git a/linux/crates/app/src/ui/picker/new_group_dialog.rs b/linux/crates/app/src/ui/picker/new_group_dialog.rs new file mode 100644 index 0000000000..eb09d3ac5f --- /dev/null +++ b/linux/crates/app/src/ui/picker/new_group_dialog.rs @@ -0,0 +1,81 @@ +use gtk4 as gtk; +use libadwaita as adw; +use libadwaita::prelude::*; +use tablepro_storage::ConnectionGroupName; + +const CANCEL: &str = "cancel"; +const CREATE: &str = "create"; + +/// Ask for the name of a new group. `None` when the user cancels. +/// +/// Create stays off until the name could be saved and names no group +/// the list already has, in any case, so the dialog never closes on a +/// name that is then refused or that reads as a group already there. +pub(crate) async fn ask_group_name( + parent: >k::Widget, + existing: &[ConnectionGroupName], +) -> Option { + let (dialog, entry) = new_group_dialog(existing); + if crate::workspace::respond(&dialog, parent).await != CREATE { + return None; + } + ConnectionGroupName::new(&entry.text()).ok() +} + +pub(super) fn new_group_dialog(existing: &[ConnectionGroupName]) -> (adw::AlertDialog, adw::EntryRow) { + let dialog = adw::AlertDialog::new(Some(&crate::i18n::gettext("New Group")), None); + let entry = adw::EntryRow::builder() + .title(crate::i18n::gettext("Name")) + .activates_default(true) + .build(); + let rows = gtk::ListBox::builder().selection_mode(gtk::SelectionMode::None).build(); + rows.add_css_class("boxed-list"); + rows.append(&entry); + dialog.set_extra_child(Some(&rows)); + dialog.add_response(CANCEL, &crate::i18n::gettext("Cancel")); + dialog.add_response(CREATE, &crate::i18n::gettext("Create")); + dialog.set_response_appearance(CREATE, adw::ResponseAppearance::Suggested); + dialog.set_default_response(Some(CREATE)); + dialog.set_close_response(CANCEL); + dialog.set_response_enabled(CREATE, false); + let taken: Vec = existing.iter().map(|group| group.as_str().to_lowercase()).collect(); + let answering = dialog.downgrade(); + entry.connect_changed(move |entry| { + let usable = + ConnectionGroupName::new(&entry.text()).is_ok_and(|name| !taken.contains(&name.as_str().to_lowercase())); + if let Some(dialog) = answering.upgrade() { + dialog.set_response_enabled(CREATE, usable); + } + }); + (dialog, entry) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[gtk4::test] + fn new_group_create_response_disabled_until_valid_unique_name() { + let _ = adw::init(); + let work = ConnectionGroupName::new("Work").expect("a group name"); + + let (dialog, entry) = new_group_dialog(&[work]); + + assert!(!dialog.is_response_enabled(CREATE), "an empty name can be created"); + for (typed, usable) in [ + (" ", false), + ("Work", false), + (" Work ", false), + ("work", false), + ("WORK", false), + ("Archive", true), + (&"x".repeat(ConnectionGroupName::MAX_CHARS + 1), false), + ("Staging", true), + ] { + entry.set_text(typed); + assert_eq!(dialog.is_response_enabled(CREATE), usable, "{typed:?}"); + } + assert_eq!(dialog.default_response().as_deref(), Some(CREATE)); + assert_eq!(dialog.close_response(), CANCEL); + } +} diff --git a/linux/crates/app/src/ui/picker/picker_actions.rs b/linux/crates/app/src/ui/picker/picker_actions.rs new file mode 100644 index 0000000000..7e4a2317a0 --- /dev/null +++ b/linux/crates/app/src/ui/picker/picker_actions.rs @@ -0,0 +1,147 @@ +use std::rc::Rc; + +use gtk4 as gtk; +use gtk4::prelude::*; +use gtk4::{gio, glib}; +use tablepro_storage::{ConnectionColor, ConnectionGroupName, SavedConnection}; +use uuid::Uuid; + +use super::connection_menu::{CONNECT, DELETE, DUPLICATE, EDIT, MOVE_TO_GROUP, NEW_GROUP, NONE, PICKER, SET_COLOR}; +use crate::runtime::{DeliverInput, InputSender}; +use crate::services::saved_connection_list::SavedConnectionList; +use crate::ui::SessionWindowInput; + +/// The `picker` actions on `root`, which every row and menu inside it +/// reach. Each names a connection by id, read again from the list at the +/// moment it runs, so a row or a menu left over from an older list acts +/// on nothing rather than on another connection. +pub(crate) fn install_picker_actions( + root: &impl IsA, + list: Rc, + sender: InputSender, +) { + let actions = gio::SimpleActionGroup::new(); + let by_id = [ + (CONNECT, OnConnection::Connect), + (EDIT, OnConnection::Edit), + (DELETE, OnConnection::Delete), + (DUPLICATE, OnConnection::Duplicate), + (NEW_GROUP, OnConnection::NewGroup), + ]; + for (name, picked) in by_id { + let action = gio::SimpleAction::new(name, Some(&String::static_variant_type())); + let (list, sender, root) = (list.clone(), sender.clone(), root.as_ref().downgrade()); + action.connect_activate(move |_, target| { + let Some(saved) = connection(&list, target.and_then(|target| target.get::()), name) else { + return; + }; + match picked { + OnConnection::Connect => sender.deliver(SessionWindowInput::OpenSaved(Box::new(saved))), + OnConnection::Edit => sender.deliver(SessionWindowInput::EditSavedConnection(saved.id)), + OnConnection::Delete => sender.deliver(SessionWindowInput::DeleteConnection(saved.id)), + OnConnection::Duplicate => sender.deliver(SessionWindowInput::DuplicateConnection(saved.id)), + OnConnection::NewGroup => { + if let Some(root) = root.upgrade() { + ask_for_group(root, saved.id, list.groups(), sender.clone()); + } + } + } + }); + actions.add_action(&action); + } + for (name, picked) in [(SET_COLOR, Setting::Color), (MOVE_TO_GROUP, Setting::Group)] { + let action = gio::SimpleAction::new(name, Some(&<(String, String)>::static_variant_type())); + let (list, sender) = (list.clone(), sender.clone()); + action.connect_activate(move |_, target| { + let Some((id, value)) = target.and_then(|target| target.get::<(String, String)>()) else { + tracing::warn!(action = name, "a picker action came without its target"); + return; + }; + let Some(saved) = connection(&list, Some(id), name) else { + return; + }; + let input = match picked { + Setting::Color => SessionWindowInput::SetConnectionColor(saved.id, ConnectionColor::from_id(&value)), + Setting::Group => match group_named(&value) { + Ok(group) => SessionWindowInput::SetConnectionGroup(saved.id, group), + Err(error) => { + tracing::warn!(action = name, %error, "a picker action named a group that cannot be saved"); + return; + } + }, + }; + sender.deliver(input); + }); + actions.add_action(&action); + } + root.insert_action_group(PICKER, Some(&actions)); +} + +/// What an action that names only a connection does with it. +#[derive(Debug, Clone, Copy)] +enum OnConnection { + Connect, + Edit, + Delete, + Duplicate, + NewGroup, +} + +/// What an action that names a connection and a value sets. +#[derive(Debug, Clone, Copy)] +enum Setting { + Color, + Group, +} + +/// The group a radio target names. The empty target is no group. +fn group_named(value: &str) -> Result, tablepro_storage::GroupNameError> { + if value == NONE { + return Ok(None); + } + ConnectionGroupName::new(value).map(Some) +} + +/// The connection a target names, or `None`, with a warning, for a +/// target that is not an id or names a connection the list no longer +/// has. +fn connection(list: &SavedConnectionList, target: Option, action: &str) -> Option { + let Some(id) = target.as_deref().and_then(|target| Uuid::parse_str(target).ok()) else { + tracing::warn!(action, "a picker action came without a connection id"); + return None; + }; + let saved = list.get(id); + if saved.is_none() { + tracing::warn!(action, %id, "a picker action named a connection the list no longer has"); + } + saved +} + +/// Ask for a new group's name, then file the connection under it. +fn ask_for_group( + parent: gtk::Widget, + id: Uuid, + existing: Vec, + sender: InputSender, +) { + glib::spawn_future_local(async move { + if let Some(group) = super::new_group_dialog::ask_group_name(&parent, &existing).await { + sender.deliver(SessionWindowInput::SetConnectionGroup(id, Some(group))); + } + }); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn an_empty_radio_target_is_no_group_and_a_blank_one_is_refused() { + assert_eq!(group_named(NONE), Ok(None)); + assert_eq!( + group_named(" Work ").map(|group| group.map(String::from)), + Ok(Some("Work".to_owned())) + ); + assert!(group_named(" ").is_err()); + } +} diff --git a/linux/crates/app/src/ui/picker/tests.rs b/linux/crates/app/src/ui/picker/tests.rs new file mode 100644 index 0000000000..c1b31830d1 --- /dev/null +++ b/linux/crates/app/src/ui/picker/tests.rs @@ -0,0 +1,439 @@ +use std::rc::Rc; +use std::sync::Arc; +use std::time::Duration; + +use gtk4 as gtk; +use gtk4::glib; +use gtk4::prelude::*; +use libadwaita as adw; +use libadwaita::prelude::*; +use tablepro_storage::{ConnectionListState, ConnectionStore, SavedConnection, StoragePaths}; +use uuid::Uuid; + +use crate::runtime::AppRuntime; +use crate::services::saved_connection_list::SavedConnectionList; +use crate::test_support::{descendants, drain_main_context, respond, test_runtime, wait_until}; +use crate::ui::delete_connection_dialog; +use crate::ui::session_window::SessionWindow; +use crate::ui::session_window::tests::{Script, TestWindow}; +use crate::ui::welcome_view::WelcomeView; + +const WAIT: Duration = Duration::from_secs(10); + +fn connection(name: &str, group: Option<&str>) -> SavedConnection { + SavedConnection { + id: Uuid::new_v4(), + name: name.to_owned(), + driver_id: "postgres".to_owned(), + host: "db".to_owned(), + port: 5432, + database: "app".to_owned(), + username: "postgres".to_owned(), + tls: tablepro_core::TlsConfig::verify_full(), + read_only: false, + auth_mode: tablepro_core::AuthMode::Password, + ssh: None, + last_opened_at: None, + color: None, + group: group.map(|group| tablepro_storage::ConnectionGroupName::new(group).expect("a group name")), + } +} + +/// One connection store, the list that follows it, and two pickers on +/// that list, as two windows have. +struct Pickers { + runtime: AppRuntime, + store: ConnectionStore, + list: Rc, + views: [WelcomeView; 2], + _dir: tempfile::TempDir, +} + +impl Pickers { + fn new() -> Self { + adw::init().expect("libadwaita initialises under the test backend"); + crate::register_test_resources(); + let dir = tempfile::tempdir().expect("a temporary directory"); + let store = ConnectionStore::new(&StoragePaths::under(dir.path(), "tablepro", crate::config::APP_ID)); + let list = SavedConnectionList::follow(&store); + let views = [ + WelcomeView::new(list.clone(), None), + WelcomeView::new(list.clone(), None), + ]; + Self { + runtime: test_runtime(), + store, + list, + views, + _dir: dir, + } + } + + /// Write as the connect dialog and a connect do: off the GTK thread. + fn write(&self, write: impl FnOnce(&ConnectionStore) + Send + 'static) { + let store = self.store.clone(); + let writing = self.runtime.tasks().spawn_blocking_task(move || write(&store)); + crate::test_support::finish_on_main(WAIT, writing) + .expect("the write to end") + .expect("the write task"); + } + + fn every_picker(&self, mut shows: impl FnMut(&[(String, Vec)]) -> bool) -> bool { + self.views.iter().all(|view| shows(&view.sections())) + } +} + +impl Drop for Pickers { + fn drop(&mut self) { + for view in &self.views { + view.shutdown(); + } + drain_main_context(); + } +} + +fn rows(sections: &[(String, Vec)]) -> Vec { + sections.iter().flat_map(|(_, rows)| rows.clone()).collect() +} + +#[gtk4::test] +fn connect_dialog_save_updates_every_picker() { + let pickers = Pickers::new(); + let corp = connection("Corp", None); + + pickers.write(move |store| store.upsert_blocking(corp).expect("the save")); + + wait_until(WAIT, || pickers.every_picker(|sections| rows(sections) == ["Corp"])) + .expect("every picker to list the new connection"); +} + +#[gtk4::test] +fn successful_connect_stamp_reorders_every_picker() { + let pickers = Pickers::new(); + let (first, second) = (connection("Alpha", None), connection("Beta", None)); + let stamped = second.id; + pickers.write(move |store| { + store.upsert_blocking(first).expect("the save"); + store.upsert_blocking(second).expect("the save"); + }); + wait_until(WAIT, || { + pickers.every_picker(|sections| rows(sections) == ["Alpha", "Beta"]) + }) + .expect("both connections, by name"); + + pickers.write(move |store| store.touch_last_opened_blocking(stamped).expect("the stamp")); + + wait_until(WAIT, || { + pickers.every_picker(|sections| rows(sections) == ["Beta", "Alpha"]) + }) + .expect("the connection opened last to lead every picker"); +} + +#[gtk4::test] +fn picker_renders_one_group_per_section() { + let pickers = Pickers::new(); + let (loose, a, b, c) = ( + connection("loose", None), + connection("a", Some("Work")), + connection("b", Some("Work")), + connection("c", Some("R&D")), + ); + pickers + .list + .apply(ConnectionListState::Ready(Arc::from([loose, a.clone(), b, c]))); + + let view = &pickers.views[0]; + assert_eq!( + view.sections(), + [ + ("Connections".to_owned(), vec!["loose".to_owned()]), + ("R&D".to_owned(), vec!["c".to_owned()]), + ("Work".to_owned(), vec!["a".to_owned(), "b".to_owned()]), + ] + ); + + pickers + .list + .apply(ConnectionListState::Ready(Arc::from([a, connection("d", None)]))); + + assert_eq!( + view.sections(), + [ + ("Connections".to_owned(), vec!["d".to_owned()]), + ("Work".to_owned(), vec!["a".to_owned()]), + ], + "a group with no connection left kept its section" + ); + pickers + .list + .apply(ConnectionListState::Ready(Arc::from([connection("e", Some("Work"))]))); + assert_eq!( + view.sections(), + [("Work".to_owned(), vec!["e".to_owned()])], + "an empty ungrouped section stayed" + ); + pickers + .list + .apply(ConnectionListState::Ready(Arc::from([connection("f", None)]))); + assert_eq!( + view.sections(), + [("Saved Connections".to_owned(), vec!["f".to_owned()])], + "with no group left the list is not one section among others" + ); +} + +#[gtk4::test] +fn the_empty_page_shows_only_for_a_list_that_is_read_and_empty() { + let pickers = Pickers::new(); + let view = &pickers.views[0]; + assert_eq!( + view.visible_page().as_deref(), + Some("list"), + "a loading list offered to add one" + ); + + pickers.list.apply(ConnectionListState::Ready(Arc::from([]))); + assert_eq!(view.visible_page().as_deref(), Some("empty")); + + pickers + .list + .apply(ConnectionListState::Ready(Arc::from([connection("Corp", None)]))); + assert_eq!(view.visible_page().as_deref(), Some("list")); +} + +#[gtk4::test] +fn a_name_is_shown_as_text_not_markup() { + let pickers = Pickers::new(); + pickers + .list + .apply(ConnectionListState::Ready(Arc::from([connection("R&D ", None)]))); + + let row = descendants(&pickers.views[0]) + .into_iter() + .find_map(|widget| widget.downcast::().ok()) + .expect("the row"); + + assert!(!row.uses_markup()); + assert_eq!(row.title(), "R&D "); +} + +fn picker_row(window: &SessionWindow, name: &str) -> adw::ActionRow { + let mut found = None; + wait_until(WAIT, || { + found = descendants(window) + .into_iter() + .filter_map(|widget| widget.downcast::().ok()) + .find(|row| row.title() == name); + found.is_some() + }) + .expect("the connection's row"); + found.expect("the row") +} + +fn alert_dialogs(window: &SessionWindow) -> Vec { + window + .dialogs() + .iter::() + .filter_map(|item| item.ok()?.downcast::().ok()) + .collect() +} + +#[gtk4::test] +fn delete_action_presents_exactly_one_dialog() { + let test = TestWindow::picker("PickerDelete"); + let row = picker_row(&test.window, "Fake"); + + row.activate_action("picker.delete", Some(&test.saved.id.to_string().to_variant())) + .expect("picker.delete"); + + wait_until(WAIT, || test.window.visible_dialog().is_some()).expect("a dialog"); + let shown = test + .window + .visible_dialog() + .and_downcast::() + .expect("an alert"); + assert_eq!(shown.heading().as_deref(), Some("Delete “Fake”?")); + assert_eq!( + alert_dialogs(&test.window), + std::slice::from_ref(&shown), + "the delete asked twice" + ); + + respond(&shown, delete_connection_dialog::CANCEL); + wait_until(WAIT, || test.window.visible_dialog().is_none()).expect("the dialog to close"); + drain_main_context(); + assert!( + test.services.saved_connections.get(test.saved.id).is_some(), + "Cancel deleted" + ); + assert!(alert_dialogs(&test.window).is_empty()); +} + +#[gtk4::test] +fn activating_a_row_connects_to_its_connection() { + let test = TestWindow::picker("PickerActivate"); + let row = picker_row(&test.window, "Fake"); + + WidgetExt::activate(&row); + + wait_until(WAIT, || !test.connector.calls().is_empty()).expect("the connect"); + assert_eq!(test.connector.calls()[0].request.metadata.id, test.saved.id); +} + +#[gtk4::test] +fn invalid_action_target_is_ignored() { + let test = TestWindow::picker("PickerInvalid"); + let row = picker_row(&test.window, "Fake"); + + for target in ["not an id".to_owned(), Uuid::new_v4().to_string()] { + row.activate_action("picker.delete", Some(&target.to_variant())) + .expect("picker.delete"); + row.activate_action("picker.connect", Some(&target.to_variant())) + .expect("picker.connect"); + } + drain_main_context(); + + assert!( + test.window.visible_dialog().is_none(), + "a target that names nothing asked" + ); + assert_eq!(test.window.page_name(), "picker"); + assert!( + test.connector.calls().is_empty(), + "a target that names nothing connected" + ); +} + +#[gtk4::test] +fn row_menu_key_opens_menu() { + let test = TestWindow::picker("PickerRowMenu"); + let row = picker_row(&test.window, "Fake"); + let button = descendants(&row) + .into_iter() + .find_map(|widget| widget.downcast::().ok()) + .expect("the row's menu button"); + + row.activate_action("picker-row.menu", None).expect("picker-row.menu"); + + wait_until(WAIT, || button.popover().is_some_and(|popover| popover.is_visible())).expect("the row's menu to open"); + assert_eq!(button.tooltip_text().as_deref(), Some("Connection Options")); + button.popdown(); +} + +#[gtk4::test] +fn the_menu_keys_reach_the_row_menu_action() { + let test = TestWindow::picker("PickerRowKeys"); + let row = picker_row(&test.window, "Fake"); + + let bound = + crate::test_support::shortcut_keys::owner_controller(&row, crate::application::ShortcutOwner::PickerRow) + .expect("the row's keys"); + let actions: Vec = crate::test_support::shortcut_keys::shortcuts(&bound) + .into_iter() + .filter_map(|shortcut| shortcut.action().and_downcast::()) + .map(|action| action.action_name().to_string()) + .collect(); + + assert_eq!(actions, ["picker-row.menu", "picker-row.menu"]); +} + +#[gtk4::test] +fn a_colour_picked_from_the_row_reaches_the_store() { + let test = TestWindow::picker("PickerColour"); + let row = picker_row(&test.window, "Fake"); + + row.activate_action("picker-row.color", Some(&"red".to_variant())) + .expect("picker-row.color"); + + wait_until(WAIT, || { + test.services + .saved_connections + .get(test.saved.id) + .is_some_and(|saved| saved.color == Some(tablepro_storage::ConnectionColor::Red)) + }) + .expect("the colour saved and shown"); +} + +#[gtk4::test] +fn unreadable_list_reveals_banner() { + let test = TestWindow::with_script( + "PickerUnreadable", + Script { + unreadable_list: true, + ..Script::default() + }, + ); + let banner = test.window.connection_list_banner().expect("the picker's banner"); + + wait_until(WAIT, || banner.is_revealed()).expect("the banner"); + assert_eq!(banner.title(), "Saved connections could not be read"); +} + +fn connect_dialog(window: &SessionWindow) -> crate::ui::connect_dialog::ConnectDialog { + let mut shown = None; + wait_until(WAIT, || { + shown = window + .visible_dialog() + .and_downcast::(); + shown.is_some() + }) + .expect("the connect dialog"); + shown.expect("the dialog") +} + +#[gtk4::test] +fn edit_opens_the_connect_dialog_on_the_saved_connection() { + let test = TestWindow::picker("PickerEdit"); + let row = picker_row(&test.window, "Fake"); + + row.activate_action("picker.edit", Some(&test.saved.id.to_string().to_variant())) + .expect("picker.edit"); + + let dialog = connect_dialog(&test.window); + assert_eq!(dialog.editing().map(|saved| saved.id), Some(test.saved.id)); + assert_eq!(dialog.title(), "Edit Connection"); + dialog.close(); +} + +#[gtk4::test] +fn duplicate_focuses_the_new_row() { + let test = TestWindow::picker("PickerDuplicate"); + let row = picker_row(&test.window, "Fake"); + + row.activate_action("picker.duplicate", Some(&test.saved.id.to_string().to_variant())) + .expect("picker.duplicate"); + + let copy = picker_row(&test.window, "Fake (Copy)"); + // The focus widget of its window, which may not be the active one + // while other tests run. + wait_until(WAIT, || copy.is_focus()).expect("the copy to take the focus"); + assert_eq!(test.services.saved_connections.connections().len(), 2); +} + +#[gtk4::test] +fn a_group_picked_from_the_row_reaches_the_store() { + let test = TestWindow::picker("PickerGroup"); + let row = picker_row(&test.window, "Fake"); + + row.activate_action("picker-row.group", Some(&"Work".to_variant())) + .expect("picker-row.group"); + + wait_until(WAIT, || { + test.services + .saved_connections + .get(test.saved.id) + .is_some_and(|saved| saved.group.as_ref().is_some_and(|group| group.as_str() == "Work")) + }) + .expect("the group saved and shown"); + let row = picker_row(&test.window, "Fake"); + row.activate_action("picker-row.group", Some(&" ".to_variant())) + .expect("picker-row.group"); + drain_main_context(); + assert!( + test.services + .saved_connections + .get(test.saved.id) + .is_some_and(|saved| saved.group.is_some()), + "a blank group took the connection out of its group" + ); +} diff --git a/linux/crates/app/src/ui/plain_toast.rs b/linux/crates/app/src/ui/plain_toast.rs new file mode 100644 index 0000000000..2cc9aee77d --- /dev/null +++ b/linux/crates/app/src/ui/plain_toast.rs @@ -0,0 +1,64 @@ +use libadwaita as adw; + +/// Toast text carries file names, column names, paths and server +/// messages, which an `&` or `<` would break as markup, leaving an +/// empty toast. +pub(crate) fn plain_toast(message: &str) -> adw::Toast { + let toast = adw::Toast::new(message); + toast.set_use_markup(false); + toast +} + +#[cfg(test)] +mod tests { + use std::path::Path; + + use super::plain_toast; + + #[gtk4::test] + fn a_toast_shows_its_text_as_written() { + let text = "ssh-keygen could not open /home/a&b/"; + + let toast = plain_toast(text); + + assert!(!toast.uses_markup()); + assert_eq!(toast.title().as_deref(), Some(text)); + } + + fn sources(dir: &Path, found: &mut Vec<(String, String)>) { + for entry in std::fs::read_dir(dir).expect("the source directory") { + let path = entry.expect("an entry").path(); + if path.is_dir() { + sources(&path, found); + } else if path.extension().is_some_and(|extension| extension == "rs") { + let text = std::fs::read_to_string(&path).expect("a source file"); + found.push((path.display().to_string(), text)); + } + } + } + + /// Every toast is plain text, so a name with an `&` in it still + /// shows. + #[test] + fn every_toast_is_built_as_plain_text() { + let mut files = Vec::new(); + sources(&Path::new(env!("CARGO_MANIFEST_DIR")).join("src"), &mut files); + let mut offenders = Vec::new(); + for (path, text) in &files { + if path.ends_with("ui/plain_toast.rs") { + continue; + } + if text.contains("adw::Toast::new(") { + offenders.push(format!("{path}: adw::Toast::new")); + } + for (start, _) in text.match_indices("Toast::builder()") { + let chain = &text[start..]; + let chain = &chain[..chain.find(".build()").unwrap_or(chain.len())]; + if !chain.contains(".use_markup(false)") { + offenders.push(format!("{path}: a Toast::builder() without use_markup(false)")); + } + } + } + assert!(offenders.is_empty(), "build toasts with plain_toast: {offenders:#?}"); + } +} diff --git a/linux/crates/app/src/ui/preferences_dialog/data_browser_group.rs b/linux/crates/app/src/ui/preferences_dialog/data_browser_group.rs new file mode 100644 index 0000000000..866614ef16 --- /dev/null +++ b/linux/crates/app/src/ui/preferences_dialog/data_browser_group.rs @@ -0,0 +1,133 @@ +use gio::prelude::SettingsExtManual; +use gtk4::glib; +use gtk4::subclass::prelude::*; +use libadwaita as adw; +use tablepro_storage::AppSettings; +use tablepro_storage::settings::keys; + +mod imp { + use libadwaita::prelude::*; + use libadwaita::subclass::prelude::*; + + use super::*; + + #[derive(Default, gtk4::CompositeTemplate)] + #[template(resource = "/app/tablepro/TablePro/ui/preferences-data-browser-group.ui")] + pub struct DataBrowserGroup { + #[template_child] + pub page_size_row: TemplateChild, + #[template_child] + pub confirm_row: TemplateChild, + } + + #[glib::object_subclass] + impl ObjectSubclass for DataBrowserGroup { + const NAME: &'static str = "TableProDataBrowserGroup"; + type Type = super::DataBrowserGroup; + type ParentType = adw::PreferencesGroup; + + fn class_init(klass: &mut Self::Class) { + klass.bind_template(); + } + + fn instance_init(object: &glib::subclass::InitializingObject) { + object.init_template(); + } + } + + impl ObjectImpl for DataBrowserGroup { + fn constructed(&self) { + self.parent_constructed(); + let threshold = crate::ui::browse_tab::BULK_DELETE_CONFIRM_THRESHOLD; + self.confirm_row.set_subtitle(&crate::i18n::ngettext_f( + "Ask before deleting {n} or more rows at once", + "Ask before deleting {n} or more rows at once", + u32::try_from(threshold).unwrap_or(u32::MAX), + &[("n", &threshold.to_string())], + )); + } + } + impl WidgetImpl for DataBrowserGroup {} + impl PreferencesGroupImpl for DataBrowserGroup {} +} + +glib::wrapper! { + /// The General page's rows for the row paginator and the delete + /// confirmation. + pub struct DataBrowserGroup(ObjectSubclass) + @extends adw::PreferencesGroup, gtk4::Widget, + @implements gtk4::Accessible, gtk4::Buildable, gtk4::ConstraintTarget; +} + +impl Default for DataBrowserGroup { + fn default() -> Self { + glib::Object::new() + } +} + +impl DataBrowserGroup { + pub(super) fn bind(&self, settings: &AppSettings) { + let imp = self.imp(); + let gio_settings = settings.gio(); + gio_settings + .bind(keys::DEFAULT_PAGE_SIZE, &imp.page_size_row.get(), "value") + .build(); + gio_settings + .bind(keys::CONFIRM_BULK_DELETE, &imp.confirm_row.get(), "active") + .build(); + } +} + +#[cfg(test)] +mod tests { + use libadwaita::prelude::*; + + use super::*; + use crate::test_support::MemorySettings; + + fn bound_group(settings: &MemorySettings) -> DataBrowserGroup { + crate::register_test_resources(); + let group = DataBrowserGroup::default(); + group.bind(settings.get()); + group + } + + #[gtk4::test] + fn preference_rows_are_bound() { + let settings = MemorySettings::new(); + settings.get().set_default_page_size(500).expect("store the page size"); + settings.get().set_confirm_bulk_delete(false).expect("store the flag"); + + let group = bound_group(&settings); + let imp = group.imp(); + + assert_eq!(imp.page_size_row.value(), 500.0); + assert!(!imp.confirm_row.is_active()); + + imp.page_size_row.set_value(2_500.0); + imp.confirm_row.set_active(true); + + assert_eq!(settings.get().default_page_size(), 2_500); + assert!(settings.get().confirm_bulk_delete()); + } + + #[gtk4::test] + fn the_group_is_titled_for_the_data_browser() { + let settings = MemorySettings::new(); + let group = bound_group(&settings); + + assert_eq!(group.title(), "Data Browser"); + assert_eq!(group.imp().page_size_row.title(), "Default page size"); + } + + #[gtk4::test] + fn the_confirm_row_names_the_rows_a_delete_asks_at() { + let settings = MemorySettings::new(); + let group = bound_group(&settings); + + assert_eq!( + group.imp().confirm_row.subtitle().as_deref(), + Some("Ask before deleting 10 or more rows at once") + ); + } +} diff --git a/linux/crates/app/src/ui/preferences_dialog/editor_page.rs b/linux/crates/app/src/ui/preferences_dialog/editor_page.rs new file mode 100644 index 0000000000..4898847d59 --- /dev/null +++ b/linux/crates/app/src/ui/preferences_dialog/editor_page.rs @@ -0,0 +1,174 @@ +use std::rc::Rc; + +use gio::prelude::SettingsExtManual; +use gtk4::glib; +use gtk4::prelude::*; +use gtk4::subclass::prelude::*; +use libadwaita as adw; +use tablepro_storage::AppSettings; +use tablepro_storage::settings::keys; + +use crate::ui::style_scheme_grid; + +mod imp { + use libadwaita::subclass::prelude::*; + + use super::*; + + #[derive(Default, gtk4::CompositeTemplate)] + #[template(resource = "/app/tablepro/TablePro/ui/preferences-editor-page.ui")] + pub struct EditorPage { + #[template_child] + pub system_font_row: TemplateChild, + #[template_child] + pub font_row: TemplateChild, + #[template_child] + pub font_button: TemplateChild, + #[template_child] + pub style_scheme_box: TemplateChild, + #[template_child] + pub timeout_row: TemplateChild, + } + + #[glib::object_subclass] + impl ObjectSubclass for EditorPage { + const NAME: &'static str = "TableProEditorPage"; + type Type = super::EditorPage; + type ParentType = adw::PreferencesPage; + + fn class_init(klass: &mut Self::Class) { + klass.bind_template(); + } + + fn instance_init(object: &glib::subclass::InitializingObject) { + object.init_template(); + } + } + + impl ObjectImpl for EditorPage {} + impl WidgetImpl for EditorPage {} + impl PreferencesPageImpl for EditorPage {} +} + +glib::wrapper! { + /// The Editor page: the font, the style scheme and the query timeout. + pub struct EditorPage(ObjectSubclass) + @extends adw::PreferencesPage, gtk4::Widget, + @implements gtk4::Accessible, gtk4::Buildable, gtk4::ConstraintTarget; +} + +impl Default for EditorPage { + fn default() -> Self { + glib::Object::new() + } +} + +impl EditorPage { + pub(super) fn bind(&self, settings: &Rc) { + let imp = self.imp(); + let gio_settings = settings.gio(); + + gio_settings + .bind(keys::QUERY_TIMEOUT_SECS, &imp.timeout_row.get(), "value") + .build(); + gio_settings + .bind(keys::USE_SYSTEM_FONT, &imp.system_font_row.get(), "active") + .build(); + + // The font row is only reachable when the system font is off, so + // the switch drives its sensitivity with the value inverted. + gio_settings + .bind(keys::USE_SYSTEM_FONT, &imp.font_row.get(), "sensitive") + .flags(gio::SettingsBindFlags::GET | gio::SettingsBindFlags::INVERT_BOOLEAN) + .build(); + + gio_settings + .bind(keys::CUSTOM_FONT, &imp.font_button.get(), "font-desc") + .mapping(|variant, _| { + let description = variant.get::()?; + Some(gtk4::pango::FontDescription::from_string(&description).to_value()) + }) + .set_mapping(|value, _| { + let description = value.get::().ok()?; + Some(description.to_str().to_variant()) + }) + .build(); + + style_scheme_grid::populate(&imp.style_scheme_box.get(), settings); + } +} + +#[cfg(test)] +mod tests { + use libadwaita::prelude::*; + use tablepro_storage::EditorFont; + + use super::*; + use crate::test_support::{MemorySettings, descendants}; + + fn bound_page(settings: &MemorySettings) -> EditorPage { + crate::register_test_resources(); + let page = EditorPage::default(); + page.bind(settings.get()); + page + } + + fn row_titles(page: &EditorPage) -> Vec { + descendants(page) + .into_iter() + .filter_map(|widget| widget.downcast::().ok()) + .map(|row| row.title().to_string()) + .collect() + } + + #[gtk4::test] + fn editor_page_contains_the_font_and_style_scheme_rows() { + let settings = MemorySettings::new(); + let page = bound_page(&settings); + + assert_eq!(page.title(), "Editor"); + assert_eq!( + row_titles(&page), + [ + "Use the System Monospace Font", + "Editor font", + "Query timeout (seconds)" + ] + ); + assert!( + page.imp().style_scheme_box.first_child().is_some(), + "no style scheme previews" + ); + } + + #[gtk4::test] + fn the_font_row_is_insensitive_while_the_system_font_is_on() { + let settings = MemorySettings::new(); + let page = bound_page(&settings); + let imp = page.imp(); + + assert!(!imp.font_row.is_sensitive()); + + settings + .get() + .set_editor_font(&EditorFont::Custom("Monospace 11".to_owned())) + .expect("store a custom font"); + + assert!(imp.font_row.is_sensitive()); + assert_eq!( + imp.font_button.font_desc().map(|font| font.to_str().to_string()), + Some("Monospace 11".to_owned()) + ); + } + + #[gtk4::test] + fn the_timeout_row_is_bound() { + let settings = MemorySettings::new(); + let page = bound_page(&settings); + let row = page.imp().timeout_row.get(); + + row.set_value(45.0); + + assert_eq!(settings.get().query_timeout(), Some(std::time::Duration::from_secs(45))); + } +} diff --git a/linux/crates/app/src/ui/preferences_dialog/general_page.rs b/linux/crates/app/src/ui/preferences_dialog/general_page.rs new file mode 100644 index 0000000000..fdbc5a4b9f --- /dev/null +++ b/linux/crates/app/src/ui/preferences_dialog/general_page.rs @@ -0,0 +1,174 @@ +use std::rc::Rc; + +use gio::prelude::SettingsExtManual; +use gtk4::glib; +use gtk4::prelude::*; +use gtk4::subclass::prelude::*; +use libadwaita as adw; +use tablepro_session::runtime::Tasks; +use tablepro_storage::AppSettings; +use tablepro_storage::settings::keys; + +use super::data_browser_group::DataBrowserGroup; +use super::history_group::HistoryGroup; +use crate::ui::file_reveal::FileRevealer; + +mod imp { + use libadwaita::subclass::prelude::*; + + use super::*; + + #[derive(Default, gtk4::CompositeTemplate)] + #[template(resource = "/app/tablepro/TablePro/ui/preferences-general-page.ui")] + pub struct GeneralPage { + #[template_child] + pub restore_session_row: TemplateChild, + #[template_child] + pub data_browser_group: TemplateChild, + #[template_child] + pub history_group: TemplateChild, + } + + #[glib::object_subclass] + impl ObjectSubclass for GeneralPage { + const NAME: &'static str = "TableProGeneralPage"; + type Type = super::GeneralPage; + type ParentType = adw::PreferencesPage; + + fn class_init(klass: &mut Self::Class) { + DataBrowserGroup::ensure_type(); + HistoryGroup::ensure_type(); + klass.bind_template(); + } + + fn instance_init(object: &glib::subclass::InitializingObject) { + object.init_template(); + } + } + + impl ObjectImpl for GeneralPage {} + impl WidgetImpl for GeneralPage {} + impl PreferencesPageImpl for GeneralPage {} +} + +glib::wrapper! { + /// The General page: startup, the data browser and the query history. + pub struct GeneralPage(ObjectSubclass) + @extends adw::PreferencesPage, gtk4::Widget, + @implements gtk4::Accessible, gtk4::Buildable, gtk4::ConstraintTarget; +} + +impl Default for GeneralPage { + fn default() -> Self { + glib::Object::new() + } +} + +impl GeneralPage { + pub(super) fn bind( + &self, + settings: &Rc, + storage: &crate::storage::SharedStorage, + history: crate::services::history_service::HistoryService, + tasks: &Tasks, + revealer: Rc, + ) { + let imp = self.imp(); + settings + .gio() + .bind(keys::RESTORE_SESSION, &imp.restore_session_row.get(), "active") + .build(); + imp.data_browser_group.bind(settings); + imp.history_group.bind(settings, storage, history, tasks, revealer); + } + + pub(super) fn history_group(&self) -> HistoryGroup { + self.imp().history_group.get() + } +} + +#[cfg(test)] +mod tests { + use libadwaita::prelude::*; + + use super::super::test_storage; + use super::*; + use crate::test_support::{MemorySettings, test_runtime}; + use crate::ui::file_reveal::RecordingRevealer; + + fn bound_page( + settings: &MemorySettings, + storage: &crate::storage::SharedStorage, + root: &tempfile::TempDir, + tasks: &Tasks, + ) -> GeneralPage { + crate::register_test_resources(); + let page = GeneralPage::default(); + page.bind( + settings.get(), + storage, + super::super::test_history(root, tasks), + tasks, + Rc::new(RecordingRevealer::succeeding()), + ); + page + } + + /// The page's groups, in the order it shows them. + fn groups(page: &GeneralPage) -> Vec { + crate::test_support::descendants(page) + .into_iter() + .filter_map(|widget| widget.downcast::().ok()) + .collect() + } + + #[gtk4::test] + fn general_page_contains_the_data_browser_and_history_groups() { + let settings = MemorySettings::new(); + let runtime = test_runtime(); + let storage = test_storage(&runtime.tasks()); + let root = tempfile::tempdir().expect("tempdir"); + let page = bound_page(&settings, &storage, &root, &runtime.tasks()); + + let groups = groups(&page); + let titles: Vec = groups.iter().map(|group| group.title().to_string()).collect(); + + assert_eq!(page.title(), "General"); + assert_eq!(titles, ["Startup", "Data Browser", "Query History"]); + assert!(groups[1].is::()); + assert!(groups[2].is::()); + } + + #[gtk4::test] + fn the_restore_session_row_is_bound() { + let settings = MemorySettings::new(); + let runtime = test_runtime(); + let storage = test_storage(&runtime.tasks()); + let root = tempfile::tempdir().expect("tempdir"); + let page = bound_page(&settings, &storage, &root, &runtime.tasks()); + let row = page.imp().restore_session_row.get(); + assert!(row.is_active(), "the row does not show the default"); + + row.set_active(false); + assert!(!settings.get().restore_session()); + + settings.get().set_restore_session(true).expect("store the flag"); + assert!(row.is_active()); + } + + #[gtk4::test] + fn the_page_binds_both_of_its_groups() { + let settings = MemorySettings::new(); + let runtime = test_runtime(); + let storage = test_storage(&runtime.tasks()); + let root = tempfile::tempdir().expect("tempdir"); + let page = bound_page(&settings, &storage, &root, &runtime.tasks()); + let imp = page.imp(); + + imp.data_browser_group.imp().confirm_row.set_active(false); + imp.history_group.imp().save_history_row.set_active(false); + + assert!(!settings.get().confirm_bulk_delete()); + assert!(!settings.get().save_query_history()); + } +} diff --git a/linux/crates/app/src/ui/preferences_dialog/history_group.rs b/linux/crates/app/src/ui/preferences_dialog/history_group.rs new file mode 100644 index 0000000000..3a59bb6c98 --- /dev/null +++ b/linux/crates/app/src/ui/preferences_dialog/history_group.rs @@ -0,0 +1,404 @@ +use std::rc::Rc; +use std::sync::OnceLock; + +use gio::prelude::SettingsExtManual; +use glib::subclass::Signal; +use gtk4::glib; +use gtk4::prelude::*; +use gtk4::subclass::prelude::*; +use libadwaita as adw; +use libadwaita::prelude::*; +use tablepro_session::runtime::Tasks; +use tablepro_storage::AppSettings; +use tablepro_storage::settings::keys; +use tokio::sync::watch; + +use crate::services::history_availability::HistoryAvailability; +use crate::services::history_service::HistoryService; +use crate::ui::file_reveal::{FileRevealer, reveal_failure_toast}; + +mod imp { + use libadwaita::subclass::prelude::*; + + use super::*; + + #[derive(Default, gtk4::CompositeTemplate)] + #[template(resource = "/app/tablepro/TablePro/ui/preferences-history-group.ui")] + pub struct HistoryGroup { + /// Follows whether the query database opened, for as long as + /// the group is shown. + pub availability: std::cell::RefCell>>, + #[template_child] + pub save_history_row: TemplateChild, + #[template_child] + pub retention_row: TemplateChild, + #[template_child] + pub clear_row: TemplateChild, + #[template_child] + pub clear_button: TemplateChild, + #[template_child] + pub storage_row: TemplateChild, + #[template_child] + pub storage_button: TemplateChild, + } + + #[glib::object_subclass] + impl ObjectSubclass for HistoryGroup { + const NAME: &'static str = "TableProHistoryGroup"; + type Type = super::HistoryGroup; + type ParentType = adw::PreferencesGroup; + + fn class_init(klass: &mut Self::Class) { + klass.bind_template(); + } + + fn instance_init(object: &glib::subclass::InitializingObject) { + object.init_template(); + } + } + + impl ObjectImpl for HistoryGroup { + fn dispose(&self) { + if let Some(watch) = self.availability.take() { + watch.abort(); + } + } + + fn signals() -> &'static [Signal] { + static SIGNALS: OnceLock> = OnceLock::new(); + SIGNALS.get_or_init(|| { + vec![ + Signal::builder("toast") + .param_types([adw::Toast::static_type()]) + .build(), + ] + }) + } + } + + impl WidgetImpl for HistoryGroup {} + impl PreferencesGroupImpl for HistoryGroup {} +} + +glib::wrapper! { + /// The General page's query history rows: whether to record, how + /// long to keep it, clearing it, and where the database is. + pub struct HistoryGroup(ObjectSubclass) + @extends adw::PreferencesGroup, gtk4::Widget, + @implements gtk4::Accessible, gtk4::Buildable, gtk4::ConstraintTarget; +} + +impl Default for HistoryGroup { + fn default() -> Self { + glib::Object::new() + } +} + +impl HistoryGroup { + pub(super) fn bind( + &self, + settings: &AppSettings, + storage: &crate::storage::SharedStorage, + history: HistoryService, + tasks: &Tasks, + revealer: Rc, + ) { + let imp = self.imp(); + let gio_settings = settings.gio(); + // Off stops new records only; Clear is what removes the old ones. + gio_settings + .bind(keys::SAVE_QUERY_HISTORY, &imp.save_history_row.get(), "active") + .build(); + gio_settings + .bind(keys::HISTORY_RETENTION_DAYS, &imp.retention_row.get(), "value") + .build(); + + // The path is known from the configuration even before the + // database opens, so the row is never blank. + let history_path = storage.paths().history_database(); + let path = history_path.display().to_string(); + imp.storage_row.set_subtitle(&path); + // AdwActionRow ellipsises a long subtitle, so the full path lives + // in the tooltip. + imp.storage_row.set_tooltip_text(Some(&path)); + + let group = self.downgrade(); + let tasks = tasks.clone(); + let service = history.clone(); + imp.clear_button.connect_clicked(move |_| { + if let (Some(group), Some(store)) = (group.upgrade(), service.store()) { + group.confirm_clear(store, &tasks); + } + }); + self.follow_availability(history.availability()); + + let group = self.downgrade(); + imp.storage_button.connect_clicked(move |_| { + if let Some(group) = group.upgrade() { + group.show_in_files(&history_path, revealer.as_ref()); + } + }); + } + + /// AdwPreferencesDialog owns the toast overlay, so the group hands + /// its toasts up rather than looking for one. + pub(super) fn connect_toast(&self, f: F) -> glib::SignalHandlerId { + self.connect_closure( + "toast", + false, + glib::closure_local!(move |group: &Self, toast: adw::Toast| f(group, &toast)), + ) + } + + /// The database opens on a background task: Clear waits for it, and + /// a database that could not open says why where the group starts. + fn follow_availability(&self, mut availability: watch::Receiver) { + let group = self.downgrade(); + let following = glib::spawn_future_local(async move { + loop { + let state = availability.borrow_and_update().clone(); + let Some(group) = group.upgrade() else { + return; + }; + group.show_availability(&state); + drop(group); + if availability.changed().await.is_err() { + return; + } + } + }); + if let Some(previous) = self.imp().availability.replace(Some(following)) { + previous.abort(); + } + } + + fn show_availability(&self, state: &HistoryAvailability) { + self.imp().clear_button.set_sensitive(state.is_ready()); + let reason = state.failure().map(|reason| { + crate::i18n::gettext_f( + "Query history and saved queries cannot be used. {reason}", + &[("reason", reason)], + ) + }); + self.set_description(reason.as_deref()); + } + + fn confirm_clear(&self, history: tablepro_storage::QueryHistory, tasks: &Tasks) { + let alert = adw::AlertDialog::new( + Some(&crate::i18n::gettext("Clear All Query History?")), + Some(&crate::i18n::gettext( + "This permanently deletes every history entry, including pinned ones. Saved queries are kept.", + )), + ); + alert.add_response("cancel", &crate::i18n::gettext("Cancel")); + alert.add_response("clear", &crate::i18n::gettext("Clear")); + alert.set_response_appearance("clear", adw::ResponseAppearance::Destructive); + alert.set_default_response(Some("cancel")); + alert.set_close_response("cancel"); + let group = self.clone(); + let tasks = tasks.clone(); + // AdwAlertDialog closes itself before it emits, so there is + // no close to do here. + alert.connect_response(None, move |_, response| { + if response != "clear" { + return; + } + let history = history.clone(); + let clearing = tasks.spawn_task(async move { history.clear_all().await }); + let group = group.clone(); + // A destructive action that reports nothing leaves the + // user guessing whether it ran. + glib::spawn_future_local(async move { + let message = match clearing.await { + Ok(Ok(removed)) => crate::i18n::ngettext_f( + "{count} query cleared", + "{count} queries cleared", + removed as u32, + &[("count", &removed.to_string())], + ), + Ok(Err(error)) => { + tracing::warn!(%error, "could not clear the query history"); + crate::i18n::gettext("The query history could not be cleared.") + } + Err(failure) => { + tracing::warn!(%failure, "the clear-history task failed"); + crate::i18n::gettext("The query history could not be cleared.") + } + }; + group.emit_by_name::<()>("toast", &[&crate::ui::plain_toast(&message)]); + }); + }); + alert.present(Some(self)); + } + + fn show_in_files(&self, history_path: &std::path::Path, revealer: &dyn FileRevealer) { + let revealing = revealer.reveal(gio::File::for_path(history_path), self.root().and_downcast()); + let group = self.downgrade(); + glib::spawn_future_local(async move { + let revealed = revealing.await; + if let (Some(toast), Some(group)) = (reveal_failure_toast(&revealed), group.upgrade()) { + group.emit_by_name::<()>("toast", &[&toast]); + } + }); + } +} + +#[cfg(test)] +mod tests { + use crate::test_support::{MemorySettings, SignalLog, drain_main_context, test_runtime, wait_until}; + use crate::ui::file_reveal::RecordingRevealer; + + use super::super::test_storage; + use super::*; + + fn bound_group( + settings: &MemorySettings, + storage: &crate::storage::SharedStorage, + root: &tempfile::TempDir, + tasks: &Tasks, + revealer: Rc, + ) -> HistoryGroup { + crate::register_test_resources(); + let group = HistoryGroup::default(); + group.bind( + settings.get(), + storage, + super::super::test_history(root, tasks), + tasks, + revealer, + ); + group + } + + #[gtk4::test] + fn preferences_history_switch_bound_to_setting() { + let settings = MemorySettings::new(); + let runtime = test_runtime(); + let storage = test_storage(&runtime.tasks()); + let root = tempfile::tempdir().expect("tempdir"); + let group = bound_group( + &settings, + &storage, + &root, + &runtime.tasks(), + Rc::new(RecordingRevealer::succeeding()), + ); + let row = group.imp().save_history_row.get(); + assert!(row.is_active(), "the row does not show the default"); + + row.set_active(false); + assert!(!settings.get().save_query_history()); + + settings.get().set_save_query_history(true).expect("store the flag"); + assert!(row.is_active()); + } + + #[gtk4::test] + fn the_storage_row_names_the_history_database() { + let settings = MemorySettings::new(); + let runtime = test_runtime(); + let storage = test_storage(&runtime.tasks()); + let root = tempfile::tempdir().expect("tempdir"); + let group = bound_group( + &settings, + &storage, + &root, + &runtime.tasks(), + Rc::new(RecordingRevealer::succeeding()), + ); + + let subtitle = group.imp().storage_row.subtitle().unwrap_or_default(); + assert!(subtitle.contains("history.db"), "{subtitle}"); + } + + #[gtk4::test] + fn preferences_show_in_files_reveals_the_database_file_not_its_folder() { + let settings = MemorySettings::new(); + let runtime = test_runtime(); + let storage = test_storage(&runtime.tasks()); + let revealer = Rc::new(RecordingRevealer::succeeding()); + let root = tempfile::tempdir().expect("tempdir"); + let group = bound_group(&settings, &storage, &root, &runtime.tasks(), revealer.clone()); + + group.imp().storage_button.emit_clicked(); + + assert_eq!(revealer.revealed(), [storage.paths().history_database()]); + } + + #[gtk4::test] + fn a_failed_reveal_is_reported_as_a_toast() { + let settings = MemorySettings::new(); + let runtime = test_runtime(); + let storage = test_storage(&runtime.tasks()); + let root = tempfile::tempdir().expect("tempdir"); + let group = bound_group( + &settings, + &storage, + &root, + &runtime.tasks(), + Rc::new(RecordingRevealer::failing()), + ); + let toasts = SignalLog::connect(&group, "toast", |values| { + values[1] + .get::() + .ok() + .and_then(|toast| toast.title()) + .map(|title| title.to_string()) + }); + + group.imp().storage_button.emit_clicked(); + wait_until(std::time::Duration::from_secs(5), || toasts.count() > 0).expect("the failure toast"); + + assert_eq!(toasts.take(), [Some("Could not show the file in Files".to_owned())]); + } + + #[gtk4::test] + fn a_shown_file_raises_no_toast() { + let settings = MemorySettings::new(); + let runtime = test_runtime(); + let storage = test_storage(&runtime.tasks()); + let root = tempfile::tempdir().expect("tempdir"); + let group = bound_group( + &settings, + &storage, + &root, + &runtime.tasks(), + Rc::new(RecordingRevealer::succeeding()), + ); + let toasts = SignalLog::connect(&group, "toast", |_| ()); + + group.imp().storage_button.emit_clicked(); + drain_main_context(); + + assert_eq!(toasts.count(), 0); + } + + #[gtk4::test] + fn clear_follows_the_database_and_a_failure_says_why() { + crate::register_test_resources(); + let group = HistoryGroup::default(); + let (state, availability) = watch::channel(HistoryAvailability::Starting); + group.follow_availability(availability); + let clear = group.imp().clear_button.get(); + drain_main_context(); + assert!( + !clear.is_sensitive(), + "Clear offered to clear a database that is not open" + ); + assert!(group.description().unwrap_or_default().is_empty()); + + state.send_replace(HistoryAvailability::Ready); + drain_main_context(); + assert!(clear.is_sensitive(), "Clear stayed off after the database opened"); + + state.send_replace(HistoryAvailability::Failed( + "The disk that holds history.db is full.".to_owned(), + )); + drain_main_context(); + assert!(!clear.is_sensitive()); + assert_eq!( + group.description().as_deref(), + Some("Query history and saved queries cannot be used. The disk that holds history.db is full.") + ); + } +} diff --git a/linux/crates/app/src/ui/preferences_dialog/imp.rs b/linux/crates/app/src/ui/preferences_dialog/imp.rs new file mode 100644 index 0000000000..5f9bb97027 --- /dev/null +++ b/linux/crates/app/src/ui/preferences_dialog/imp.rs @@ -0,0 +1,44 @@ +use std::cell::RefCell; +use std::rc::Rc; + +use gtk4::glib; +use gtk4::prelude::*; +use gtk4::subclass::prelude::*; +use libadwaita as adw; +use libadwaita::subclass::prelude::*; +use tablepro_storage::AppSettings; + +use super::editor_page::EditorPage; +use super::general_page::GeneralPage; + +#[derive(Default, gtk4::CompositeTemplate)] +#[template(resource = "/app/tablepro/TablePro/ui/preferences-dialog.ui")] +pub struct PreferencesDialog { + #[template_child] + pub general_page: TemplateChild, + #[template_child] + pub editor_page: TemplateChild, + pub settings: RefCell>>, +} + +#[glib::object_subclass] +impl ObjectSubclass for PreferencesDialog { + const NAME: &'static str = "TableProPreferencesDialog"; + type Type = super::PreferencesDialog; + type ParentType = adw::PreferencesDialog; + + fn class_init(klass: &mut Self::Class) { + GeneralPage::ensure_type(); + EditorPage::ensure_type(); + klass.bind_template(); + } + + fn instance_init(object: &glib::subclass::InitializingObject) { + object.init_template(); + } +} + +impl ObjectImpl for PreferencesDialog {} +impl WidgetImpl for PreferencesDialog {} +impl AdwDialogImpl for PreferencesDialog {} +impl PreferencesDialogImpl for PreferencesDialog {} diff --git a/linux/crates/app/src/ui/preferences_dialog/mod.rs b/linux/crates/app/src/ui/preferences_dialog/mod.rs new file mode 100644 index 0000000000..be63dcb51e --- /dev/null +++ b/linux/crates/app/src/ui/preferences_dialog/mod.rs @@ -0,0 +1,339 @@ +mod data_browser_group; +mod editor_page; +mod general_page; +mod history_group; +mod imp; + +use std::rc::Rc; + +use gtk4::glib; +use gtk4::subclass::prelude::*; +use libadwaita as adw; +use libadwaita::prelude::*; +use tablepro_session::runtime::Tasks; +use tablepro_storage::AppSettings; + +use crate::services::history_service::HistoryService; +use crate::ui::file_reveal::FileRevealer; + +#[cfg(test)] +pub(super) use self::{ + data_browser_group::DataBrowserGroup, editor_page::EditorPage, general_page::GeneralPage, + history_group::HistoryGroup, +}; + +glib::wrapper! { + pub struct PreferencesDialog(ObjectSubclass) + @extends adw::PreferencesDialog, adw::Dialog, gtk4::Widget, + @implements gtk4::Accessible, gtk4::Buildable, gtk4::ConstraintTarget; +} + +impl PreferencesDialog { + /// Every row binds straight to GSettings, so a change reaches any + /// other view through `changed::` without a save step. + pub(crate) fn new( + settings: &Rc, + storage: &crate::storage::SharedStorage, + history: HistoryService, + tasks: &Tasks, + revealer: Rc, + ) -> Self { + let dialog: Self = glib::Object::new(); + let imp = dialog.imp(); + imp.settings.replace(Some(settings.clone())); + imp.general_page.bind(settings, storage, history, tasks, revealer); + imp.editor_page.bind(settings); + let shown_in = dialog.downgrade(); + imp.general_page.history_group().connect_toast(move |_, toast| { + if let Some(dialog) = shown_in.upgrade() { + dialog.add_toast(toast.clone()); + } + }); + dialog + } +} + +/// A query history of its own under `root`, opening in the background +/// as the app's does. +#[cfg(test)] +fn test_history(root: &tempfile::TempDir, tasks: &Tasks) -> HistoryService { + let paths = tablepro_storage::StoragePaths::under(root.path(), "tablepro-test", "app.tablepro.TablePro.Devel"); + let preferences = crate::services::history_preferences::HistoryPreferences { + retention_days: tokio::sync::watch::channel(30).1, + save: tokio::sync::watch::channel(true).1, + }; + HistoryService::start(paths, preferences, tasks) +} + +/// A storage under a temporary root, so the dialog's storage row and +/// history actions stay away from the developer's own files. +#[cfg(test)] +fn test_storage(tasks: &Tasks) -> crate::storage::SharedStorage { + let root = std::env::temp_dir().join(format!("tablepro-prefs-{}", std::process::id())); + let paths = tablepro_storage::StoragePaths::under(&root, "tablepro-test", "app.tablepro.TablePro.Devel"); + Rc::new(crate::storage::AppStorage::new( + paths, + std::sync::Arc::new(tablepro_storage::SecretStore::new(crate::config::secret_schema())), + tasks, + )) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_support::{MemorySettings, assert_labelled, descendants, test_runtime, unlabelled_icon_buttons}; + use crate::ui::file_reveal::RecordingRevealer; + + /// The template lives in the embedded resources, so a test that + /// builds the dialog has to register them first. Test order is not + /// fixed, so it cannot rely on another test having done it. + fn new_dialog( + settings: &MemorySettings, + storage: &crate::storage::SharedStorage, + history: HistoryService, + tasks: &Tasks, + ) -> PreferencesDialog { + crate::register_test_resources(); + PreferencesDialog::new( + settings.get(), + storage, + history, + tasks, + Rc::new(RecordingRevealer::succeeding()), + ) + } + + /// The dialog's pages, in the order it shows them. They are not in + /// its widget tree until it is presented, so they come from the + /// template. + fn pages(dialog: &PreferencesDialog) -> [adw::PreferencesPage; 2] { + let imp = dialog.imp(); + [imp.general_page.get().upcast(), imp.editor_page.get().upcast()] + } + + #[gtk4::test] + fn preferences_dialog_template_builds() { + let settings = MemorySettings::new(); + let runtime = test_runtime(); + let storage = test_storage(&runtime.tasks()); + let root = tempfile::tempdir().expect("tempdir"); + let dialog = new_dialog( + &settings, + &storage, + test_history(&root, &runtime.tasks()), + &runtime.tasks(), + ); + let [general, editor] = pages(&dialog); + + assert_eq!(general.title(), "General"); + assert_eq!(editor.title(), "Editor"); + assert_eq!(dialog.visible_page().as_ref(), Some(&general)); + dialog.set_visible_page(&editor); + assert_eq!( + dialog.visible_page().as_ref(), + Some(&editor), + "the Editor page is not in the dialog" + ); + } + + #[gtk4::test] + fn every_preferences_page_audits_clean() { + let settings = MemorySettings::new(); + let runtime = test_runtime(); + let storage = test_storage(&runtime.tasks()); + let root = tempfile::tempdir().expect("tempdir"); + let dialog = new_dialog( + &settings, + &storage, + test_history(&root, &runtime.tasks()), + &runtime.tasks(), + ); + + for page in pages(&dialog) { + let rows: Vec = descendants(&page) + .into_iter() + .filter_map(|widget| widget.downcast::().ok()) + .collect(); + assert!(!rows.is_empty(), "{} has no rows", page.title()); + for row in rows { + assert_labelled(&row).unwrap_or_else(|problem| panic!("{}: {problem}", page.title())); + } + for group in descendants(&page) + .into_iter() + .filter_map(|widget| widget.downcast::().ok()) + { + assert!(!group.title().is_empty(), "{} has a group with no title", page.title()); + } + assert_eq!(unlabelled_icon_buttons(&page), Vec::::new(), "{}", page.title()); + } + } + + #[gtk4::test] + fn the_dialog_is_freed_once_dropped() { + let settings = MemorySettings::new(); + let runtime = test_runtime(); + let storage = test_storage(&runtime.tasks()); + let root = tempfile::tempdir().expect("tempdir"); + let dialog = new_dialog( + &settings, + &storage, + test_history(&root, &runtime.tasks()), + &runtime.tasks(), + ); + let history_group = dialog.imp().general_page.history_group().downgrade(); + let freed = dialog.downgrade(); + + drop(dialog); + crate::test_support::drain_main_context(); + + assert!(freed.upgrade().is_none(), "the dialog outlived its last reference"); + assert!( + history_group.upgrade().is_none(), + "the history group outlived its dialog" + ); + } + + #[gtk4::test] + fn clear_history_shows_toast_and_empties_store() { + let settings = MemorySettings::new(); + let runtime = test_runtime(); + let storage = test_storage(&runtime.tasks()); + let root = tempfile::tempdir().expect("tempdir"); + let service = test_history(&root, &runtime.tasks()); + let history = block_on(&runtime, service.ready_store()).expect("the history to open"); + record_one(&runtime, &history); + assert_eq!(count(&runtime, &history), 1); + + let dialog = new_dialog(&settings, &storage, service, &runtime.tasks()); + crate::test_support::wait_until(WAIT, || clear_button(&dialog).is_sensitive()) + .expect("Clear offered once the history opened"); + let window = present_on_a_window(&dialog); + clear_button(&dialog).emit_clicked(); + confirm_the_alert(&window); + + // The toast is posted once the clear has finished, so it is + // the signal to wait on. Waiting on the row count instead ran + // a database query on every spin of the main loop, and on a + // loaded machine the budget went on that polling rather than + // on the work, which failed the test for being busy. + crate::test_support::wait_until(WAIT, || toast_text(&window).is_some()) + .unwrap_or_else(|_| panic!("clearing said nothing, and left {} entries", count(&runtime, &history))); + + let toast = toast_text(&window).unwrap_or_default(); + assert!(toast.contains("cleared"), "{toast}"); + assert_eq!( + count(&runtime, &history), + 0, + "the toast said the history was cleared while its rows were still there" + ); + drop(window); + } + + fn clear_button(dialog: &PreferencesDialog) -> gtk4::Button { + dialog.imp().general_page.history_group().imp().clear_button.get() + } + + /// How long a test waits for work that crosses a thread. Long + /// enough that a loaded machine cannot decide the outcome; a + /// passing test returns as soon as its condition holds and never + /// spends it. + const WAIT: std::time::Duration = std::time::Duration::from_secs(30); + + /// An AdwDialog needs a real window to present into before its + /// toast overlay renders anything. This one opens at a session + /// window's size (session-window.ui). A window left at its 360 px + /// floor gives its content 350 px under Xvfb, which has no + /// compositor, and libadwaita warns that the dialog's bottom sheet, + /// which needs 360, does not fit. + fn present_on_a_window(dialog: &PreferencesDialog) -> crate::test_support::OwnedWindow { + let window = crate::test_support::OwnedWindow::presented( + adw::Window::builder().default_width(1200).default_height(760).build(), + ); + dialog.present(Some(&*window)); + crate::test_support::wait_until(WAIT, || clear_button(dialog).is_mapped()).expect("the dialog never appeared"); + window + } + + #[gtk4::test] + fn the_dialog_shows_without_a_warning() { + let settings = MemorySettings::new(); + let runtime = test_runtime(); + let storage = test_storage(&runtime.tasks()); + let root = tempfile::tempdir().expect("tempdir"); + let dialog = new_dialog( + &settings, + &storage, + test_history(&root, &runtime.tasks()), + &runtime.tasks(), + ); + + let previous = gtk4::glib::log_set_always_fatal( + gtk4::glib::LogLevels::LEVEL_WARNING | gtk4::glib::LogLevels::LEVEL_CRITICAL, + ); + let window = present_on_a_window(&dialog); + crate::test_support::after_next_frame(&*window); + gtk4::glib::log_set_always_fatal(previous); + + assert!(clear_button(&dialog).is_mapped()); + } + + /// What the dialog's toast says, read off the label the overlay + /// renders. AdwPreferencesDialog owns the overlay itself and does + /// not hand it out. + fn toast_text(window: &adw::Window) -> Option { + crate::test_support::drain_main_context(); + crate::test_support::descendants(window) + .into_iter() + .filter_map(|widget| widget.downcast::().ok()) + .map(|label| label.label().to_string()) + .find(|text| text.contains("cleared") || text.contains("could not be cleared")) + } + + fn record_one(runtime: &crate::runtime::AppRuntime, history: &tablepro_storage::QueryHistory) { + let entry = tablepro_storage::query_history::NewEntry { + query: "SELECT 1".to_owned(), + driver_id: "postgres".to_owned(), + connection_id: uuid::Uuid::new_v4(), + connection_name: "local".to_owned(), + executed_at: std::time::SystemTime::now(), + duration_ms: Some(1), + rows_affected: Some(0), + outcome: tablepro_storage::query_history::Outcome::Success, + }; + block_on(runtime, history.record(entry)).expect("record"); + } + + fn count(runtime: &crate::runtime::AppRuntime, history: &tablepro_storage::QueryHistory) -> usize { + block_on( + runtime, + history.search_page( + &tablepro_storage::query_history::HistoryQuery::default(), + tablepro_storage::query_history::HistoryPageRequest::first(100), + ), + ) + .expect("search") + .entries + .len() + } + + /// The test drives the runtime from outside it, which is the one + /// place blocking on it cannot deadlock. + #[expect( + clippy::disallowed_methods, + reason = "a GTK test has no runtime of its own, so it drives the app's from outside" + )] + fn block_on(runtime: &crate::runtime::AppRuntime, future: F) -> F::Output { + runtime.tasks().handle().block_on(future) + } + + /// Press Clear on the confirmation alert the button raised. The + /// alert presents into the window's dialog host, not into the + /// preferences dialog, so that is where it is. + fn confirm_the_alert(window: &adw::Window) { + crate::test_support::drain_main_context(); + let alert = + crate::test_support::first_descendant_of_type::(window).expect("the confirmation alert"); + crate::test_support::respond(&alert, "clear"); + crate::test_support::drain_main_context(); + } +} diff --git a/linux/crates/app/src/ui/result_pane/mod.rs b/linux/crates/app/src/ui/result_pane/mod.rs new file mode 100644 index 0000000000..b9a406d42d --- /dev/null +++ b/linux/crates/app/src/ui/result_pane/mod.rs @@ -0,0 +1,70 @@ +//! One result set of an editor run as the results list shows it: a +//! banner when the run's row limit cut it short, its grid, and how +//! many rows it holds. + +mod result_limit_banner; +mod result_set_view; +mod result_summary; + +use gtk4 as gtk; +use gtk4::prelude::*; + +pub(crate) use self::result_limit_banner::ResultLimitBanner; +pub(crate) use self::result_set_view::ResultSetView; +pub(crate) use self::result_summary::ResultSummary; +use crate::i18n::ngettext_f; +use crate::ui::export::ExportSource; +use crate::ui::format::{format_thousands, plural_count}; +use crate::ui::grid::{GridSink, RowObject, TabGridContext, build_result_grid, export_snapshot}; + +/// The page for `view`, its grid named `name` as the results list names +/// it. Export All on its banner hands `export_all` the rows the grid +/// shows, in the order it shows them, for the dialog's Shown Rows. +pub(crate) fn result_set_page( + view: &ResultSetView, + name: &str, + sink: GridSink, + export_all: impl Fn(ExportSource) + 'static, +) -> gtk::Widget { + let rows = view.rows.iter().cloned().map(RowObject::result).collect(); + let (column_view, _selection) = build_result_grid(view.columns.clone(), rows, sink); + column_view.update_property(&[gtk::accessible::Property::Label(name)]); + let banner = ResultLimitBanner::new(&view.summary); + let columns = view.columns.clone(); + let grid = column_view.downgrade(); + banner.connect_export_all(move || { + let Some(column_view) = grid.upgrade() else { + return; + }; + export_all(ExportSource::Rows { + columns: columns.clone(), + rows: export_snapshot(&column_view, &TabGridContext::default()), + }); + }); + let scrolled = gtk::ScrolledWindow::builder() + .child(&column_view) + .hexpand(true) + .vexpand(true) + .build(); + let shown = view.row_count() as u64; + let footer = gtk::Label::builder() + .label(ngettext_f( + "{n} row", + "{n} rows", + plural_count(shown), + &[("n", &format_thousands(shown))], + )) + .xalign(0.0) + .margin_start(12) + .margin_end(12) + .margin_top(4) + .margin_bottom(4) + .build(); + footer.add_css_class("dim-label"); + footer.add_css_class("caption"); + let page = gtk::Box::builder().orientation(gtk::Orientation::Vertical).build(); + page.append(banner.widget()); + page.append(&scrolled); + page.append(&footer); + page.upcast() +} diff --git a/linux/crates/app/src/ui/result_pane/result_limit_banner.rs b/linux/crates/app/src/ui/result_pane/result_limit_banner.rs new file mode 100644 index 0000000000..9f7ac7dd57 --- /dev/null +++ b/linux/crates/app/src/ui/result_pane/result_limit_banner.rs @@ -0,0 +1,37 @@ +use libadwaita as adw; + +use super::ResultSummary; +use crate::i18n::gettext; + +/// The banner over a result the run's row limit cut short, saying how +/// much of it the grid holds and offering every row as a file. +#[derive(Debug, Clone)] +pub(crate) struct ResultLimitBanner { + banner: adw::Banner, +} + +impl ResultLimitBanner { + pub(crate) fn new(summary: &ResultSummary) -> Self { + let banner = adw::Banner::new(""); + // The title holds the locale's digit grouping, which is not + // markup. + banner.set_use_markup(false); + banner.set_button_label(Some(&gettext("Export All…"))); + match summary.limit_title() { + Some(title) => { + banner.set_title(&title); + banner.set_revealed(true); + } + None => banner.set_revealed(false), + } + Self { banner } + } + + pub(crate) fn connect_export_all(&self, export_all: impl Fn() + 'static) { + self.banner.connect_button_clicked(move |_| export_all()); + } + + pub(crate) fn widget(&self) -> &adw::Banner { + &self.banner + } +} diff --git a/linux/crates/app/src/ui/result_pane/result_set_view.rs b/linux/crates/app/src/ui/result_pane/result_set_view.rs new file mode 100644 index 0000000000..e329af13bf --- /dev/null +++ b/linux/crates/app/src/ui/result_pane/result_set_view.rs @@ -0,0 +1,33 @@ +use std::sync::Arc; + +use tablepro_core::column::ResultColumn; +use tablepro_core::{UserSql, Value}; + +use super::ResultSummary; + +/// One result set of an editor run, with the statement that produced +/// it. The grid that shows it shares each row, so a page built again +/// for the same result copies no cell. +#[derive(Debug, Clone)] +pub struct ResultSetView { + pub columns: Arc<[ResultColumn]>, + pub rows: Vec>, + pub summary: ResultSummary, + /// The batch the result came from, which Export All runs again. + pub statement: UserSql, +} + +impl ResultSetView { + pub(crate) fn new(columns: Arc<[ResultColumn]>, statement: UserSql) -> Self { + Self { + columns, + rows: Vec::new(), + summary: ResultSummary::complete(0), + statement, + } + } + + pub(crate) fn row_count(&self) -> usize { + self.rows.len() + } +} diff --git a/linux/crates/app/src/ui/result_pane/result_summary.rs b/linux/crates/app/src/ui/result_pane/result_summary.rs new file mode 100644 index 0000000000..92b79cea66 --- /dev/null +++ b/linux/crates/app/src/ui/result_pane/result_summary.rs @@ -0,0 +1,221 @@ +use tablepro_core::Truncation; + +use crate::i18n::{gettext_f, ngettext_f}; +use crate::ui::format::{format_thousands, plural_count}; + +/// How much of a result set an editor run kept, as the driver reported +/// it when the set ended. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ResultSummary { + /// The rows the result holds. + pub retained: u64, + /// What the server says the whole set held, when it says. + pub total_rows: Option, + pub truncation: Truncation, + /// The run's limit on the rows of all its results, when that limit + /// cut this one short. + pub run_limit: Option, +} + +impl ResultSummary { + /// A set that has not ended, or ended with every row it held. + pub(crate) fn complete(retained: u64) -> Self { + Self { + retained, + total_rows: None, + truncation: Truncation::Complete, + run_limit: None, + } + } + + pub(crate) fn is_truncated(&self) -> bool { + self.truncation != Truncation::Complete || self.run_limit.is_some() + } + + /// What the banner over a cut-short result says. `None` for a + /// complete one, which shows no banner. + pub(crate) fn limit_title(&self) -> Option { + self.limit_title_with(format_thousands) + } + + fn limit_title_with(&self, format: impl Fn(u64) -> String) -> Option { + let shown = format(self.retained); + if let Some(limit) = self.run_limit { + let limit = format(limit); + return Some(match self.retained { + 0 => gettext_f( + "No rows kept; a run keeps at most {limit} rows across its results", + &[("limit", &limit)], + ), + retained => ngettext_f( + "Showing the first {shown} row; a run keeps at most {limit} rows across its results", + "Showing the first {shown} rows; a run keeps at most {limit} rows across its results", + plural_count(retained), + &[("shown", &shown), ("limit", &limit)], + ), + }); + } + match (self.truncation, self.total_rows) { + (Truncation::Complete, _) => None, + (Truncation::ReadPastLimit, Some(total)) => Some(ngettext_f( + "Showing {shown} of {total} row", + "Showing {shown} of {total} rows", + plural_count(total), + &[("shown", &shown), ("total", &format(total))], + )), + (Truncation::ReadPastLimit, None) => Some(ngettext_f( + "Showing the first {shown} row", + "Showing the first {shown} rows", + plural_count(self.retained), + &[("shown", &shown)], + )), + // A SELECT that calls a function per row did that for these + // rows only, which "showing" alone would hide. + (Truncation::ServerStopped, _) => Some(ngettext_f( + "Stopped after the first {shown} row: the statement did not run past it", + "Stopped after the first {shown} rows: the statement did not run past them", + plural_count(self.retained), + &[("shown", &shown)], + )), + } + } + + /// The result's line in the results list: its place in the run and + /// how many rows it has, with a `+` when there were more than it + /// kept and nobody counted them. + pub(crate) fn tab_label(&self, index: usize) -> String { + self.tab_label_with(index, format_thousands) + } + + fn tab_label_with(&self, index: usize, format: impl Fn(u64) -> String) -> String { + let rows = match (self.is_truncated(), self.total_rows) { + (false, _) => format(self.retained), + (true, Some(total)) => format(total), + (true, None) => gettext_f("{rows}+", &[("rows", &format(self.retained))]), + }; + gettext_f( + "Result {n} ({rows})", + &[("n", &(index + 1).to_string()), ("rows", &rows)], + ) + } + + /// The rows the history line records for this result: every row it + /// had, which is unknown for a result cut short with no total. + pub(crate) fn history_row_count(&self) -> Option { + let rows = match (self.is_truncated(), self.total_rows) { + (false, _) => Some(self.retained), + (true, total) => total, + }; + rows.map(|rows| i64::try_from(rows).unwrap_or(i64::MAX)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ui::format::Thousands; + + fn english(n: u64) -> String { + Thousands::for_posix_locale("en_US.UTF-8").format(n) + } + + fn summary(retained: u64, total_rows: Option, truncation: Truncation) -> ResultSummary { + ResultSummary { + retained, + total_rows, + truncation, + run_limit: None, + } + } + + #[test] + fn limit_title_for_server_stopped_without_total() { + let stopped = summary(1_000, None, Truncation::ServerStopped); + let read_past = summary(1_000, None, Truncation::ReadPastLimit); + + assert_eq!( + stopped.limit_title_with(english).as_deref(), + Some("Stopped after the first 1,000 rows: the statement did not run past them") + ); + assert_eq!( + read_past.limit_title_with(english).as_deref(), + Some("Showing the first 1,000 rows") + ); + } + + #[test] + fn limit_title_for_read_past_limit_with_total_uses_thousands() { + let read_past = summary(10_000, Some(1_234_567), Truncation::ReadPastLimit); + + assert_eq!( + read_past.limit_title_with(english).as_deref(), + Some("Showing 10,000 of 1,234,567 rows") + ); + } + + #[test] + fn limit_title_names_the_run_limit_when_it_cut_the_result() { + let cut = |retained| ResultSummary { + run_limit: Some(100_000), + ..summary(retained, None, Truncation::Complete) + }; + + assert_eq!( + cut(2_500).limit_title_with(english).as_deref(), + Some("Showing the first 2,500 rows; a run keeps at most 100,000 rows across its results") + ); + assert_eq!( + cut(0).limit_title_with(english).as_deref(), + Some("No rows kept; a run keeps at most 100,000 rows across its results") + ); + assert_eq!(cut(0).tab_label_with(4, english), "Result 5 (0+)"); + assert_eq!(cut(0).history_row_count(), None); + } + + #[test] + fn limit_title_none_when_complete() { + assert_eq!( + summary(5, Some(5), Truncation::Complete).limit_title_with(english), + None + ); + assert_eq!(ResultSummary::complete(0).limit_title_with(english), None); + } + + #[test] + fn tab_label_marks_truncated_count_with_plus() { + assert_eq!( + summary(10_000, None, Truncation::ServerStopped).tab_label_with(0, english), + "Result 1 (10,000+)" + ); + assert_eq!( + summary(10_000, Some(25_000), Truncation::ReadPastLimit).tab_label_with(1, english), + "Result 2 (25,000)" + ); + assert_eq!( + summary(3, Some(3), Truncation::Complete).tab_label_with(2, english), + "Result 3 (3)" + ); + } + + #[test] + fn history_row_count_is_none_for_capped_result_without_total() { + assert_eq!( + summary(1_000, None, Truncation::ServerStopped).history_row_count(), + None + ); + assert_eq!( + summary(1_000, None, Truncation::ReadPastLimit).history_row_count(), + None + ); + } + + #[test] + fn history_row_count_is_total_for_read_past_limit() { + assert_eq!( + summary(1_000, Some(4_321), Truncation::ReadPastLimit).history_row_count(), + Some(4_321) + ); + assert_eq!(summary(7, Some(7), Truncation::Complete).history_row_count(), Some(7)); + assert_eq!(ResultSummary::complete(2).history_row_count(), Some(2)); + } +} diff --git a/linux/crates/app/src/ui/saved_queries_dialog.rs b/linux/crates/app/src/ui/saved_queries_dialog.rs new file mode 100644 index 0000000000..77f055c7b6 --- /dev/null +++ b/linux/crates/app/src/ui/saved_queries_dialog.rs @@ -0,0 +1,837 @@ +use relm4::adw::prelude::*; +use relm4::gtk::gio; +use relm4::prelude::*; +use relm4::{adw, gtk}; + +use tablepro_storage::SavedQueries; +use tablepro_storage::saved_queries::SavedQuery; +use uuid::Uuid; + +use crate::runtime::DeliverInput; + +/// The user's named queries, grouped by the connection each was saved +/// against. +/// +/// The active connection comes first, because that is the one whose +/// tables the user is looking at. The rest stay listed rather than +/// hidden: a query saved against a connection is still worth reading +/// when connected elsewhere. +pub struct SavedQueriesDialog { + store: Option, + active_connection: Option, + root: adw::Dialog, + /// The dialog's own, since it covers the window's. + toasts: adw::ToastOverlay, + search: gtk::SearchEntry, + groups: gtk::Box, + stack: gtk::Stack, + status_page: adw::StatusPage, + error_page: adw::StatusPage, + queries: Vec, + /// Whether a list was read once. A read that fails after that keeps + /// the list on screen. + loaded: bool, +} + +pub struct SavedQueriesDialogInit { + pub store: Option, + pub active_connection: Option, +} + +#[derive(Debug)] +pub enum SavedQueriesDialogInput { + Refresh, + SearchChanged, + Activate(i64), + ReplaceCurrent(i64), + CopySql(i64), + RenameRequested(i64), + Rename(i64, String), + DeleteRequested(i64), + Delete(i64), +} + +#[derive(Debug)] +pub enum SavedQueriesDialogOutput { + OpenInNewTab(String), + ReplaceCurrentTabQuery(String), +} + +#[derive(Debug)] +pub enum SavedQueriesDialogCmd { + Loaded(Vec), + /// The list could not be read, and why. + LoadFailed(String), + Failed(String), + Changed, +} + +impl Component for SavedQueriesDialog { + type Init = SavedQueriesDialogInit; + type Input = SavedQueriesDialogInput; + type Output = SavedQueriesDialogOutput; + type CommandOutput = SavedQueriesDialogCmd; + type Root = adw::Dialog; + type Widgets = (); + + fn init_root() -> Self::Root { + adw::Dialog::builder() + .title(crate::i18n::gettext("Saved Queries")) + .content_width(560) + .content_height(600) + .build() + } + + fn init(init: Self::Init, root: Self::Root, sender: ComponentSender) -> ComponentParts { + let toolbar = adw::ToolbarView::new(); + let header = adw::HeaderBar::builder().show_end_title_buttons(true).build(); + header.set_title_widget(Some(&adw::WindowTitle::new(&crate::i18n::gettext("Saved Queries"), ""))); + toolbar.add_top_bar(&header); + + let search_label = crate::i18n::gettext("Search saved queries"); + let search = gtk::SearchEntry::builder() + .placeholder_text(&search_label) + .hexpand(true) + .build(); + // A placeholder is gone once text is typed, so it names nothing. + search.update_property(&[gtk::accessible::Property::Label(&search_label)]); + let search_bar = gtk::Box::builder() + .orientation(gtk::Orientation::Horizontal) + .margin_start(12) + .margin_end(12) + .margin_top(6) + .margin_bottom(6) + .build(); + search_bar.append(&search); + toolbar.add_top_bar(&search_bar); + let sender_for_search = sender.clone(); + search.connect_search_changed(move |_| sender_for_search.deliver(SavedQueriesDialogInput::SearchChanged)); + + let groups = gtk::Box::builder() + .orientation(gtk::Orientation::Vertical) + .spacing(18) + .margin_start(12) + .margin_end(12) + .margin_top(12) + .margin_bottom(12) + .build(); + let scroller = gtk::ScrolledWindow::builder() + .hexpand(true) + .vexpand(true) + .hscrollbar_policy(gtk::PolicyType::Never) + .child(&groups) + .build(); + + let status_page = adw::StatusPage::builder() + .icon_name(crate::ui::icons::DOCUMENT_OPEN_RECENT) + .title(crate::i18n::gettext("No Saved Queries")) + .description(crate::i18n::gettext( + "Save a query from the editor and it will be listed here.", + )) + .build(); + + let retry = gtk::Button::builder() + .label(crate::i18n::gettext("Try Again")) + .halign(gtk::Align::Center) + .build(); + retry.add_css_class("pill"); + let sender_for_retry = sender.clone(); + retry.connect_clicked(move |_| sender_for_retry.deliver(SavedQueriesDialogInput::Refresh)); + let error_page = adw::StatusPage::builder() + .icon_name(crate::ui::icons::DIALOG_ERROR) + .title(crate::i18n::gettext("Could Not Read Saved Queries")) + .child(&retry) + .build(); + + let stack = gtk::Stack::new(); + stack.add_named(&scroller, Some("list")); + stack.add_named(&status_page, Some("empty")); + stack.add_named(&error_page, Some("error")); + stack.set_visible_child_name("empty"); + toolbar.set_content(Some(&stack)); + let toasts = adw::ToastOverlay::new(); + toasts.set_child(Some(&toolbar)); + root.set_child(Some(&toasts)); + + let model = SavedQueriesDialog { + store: init.store, + active_connection: init.active_connection, + root: root.clone(), + toasts, + search, + groups, + stack, + status_page, + error_page, + queries: Vec::new(), + loaded: false, + }; + sender.deliver(SavedQueriesDialogInput::Refresh); + + ComponentParts { model, widgets: () } + } + + fn update(&mut self, msg: Self::Input, sender: ComponentSender, _root: &Self::Root) { + match msg { + SavedQueriesDialogInput::Refresh => self.load(&sender), + SavedQueriesDialogInput::SearchChanged => self.render(&sender), + SavedQueriesDialogInput::Activate(id) => { + if let Some(query) = self.query_for(id) { + let _ = sender.output(SavedQueriesDialogOutput::OpenInNewTab(query)); + self.root.close(); + } + } + SavedQueriesDialogInput::ReplaceCurrent(id) => { + if let Some(query) = self.query_for(id) { + let _ = sender.output(SavedQueriesDialogOutput::ReplaceCurrentTabQuery(query)); + self.root.close(); + } + } + SavedQueriesDialogInput::CopySql(id) => { + if let Some(query) = self.query_for(id) { + crate::ui::clipboard::copy_text(&self.root, query, |message| self.toast(message)); + } + } + SavedQueriesDialogInput::RenameRequested(id) => self.prompt_rename(id, &sender), + SavedQueriesDialogInput::Rename(id, name) => { + let Some(store) = self.store.clone() else { return }; + sender.oneshot_command(async move { + match store.rename(id, &name).await { + Ok(()) => SavedQueriesDialogCmd::Changed, + Err(error) => { + tracing::warn!(%error, "could not rename the saved query"); + SavedQueriesDialogCmd::Failed(rename_failure_text(&error)) + } + } + }); + } + SavedQueriesDialogInput::DeleteRequested(id) => self.confirm_delete(id, &sender), + SavedQueriesDialogInput::Delete(id) => { + let Some(store) = self.store.clone() else { return }; + sender.oneshot_command(async move { + match store.delete(id).await { + Ok(()) => SavedQueriesDialogCmd::Changed, + Err(error) => { + tracing::warn!(%error, "could not delete the saved query"); + SavedQueriesDialogCmd::Failed(crate::i18n::gettext("Could not delete that saved query.")) + } + } + }); + } + } + } + + fn update_cmd(&mut self, msg: Self::CommandOutput, sender: ComponentSender, _root: &Self::Root) { + match msg { + SavedQueriesDialogCmd::Loaded(queries) => { + self.queries = queries; + self.loaded = true; + self.render(&sender); + } + SavedQueriesDialogCmd::LoadFailed(reason) if self.loaded => self.toast(&reason), + SavedQueriesDialogCmd::LoadFailed(reason) => { + self.error_page.set_description(Some(&reason)); + self.stack.set_visible_child_name("error"); + } + SavedQueriesDialogCmd::Changed => sender.deliver(SavedQueriesDialogInput::Refresh), + SavedQueriesDialogCmd::Failed(message) => { + self.toast(&message); + sender.deliver(SavedQueriesDialogInput::Refresh); + } + } + } +} + +impl SavedQueriesDialog { + pub fn dialog(&self) -> &adw::Dialog { + &self.root + } + + fn toast(&self, message: &str) { + self.toasts.add_toast(crate::ui::plain_toast(message)); + } + + fn load(&self, sender: &ComponentSender) { + let Some(store) = self.store.clone() else { + return; + }; + sender.oneshot_command(async move { + match store.list(None).await { + Ok(queries) => SavedQueriesDialogCmd::Loaded(queries), + Err(error) => { + tracing::warn!(%error, "could not read the saved queries"); + SavedQueriesDialogCmd::LoadFailed(crate::i18n::gettext( + "The query history file could not be read. It may be damaged.", + )) + } + } + }); + } + + fn query_for(&self, id: i64) -> Option { + self.queries + .iter() + .find(|saved| saved.id == id) + .map(|saved| saved.query.clone()) + } + + fn name_for(&self, id: i64) -> Option { + self.queries + .iter() + .find(|saved| saved.id == id) + .map(|saved| saved.name.clone()) + } + + fn render(&self, sender: &ComponentSender) { + while let Some(child) = self.groups.first_child() { + self.groups.remove(&child); + } + + let groups = grouped(&self.queries, &self.search.text(), self.active_connection); + if groups.is_empty() { + let searching = !self.search.text().to_string().trim().is_empty(); + if searching { + self.status_page.set_title(&crate::i18n::gettext("No Matches")); + self.status_page + .set_description(Some(&crate::i18n::gettext("Try a different search term."))); + self.status_page.set_icon_name(Some(crate::ui::icons::SYSTEM_SEARCH)); + } else { + self.status_page.set_title(&crate::i18n::gettext("No Saved Queries")); + self.status_page.set_description(Some(&crate::i18n::gettext( + "Save a query from the editor and it will be listed here.", + ))); + self.status_page + .set_icon_name(Some(crate::ui::icons::DOCUMENT_OPEN_RECENT)); + } + self.stack.set_visible_child_name("empty"); + return; + } + self.stack.set_visible_child_name("list"); + + for (connection_name, rows) in groups { + let group = adw::PreferencesGroup::builder().title(&connection_name).build(); + for saved in rows { + group.add(&self.build_row(saved, sender.clone())); + } + self.groups.append(&group); + } + } + + fn build_row(&self, saved: &SavedQuery, sender: ComponentSender) -> adw::ActionRow { + let row = adw::ActionRow::builder() + .title(glib::markup_escape_text(&saved.name)) + .subtitle(glib::markup_escape_text(saved.summary())) + .activatable(true) + .build(); + row.set_subtitle_lines(1); + + let id = saved.id; + let sender_for_activate = sender.clone(); + row.connect_activated(move |_| sender_for_activate.deliver(SavedQueriesDialogInput::Activate(id))); + + let menu = gio::Menu::new(); + let open_section = gio::Menu::new(); + open_section.append(Some(&crate::i18n::gettext("Open in New Tab")), Some("saved.open")); + open_section.append( + Some(&crate::i18n::gettext("Replace Current Tab")), + Some("saved.replace"), + ); + open_section.append(Some(&crate::i18n::gettext("Copy SQL")), Some("saved.copy")); + menu.append_section(None, &open_section); + let edit_section = gio::Menu::new(); + edit_section.append(Some(&crate::i18n::gettext("Rename…")), Some("saved.rename")); + menu.append_section(None, &edit_section); + let danger_section = gio::Menu::new(); + danger_section.append(Some(&crate::i18n::gettext("Delete")), Some("saved.delete")); + menu.append_section(None, &danger_section); + + let actions = gio::SimpleActionGroup::new(); + let entry = |name: &str, message: fn(i64) -> SavedQueriesDialogInput, sender: ComponentSender| { + gio::ActionEntry::::builder(name) + .activate(move |_, _, _| sender.deliver(message(id))) + .build() + }; + actions.add_action_entries([ + entry("open", SavedQueriesDialogInput::Activate, sender.clone()), + entry("replace", SavedQueriesDialogInput::ReplaceCurrent, sender.clone()), + entry("copy", SavedQueriesDialogInput::CopySql, sender.clone()), + entry("rename", SavedQueriesDialogInput::RenameRequested, sender.clone()), + entry("delete", SavedQueriesDialogInput::DeleteRequested, sender), + ]); + + let button = gtk::MenuButton::builder() + .icon_name(crate::ui::icons::VIEW_MORE) + .valign(gtk::Align::Center) + .menu_model(&menu) + .build(); + button.add_css_class("flat"); + crate::ui::a11y::describe_icon_button(&button, &crate::i18n::gettext("Saved Query Options"), None); + button.insert_action_group("saved", Some(&actions)); + row.add_suffix(&button); + + row + } + + fn prompt_rename(&self, id: i64, sender: &ComponentSender) { + let Some(current) = self.name_for(id) else { + return; + }; + let dialog = adw::AlertDialog::new(Some(&crate::i18n::gettext("Rename Saved Query")), None); + let entry = adw::EntryRow::builder() + .title(crate::i18n::gettext("Name")) + .activates_default(true) + .build(); + entry.set_text(¤t); + let group = adw::PreferencesGroup::new(); + group.add(&entry); + dialog.set_extra_child(Some(&group)); + dialog.add_response("cancel", &crate::i18n::gettext("Cancel")); + dialog.add_response("rename", &crate::i18n::gettext("Rename")); + dialog.set_response_appearance("rename", adw::ResponseAppearance::Suggested); + dialog.set_default_response(Some("rename")); + dialog.set_close_response("cancel"); + crate::ui::name_entry::require_name(&dialog, "rename", &entry); + + let sender = sender.clone(); + dialog.connect_response(None, move |_, response| { + if response != "rename" { + return; + } + let name = entry.text().to_string(); + if name.trim().is_empty() { + return; + } + sender.deliver(SavedQueriesDialogInput::Rename(id, name)); + }); + dialog.present(Some(&self.root)); + } + + fn confirm_delete(&self, id: i64, sender: &ComponentSender) { + let Some(name) = self.name_for(id) else { + return; + }; + let dialog = adw::AlertDialog::new( + Some(&crate::i18n::gettext_f("Delete “{name}”?", &[("name", &name)])), + Some(&crate::i18n::gettext( + "The saved query is removed. The SQL itself is not run or changed.", + )), + ); + dialog.add_response("cancel", &crate::i18n::gettext("Cancel")); + dialog.add_response("delete", &crate::i18n::gettext("Delete")); + dialog.set_response_appearance("delete", adw::ResponseAppearance::Destructive); + dialog.set_default_response(Some("cancel")); + dialog.set_close_response("cancel"); + + let sender = sender.clone(); + dialog.connect_response(None, move |_, response| { + if response == "delete" { + sender.deliver(SavedQueriesDialogInput::Delete(id)); + } + }); + dialog.present(Some(&self.root)); + } +} + +/// What a failed rename tells the user. A taken name is the one the +/// user can fix from the dialog. +fn rename_failure_text(error: &tablepro_storage::StorageError) -> String { + use tablepro_storage::StorageError; + match error { + StorageError::NameTaken => crate::i18n::gettext("That name is already used by another saved query."), + StorageError::TooLarge { .. } => crate::i18n::gettext("That name is too long."), + StorageError::NotFound => crate::i18n::gettext("That saved query was deleted elsewhere."), + _ => crate::i18n::gettext("The saved query could not be renamed."), + } +} + +/// Everything matching the search, grouped by connection, with the +/// active connection's group first and the rest in the order the store +/// returned them. +fn grouped<'a>(queries: &'a [SavedQuery], needle: &str, active: Option) -> Vec<(String, Vec<&'a SavedQuery>)> { + let needle = needle.to_lowercase(); + let mut groups: Vec<(String, Vec<&SavedQuery>)> = Vec::new(); + for saved in queries.iter().filter(|saved| matches(saved, &needle)) { + match groups.iter_mut().find(|(_, rows)| { + rows.first() + .is_some_and(|first| first.connection_id == saved.connection_id) + }) { + Some((_, rows)) => rows.push(saved), + None => groups.push((saved.connection_name.clone(), vec![saved])), + } + } + if let Some(active) = active { + // A stable sort, so everything but the active connection keeps + // the order the store gave it. + groups.sort_by_key(|(_, rows)| !rows.first().is_some_and(|first| first.connection_id == active)); + } + groups +} + +/// A saved query matches when the needle is in its name, its SQL or the +/// connection it was saved against, so searching for the connection +/// narrows to it without a filter control. +fn matches(saved: &SavedQuery, needle: &str) -> bool { + if needle.trim().is_empty() { + return true; + } + let needle = needle.trim(); + saved.name.to_lowercase().contains(needle) + || saved.query.to_lowercase().contains(needle) + || saved.connection_name.to_lowercase().contains(needle) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::time::SystemTime; + + fn saved(name: &str, query: &str, connection: &str) -> SavedQuery { + on_connection(name, query, connection, Uuid::nil()) + } + + fn on_connection(name: &str, query: &str, connection: &str, connection_id: Uuid) -> SavedQuery { + SavedQuery { + id: 1, + name: name.to_owned(), + query: query.to_owned(), + connection_id, + connection_name: connection.to_owned(), + created_at: SystemTime::UNIX_EPOCH, + updated_at: SystemTime::UNIX_EPOCH, + } + } + + fn group_names(groups: &[(String, Vec<&SavedQuery>)]) -> Vec { + groups.iter().map(|(name, _)| name.clone()).collect() + } + + #[test] + fn queries_are_grouped_by_the_connection_they_were_saved_against() { + let (local, staging) = (Uuid::new_v4(), Uuid::new_v4()); + let queries = vec![ + on_connection("a", "SELECT 1", "local", local), + on_connection("b", "SELECT 2", "staging", staging), + on_connection("c", "SELECT 3", "local", local), + ]; + + let groups = grouped(&queries, "", None); + + assert_eq!(group_names(&groups), vec!["local", "staging"]); + assert_eq!(groups[0].1.len(), 2); + assert_eq!(groups[1].1.len(), 1); + } + + #[test] + fn the_connection_the_user_is_on_is_listed_first() { + let (local, staging) = (Uuid::new_v4(), Uuid::new_v4()); + let queries = vec![ + on_connection("a", "SELECT 1", "local", local), + on_connection("b", "SELECT 2", "staging", staging), + ]; + + let groups = grouped(&queries, "", Some(staging)); + + assert_eq!(group_names(&groups), vec!["staging", "local"]); + } + + #[test] + fn two_connections_sharing_a_name_stay_apart() { + // Duplicating a connection and renaming neither is enough for + // this: the id is what says they are different. + let (first, second) = (Uuid::new_v4(), Uuid::new_v4()); + let queries = vec![ + on_connection("a", "SELECT 1", "local", first), + on_connection("b", "SELECT 2", "local", second), + ]; + + let groups = grouped(&queries, "", None); + + assert_eq!(groups.len(), 2); + } + + #[test] + fn a_search_that_matches_nothing_leaves_no_groups() { + let queries = vec![saved("daily", "SELECT 1", "local")]; + + assert!(grouped(&queries, "invoices", None).is_empty()); + } + + #[test] + fn an_empty_search_matches_everything() { + assert!(matches(&saved("daily", "SELECT 1", "local"), "")); + assert!(matches(&saved("daily", "SELECT 1", "local"), " ")); + } + + #[test] + fn a_search_reads_the_name_the_sql_and_the_connection() { + let query = saved("daily report", "SELECT * FROM orders", "staging"); + + assert!(matches(&query, "report")); + assert!(matches(&query, "orders")); + assert!(matches(&query, "staging")); + assert!(!matches(&query, "invoices")); + } + + #[test] + fn a_search_ignores_case_and_surrounding_space() { + assert!(matches(&saved("Daily", "SELECT 1", "local"), " daily ")); + } +} + +#[cfg(test)] +mod dialog_tests { + use super::*; + use std::time::Duration; + + use tablepro_storage::QueryHistory; + use tablepro_storage::saved_queries::NewSavedQuery; + + const RENDER_WAIT: Duration = Duration::from_secs(5); + + /// A store on a temporary database, seeded with one query per + /// connection. The directory is returned so it outlives the store. + fn seeded() -> (tempfile::TempDir, SavedQueries, Uuid, Uuid) { + let root = tempfile::tempdir().expect("tempdir"); + let paths = tablepro_storage::StoragePaths::under(root.path(), "tablepro", "app.tablepro.TablePro"); + let (local, staging) = (Uuid::new_v4(), Uuid::new_v4()); + let store = relm4::tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("runtime") + .block_on(async { + let store = QueryHistory::open(&paths).await.expect("open").saved_queries(); + for (name, sql, id, connection) in [ + ("daily orders", "SELECT * FROM orders", local, "local"), + ("stale carts", "SELECT * FROM carts", staging, "staging"), + ] { + store + .save(NewSavedQuery { + name: name.to_owned(), + query: sql.to_owned(), + connection_id: id, + connection_name: connection.to_owned(), + }) + .await + .expect("save"); + } + store + }); + (root, store, local, staging) + } + + fn row_titles(dialog: &SavedQueriesDialog) -> Vec { + crate::test_support::descendants(&dialog.groups) + .into_iter() + .filter_map(|widget| widget.downcast::().ok()) + .map(|row| row.title().to_string()) + .collect() + } + + #[gtk4::test] + fn the_saved_queries_reach_the_list() { + let (_root, store, local, _staging) = seeded(); + let dialog = SavedQueriesDialog::builder().launch(SavedQueriesDialogInit { + store: Some(store), + active_connection: Some(local), + }); + + crate::test_support::wait_until(RENDER_WAIT, || !row_titles(&dialog.model()).is_empty()) + .expect("the saved queries never rendered"); + + // The active connection's query comes first, whatever order the + // store returned them in. + assert_eq!(row_titles(&dialog.model()), vec!["daily orders", "stale carts"]); + } + + #[gtk4::test] + fn searching_narrows_the_list_to_what_matches() { + let (_root, store, local, _staging) = seeded(); + let dialog = SavedQueriesDialog::builder().launch(SavedQueriesDialogInit { + store: Some(store), + active_connection: Some(local), + }); + crate::test_support::wait_until(RENDER_WAIT, || row_titles(&dialog.model()).len() == 2) + .expect("the saved queries never rendered"); + + dialog.model().search.set_text("carts"); + + crate::test_support::wait_until(RENDER_WAIT, || row_titles(&dialog.model()) == vec!["stale carts"]) + .expect("the search did not narrow the list"); + } + + /// No scenario opens the dialog, so its one field is checked here. + #[gtk4::test] + fn the_search_field_is_named() { + let (_root, store, local, _staging) = seeded(); + let dialog = SavedQueriesDialog::builder().launch(SavedQueriesDialogInit { + store: Some(store), + active_connection: Some(local), + }); + + assert_eq!(crate::test_support::assert_labelled(&dialog.model().search), Ok(())); + } + + #[gtk4::test] + fn deleting_a_query_takes_it_out_of_the_list() { + let (_root, store, local, _staging) = seeded(); + let dialog = SavedQueriesDialog::builder().launch(SavedQueriesDialogInit { + store: Some(store), + active_connection: Some(local), + }); + crate::test_support::wait_until(RENDER_WAIT, || row_titles(&dialog.model()).len() == 2) + .expect("the saved queries never rendered"); + let id = dialog + .model() + .queries + .iter() + .find(|saved| saved.name == "daily orders") + .expect("the seeded query") + .id; + + dialog.sender().send(SavedQueriesDialogInput::Delete(id)).expect("send"); + + crate::test_support::wait_until(RENDER_WAIT, || row_titles(&dialog.model()) == vec!["stale carts"]) + .expect("the deleted query stayed in the list"); + } + + /// Take away the table the store reads, as a damaged file does, + /// through a connection of its own. + fn drop_the_table(root: &tempfile::TempDir) { + use tablepro_core::statement::{GuardedStatement, StatementBuilder}; + use tablepro_core::{ + CallOptions, ConnectEnvironment, ConnectOptions, ConnectTarget, DatabaseDriver, FileEndpoint, + ForeignKeyMode, TableRef, WorkspaceSettings, WriteBatch, WriteKind, + }; + use tokio_util::sync::CancellationToken; + + let paths = tablepro_storage::StoragePaths::under(root.path(), "tablepro", "app.tablepro.TablePro"); + let endpoint = FileEndpoint::open_existing(paths.history_database()).expect("the history file"); + let options = ConnectOptions { + target: ConnectTarget::File(endpoint), + workspace_settings: WorkspaceSettings::workspace(false), + liveness: tablepro_core::LivenessPolicy::DESKTOP, + environment: ConnectEnvironment::new("test"), + }; + let mut drop = StatementBuilder::new(); + drop.push_sql("DROP TABLE saved_queries"); + let batch = WriteBatch::new( + WriteKind::SchemaChanges, + TableRef::unqualified("saved_queries"), + vec![GuardedStatement::unguarded(drop.finish())], + ForeignKeyMode::Enforced, + ); + relm4::tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("runtime") + .block_on(async { + let connection = tablepro_driver_sqlite::SqliteDriver + .connect(options, CancellationToken::new()) + .await + .expect("connect"); + connection + .write_batch(&batch, CallOptions::new(None, CancellationToken::new())) + .await + .expect("the table dropped"); + connection.close().await.expect("close"); + }); + } + + fn visible_page(dialog: &SavedQueriesDialog) -> Option { + dialog.stack.visible_child_name().map(|name| name.to_string()) + } + + #[gtk4::test] + fn a_list_that_cannot_be_read_says_so_and_offers_try_again() { + let (root, store, local, _staging) = seeded(); + drop_the_table(&root); + let dialog = SavedQueriesDialog::builder().launch(SavedQueriesDialogInit { + store: Some(store), + active_connection: Some(local), + }); + + crate::test_support::wait_until(RENDER_WAIT, || { + visible_page(&dialog.model()).as_deref() == Some("error") + }) + .expect("the error page"); + let model = dialog.model(); + assert_eq!(model.error_page.title(), "Could Not Read Saved Queries"); + assert_eq!( + model.error_page.description().as_deref(), + Some("The query history file could not be read. It may be damaged.") + ); + let retry = model + .error_page + .child() + .and_downcast::() + .expect("a Try Again button"); + assert_eq!(retry.label().as_deref(), Some("Try Again")); + drop(model); + + retry.emit_clicked(); + crate::test_support::drain_main_context(); + assert_eq!( + visible_page(&dialog.model()).as_deref(), + Some("error"), + "Try Again found the table back" + ); + } + + #[gtk4::test] + fn copying_a_query_says_so_inside_the_dialog() { + let (_root, store, local, _staging) = seeded(); + let dialog = SavedQueriesDialog::builder().launch(SavedQueriesDialogInit { + store: Some(store), + active_connection: Some(local), + }); + crate::test_support::wait_until(RENDER_WAIT, || row_titles(&dialog.model()).len() == 2) + .expect("the saved queries never rendered"); + let id = dialog.model().queries[0].id; + + dialog + .sender() + .send(SavedQueriesDialogInput::CopySql(id)) + .expect("send"); + crate::test_support::drain_main_context(); + + let toasts = dialog.model().toasts.clone(); + assert!( + crate::test_support::descendants(&toasts) + .iter() + .filter_map(|widget| widget.downcast_ref::()) + .any(|label| label.label() == "Copied to clipboard"), + "the toast went somewhere the dialog covers" + ); + } + + #[test] + fn a_rename_failure_says_what_went_wrong() { + use tablepro_storage::StorageError; + + assert_eq!( + rename_failure_text(&StorageError::NameTaken), + "That name is already used by another saved query." + ); + assert_eq!( + rename_failure_text(&StorageError::NotFound), + "That saved query was deleted elsewhere." + ); + assert_eq!( + rename_failure_text(&StorageError::NotInitialised), + "The saved query could not be renamed." + ); + } + + #[gtk4::test] + fn a_dialog_with_no_database_shows_the_empty_state_rather_than_failing() { + let dialog = SavedQueriesDialog::builder().launch(SavedQueriesDialogInit { + store: None, + active_connection: None, + }); + + crate::test_support::drain_main_context(); + + assert_eq!(dialog.model().stack.visible_child_name().as_deref(), Some("empty")); + assert!(row_titles(&dialog.model()).is_empty()); + } +} diff --git a/linux/crates/app/src/ui/session_window/application_notification_sink.rs b/linux/crates/app/src/ui/session_window/application_notification_sink.rs new file mode 100644 index 0000000000..49b3d76014 --- /dev/null +++ b/linux/crates/app/src/ui/session_window/application_notification_sink.rs @@ -0,0 +1,40 @@ +use gtk4 as gtk; +use gtk4::glib; +use gtk4::prelude::*; + +use super::notification_request::NotificationRequest; +use super::notification_sink::NotificationSink; + +/// The application's own notifications, which the desktop shows under +/// its name and icon. Clicking one runs `app.focus-tab`. +pub(crate) struct ApplicationNotificationSink { + /// The application owns its windows, and so this. + app: glib::WeakRef, +} + +impl ApplicationNotificationSink { + pub(crate) fn new(app: >k::Application) -> Self { + Self { app: app.downgrade() } + } +} + +impl NotificationSink for ApplicationNotificationSink { + fn send(&self, request: &NotificationRequest) { + let Some(app) = self.app.upgrade() else { + return; + }; + let notification = gio::Notification::new(&request.title); + notification.set_body(Some(&request.body)); + notification.set_default_action_and_target_value( + "app.focus-tab", + Some(&(request.window_id, request.tab.to_string()).to_variant()), + ); + app.send_notification(Some(&request.id), ¬ification); + } + + fn withdraw(&self, id: &str) { + if let Some(app) = self.app.upgrade() { + app.withdraw_notification(id); + } + } +} diff --git a/linux/crates/app/src/ui/session_window/connect_failed_page.rs b/linux/crates/app/src/ui/session_window/connect_failed_page.rs new file mode 100644 index 0000000000..9262db2200 --- /dev/null +++ b/linux/crates/app/src/ui/session_window/connect_failed_page.rs @@ -0,0 +1,208 @@ +use gtk4 as gtk; +use gtk4::glib; +use gtk4::prelude::*; +use gtk4::subclass::prelude::*; +use libadwaita as adw; +use tablepro_core::credentials::SecretKind; + +use super::connect_failure_view::ConnectFailureView; + +mod imp { + use super::*; + use libadwaita::subclass::prelude::*; + + #[derive(Default, gtk::CompositeTemplate)] + #[template(resource = "/app/tablepro/TablePro/ui/connect-failed-page.ui")] + pub struct ConnectFailedPage { + #[template_child] + pub toast_overlay: TemplateChild, + #[template_child] + pub header_bar: TemplateChild, + #[template_child] + pub status_page: TemplateChild, + #[template_child] + pub reenter_button: TemplateChild, + #[template_child] + pub retry_button: TemplateChild, + #[template_child] + pub edit_button: TemplateChild, + #[template_child] + pub choose_button: TemplateChild, + #[cfg(test)] + pub announced: std::cell::RefCell>, + } + + #[glib::object_subclass] + impl ObjectSubclass for ConnectFailedPage { + const NAME: &'static str = "TableProConnectFailedPage"; + type Type = super::ConnectFailedPage; + type ParentType = adw::Bin; + + fn class_init(klass: &mut Self::Class) { + klass.bind_template(); + } + + fn instance_init(object: &glib::subclass::InitializingObject) { + object.init_template(); + } + } + + impl ObjectImpl for ConnectFailedPage {} + impl WidgetImpl for ConnectFailedPage {} + impl BinImpl for ConnectFailedPage {} +} + +glib::wrapper! { + /// A connect that failed: why, and Enter Password, Retry, Edit + /// Connection and Choose Connection as the ways on. + pub struct ConnectFailedPage(ObjectSubclass) + @extends adw::Bin, gtk::Widget, + @implements gtk::Accessible, gtk::Buildable, gtk::ConstraintTarget; +} + +impl ConnectFailedPage { + /// `edit_label` names the button that opens the connect dialog, + /// shown when `view` says the dialog can edit what failed. `reenter` + /// is the secret the server refused, which the user is offered to + /// type again. + pub(crate) fn new( + view: &ConnectFailureView, + edit_label: &str, + reenter: Option, + app: Option<>k::Application>, + ) -> Self { + let page: Self = glib::Object::new(); + let imp = page.imp(); + imp.header_bar.pack_end(&crate::ui::shell::primary_menu_button(app)); + imp.status_page.set_icon_name(Some(crate::ui::icons::NETWORK_ERROR)); + if let Some(heading) = &view.heading { + imp.status_page.set_title(heading); + } + // The description is markup, and the reason quotes server text, + // file paths and host names. + imp.status_page + .set_description(Some(&glib::markup_escape_text(&view.message))); + imp.edit_button.set_label(edit_label); + imp.edit_button.set_visible(view.can_edit); + if let Some(kind) = reenter { + imp.reenter_button.set_label(&super::secret_retry::reenter_label(kind)); + // The target goes first: an action that takes one, named on a + // button without it, warns that it cannot be activated. + imp.reenter_button + .set_action_target_value(Some(&kind.attribute().to_variant())); + imp.reenter_button.set_action_name(Some("win.retry-with-new-secret")); + imp.reenter_button.set_visible(true); + imp.retry_button.remove_css_class("suggested-action"); + } + page + } + + /// Say why the connect failed, once the page is in its window: the + /// page replaces the window's content without moving the focus, so + /// a screen reader hears this rather than what it read before. Enter + /// takes the suggested way on. + pub fn show(&self) { + let imp = self.imp(); + let heading = imp.status_page.title(); + if let Some(window) = self.root().and_downcast::() { + window.set_title(Some(&heading)); + window.set_default_widget(Some(&self.suggested_button())); + } + let message = imp.status_page.description().unwrap_or_default(); + let announcement = format!("{heading}. {message}"); + let priority = gtk::AccessibleAnnouncementPriority::High; + #[cfg(test)] + imp.announced.borrow_mut().push((announcement.clone(), priority)); + // libadwaita's own Accessible impls sit behind a feature left off, + // and every widget is one. + imp.status_page + .upcast_ref::() + .announce(&announcement, priority); + } + + /// Enter Password when the server refused one, which Retry would + /// only send again; Retry otherwise. + fn suggested_button(&self) -> gtk::Button { + let imp = self.imp(); + if imp.reenter_button.is_visible() { + imp.reenter_button.get() + } else { + imp.retry_button.get() + } + } + + pub fn toast_overlay(&self) -> adw::ToastOverlay { + self.imp().toast_overlay.get() + } + + /// The reason as the page shows it. + #[cfg(test)] + pub fn message(&self) -> Option { + crate::test_support::status_page_description(&self.imp().status_page) + } + + /// The button that types a refused secret again, while it shows. + #[cfg(test)] + pub fn reenter_button(&self) -> Option { + let button = self.imp().reenter_button.get(); + button.is_visible().then_some(button) + } + + #[cfg(test)] + pub fn status_page(&self) -> adw::StatusPage { + self.imp().status_page.get() + } + + /// Retry, Edit Connection and Choose Connection, in that order. + #[cfg(test)] + pub fn buttons(&self) -> [gtk::Button; 3] { + let imp = self.imp(); + [imp.retry_button.get(), imp.edit_button.get(), imp.choose_button.get()] + } + + #[cfg(test)] + pub fn announced(&self) -> Vec<(String, gtk::AccessibleAnnouncementPriority)> { + self.imp().announced.borrow().clone() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn page(heading: Option<&str>, reenter: Option) -> ConnectFailedPage { + adw::init().expect("libadwaita initialises under the test backend"); + crate::register_test_resources(); + let view = ConnectFailureView { + message: "The saved connections could not be written: the disk is full".to_owned(), + can_edit: true, + heading: heading.map(str::to_owned), + }; + ConnectFailedPage::new(&view, "Edit Connection…", reenter, None) + } + + #[gtk4::test] + fn a_save_that_failed_after_the_connect_is_not_called_a_failed_connect() { + assert_eq!(page(None, None).status_page().title(), "Could Not Connect"); + assert_eq!( + page(Some("Could Not Save the Connection"), None).status_page().title(), + "Could Not Save the Connection" + ); + } + + #[gtk4::test] + fn the_reenter_action_is_named_only_with_its_target() { + let hidden = page(None, None).imp().reenter_button.get(); + assert!(!hidden.is_visible()); + assert_eq!( + hidden.action_name(), + None, + "an action that takes a target was named on a button without one" + ); + + let kind = SecretKind::DatabasePassword; + let shown = page(None, Some(kind)).imp().reenter_button.get(); + assert_eq!(shown.action_name().as_deref(), Some("win.retry-with-new-secret")); + assert_eq!(shown.action_target_value(), Some(kind.attribute().to_variant())); + } +} diff --git a/linux/crates/app/src/ui/session_window/connect_failure_view.rs b/linux/crates/app/src/ui/session_window/connect_failure_view.rs new file mode 100644 index 0000000000..11e2977b24 --- /dev/null +++ b/linux/crates/app/src/ui/session_window/connect_failure_view.rs @@ -0,0 +1,106 @@ +use tablepro_session::ConnectSource; + +use super::session_window_command::ConnectFailure; + +/// What the failed page says, and whether it offers Edit Connection. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct ConnectFailureView { + /// The error as `error_text` words it, with any server diagnostic + /// it carries. + pub message: String, + pub can_edit: bool, + /// Set when the failure was not the connect's, which the default + /// "Could Not Connect" would misname. + pub heading: Option, +} + +impl ConnectFailureView { + pub(crate) fn new(failure: &ConnectFailure, source: &ConnectSource) -> Self { + Self { + message: failure.message.clone(), + can_edit: can_edit(source), + heading: failure.heading.clone(), + } + } +} + +/// Only what the connect dialog shows can be edited: a saved connection, +/// or one typed into the dialog. A file opened from the file manager was +/// never in it. +fn can_edit(source: &ConnectSource) -> bool { + match source { + ConnectSource::Saved(_) => true, + ConnectSource::Intent(intent) => intent.pending_save.is_some(), + ConnectSource::File(_) => false, + } +} + +#[cfg(test)] +mod tests { + use secrecy::SecretString; + use tablepro_core::{AuthMode, Credentials, NetworkEndpoint, TlsConfig}; + use tablepro_session::registry::FileIdentity; + use tablepro_session::{ConnectIntent, ConnectRequest, PendingSave, RequestTarget, SessionKey}; + use tablepro_storage::SavedConnection; + use uuid::Uuid; + + use super::*; + + fn saved() -> SavedConnection { + SavedConnection { + id: Uuid::new_v4(), + name: "Prod".to_owned(), + driver_id: "postgres".to_owned(), + host: "db.example.com".to_owned(), + port: 5432, + database: "shop".to_owned(), + username: "app".to_owned(), + tls: TlsConfig::verify_full(), + read_only: false, + auth_mode: AuthMode::Password, + ssh: None, + last_opened_at: None, + color: None, + group: None, + } + } + + /// A connect typed into the dialog. + fn typed() -> ConnectSource { + let connection = saved(); + let mut request: ConnectRequest = tablepro_session::fakes::FakeConnector::request().expect("a request"); + request.target = RequestTarget::Network { + endpoint: NetworkEndpoint::new("db.example.com", 5432).expect("an endpoint"), + ssh: None, + credentials: Credentials::Password { + username: "app".to_owned(), + password: SecretString::from("s3cret"), + }, + database: Some("shop".to_owned()), + tls: TlsConfig::verify_full(), + }; + ConnectSource::Intent(Box::new(ConnectIntent { + key: SessionKey::Saved(connection.id), + request, + pending_save: Some(PendingSave { + connection, + secrets: Vec::new(), + forget: Vec::new(), + is_new: true, + }), + })) + } + + fn file() -> ConnectSource { + ConnectSource::File(FileIdentity::new(None, "file:///srv/app.db".to_owned())) + } + + #[test] + fn edit_hidden_for_file_source() { + let failure = ConnectFailure::new("gone".to_owned()); + + assert!(!ConnectFailureView::new(&failure, &file()).can_edit); + assert!(ConnectFailureView::new(&failure, &ConnectSource::Saved(Box::new(saved()))).can_edit); + assert!(ConnectFailureView::new(&failure, &typed()).can_edit); + } +} diff --git a/linux/crates/app/src/ui/session_window/connect_flow.rs b/linux/crates/app/src/ui/session_window/connect_flow.rs new file mode 100644 index 0000000000..7bd80d56b5 --- /dev/null +++ b/linux/crates/app/src/ui/session_window/connect_flow.rs @@ -0,0 +1,587 @@ +//! The work behind a connect the window starts, run off the GTK thread. +//! +//! Every step stops at the attempt's cancel: a cancelled connect asks +//! for nothing more and adds no entry or secret. + +use tablepro_core::DriverRegistry; +use tablepro_core::credentials::{CredentialInteraction, SecretError, SecretKind, SecretVault}; +use tablepro_session::connect::ConnectionWrites; +use tablepro_session::{ + ConnectError, ConnectRequest, Connector, CredentialSource, PendingSave, PendingSession, PersistReport, + RefreshedRequest, +}; +use tablepro_storage::{CredentialDemand, SavedConnection, StorageError}; +use tokio_util::sync::CancellationToken; +use uuid::Uuid; + +/// Why a submitted connection did not open. +#[derive(Debug)] +pub(super) enum SubmitError { + Connect(ConnectError), + Save(StorageError), +} + +/// Connect to what the dialog was filled in with, then save it. The +/// entry is only written once the connect works, so a typo never lands +/// in the connection list. +pub(super) async fn connect_then_save( + connector: &dyn Connector, + request: ConnectRequest, + interaction: CredentialInteraction, + cancel: CancellationToken, + secrets: &dyn SecretVault, + writes: &dyn ConnectionWrites, + save: PendingSave, +) -> Result<(PendingSession, PersistReport), SubmitError> { + let pending = connector + .establish(request, interaction, cancel.clone()) + .await + .map_err(SubmitError::Connect)?; + let pending = unless_cancelled(pending, &cancel).await.map_err(SubmitError::Connect)?; + let is_new = save.is_new; + let report = match tablepro_session::persist_pending_save(secrets, writes, save).await { + Ok(report) => report, + Err(error) => { + let report = pending.discard().await; + tracing::debug!(?report, "closed a connection whose entry could not be saved"); + return Err(SubmitError::Save(error)); + } + }; + // A keyring unlock prompt can hold the save up long enough for the + // user to cancel. An overwritten entry has no earlier version left + // to restore, so only a new one is taken back. + if is_new && cancel.is_cancelled() { + unsave(secrets, writes, report.saved.id).await; + } + unless_cancelled(pending, &cancel) + .await + .map(|pending| (pending, report)) + .map_err(SubmitError::Connect) +} + +async fn unsave(secrets: &dyn SecretVault, writes: &dyn ConnectionWrites, id: Uuid) { + // A secret with no entry is orphaned in the keyring, while an + // entry with no secrets only asks for them again. + if let Err(error) = secrets.delete_connection(id).await { + tracing::warn!(%error, "kept a cancelled connect's entry, whose secrets could not be deleted"); + return; + } + if let Err(error) = writes.remove(id).await { + tracing::warn!(%error, "a cancelled connect's entry is still saved"); + } +} + +/// A saved connection that connected, and what became of the secrets +/// typed for it. +#[derive(Debug)] +pub(super) struct SavedLogin { + pub pending: PendingSession, + /// Secrets the user asked to remember that the keyring refused. + pub refused: Vec<(SecretKind, SecretError)>, + /// Secrets typed for this login that the keyring does not hold, + /// which the session's reconnects keep using. + pub session_only: Vec, +} + +/// A secret typed into a prompt is kept only once the login it was +/// typed for has worked. `demand` names a secret the server refused, +/// which is asked for again rather than read from the keyring. +pub(super) async fn connect_saved( + saved: &SavedConnection, + drivers: &DriverRegistry, + credentials: &dyn CredentialSource, + connector: &dyn Connector, + interaction: CredentialInteraction, + demand: CredentialDemand, + cancel: CancellationToken, +) -> Result { + // Dropping the resolve on a cancel drops its prompt too, which is + // what takes a password dialog still on screen down with it. + let resolving = tablepro_session::resolve_saved(saved, drivers, credentials, interaction.clone(), demand); + let RefreshedRequest { + request, + remember, + typed, + } = cancel + .run_until_cancelled(resolving) + .await + .ok_or(ConnectError::Cancelled)??; + let pending = connector.establish(request, interaction, cancel.clone()).await?; + let pending = unless_cancelled(pending, &cancel).await?; + let kept: Vec = remember.iter().map(|save| save.kind).collect(); + let refused = credentials.remember(remember).await; + let session_only = typed + .into_iter() + .filter(|kind| !kept.contains(kind) || refused.iter().any(|(failed, _)| failed == kind)) + .collect(); + Ok(SavedLogin { + pending, + refused, + session_only, + }) +} + +/// Connect to a request that carries every secret it needs, such as a +/// file nobody saved. +pub(super) async fn connect_request( + connector: &dyn Connector, + request: ConnectRequest, + interaction: CredentialInteraction, + cancel: CancellationToken, +) -> Result { + let pending = connector.establish(request, interaction, cancel.clone()).await?; + unless_cancelled(pending, &cancel).await +} + +/// A driver blocked in a C call can still connect after the user +/// cancelled. That connection is closed here rather than saved or kept. +async fn unless_cancelled(pending: PendingSession, cancel: &CancellationToken) -> Result { + if !cancel.is_cancelled() { + return Ok(pending); + } + let report = pending.discard().await; + tracing::debug!( + ?report, + "closed a connection that arrived after its connect was cancelled" + ); + Err(ConnectError::Cancelled) +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + use std::time::Duration; + + use futures::future::BoxFuture; + use secrecy::SecretString; + use tablepro_core::credentials::{CredentialPrompter, SecretSave}; + use tablepro_core::{AuthMode, TlsConfig}; + use tablepro_session::connect::StoreConnectionWrites; + use tablepro_session::fakes::{FakeConnector, FakeEstablish}; + use tablepro_storage::{ConnectionStore, KeyringCredentials, StoragePaths}; + use tablepro_test_support::{ConnectionScript, FakeDriver, FakeSecretVault}; + + use super::*; + use crate::ui::credential_prompt::GtkCredentialPrompter; + + const LATE: Duration = Duration::from_secs(5); + + fn connection(id: Uuid) -> SavedConnection { + SavedConnection { + id, + name: "Prod".to_owned(), + driver_id: "fake".to_owned(), + host: "db.example.com".to_owned(), + port: 5432, + database: "shop".to_owned(), + username: "app".to_owned(), + tls: TlsConfig::verify_full(), + read_only: false, + auth_mode: AuthMode::Password, + ssh: None, + last_opened_at: None, + color: None, + group: None, + } + } + + fn save(id: Uuid) -> PendingSave { + PendingSave { + connection: connection(id), + secrets: vec![SecretSave { + kind: SecretKind::DatabasePassword, + secret: SecretString::from("s3cret"), + label: "Prod".to_owned(), + }], + forget: Vec::new(), + is_new: true, + } + } + + fn late_connector() -> FakeConnector { + FakeConnector::new([FakeEstablish::Late { + after: LATE, + script: ConnectionScript::default(), + }]) + } + + struct CancelledWhileSaving { + writes: StoreConnectionWrites, + cancel: CancellationToken, + } + + impl ConnectionWrites for CancelledWhileSaving { + fn upsert(&self, connection: SavedConnection) -> BoxFuture<'_, Result<(), StorageError>> { + self.cancel.cancel(); + self.writes.upsert(connection) + } + + fn remove(&self, id: Uuid) -> BoxFuture<'_, Result<(), StorageError>> { + self.writes.remove(id) + } + } + + fn drivers() -> DriverRegistry { + let mut drivers = DriverRegistry::new(); + drivers.register(Arc::new(FakeDriver::connecting( + &FakeDriver::NETWORK_CAPABILITIES, + ConnectionScript::default(), + ))); + drivers + } + + #[tokio::test(start_paused = true)] + async fn a_connect_that_lands_after_its_cancel_saves_nothing() { + let root = tempfile::tempdir().expect("a temporary directory"); + let store = ConnectionStore::new(&StoragePaths::under(root.path(), "tablepro-test", "app.tablepro.Test")); + let writes = StoreConnectionWrites::new(store.clone(), crate::test_support::paused_tasks()); + let vault = FakeSecretVault::new(); + let connector = late_connector(); + let cancel = CancellationToken::new(); + let request = FakeConnector::request().expect("a request"); + + let connecting = connect_then_save( + &connector, + request, + CredentialInteraction::Unattended, + cancel.clone(), + &vault, + &writes, + save(Uuid::new_v4()), + ); + cancel.cancel(); + let outcome = connecting.await; + + assert!( + matches!(outcome, Err(SubmitError::Connect(ConnectError::Cancelled))), + "{outcome:?}" + ); + assert!(vault.is_empty(), "a cancelled connect stored its secrets"); + assert!( + store.load_blocking().expect("the list").is_empty(), + "a cancelled connect saved its entry" + ); + let built = connector.connections(); + assert_eq!(built.len(), 1); + assert!(built[0].is_closed(), "the late connection was left open"); + } + + #[tokio::test(start_paused = true)] + async fn a_connect_that_is_not_cancelled_saves_its_entry_and_secrets() { + let root = tempfile::tempdir().expect("a temporary directory"); + let store = ConnectionStore::new(&StoragePaths::under(root.path(), "tablepro-test", "app.tablepro.Test")); + let writes = StoreConnectionWrites::new(store.clone(), crate::test_support::paused_tasks()); + let vault = FakeSecretVault::new(); + let connector = late_connector(); + let id = Uuid::new_v4(); + + let (pending, report) = connect_then_save( + &connector, + FakeConnector::request().expect("a request"), + CredentialInteraction::Unattended, + CancellationToken::new(), + &vault, + &writes, + save(id), + ) + .await + .expect("a saved connection"); + + assert_eq!(report.saved.id, id); + assert!(!vault.is_empty()); + assert_eq!(store.load_blocking().expect("the list").len(), 1); + let _closed = pending.discard().await; + } + + #[tokio::test(start_paused = true)] + async fn a_new_entry_saved_as_its_connect_is_cancelled_is_taken_back() { + let root = tempfile::tempdir().expect("a temporary directory"); + let store = ConnectionStore::new(&StoragePaths::under(root.path(), "tablepro-test", "app.tablepro.Test")); + let cancel = CancellationToken::new(); + let writes = CancelledWhileSaving { + writes: StoreConnectionWrites::new(store.clone(), crate::test_support::paused_tasks()), + cancel: cancel.clone(), + }; + let vault = FakeSecretVault::new(); + let connector = FakeConnector::new([FakeEstablish::Connect(ConnectionScript::default())]); + + let outcome = connect_then_save( + &connector, + FakeConnector::request().expect("a request"), + CredentialInteraction::Unattended, + cancel, + &vault, + &writes, + save(Uuid::new_v4()), + ) + .await; + + assert!( + matches!(outcome, Err(SubmitError::Connect(ConnectError::Cancelled))), + "{outcome:?}" + ); + assert!(vault.is_empty(), "the cancelled connect's secrets stayed"); + assert!( + store.load_blocking().expect("the list").is_empty(), + "the cancelled connect's entry stayed" + ); + let built = connector.connections(); + assert_eq!(built.len(), 1); + assert!(built[0].is_closed(), "the connection was left open"); + } + + #[tokio::test(start_paused = true)] + async fn an_existing_entry_saved_as_its_connect_is_cancelled_keeps_the_edit() { + let root = tempfile::tempdir().expect("a temporary directory"); + let store = ConnectionStore::new(&StoragePaths::under(root.path(), "tablepro-test", "app.tablepro.Test")); + let id = Uuid::new_v4(); + store.upsert_blocking(connection(id)).expect("the saved entry"); + let cancel = CancellationToken::new(); + let writes = CancelledWhileSaving { + writes: StoreConnectionWrites::new(store.clone(), crate::test_support::paused_tasks()), + cancel: cancel.clone(), + }; + let vault = FakeSecretVault::new(); + let connector = FakeConnector::new([FakeEstablish::Connect(ConnectionScript::default())]); + let mut edit = save(id); + edit.connection.name = "Prod (edited)".to_owned(); + edit.is_new = false; + + let outcome = connect_then_save( + &connector, + FakeConnector::request().expect("a request"), + CredentialInteraction::Unattended, + cancel, + &vault, + &writes, + edit, + ) + .await; + + assert!( + matches!(outcome, Err(SubmitError::Connect(ConnectError::Cancelled))), + "{outcome:?}" + ); + let list = store.load_blocking().expect("the list"); + assert_eq!(list.len(), 1); + assert_eq!(list[0].name, "Prod (edited)"); + assert!(!vault.is_empty(), "the entry lost its secrets"); + assert!(connector.connections()[0].is_closed(), "the connection was left open"); + } + + #[tokio::test(start_paused = true)] + async fn cancelling_while_a_password_is_asked_for_ends_the_prompt() { + let saved = connection(Uuid::new_v4()); + let credentials = KeyringCredentials::for_saved(Arc::new(FakeSecretVault::new()), &saved); + let (prompter, mut queue) = GtkCredentialPrompter::new(); + let prompter: Arc = Arc::new(prompter); + let connector = FakeConnector::new([FakeEstablish::Connect(ConnectionScript::default())]); + let cancel = CancellationToken::new(); + let drivers = drivers(); + + let connecting = connect_saved( + &saved, + &drivers, + &credentials, + &connector, + CredentialInteraction::Attended(prompter), + CredentialDemand::default(), + cancel.clone(), + ); + let asking = async { + let shown = queue.next().await.expect("the password prompt"); + cancel.cancel(); + shown + }; + let (outcome, shown) = tokio::time::timeout(LATE, async { tokio::join!(connecting, asking) }) + .await + .expect("the cancel did not end the connect while its prompt was up"); + + assert!(matches!(outcome, Err(ConnectError::Cancelled)), "{outcome:?}"); + assert!(shown.reply.is_closed(), "the prompt is still waited on"); + assert!(connector.calls().is_empty(), "a cancelled attempt still connected"); + } + + #[tokio::test(start_paused = true)] + async fn a_refused_password_is_asked_for_again_and_kept_once_it_works() { + use tablepro_core::credentials::{PromptPurpose, PromptReason, PromptReply}; + use tablepro_test_support::Call; + + let saved = connection(Uuid::new_v4()); + let vault = Arc::new(FakeSecretVault::new()); + vault + .store( + saved.id, + SecretKind::DatabasePassword, + &SecretString::from("old"), + "label", + ) + .await + .expect("the refused password"); + let credentials = KeyringCredentials::for_saved(vault.clone(), &saved); + let (prompter, mut queue) = GtkCredentialPrompter::new(); + let prompter: Arc = Arc::new(prompter); + let connector = FakeConnector::new([FakeEstablish::Connect(ConnectionScript::default())]); + let drivers = drivers(); + let loads_before = vault.calls().len(); + + let connecting = connect_saved( + &saved, + &drivers, + &credentials, + &connector, + CredentialInteraction::Attended(prompter), + CredentialDemand::reenter(SecretKind::DatabasePassword), + CancellationToken::new(), + ); + let answering = async { + let shown = queue.next().await.expect("the password prompt"); + assert_eq!( + shown.prompt.purpose, + PromptPurpose::DatabasePassword { + reason: PromptReason::Rejected + } + ); + shown + .reply + .send(PromptReply::Submitted { + values: vec![SecretString::from("new")], + remember: true, + }) + .expect("the connect is waiting"); + }; + let (outcome, ()) = tokio::join!(connecting, answering); + + let login = outcome.expect("a connection"); + assert!(login.refused.is_empty()); + assert!( + login.session_only.is_empty(), + "a password the keyring now holds was kept for the session" + ); + assert!( + !vault.calls()[loads_before..].contains(&Call::Load(saved.id, SecretKind::DatabasePassword)), + "the refused password was read again" + ); + assert_eq!( + vault.stored_secret(saved.id, SecretKind::DatabasePassword).as_deref(), + Some("new") + ); + let _closed = login.pending.discard().await; + } + + #[tokio::test(start_paused = true)] + async fn a_password_typed_but_not_kept_is_the_sessions_own() { + use tablepro_core::credentials::PromptReply; + + let saved = connection(Uuid::new_v4()); + let vault = Arc::new(FakeSecretVault::new()); + vault + .store( + saved.id, + SecretKind::DatabasePassword, + &SecretString::from("old"), + "label", + ) + .await + .expect("the refused password"); + let credentials = KeyringCredentials::for_saved(vault.clone(), &saved); + let prompter: Arc = + Arc::new(tablepro_test_support::FakePrompter::new([PromptReply::Submitted { + values: vec![SecretString::from("new")], + remember: false, + }])); + let connector = FakeConnector::new([FakeEstablish::Connect(ConnectionScript::default())]); + + let login = connect_saved( + &saved, + &drivers(), + &credentials, + &connector, + CredentialInteraction::Attended(prompter), + CredentialDemand::reenter(SecretKind::DatabasePassword), + CancellationToken::new(), + ) + .await + .expect("a connection"); + + assert_eq!(login.session_only, [SecretKind::DatabasePassword]); + assert_eq!( + vault.stored_secret(saved.id, SecretKind::DatabasePassword).as_deref(), + Some("old") + ); + let _closed = login.pending.discard().await; + } + + #[tokio::test(start_paused = true)] + async fn a_password_the_keyring_refused_to_keep_is_the_sessions_own() { + use tablepro_core::credentials::PromptReply; + + let saved = connection(Uuid::new_v4()); + let vault = Arc::new(FakeSecretVault::new()); + vault.fail_store(SecretKind::DatabasePassword, SecretError::Locked); + let credentials = KeyringCredentials::for_saved(vault.clone(), &saved); + let prompter: Arc = + Arc::new(tablepro_test_support::FakePrompter::new([PromptReply::Submitted { + values: vec![SecretString::from("typed")], + remember: true, + }])); + let connector = FakeConnector::new([FakeEstablish::Connect(ConnectionScript::default())]); + + let login = connect_saved( + &saved, + &drivers(), + &credentials, + &connector, + CredentialInteraction::Attended(prompter), + CredentialDemand::default(), + CancellationToken::new(), + ) + .await + .expect("a connection"); + + assert_eq!(login.refused.len(), 1); + assert_eq!(login.session_only, [SecretKind::DatabasePassword]); + let _closed = login.pending.discard().await; + } + + #[tokio::test(start_paused = true)] + async fn a_saved_connect_that_lands_after_its_cancel_keeps_no_typed_secret() { + let saved = connection(Uuid::new_v4()); + let vault = Arc::new(FakeSecretVault::new()); + let credentials = KeyringCredentials::for_saved(vault.clone(), &saved); + let (prompter, mut queue) = GtkCredentialPrompter::new(); + let prompter: Arc = Arc::new(prompter); + let connector = late_connector(); + let cancel = CancellationToken::new(); + let drivers = drivers(); + + let connecting = connect_saved( + &saved, + &drivers, + &credentials, + &connector, + CredentialInteraction::Attended(prompter), + CredentialDemand::default(), + cancel.clone(), + ); + let answering = async { + let shown = queue.next().await.expect("the password prompt"); + shown + .reply + .send(tablepro_core::credentials::PromptReply::Submitted { + values: vec![SecretString::from("typed")], + remember: true, + }) + .expect("the connect is waiting"); + tokio::time::sleep(LATE / 2).await; + cancel.cancel(); + }; + let (outcome, ()) = tokio::join!(connecting, answering); + + assert!(matches!(outcome, Err(ConnectError::Cancelled)), "{outcome:?}"); + assert!(vault.is_empty(), "a cancelled connect remembered the typed password"); + let built = connector.connections(); + assert_eq!(built.len(), 1); + assert!(built[0].is_closed(), "the late connection was left open"); + } +} diff --git a/linux/crates/app/src/ui/session_window/connecting_page.rs b/linux/crates/app/src/ui/session_window/connecting_page.rs new file mode 100644 index 0000000000..05798b1d5c --- /dev/null +++ b/linux/crates/app/src/ui/session_window/connecting_page.rs @@ -0,0 +1,101 @@ +use gtk4 as gtk; +use gtk4::glib; +use gtk4::prelude::*; +use gtk4::subclass::prelude::*; +use libadwaita as adw; + +mod imp { + use super::*; + use libadwaita::subclass::prelude::*; + + #[derive(Default, gtk::CompositeTemplate)] + #[template(resource = "/app/tablepro/TablePro/ui/connecting-page.ui")] + pub struct ConnectingPage { + #[template_child] + pub toast_overlay: TemplateChild, + #[template_child] + pub header_bar: TemplateChild, + #[template_child] + pub status_page: TemplateChild, + #[template_child] + pub cancel_button: TemplateChild, + #[cfg(test)] + pub announced: std::cell::RefCell>, + } + + #[glib::object_subclass] + impl ObjectSubclass for ConnectingPage { + const NAME: &'static str = "TableProConnectingPage"; + type Type = super::ConnectingPage; + type ParentType = adw::Bin; + + fn class_init(klass: &mut Self::Class) { + klass.bind_template(); + } + + fn instance_init(object: &glib::subclass::InitializingObject) { + object.init_template(); + } + } + + impl ObjectImpl for ConnectingPage {} + impl WidgetImpl for ConnectingPage {} + impl BinImpl for ConnectingPage {} +} + +glib::wrapper! { + /// A connect on its way, which `win.cancel-connect` stops. A remote + /// or tunnelled connect can take the whole connect timeout. + pub struct ConnectingPage(ObjectSubclass) + @extends adw::Bin, gtk::Widget, + @implements gtk::Accessible, gtk::Buildable, gtk::ConstraintTarget; +} + +impl ConnectingPage { + pub fn new(app: Option<>k::Application>) -> Self { + let page: Self = glib::Object::new(); + page.imp() + .header_bar + .pack_end(&crate::ui::shell::primary_menu_button(app)); + page + } + + /// Say that `name` is connecting, once the page is in its window. + /// The announcement is what a screen reader hears: the page replaces + /// the window's content without moving the focus into it. Enter + /// cancels, as the one thing to do here. + pub fn show(&self, name: &str) { + let imp = self.imp(); + let title = crate::i18n::gettext_f("Connecting to {name}", &[("name", name)]); + imp.status_page.set_title(&title); + if let Some(window) = self.root().and_downcast::() { + window.set_title(Some(&crate::i18n::gettext("Connecting"))); + window.set_default_widget(Some(&imp.cancel_button.get())); + } + let priority = gtk::AccessibleAnnouncementPriority::Medium; + #[cfg(test)] + imp.announced.borrow_mut().push((title.clone(), priority)); + // libadwaita's own Accessible impls sit behind a feature left off, + // and every widget is one. + imp.status_page.upcast_ref::().announce(&title, priority); + } + + pub fn toast_overlay(&self) -> adw::ToastOverlay { + self.imp().toast_overlay.get() + } + + #[cfg(test)] + pub fn status_page(&self) -> adw::StatusPage { + self.imp().status_page.get() + } + + #[cfg(test)] + pub fn cancel_button(&self) -> gtk::Button { + self.imp().cancel_button.get() + } + + #[cfg(test)] + pub fn announced(&self) -> Vec<(String, gtk::AccessibleAnnouncementPriority)> { + self.imp().announced.borrow().clone() + } +} diff --git a/linux/crates/app/src/ui/session_window/connections.rs b/linux/crates/app/src/ui/session_window/connections.rs new file mode 100644 index 0000000000..23f08826e4 --- /dev/null +++ b/linux/crates/app/src/ui/session_window/connections.rs @@ -0,0 +1,1099 @@ +//! The saved connections a window's picker lists, and the connect +//! dialog that adds to them. + +use gtk4 as gtk; +use gtk4::subclass::prelude::*; +use gtk4::{gio, glib}; +use libadwaita as adw; +use libadwaita::prelude::*; + +use tablepro_session::connect::StoreConnectionWrites; +use tablepro_session::{ConnectSource, PendingSave, RequestTarget, SessionKey}; +use tablepro_storage::{ConnectionColor, ConnectionGroupName, ConnectionListState, SavedConnection}; +use uuid::Uuid; + +use super::session_page::SessionPage; +use super::{SessionWindow, SessionWindowInput}; +use crate::runtime::{DeliverInput, InputSender}; +use crate::services::connection_deletion::DeletionOptions; +use crate::services::deletion_error::DeletionError; +use crate::services::saved_connection_error::SavedConnectionError; +use crate::ui::connect_dialog::{ConnectDialog, ConnectDialogInit, ConnectDialogMode, ConnectPrefill, FormField}; +use crate::ui::delete_connection_dialog::{self, DeleteConnectionDialog}; +use crate::ui::file_read::{FileReadError, read_capped_file}; +use crate::ui::ssh_inputs::SshEnvironment; + +/// The password the user typed, so editing after a failure does not +/// make them type it again. It stays in memory. +fn typed_password(target: &RequestTarget) -> Option { + match target { + RequestTarget::Network { + credentials: tablepro_core::Credentials::Password { password, .. }, + .. + } => Some(password.clone()), + _ => None, + } +} + +/// What the connect dialog reopens on after a connect from it failed: +/// the connection as it was submitted, with the password typed for it. +pub(super) fn prefill_for(source: &ConnectSource) -> Option { + match source { + ConnectSource::Intent(intent) => intent.pending_save.as_ref().map(|save| ConnectPrefill { + connection: save.connection.clone(), + password: typed_password(&intent.request.target), + }), + ConnectSource::Saved(_) | ConnectSource::File(_) => None, + } +} + +impl SessionWindow { + /// Show the connect dialog over this window for `mode`, filled from + /// `prefill` when a connect from it failed, with the focus on + /// `focus`. It opens once the user's SSH keys are looked for, which + /// reads the disk and so runs off the GTK thread. + pub(super) fn present_connect_dialog( + &self, + mode: ConnectDialogMode, + prefill: Option, + focus: Option, + ) { + let Some(state) = self.state() else { + return; + }; + // A second request while the first waits would stack two dialogs. + if self.imp().dialog_opening.replace(true) { + return; + } + let detecting = state + .services + .tasks + .spawn_blocking_task(SshEnvironment::detect_blocking); + let window = self.downgrade(); + glib::spawn_future_local(async move { + let environment = detecting.await.unwrap_or_else(|failure| { + tracing::warn!(%failure, "could not look for SSH keys"); + SshEnvironment::default() + }); + if let Some(window) = window.upgrade() { + window.imp().dialog_opening.set(false); + window.show_connect_dialog(mode, prefill, focus, environment); + } + }); + } + + fn show_connect_dialog( + &self, + mode: ConnectDialogMode, + prefill: Option, + focus: Option, + ssh: SshEnvironment, + ) { + let (Some(state), Some(input)) = (self.state(), self.input()) else { + return; + }; + if self.imp().shut_down.get() { + return; + } + let context = &state.context; + let dialog = ConnectDialog::new(ConnectDialogInit { + drivers: context.drivers.clone(), + connector: context.connector.clone(), + interaction: context.attended(), + tasks: state.services.tasks.clone(), + saved_connections: state.services.saved_connections.connections(), + remembered_driver: state.services.settings.connect_dialog_driver(), + ssh_environment: ssh, + prefill, + mode, + editor: state.services.saved_connection_editor.clone(), + credentials: context.credentials.clone(), + hold: state.services.hold(), + }); + let submitted = input.clone(); + dialog.connect_submitted(move |_, submission| { + submitted.deliver(SessionWindowInput::ConnectFromDialog(Box::new(submission))); + }); + let saved = input.clone(); + dialog.connect_saved(move |_, id| saved.deliver(SessionWindowInput::ConnectionSaved(id))); + dialog.connect_closed(move |_| input.deliver(SessionWindowInput::DialogClosed)); + dialog.present(Some(self)); + if let Some(field) = focus { + dialog.reveal_field(field); + } + self.imp().dialog.replace(Some(dialog)); + } + + /// Keep the dialog's edits to a connection open in another window, + /// for its next connect. The keyring is written first, so a secret + /// it refuses is reported rather than lost without a word. + pub(super) fn persist_pending_save(&self, save: PendingSave) { + let (Some(state), Some(input)) = (self.state(), self.input()) else { + return; + }; + let secrets = state.context.secrets.clone(); + let writes = StoreConnectionWrites::new( + state.services.storage.connections().clone(), + state.services.tasks.clone(), + ); + crate::runtime::spawn_held(&state.services.tasks, &*state.services.hold(), async move { + match tablepro_session::persist_pending_save(&*secrets, &writes, save).await { + Ok(report) => { + for (_, error) in &report.secret_failures { + input.deliver(SessionWindowInput::ShowToast( + crate::services::secret_save_report::not_saved_message(error), + )); + } + } + Err(error) => { + tracing::warn!(%error, "could not save the edited connection"); + input.deliver(SessionWindowInput::ShowToast(crate::i18n::gettext_f( + "The connection could not be saved: {error}", + &[("error", &error.to_string())], + ))); + } + } + }); + } + + /// Stamp the connection's last-opened time, which every picker sorts + /// by once the store publishes the list with it. + pub(super) fn touch_last_opened(&self, id: Uuid) { + let Some(state) = self.state() else { + return; + }; + let connections = state.services.storage.connections().clone(); + let hold = state.services.hold().acquire(); + let touching = state + .services + .tasks + .spawn_blocking_task(move || connections.touch_last_opened_blocking(id)); + glib::spawn_future_local(async move { + match touching.await { + Ok(Err(error)) => tracing::warn!(%error, "could not stamp the last-opened time"), + Err(error) => tracing::warn!(%error, "the last-opened task failed"), + Ok(Ok(())) => {} + } + drop(hold); + }); + } + + /// Read the connection list, once per process: the store publishes + /// it, and every picker follows the store from then on. + pub(super) fn on_load_connections(&self) { + let Some(state) = self.state() else { + return; + }; + let store = state.services.storage.connections().clone(); + let reading = state.services.tasks.spawn_blocking_task(move || store.load_blocking()); + glib::spawn_future_local(async move { + match reading.await { + Ok(Ok(_)) => {} + Ok(Err(error)) => tracing::warn!(%error, "the saved connections could not be read"), + Err(failure) => tracing::warn!(%failure, "the connections read task failed"), + } + }); + } + + /// `saved` as the connection list holds it now, or `None` once it is + /// deleted. The list every window shares is read without touching + /// the disk; before it is first read, `saved` is all there is. + pub(super) fn saved_as_stored(&self, saved: &SavedConnection) -> Option { + let Some(state) = self.state() else { + return Some(saved.clone()); + }; + match state.services.storage.connections().snapshot().state { + ConnectionListState::Ready(list) => list.iter().find(|entry| entry.id == saved.id).cloned(), + ConnectionListState::Loading | ConnectionListState::Unavailable(_) => Some(saved.clone()), + } + } + + fn saved_connection(&self, id: Uuid) -> Option { + self.state()?.services.saved_connections.get(id) + } + + /// The name a dialog or toast calls a saved connection by. + fn connection_name(&self, id: Uuid) -> String { + self.saved_connection(id) + .map_or_else(|| crate::i18n::gettext("this connection"), |saved| saved.name) + } + + /// Ask before deleting: the delete takes the keyring secrets with it + /// and has no undo, so it asks whatever the preferences say. + pub(super) fn on_delete_connection(&self, id: Uuid) { + let (Some(state), Some(input)) = (self.state(), self.input()) else { + return; + }; + let name = self.connection_name(id); + if self.offer_open_window(id, &name) { + return; + } + let asking = DeleteConnectionDialog::new(&name); + let history = state.services.history(); + let tasks = state.services.tasks.clone(); + let counting = asking.clone(); + glib::spawn_future_local(async move { + let Some(store) = history.ready_store().await else { + counting.show_history_unavailable(); + return; + }; + match tasks + .spawn_task(async move { store.count_for_connection(id).await }) + .await + { + Ok(Ok(count)) => counting.show_history_count(count), + Ok(Err(error)) => tracing::warn!(%error, "could not count the connection's query history"), + Err(failure) => tracing::warn!(%failure, "the query history count did not finish"), + } + }); + asking.connect_delete(move |options| input.deliver(SessionWindowInput::ConfirmDelete(id, options))); + asking.dialog.present(Some(self)); + } + + /// A connection open in a window, or connecting in one, is not + /// deleted from under it: the user is offered that window instead. + /// True when it is open. + fn offer_open_window(&self, id: Uuid, name: &str) -> bool { + let Some(state) = self.state() else { + return false; + }; + let Some(owner) = state.context.sessions.window_for(&SessionKey::Saved(id)) else { + return false; + }; + let dialog = delete_connection_dialog::is_open_dialog(name); + let host = state.host.clone(); + dialog.connect_response(Some(delete_connection_dialog::SHOW), move |_, _| { + if !host.present_window(owner) { + tracing::warn!(owner, "the window holding the connection is gone"); + } + }); + dialog.present(Some(self)); + true + } + + /// Delete a saved connection the user confirmed, under a hold so a + /// quit right after it does not cut it off between the keyring and + /// the list. + /// + /// The delete claims the connection's key until it ends, so a + /// connect started meanwhile from a picker that still lists it is + /// refused rather than given an entry whose tabs are being purged. + pub(super) fn on_confirm_delete(&self, id: Uuid, options: DeletionOptions) { + let (Some(state), Some(input)) = (self.state(), self.input()) else { + return; + }; + let name = self.connection_name(id); + // It may have been opened since the dialog asked. + let Ok(claim) = state.context.sessions.claim(SessionKey::Saved(id), self.id()) else { + self.offer_open_window(id, &name); + return; + }; + let services = state.services.clone(); + let hold = services.hold().acquire(); + glib::spawn_future_local(async move { + let history = services.history().ready_store().await; + match services.connection_deletion(history).run(id, options).await { + Ok(report) if report.is_complete() => {} + Ok(report) => { + for failure in &report.purge_failures { + tracing::warn!(%failure, "kept something stored for a deleted connection"); + } + if let Some(summary) = report.summary(&name) { + input.deliver(SessionWindowInput::ShowToast(summary)); + } + } + Err(DeletionError::Secrets(error)) => { + tracing::warn!(%error, "could not delete the connection's secrets; keeping the entry"); + input.deliver(SessionWindowInput::SecretDeleteFailed(id, options)); + } + Err(error) => { + tracing::warn!(%error, "could not delete the connection"); + input.deliver(SessionWindowInput::ShowToast(error.summary(&name))); + } + } + drop(claim); + drop(hold); + }); + } + + /// Open the connect dialog on the saved connection `id` to change + /// it, as the list holds it now. + pub(super) fn on_edit_saved_connection(&self, id: Uuid) { + match self.saved_connection(id) { + Some(saved) => self.present_connect_dialog(ConnectDialogMode::Edit(Box::new(saved)), None, None), + None => self.show_toast(&crate::i18n::gettext("The connection was deleted.")), + } + } + + /// An edit was saved. A connect that failed here tries again with + /// it; a session open on the connection keeps what it connected + /// with, since an edit never moves a live session to another server. + pub(super) fn on_connection_saved(&self, id: Uuid) { + let key = SessionKey::Saved(id); + if self.page_is(|page| matches!(page, SessionPage::ConnectFailed { claim, .. } if *claim.key() == key)) { + self.on_retry_connect(); + return; + } + let Some(state) = self.state() else { + return; + }; + if state.context.sessions.window_for(&key).is_some() { + self.show_toast(&crate::i18n::gettext("Changes apply the next time you connect")); + } + } + + /// Copy a saved connection so a second database on the same server + /// does not have to be typed out again, then put the focus on the + /// copy, which sorts among the connections never opened. + pub(super) fn on_duplicate_connection(&self, id: Uuid) { + let Some(state) = self.state() else { + return; + }; + let editor = state.services.saved_connection_editor.clone(); + let hold = state.services.hold().acquire(); + let window = self.downgrade(); + crate::runtime::spawn_and_deliver( + &state.services.tasks, + async move { editor.duplicate(id).await }, + move |outcome| { + drop(hold); + let Some(window) = window.upgrade() else { + return; + }; + match flatten(outcome) { + Ok(copy) => { + if let Some(welcome) = window.imp().welcome.borrow().as_ref() { + welcome.focus_connection(copy.id); + } + } + Err(error) => window.show_saved_connection_error( + |reason| { + crate::i18n::gettext_f( + "The connection could not be duplicated. {reason}", + &[("reason", reason)], + ) + }, + &error, + ), + } + }, + ); + } + + /// File a connection under a group, or take it out of the one it + /// is in. + pub(super) fn on_set_connection_group(&self, id: Uuid, group: Option) { + let Some(state) = self.state() else { + return; + }; + let editor = state.services.saved_connection_editor.clone(); + self.change_connection(async move { editor.move_to_group(id, group).await }, |reason| { + crate::i18n::gettext_f("The group could not be saved. {reason}", &[("reason", reason)]) + }); + } + + /// Put a colour on a connection, or take the one it has off. + pub(super) fn on_set_connection_color(&self, id: Uuid, color: Option) { + let Some(state) = self.state() else { + return; + }; + let editor = state.services.saved_connection_editor.clone(); + self.change_connection(async move { editor.set_color(id, color).await }, |reason| { + crate::i18n::gettext_f("The colour could not be saved. {reason}", &[("reason", reason)]) + }); + } + + /// Run a change to a saved connection, holding the process until it + /// lands so a quit right after it keeps it. Every picker redraws + /// from the list the store then publishes. + fn change_connection( + &self, + change: impl std::future::Future> + Send + 'static, + failure: fn(&str) -> String, + ) { + let Some(state) = self.state() else { + return; + }; + let hold = state.services.hold().acquire(); + let window = self.downgrade(); + crate::runtime::spawn_and_deliver(&state.services.tasks, change, move |outcome| { + drop(hold); + if let (Err(error), Some(window)) = (flatten(outcome), window.upgrade()) { + window.show_saved_connection_error(failure, &error); + } + }); + } + + /// Say a change did not happen, with `failure` putting the reason + /// where the translation wants it. + fn show_saved_connection_error(&self, failure: fn(&str) -> String, error: &SavedConnectionError) { + tracing::warn!(%error, "a change to a saved connection did not happen"); + self.show_toast(&failure(&crate::ui::error_text::saved_connection_error_message(error))); + } + + /// Write the connection list to a file the user picks. + /// + /// The file carries no passwords, and the dialog's name says what + /// it does carry, because it is going somewhere the keyring does + /// not protect. + pub(super) fn on_export_connections(&self) { + let (Some(state), Some(input)) = (self.state(), self.input()) else { + return; + }; + let connections = state.services.storage.connections().clone(); + let reading = state + .services + .tasks + .spawn_blocking_task(move || connections.export_blocking()); + let window = self.downgrade(); + glib::spawn_future_local(async move { + let error = match reading.await { + Ok(Ok(bytes)) => { + if let Some(window) = window.upgrade() { + ask_where_to_export(&window, bytes, input); + } + return; + } + Ok(Err(error)) => error.to_string(), + Err(error) => error.to_string(), + }; + tracing::warn!(%error, "could not read the connections to export"); + input.deliver(SessionWindowInput::ShowToast(crate::i18n::gettext( + "The connections could not be read.", + ))); + }); + } + + /// Ask for an exported file and read it off the GTK thread. + pub(super) fn on_import_connections(&self) { + let Some(input) = self.input() else { + return; + }; + let dialog = gtk::FileDialog::builder() + .title(crate::i18n::gettext("Import Connections")) + .modal(true) + .default_filter(&json_filter()) + .filters(&json_filters()) + .build(); + dialog.open(Some(self), gio::Cancellable::NONE, move |outcome| { + let Ok(file) = outcome else { return }; + glib::spawn_future_local(async move { + let path = file.path().unwrap_or_default(); + input.deliver(match read_connections_file(&file).await { + Ok(bytes) => SessionWindowInput::ConnectionsFileRead { path, bytes }, + Err(message) => SessionWindowInput::ShowToast(message), + }); + }); + }); + } + + pub(super) fn on_connections_file_read(&self, path: std::path::PathBuf, bytes: Vec) { + let (Some(state), Some(input)) = (self.state(), self.input()) else { + return; + }; + let connections = state.services.storage.connections().clone(); + let tasks = state.services.tasks.clone(); + let file = path.clone(); + crate::runtime::spawn_held(&state.services.tasks, &*state.services.hold(), async move { + let imported = tasks + .spawn_blocking_task(move || connections.import_blocking(&path, &bytes)) + .await; + let message = match imported { + Ok(Ok(report)) if report.total() == 0 => crate::i18n::gettext("That file has no connections in it."), + Ok(Ok(report)) => crate::i18n::ngettext_f( + "Imported {n} connection.", + "Imported {n} connections.", + u32::try_from(report.total()).unwrap_or(u32::MAX), + &[("n", &report.total().to_string())], + ), + Ok(Err(error)) => { + tracing::warn!(%error, "could not import the connections"); + import_failure_message(error, &file) + } + Err(error) => { + tracing::warn!(%error, "the import task did not finish"); + crate::i18n::gettext("The connections could not be imported.") + } + }; + input.deliver(SessionWindowInput::ShowToast(message)); + }); + } + + /// The list could not be read, so the user is told rather than shown + /// an empty picker that looks like their connections vanished. + pub(super) fn show_connection_list_banner(&self) { + let (Some(state), Some(picker)) = (self.state(), self.imp().picker.get()) else { + return; + }; + let banner = picker.banner(); + let Some(content) = + crate::ui::connection_list_banner::banner_content(&state.services.saved_connections.state()) + else { + banner.set_revealed(false); + return; + }; + banner.set_title(&content.title); + banner.set_button_label(Some(&content.button_label)); + banner.set_revealed(true); + } + + /// The keyring refused, so the entry stays and the user is offered + /// another go rather than being left with orphaned secrets. Retry + /// runs the delete they already confirmed. + pub(super) fn on_secret_delete_failed(&self, id: Uuid, options: DeletionOptions) { + let Some(input) = self.input() else { + return; + }; + let toast = crate::ui::plain_toast(&crate::i18n::gettext( + "The stored passwords could not be deleted, so the connection was kept.", + )); + toast.set_button_label(Some(&crate::i18n::gettext("Retry"))); + toast.connect_button_clicked(move |_| input.deliver(SessionWindowInput::ConfirmDelete(id, options))); + self.add_toast(toast); + } + + pub(super) fn on_reset_connection_list(&self) { + let dialog = adw::AlertDialog::new( + Some(&crate::i18n::gettext("Reset Saved Connections?")), + Some(&crate::i18n::gettext( + "The unreadable file will be renamed so it can be recovered. Stored passwords stay in the keyring.", + )), + ); + dialog.add_response("cancel", &crate::i18n::gettext("Cancel")); + dialog.add_response("reset", &crate::i18n::gettext("Reset")); + dialog.set_response_appearance("reset", adw::ResponseAppearance::Destructive); + dialog.set_default_response(Some("cancel")); + dialog.set_close_response("cancel"); + let window = self.downgrade(); + dialog.connect_response(None, move |_, response| { + if response == "reset" + && let Some(window) = window.upgrade() + { + window.reset_connection_list(); + } + }); + dialog.present(Some(self)); + } + + /// Move the unreadable list aside, holding the process until the + /// rename lands so a quit right after Reset keeps it. + pub(super) fn reset_connection_list(&self) { + let (Some(state), Some(picker)) = (self.state(), self.imp().picker.get()) else { + return; + }; + let store = state.services.storage.connections().clone(); + let held = state.services.hold().acquire(); + let resetting = state + .services + .tasks + .spawn_blocking_task(move || store.reset_unreadable_blocking(std::time::SystemTime::now())); + let banner = picker.banner(); + let toasts = picker.toast_overlay(); + glib::spawn_future_local(async move { + let reset = resetting.await; + drop(held); + let error = match reset { + Ok(Ok(moved)) => { + banner.set_revealed(false); + toasts.add_toast(crate::ui::plain_toast( + &crate::ui::connection_list_banner::reset_toast_text(&moved), + )); + return; + } + Ok(Err(error)) => error.to_string(), + Err(error) => error.to_string(), + }; + tracing::warn!(%error, "could not reset the saved connections"); + toasts.add_toast(crate::ui::plain_toast(&crate::i18n::gettext_f( + "Could not reset the saved connections: {error}", + &[("error", &error)], + ))); + }); + } +} + +/// One result from work that can fail and a task that can die. +fn flatten( + outcome: Result, tablepro_session::runtime::TaskFailure>, +) -> Result { + outcome.map_err(SavedConnectionError::from).and_then(|result| result) +} + +fn ask_where_to_export(window: &SessionWindow, bytes: Vec, input: InputSender) { + let dialog = gtk::FileDialog::builder() + .title(crate::i18n::gettext("Export Connections")) + .modal(true) + .initial_name("tablepro-connections.json") + .default_filter(&json_filter()) + .filters(&json_filters()) + .build(); + dialog.save(Some(window), gio::Cancellable::NONE, move |outcome| { + let Ok(file) = outcome else { return }; + glib::spawn_future_local(async move { + let message = match crate::ui::file_write::replace_file(&file, bytes).await { + Ok(()) => crate::i18n::gettext("Connections exported."), + Err(error) => { + tracing::warn!(%error, uri = %file.uri(), "could not write the exported connections"); + crate::i18n::gettext("The connections could not be written to that file.") + } + }; + input.deliver(SessionWindowInput::ShowToast(message)); + }); + }); +} + +/// Why an import changed nothing. Only a problem with the chosen file +/// is the file's fault; the saved list may be unreadable or the disk +/// full. +fn import_failure_message(error: tablepro_storage::StorageError, file: &std::path::Path) -> String { + match error { + tablepro_storage::StorageError::DocumentUnavailable(problem) if problem.path == file => { + if problem.is_newer_version() { + crate::i18n::gettext("That export comes from a newer version of TablePro.") + } else { + crate::i18n::gettext("That file is not a TablePro connection export.") + } + } + other => crate::ui::error_text::saved_connection_error_message(&SavedConnectionError::Storage(other)), + } +} + +/// An export holds names, hosts and options, never secrets, so even +/// thousands of connections come to well under this. +const MAX_CONNECTIONS_FILE_BYTES: usize = 8 * 1024 * 1024; + +/// The chosen export, whole, or what to tell the user instead. +async fn read_connections_file(file: &gio::File) -> Result, String> { + read_capped_file(file, MAX_CONNECTIONS_FILE_BYTES) + .await + .map_err(|error| match error { + FileReadError::NotRegular => crate::i18n::gettext("Only regular files can be imported."), + FileReadError::TooLarge => crate::i18n::gettext("That file is too large to be a connection export."), + FileReadError::Io(error) => { + tracing::warn!(%error, "could not read the file to import"); + crate::i18n::gettext("That file could not be read.") + } + }) +} + +/// The filter both file dialogs open with. Built fresh each time +/// because a GtkFileFilter belongs to the dialog it is given to. +fn json_filter() -> gtk::FileFilter { + let filter = gtk::FileFilter::new(); + filter.set_name(Some(&crate::i18n::gettext("JSON files"))); + filter.add_suffix("json"); + filter.add_mime_type("application/json"); + filter +} + +fn json_filters() -> gio::ListStore { + let filters = gio::ListStore::new::(); + filters.append(&json_filter()); + filters +} + +#[cfg(test)] +mod tests { + use std::time::Duration; + + use secrecy::SecretString; + use tablepro_core::credentials::{SecretError, SecretKind, SecretVault}; + use tablepro_storage::ConnectionColor; + use tablepro_test_support::Call; + + use super::*; + use crate::test_support::{drain_main_context, finish_on_main, respond, wait_until}; + use crate::ui::session_window::tests::{TestWindow, WAIT}; + + fn read(path: &std::path::Path) -> Result, String> { + let file = gio::File::for_path(path); + crate::test_support::finish_on_main( + Duration::from_secs(10), + async move { read_connections_file(&file).await }, + ) + .expect("the read to end") + } + + #[gtk4::test] + fn an_import_from_a_pipe_is_refused_without_waiting_for_a_writer() { + let root = tempfile::tempdir().expect("tempdir"); + let path = root.path().join("connections.json"); + let made = std::process::Command::new("mkfifo") + .arg(&path) + .status() + .expect("mkfifo"); + assert!(made.success()); + + let refused = read(&path).expect_err("a refusal"); + + assert!(refused.contains("regular files"), "{refused}"); + } + + #[gtk4::test] + fn an_import_past_the_limit_is_refused() { + let root = tempfile::tempdir().expect("tempdir"); + let path = root.path().join("connections.json"); + std::fs::write(&path, vec![b' '; MAX_CONNECTIONS_FILE_BYTES + 1]).expect("write"); + + let refused = read(&path).expect_err("a refusal"); + + assert!(refused.contains("too large"), "{refused}"); + } + + #[test] + fn only_a_problem_with_the_chosen_file_blames_it() { + use tablepro_storage::{DocumentProblem, DocumentProblemKind, StorageError}; + + let chosen = std::path::Path::new("/home/u/export.json"); + let problem = |path: &str| { + StorageError::DocumentUnavailable(DocumentProblem::new(path, DocumentProblemKind::MissingVersion)) + }; + + assert_eq!( + import_failure_message(problem("/home/u/export.json"), chosen), + "That file is not a TablePro connection export." + ); + let list_unreadable = import_failure_message(problem("/home/u/.local/share/connections.json"), chosen); + assert!(!list_unreadable.contains("That file"), "{list_unreadable}"); + let disk_full = import_failure_message(StorageError::Schema("the disk is full".to_owned()), chosen); + assert!(disk_full.contains("the disk is full"), "{disk_full}"); + } + + #[gtk4::test] + fn an_export_is_read_whole() { + let root = tempfile::tempdir().expect("tempdir"); + let path = root.path().join("connections.json"); + std::fs::write(&path, br#"{"connections":[]}"#).expect("write"); + + assert_eq!(read(&path).as_deref(), Ok(&br#"{"connections":[]}"#[..])); + } + + fn open_alert(window: &SessionWindow) -> Option { + window + .dialogs() + .iter::() + .filter_map(|item| item.ok()?.downcast::().ok()) + .next() + } + + fn wait_for_alert(window: &SessionWindow) -> adw::AlertDialog { + let mut shown = None; + wait_until(WAIT, || { + shown = open_alert(window); + shown.is_some() + }) + .expect("a dialog"); + shown.expect("the dialog") + } + + /// The picker lists the test's connection, which names it in the + /// dialogs. + fn wait_for_list(window: &SessionWindow) { + let list = window + .state() + .expect("the window's state") + .services + .saved_connections + .clone(); + wait_until(WAIT, || !list.connections().is_empty()).expect("the connection list"); + } + + fn listed(test: &TestWindow, id: Uuid) -> bool { + test.services + .storage + .connections() + .load_blocking() + .expect("the list") + .iter() + .any(|saved| saved.id == id) + } + + fn store_password(test: &TestWindow) { + let (vault, id) = (test.vault.clone(), test.saved.id); + finish_on_main(WAIT, async move { + vault + .store(id, SecretKind::DatabasePassword, &SecretString::from("s3cret"), "label") + .await + }) + .expect("the store to end") + .expect("the password stored"); + } + + #[gtk4::test] + fn open_connection_shows_is_open_dialog() { + let test = TestWindow::connected("DeleteOpen"); + let other = test + .app + .open_window(crate::application::WindowLaunch::Picker) + .and_then(|window| window.downcast::().ok()) + .expect("a second window"); + wait_until(WAIT, || other.page_name() == "picker").expect("the second window's picker"); + wait_for_list(&other); + test.window.set_visible(false); + + other.send(SessionWindowInput::DeleteConnection(test.saved.id)); + let alert = wait_for_alert(&other); + assert_eq!(alert.heading().as_deref(), Some("“Fake” Is Open")); + respond(&alert, delete_connection_dialog::SHOW); + wait_until(WAIT, || test.window.is_visible()).expect("the owner to come forward"); + + assert!(listed(&test, test.saved.id), "an open connection was deleted"); + assert!( + !test.vault.calls().contains(&Call::DeleteConnection(test.saved.id)), + "an open connection's secrets were deleted" + ); + } + + #[gtk4::test] + fn a_confirmed_delete_removes_the_entry_and_its_secrets() { + let test = TestWindow::picker("DeleteConfirmed"); + wait_for_list(&test.window); + store_password(&test); + + test.window.send(SessionWindowInput::DeleteConnection(test.saved.id)); + let alert = wait_for_alert(&test.window); + assert_eq!(alert.heading().as_deref(), Some("Delete “Fake”?")); + let switch = crate::test_support::descendants(&alert.extra_child().expect("the dialog's rows")) + .into_iter() + .find_map(|widget| widget.downcast::().ok()) + .expect("the history switch"); + assert!(!switch.is_active(), "the history would go by default"); + respond(&alert, delete_connection_dialog::DELETE); + + wait_until(WAIT, || !listed(&test, test.saved.id)).expect("the entry to go"); + assert!(test.vault.is_empty(), "the secrets outlived the entry"); + wait_until(WAIT, || test.services.saved_connections.connections().is_empty()) + .expect("the picker to drop the entry"); + } + + #[gtk4::test] + fn retry_toast_reruns_deletion() { + let test = TestWindow::picker("DeleteRetry"); + wait_for_list(&test.window); + store_password(&test); + test.vault.fail_delete_connection(SecretError::Locked); + + test.window.send(SessionWindowInput::DeleteConnection(test.saved.id)); + respond(&wait_for_alert(&test.window), delete_connection_dialog::DELETE); + let mut retry = None; + wait_until(WAIT, || { + retry = test + .window + .imp() + .toasts + .borrow() + .iter() + .find(|toast| toast.button_label().is_some()) + .cloned(); + retry.is_some() + }) + .expect("a toast offering Retry"); + assert!(listed(&test, test.saved.id), "the entry went while its secrets stayed"); + assert!(!test.vault.is_empty()); + test.vault.clear_failures(); + + retry.expect("the toast").emit_by_name::<()>("button-clicked", &[]); + + wait_until(WAIT, || !listed(&test, test.saved.id)).expect("the retried delete"); + assert!(test.vault.is_empty()); + assert!(open_alert(&test.window).is_none(), "Retry asked again"); + } + + #[gtk4::test] + fn connection_list_writes_hold_the_process_until_they_land() { + let test = TestWindow::picker("ListWritesHeld"); + wait_for_list(&test.window); + wait_until(WAIT, || test.hold.live() == 0).expect("nothing else to hold the process"); + let id = test.saved.id; + let stored = || test.services.storage.connections().load_blocking().expect("the list"); + + test.window.on_set_connection_color(id, Some(ConnectionColor::Red)); + assert_eq!(test.hold.live(), 1, "the colour was written without a hold"); + wait_until(WAIT, || test.hold.live() == 0).expect("the hold to end"); + assert_eq!( + stored()[0].color, + Some(ConnectionColor::Red), + "the hold ended before the write" + ); + + test.window.on_duplicate_connection(test.saved.id); + assert_eq!(test.hold.live(), 1, "the copy was written without a hold"); + wait_until(WAIT, || test.hold.live() == 0).expect("the hold to end"); + let copy = stored() + .iter() + .find(|saved| saved.id != id) + .cloned() + .expect("the copy, written before the hold ended"); + + test.window.on_confirm_delete(copy.id, DeletionOptions::default()); + assert_eq!(test.hold.live(), 1, "the delete ran without a hold"); + wait_until(WAIT, || test.hold.live() == 0).expect("the hold to end"); + assert!(!listed(&test, copy.id), "the hold ended before the delete"); + } + + #[gtk4::test] + fn a_refused_copy_says_what_failed_and_why() { + let test = TestWindow::picker("CopyRefused"); + wait_for_list(&test.window); + store_password(&test); + test.vault.fail_store(SecretKind::DatabasePassword, SecretError::Locked); + + let toasts = || -> Vec { + test.window + .imp() + .toasts + .borrow() + .iter() + .filter_map(|toast| toast.title().map(|title| title.to_string())) + .collect() + }; + + test.window.on_duplicate_connection(test.saved.id); + wait_until(WAIT, || !toasts().is_empty()).expect("the refusal"); + + assert_eq!( + toasts(), + ["The connection could not be duplicated. The keyring is locked. Unlock it and try again."] + ); + } + + #[gtk4::test] + fn a_delete_keeps_the_connection_from_opening_until_it_ends() { + let test = TestWindow::picker("DeleteClaims"); + wait_for_list(&test.window); + let key = test.key(); + + test.window.on_confirm_delete(test.saved.id, DeletionOptions::default()); + + assert!( + test.services + .sessions + .claim(key.clone(), test.window.id() + 100) + .is_err(), + "a connect could claim the entry being deleted" + ); + wait_until(WAIT, || test.hold.live() == 0).expect("the delete to end"); + assert!(!listed(&test, test.saved.id)); + assert_eq!( + test.services.sessions.window_for(&key), + None, + "the key stayed claimed after the delete" + ); + } + + #[gtk4::test] + fn a_deleted_connection_still_listed_elsewhere_does_not_open() { + let test = TestWindow::picker("DeletedRow"); + wait_for_list(&test.window); + test.window.on_confirm_delete(test.saved.id, DeletionOptions::default()); + wait_until(WAIT, || test.hold.live() == 0).expect("the delete to end"); + + test.window + .send(SessionWindowInput::OpenSaved(Box::new(test.saved.clone()))); + wait_until(WAIT, || { + test.window + .imp() + .toasts + .borrow() + .iter() + .any(|toast| toast.title().as_deref() == Some("The connection was deleted.")) + }) + .expect("the user to be told"); + + assert_eq!(test.window.page_name(), "picker"); + assert!( + test.connector.calls().is_empty(), + "a deleted connection was connected to" + ); + assert_eq!(test.services.sessions.window_for(&test.key()), None); + } + + #[gtk4::test] + fn reset_holds_the_process_until_the_list_is_moved_aside() { + let test = TestWindow::with_script( + "ResetHeld", + crate::ui::session_window::tests::Script { + unreadable_list: true, + ..Default::default() + }, + ); + let state = || test.services.storage.connections().snapshot().state; + wait_until(WAIT, || matches!(state(), ConnectionListState::Unavailable(_))).expect("the unreadable list"); + wait_until(WAIT, || test.hold.live() == 0).expect("nothing else to hold the process"); + + test.window.reset_connection_list(); + assert_eq!(test.hold.live(), 1, "the reset ran without a hold"); + wait_until(WAIT, || test.hold.live() == 0).expect("the hold to end"); + + assert!( + matches!(state(), ConnectionListState::Ready(_)), + "the hold ended before the list was moved aside" + ); + } + + #[gtk4::test] + fn reset_closes_its_alert_once() { + let test = TestWindow::with_script( + "ResetClosedOnce", + crate::ui::session_window::tests::Script { + unreadable_list: true, + ..Default::default() + }, + ); + let state = || test.services.storage.connections().snapshot().state; + wait_until(WAIT, || matches!(state(), ConnectionListState::Unavailable(_))).expect("the unreadable list"); + test.window.on_reset_connection_list(); + wait_for_alert(&test.window); + + // With animations off, closing the alert after its click already + // closed it logs a critical, which this makes abort the test. + let previous = glib::log_set_always_fatal(glib::LogLevels::LEVEL_CRITICAL); + let answered = crate::workspace::answer(&test.window, "reset"); + crate::test_support::drain_main_context(); + glib::log_set_always_fatal(previous); + + assert!(answered, "the alert has no Reset button"); + assert!(open_alert(&test.window).is_none(), "the alert stayed open"); + wait_until(WAIT, || matches!(state(), ConnectionListState::Ready(_))).expect("the list to be moved aside"); + } + + #[gtk4::test] + fn an_edit_saved_while_the_connection_is_open_says_when_it_applies() { + let test = TestWindow::connected("EditWhileOpen"); + let other = test + .app + .open_window(crate::application::WindowLaunch::Picker) + .and_then(|window| window.downcast::().ok()) + .expect("a second window"); + wait_until(WAIT, || other.page_name() == "picker").expect("the second window's picker"); + + other.send(SessionWindowInput::ConnectionSaved(test.saved.id)); + + wait_until(WAIT, || { + other + .imp() + .toasts + .borrow() + .iter() + .any(|toast| toast.title().as_deref() == Some("Changes apply the next time you connect")) + }) + .expect("the user to be told"); + assert_eq!( + test.window.page_name(), + "workspace", + "the edit touched the open session" + ); + assert_eq!(test.connector.calls().len(), 1, "the edit reconnected the session"); + } + + #[gtk4::test] + fn an_edit_of_a_closed_connection_says_nothing() { + let test = TestWindow::picker("EditWhileClosed"); + + test.window.send(SessionWindowInput::ConnectionSaved(test.saved.id)); + drain_main_context(); + + assert!(test.window.imp().toasts.borrow().is_empty()); + assert_eq!(test.window.page_name(), "picker"); + } +} diff --git a/linux/crates/app/src/ui/session_window/crash_notice.rs b/linux/crates/app/src/ui/session_window/crash_notice.rs new file mode 100644 index 0000000000..b35ec65431 --- /dev/null +++ b/linux/crates/app/src/ui/session_window/crash_notice.rs @@ -0,0 +1,159 @@ +use std::path::{Path, PathBuf}; + +use gtk4::prelude::*; +use gtk4::subclass::prelude::*; +use gtk4::{gio, glib}; +use libadwaita as adw; + +use super::SessionWindow; +use crate::i18n::gettext; +use crate::ui::file_reveal::reveal_failure_toast; + +/// The report the last run left when it crashed, said until the user or +/// the toast's timeout dismisses it. +pub(crate) struct CrashNotice { + report: PathBuf, + shown: Option<(adw::Toast, glib::SignalHandlerId)>, +} + +impl SessionWindow { + pub(crate) fn announce_crash(&self, report: PathBuf) { + self.withdraw_crash_toast(); + self.imp() + .crash_notice + .replace(Some(CrashNotice { report, shown: None })); + self.show_crash_notice(); + } + + /// A toast lives in the overlay of the page it was added to, so a + /// restored session, which goes from the picker through connecting + /// to its workspace, would take an unread one away. Each page shown + /// gets it again until it is dismissed. + pub(super) fn show_crash_notice(&self) { + self.withdraw_crash_toast(); + let Some(report) = self + .imp() + .crash_notice + .borrow() + .as_ref() + .map(|notice| notice.report.clone()) + else { + return; + }; + let toast = adw::Toast::builder() + .title(gettext("TablePro quit unexpectedly last time")) + .button_label(gettext("Show Report")) + .use_markup(false) + .build(); + let window = self.downgrade(); + toast.connect_button_clicked(move |_| { + if let Some(window) = window.upgrade() { + window.reveal_crash_report(&report); + } + }); + let window = self.downgrade(); + let dismissed = toast.connect_dismissed(move |_| { + if let Some(window) = window.upgrade() { + window.imp().crash_notice.take(); + } + }); + if let Some(notice) = self.imp().crash_notice.borrow_mut().as_mut() { + notice.shown = Some((toast.clone(), dismissed)); + } + self.add_toast(toast); + } + + /// Takes the toast off the page it is on without counting that as + /// the user dismissing it. An overlay that goes away dismisses its + /// toasts, so this runs before the old page can. + fn withdraw_crash_toast(&self) { + let shown = self + .imp() + .crash_notice + .borrow_mut() + .as_mut() + .and_then(|notice| notice.shown.take()); + if let Some((toast, dismissed)) = shown { + toast.disconnect(dismissed); + toast.dismiss(); + } + } + + fn reveal_crash_report(&self, report: &Path) { + let Some(state) = self.imp().state.get() else { + return; + }; + let revealing = state + .services + .file_revealer + .reveal(gio::File::for_path(report), Some(self.clone().upcast())); + let window = self.downgrade(); + glib::spawn_future_local(async move { + let revealed = revealing.await; + if let (Some(toast), Some(window)) = (reveal_failure_toast(&revealed), window.upgrade()) { + window.add_toast(toast); + } + }); + } +} + +#[cfg(test)] +mod tests { + use std::rc::Rc; + + use super::super::SessionWindowInput; + use super::super::tests::{Script, TestWindow, WAIT}; + use super::*; + use crate::test_support::{descendants, drain_main_context, wait_until}; + use crate::ui::file_reveal::RecordingRevealer; + + const TITLE: &str = "TablePro quit unexpectedly last time"; + + fn said(window: &SessionWindow) -> usize { + window + .imp() + .toasts + .borrow() + .iter() + .filter(|toast| toast.title().as_deref() == Some(TITLE)) + .count() + } + + fn show_report(window: &SessionWindow) -> Option { + descendants(window) + .into_iter() + .filter_map(|widget| widget.downcast::().ok()) + .find(|button| button.label().as_deref() == Some("Show Report") && button.is_mapped()) + } + + #[gtk4::test] + fn a_crash_report_is_said_on_each_page_until_the_user_opens_it() { + let revealer = Rc::new(RecordingRevealer::succeeding()); + let test = TestWindow::with_script( + "CrashNotice", + Script { + revealer: Some(revealer.clone()), + ..Script::default() + }, + ); + let report = test.dir().join("crash-20261017T000000Z-1.txt"); + + test.window.announce_crash(report.clone()); + assert_eq!(said(&test.window), 1); + + test.window + .send(SessionWindowInput::OpenSaved(Box::new(test.saved.clone()))); + test.wait_for_page("workspace"); + assert!(said(&test.window) > 1, "the toast stayed on the picker"); + wait_until(WAIT, || show_report(&test.window).is_some()).expect("the toast on the workspace"); + + show_report(&test.window).expect("the button").emit_clicked(); + drain_main_context(); + + assert_eq!(revealer.revealed(), [report]); + assert!(test.window.imp().crash_notice.borrow().is_none()); + let before = said(&test.window); + test.window.show_crash_notice(); + assert_eq!(said(&test.window), before, "a dismissed notice came back"); + } +} diff --git a/linux/crates/app/src/ui/session_window/imp.rs b/linux/crates/app/src/ui/session_window/imp.rs new file mode 100644 index 0000000000..391937f241 --- /dev/null +++ b/linux/crates/app/src/ui/session_window/imp.rs @@ -0,0 +1,112 @@ +use std::cell::{Cell, OnceCell, RefCell}; +use std::rc::Rc; + +use gtk4 as gtk; +use gtk4::glib; +use gtk4::subclass::prelude::*; +use libadwaita as adw; +use libadwaita::subclass::prelude::*; +use tablepro_session::{ConnectAttempts, SessionKey}; + +use super::crash_notice::CrashNotice; +use super::locate_file::LocateChooser; +use super::picker_page::PickerPage; +use super::session_page::SessionPage; +use super::work_notifier::WorkNotifier; +use super::{SessionWindowInput, WindowState}; +use crate::runtime::InputSender; +use crate::services::file_location::LocationClassifier; +use crate::services::saved_connection_list::StateObserverId; +use crate::ui::connect_dialog::ConnectDialog; +use crate::ui::welcome_view::WelcomeView; +use crate::workspace::{CloseGuards, WindowInhibitor}; + +#[derive(Default, gtk::CompositeTemplate)] +#[template(resource = "/app/tablepro/TablePro/ui/session-window.ui")] +pub struct SessionWindow { + /// The toasts of the page on screen. Each page has its own, under + /// its header bar, so a workspace's sit inside the tab overview. + pub(super) page_toasts: RefCell>, + /// What the application handed the window, set once it is added. + pub(super) state: OnceCell, + pub(super) input: OnceCell>, + /// Handles the queued input. Aborted at shutdown, which closes a + /// connect still queued and drops every other reply. + pub(super) pump: RefCell>>, + /// Ends the credential prompt loop with the window. + pub(super) prompt_presenter: RefCell>>, + pub(super) page: RefCell, + /// Which connect attempt is current, so a late result is discarded. + pub(super) attempts: ConnectAttempts, + /// The recorded session the window was opened to restore, until the + /// saved connection is looked up or the file resolved. + pub(super) restoring: RefCell>, + /// One close prompt at a time, for a tab, the window or a + /// disconnect. + pub(super) close_guards: OnceCell>, + /// Holds logout back while the workspace has something to lose. + pub(super) inhibitor: RefCell>, + pub(super) dirty: Cell, + pub(super) saving: Cell, + pub(super) welcome: RefCell>, + /// Shows the banner while the shared connection list cannot be read. + pub(super) list_observer: Cell>, + pub(super) picker: OnceCell, + pub(super) dialog: RefCell>, + /// The connect dialog was asked for and opens once the SSH keys are + /// looked for. + pub(super) dialog_opening: Cell, + pub(super) shut_down: Cell, + /// The crash the last run ended in, until its toast is dismissed. + pub(super) crash_notice: RefCell>, + /// Every toast shown, which the overlay does not list. + #[cfg(test)] + pub(super) toasts: RefCell>, + /// A file handed to the window is being resolved, so the window is + /// not free for another one. + pub(super) opening_file: Cell, + /// The window opened for a file and has shown nothing else yet, so + /// it closes again when another window already has that file. + pub(super) opened_for_file: Cell, + /// Reads which filesystem a database file is on. `None` reads the + /// real one; a test stands in for a network share. + pub(super) classify_location: Cell>, + /// Asks where a lost database file is now. `None` opens the file + /// chooser; a test answers for the user. + pub(super) locate_chooser: RefCell>>, + /// Tells the desktop when work ends in a tab the user is not + /// looking at. + pub(super) notifier: RefCell>, + /// Whether the window counts as the active one, which a test session + /// with no window manager cannot decide. + #[cfg(test)] + pub(super) active_for_test: Cell>, +} + +#[glib::object_subclass] +impl ObjectSubclass for SessionWindow { + const NAME: &'static str = "TableProSessionWindow"; + type Type = super::SessionWindow; + type ParentType = adw::ApplicationWindow; + + fn class_init(klass: &mut Self::Class) { + klass.bind_template(); + } + + fn instance_init(object: &glib::subclass::InitializingObject) { + object.init_template(); + } +} + +impl ObjectImpl for SessionWindow { + /// A window dropped without its close request still closes what it + /// holds. + fn dispose(&self) { + self.obj().shutdown(); + } +} + +impl WidgetImpl for SessionWindow {} +impl WindowImpl for SessionWindow {} +impl ApplicationWindowImpl for SessionWindow {} +impl AdwApplicationWindowImpl for SessionWindow {} diff --git a/linux/crates/app/src/ui/session_window/locate_file.rs b/linux/crates/app/src/ui/session_window/locate_file.rs new file mode 100644 index 0000000000..21978df47d --- /dev/null +++ b/linux/crates/app/src/ui/session_window/locate_file.rs @@ -0,0 +1,396 @@ +//! A saved SQLite connection whose file is gone: find the file again, +//! save its new path and connect to it. + +use std::path::{Path, PathBuf}; +use std::rc::Rc; + +use futures::FutureExt; +use futures::future::LocalBoxFuture; +use gtk4 as gtk; +use gtk4::glib; +use gtk4::prelude::*; +use gtk4::subclass::prelude::*; +use libadwaita as adw; +use libadwaita::prelude::*; +use tablepro_session::ConnectSource; +use tablepro_session::connect::{ConnectionWrites, StoreConnectionWrites}; +use tablepro_storage::SavedConnection; +use uuid::Uuid; + +use super::session_page::SessionPage; +use super::{SessionWindow, SessionWindowInput}; +use crate::runtime::DeliverInput; + +const CANCEL: &str = "cancel"; +const LOCATE: &str = "locate"; + +/// Where the user says a lost database file is now. +pub(crate) trait LocateChooser { + fn choose(&self, parent: >k::Window, missing: &Path) -> LocalBoxFuture<'static, Option>; +} + +/// The file chooser, opened where the file used to be. +struct FileDialogChooser; + +impl LocateChooser for FileDialogChooser { + fn choose(&self, parent: >k::Window, missing: &Path) -> LocalBoxFuture<'static, Option> { + let dialog = crate::ui::connect_dialog::sqlite_file_dialog(&crate::i18n::gettext("Locate Database File")); + // An initial file is read synchronously, which hangs on a dead + // mount; a folder is only stored. + if let Some(folder) = missing.parent() { + dialog.set_initial_folder(Some(&gio::File::for_path(folder))); + } + let parent = parent.clone(); + async move { + dialog + .open_future(Some(&parent)) + .await + .ok() + .and_then(|file| file.path()) + } + .boxed_local() + } +} + +fn not_found_dialog(missing: &Path) -> adw::AlertDialog { + let dialog = adw::AlertDialog::new( + Some(&crate::i18n::gettext("Database File Not Found")), + Some(&crate::i18n::gettext_f( + "{file} could not be found. If it was moved or renamed, locate it to update the connection.", + &[("file", &missing.display().to_string())], + )), + ); + dialog.add_responses(&[ + (CANCEL, &crate::i18n::gettext("_Cancel")), + (LOCATE, &crate::i18n::gettext("_Locate…")), + ]); + dialog.set_response_appearance(LOCATE, adw::ResponseAppearance::Suggested); + dialog.set_default_response(Some(LOCATE)); + dialog.set_close_response(CANCEL); + dialog +} + +impl SessionWindow { + /// Offer to find the file a saved connection lost. The failed page + /// stays behind the question, so Cancel leaves Retry and Edit + /// Connection. + pub(super) fn offer_locate(&self, id: Uuid, missing: PathBuf) { + let dialog = not_found_dialog(&missing); + let window = self.downgrade(); + glib::spawn_future_local(async move { + let Some(window) = window.upgrade() else { + return; + }; + if crate::workspace::respond(&dialog, window.upcast_ref()).await != LOCATE { + return; + } + let chooser = window + .imp() + .locate_chooser + .borrow() + .clone() + .unwrap_or_else(|| Rc::new(FileDialogChooser)); + let chosen = chooser.choose(window.upcast_ref(), &missing).await; + if let Some(path) = chosen { + window.send(SessionWindowInput::FileLocated { id, path }); + } + }); + } + + /// Save the file's new path on the connection the failed page is + /// about, then connect to it once the write lands, so the entry and + /// the open session name the same file. + pub(super) fn on_file_located(&self, id: Uuid, path: PathBuf) { + let saved = match &*self.imp().page.borrow() { + SessionPage::ConnectFailed { + source: ConnectSource::Saved(saved), + .. + } if saved.id == id => (**saved).clone(), + _ => return, + }; + let (Some(state), Some(input)) = (self.state(), self.input()) else { + return; + }; + // Deleted in another window while the question was open: writing + // the page's copy back would bring it back without its secrets. + let Some(mut located) = self.saved_as_stored(&saved) else { + self.show_page(SessionPage::Picker); + self.show_toast(&crate::i18n::gettext("The connection was deleted.")); + return; + }; + located.database = path.display().to_string(); + let writes = StoreConnectionWrites::new( + state.services.storage.connections().clone(), + state.services.tasks.clone(), + ); + let update = located.clone(); + let held = state.services.hold().acquire(); + crate::runtime::spawn_and_deliver( + &state.services.tasks, + async move { writes.upsert(update).await }, + move |written| { + drop(held); + let error = match written { + Ok(Ok(())) => None, + Ok(Err(error)) => Some(error.to_string()), + Err(failure) => Some(failure.to_string()), + }; + if let Some(error) = error { + tracing::warn!(%error, "could not save the database file's new location"); + input.deliver(SessionWindowInput::ShowToast(crate::i18n::gettext( + "The new location could not be saved.", + ))); + } + input.deliver(SessionWindowInput::LocatedSaved(Box::new(located))); + }, + ); + } + + /// Connect to the located file, unless the user left the failed + /// page while its path was saved. + pub(super) fn on_located_saved(&self, located: SavedConnection) { + let current = match &*self.imp().page.borrow() { + SessionPage::ConnectFailed { + source: ConnectSource::Saved(saved), + .. + } => saved.id == located.id, + _ => false, + }; + if !current { + return; + } + let SessionPage::ConnectFailed { claim, .. } = self.imp().page.replace(SessionPage::Picker) else { + return; + }; + self.begin_connect(claim, ConnectSource::Saved(Box::new(located))); + } +} + +#[cfg(test)] +mod tests { + use tablepro_core::{DriverError, FileEndpoint}; + use tablepro_session::fakes::FakeEstablish; + use tablepro_session::{ConnectError, RequestTarget}; + + use super::super::tests::{Script, TestWindow, WAIT, dialog_submission}; + use super::*; + use crate::test_support::wait_until; + use crate::workspace::{answer, visible_alert}; + + /// Answers with a file the test made, as a user picking it would. + struct ChosenFile(PathBuf); + + impl LocateChooser for ChosenFile { + fn choose(&self, _parent: >k::Window, _missing: &Path) -> LocalBoxFuture<'static, Option> { + let path = self.0.clone(); + async move { Some(path) }.boxed_local() + } + } + + fn not_found() -> Script { + Script { + establish: vec![FakeEstablish::Fail(ConnectError::Driver(DriverError::FileNotFound { + path: PathBuf::from("/srv/gone.db"), + }))], + ..Script::default() + } + } + + fn opened_path(call: &tablepro_session::fakes::EstablishCall) -> PathBuf { + match &call.request.target { + RequestTarget::File(endpoint) => { + assert_eq!(endpoint.mode(), tablepro_core::FileOpenMode::OpenExisting); + endpoint.path().to_path_buf() + } + RequestTarget::Network { .. } => panic!("a file connection opened a network target"), + } + } + + #[gtk4::test] + fn file_not_found_offers_locate_and_retries_with_new_path() { + let test = TestWindow::with_script("Locate", not_found()); + let moved = tempfile::tempdir().expect("tempdir"); + let found = moved.path().join("moved.db"); + std::fs::write(&found, b"").expect("seed"); + test.window + .imp() + .locate_chooser + .replace(Some(Rc::new(ChosenFile(found.clone())))); + + test.window + .send(SessionWindowInput::OpenSaved(Box::new(test.saved.clone()))); + test.wait_for_page("connect-failed"); + wait_until(WAIT, || visible_alert(&test.window).is_some()).expect("the not found question"); + let alert = visible_alert(&test.window).expect("the alert"); + assert_eq!(alert.heading().as_deref(), Some("Database File Not Found")); + assert_eq!(alert.default_response().as_deref(), Some(LOCATE)); + + assert!(answer(&test.window, LOCATE)); + test.wait_for_page("workspace"); + + let calls = test.connector.calls(); + assert_eq!(calls.len(), 2); + assert_eq!(opened_path(&calls[1]), found); + let stored = test + .services + .storage + .connections() + .load_blocking() + .expect("the connection list"); + let entry = stored + .iter() + .find(|entry| entry.id == test.saved.id) + .expect("the entry"); + assert_eq!(entry.database, found.display().to_string()); + assert_eq!(entry.name, test.saved.name); + } + + #[gtk4::test] + fn cancel_leaves_the_failed_page_and_saves_nothing() { + let test = TestWindow::with_script("LocateCancel", not_found()); + + test.window + .send(SessionWindowInput::OpenSaved(Box::new(test.saved.clone()))); + test.wait_for_page("connect-failed"); + wait_until(WAIT, || visible_alert(&test.window).is_some()).expect("the not found question"); + let body = visible_alert(&test.window) + .map(|alert| alert.body().to_string()) + .unwrap_or_default(); + assert!(body.contains("/srv/gone.db"), "{body}"); + assert!(answer(&test.window, CANCEL)); + wait_until(WAIT, || visible_alert(&test.window).is_none()).expect("the question to close"); + crate::test_support::drain_main_context(); + + assert_eq!(test.window.page_name(), "connect-failed"); + assert_eq!(test.connector.calls().len(), 1); + let stored = test + .services + .storage + .connections() + .load_blocking() + .expect("the connection list"); + assert_eq!( + stored + .iter() + .find(|entry| entry.id == test.saved.id) + .map(|entry| &entry.database), + Some(&test.saved.database) + ); + } + + #[gtk4::test] + fn another_failure_offers_no_locate() { + let test = TestWindow::with_script( + "LocateOther", + Script { + establish: vec![FakeEstablish::Fail(ConnectError::Driver(DriverError::Auth { + diagnostics: None, + }))], + ..Script::default() + }, + ); + + test.window + .send(SessionWindowInput::OpenSaved(Box::new(test.saved.clone()))); + test.wait_for_page("connect-failed"); + crate::test_support::drain_main_context(); + + assert!(visible_alert(&test.window).is_none()); + } + + #[gtk4::test] + fn locate_after_the_entry_was_deleted_returns_to_the_picker_and_saves_nothing() { + let test = TestWindow::with_script("LocateDeleted", not_found()); + let moved = tempfile::tempdir().expect("tempdir"); + test.window + .imp() + .locate_chooser + .replace(Some(Rc::new(ChosenFile(moved.path().join("moved.db"))))); + let store = test.services.storage.connections().clone(); + + test.window + .send(SessionWindowInput::OpenSaved(Box::new(test.saved.clone()))); + test.wait_for_page("connect-failed"); + wait_until(WAIT, || visible_alert(&test.window).is_some()).expect("the not found question"); + store.remove_blocking(test.saved.id).expect("delete the entry"); + assert!(answer(&test.window, LOCATE)); + test.wait_for_page("picker"); + crate::test_support::drain_main_context(); + + assert_eq!(test.connector.calls().len(), 1); + assert_eq!(test.services.sessions.window_for(&test.key()), None); + let stored = store.load_blocking().expect("the connection list"); + assert!( + stored.iter().all(|entry| entry.id != test.saved.id), + "the deleted entry came back" + ); + } + + #[gtk4::test] + fn a_file_located_for_another_connection_is_ignored() { + let test = TestWindow::with_script("LocateOtherId", not_found()); + let moved = tempfile::tempdir().expect("tempdir"); + test.window + .send(SessionWindowInput::OpenSaved(Box::new(test.saved.clone()))); + test.wait_for_page("connect-failed"); + wait_until(WAIT, || visible_alert(&test.window).is_some()).expect("the not found question"); + assert!(answer(&test.window, CANCEL)); + + test.window.send(SessionWindowInput::FileLocated { + id: Uuid::new_v4(), + path: moved.path().join("moved.db"), + }); + + assert!( + wait_until(std::time::Duration::from_millis(500), || test.connector.calls().len() + > 1) + .is_err(), + "connected to a file located for another connection" + ); + assert_eq!(test.window.page_name(), "connect-failed"); + let stored = test + .services + .storage + .connections() + .load_blocking() + .expect("the connection list"); + assert_eq!( + stored + .iter() + .find(|entry| entry.id == test.saved.id) + .map(|entry| &entry.database), + Some(&test.saved.database) + ); + } + + #[gtk4::test] + fn a_file_handed_over_that_is_not_found_offers_no_locate() { + let test = TestWindow::with_script("LocateFileSource", not_found()); + let dir = tempfile::tempdir().expect("tempdir"); + let database = dir.path().join("app.db"); + std::fs::write(&database, b"").expect("seed"); + + test.window + .send(SessionWindowInput::OpenFile(gio::File::for_path(&database))); + test.wait_for_page("connect-failed"); + crate::test_support::drain_main_context(); + + assert!(visible_alert(&test.window).is_none()); + } + + #[gtk4::test] + fn a_dialog_connect_that_is_not_found_offers_no_locate() { + let test = TestWindow::with_script("LocateIntent", not_found()); + let dir = tempfile::tempdir().expect("tempdir"); + let endpoint = FileEndpoint::open_existing(dir.path().join("app.db")).expect("an endpoint"); + + test.window + .send(SessionWindowInput::ConnectFromDialog(Box::new(dialog_submission( + endpoint, + )))); + test.wait_for_page("connect-failed"); + crate::test_support::drain_main_context(); + + assert!(visible_alert(&test.window).is_none()); + } +} diff --git a/linux/crates/app/src/ui/session_window/mod.rs b/linux/crates/app/src/ui/session_window/mod.rs new file mode 100644 index 0000000000..1d2deebc12 --- /dev/null +++ b/linux/crates/app/src/ui/session_window/mod.rs @@ -0,0 +1,1216 @@ +//! One window per connection session. It opens on the picker, shows +//! the connect while it runs and the failure when it fails, then the +//! session's workspace. A disconnect tears the workspace down in order +//! and returns to the picker; it never moves to another session. + +mod application_notification_sink; +mod connect_failed_page; +mod connect_failure_view; +mod connect_flow; +mod connecting_page; +mod connections; +mod crash_notice; +mod imp; +mod locate_file; +mod notification_request; +mod notification_sink; +mod notifications; +mod picker_page; +mod resolve_file_identity; +mod secret_retry; +mod session_page; +mod session_restore; +mod session_teardown; +mod session_window_command; +mod session_window_input; +mod sqlite_location_dialog; +#[cfg(test)] +pub(crate) mod tests; +mod window_actions; +mod work_notifier; + +use std::rc::Rc; + +use futures::FutureExt; +use futures::future::BoxFuture; +use gtk4 as gtk; +use gtk4::glib; +use gtk4::prelude::*; +use gtk4::subclass::prelude::*; +use libadwaita as adw; +use libadwaita::prelude::AdwApplicationWindowExt; + +use tablepro_core::{DatabaseDriver, DriverError, TransportClass}; +use tablepro_session::connect::StoreConnectionWrites; +use tablepro_session::registry::FileIdentity; +use tablepro_session::runtime::ProcessHold; +use tablepro_session::{ + ClaimError, ConnectError, ConnectIntent, ConnectSource, MonitorSignal, PendingSession, RequestTarget, Session, + SessionClaim, SessionKey, +}; +use tablepro_storage::{CredentialDemand, SavedConnection}; + +pub(super) use self::connect_failed_page::ConnectFailedPage; +use self::connect_failure_view::ConnectFailureView; +pub(super) use self::connecting_page::ConnectingPage; +pub(super) use self::picker_page::PickerPage; +use self::resolve_file_identity::{FileResolveError, FileTarget, ResolvedFile, resolve_file_blocking}; +use self::session_page::SessionPage; +use self::session_window_command::{AttemptError, ConnectFailure, Connected, SessionWindowCommand}; +pub(crate) use self::session_window_input::SessionWindowInput; +use self::sqlite_location_dialog::FileCheck; +use crate::application::{AppServices, WindowHost, WindowLaunch}; +use crate::runtime::{DeliverInput, InputSender}; +use crate::services::session_context::SessionContext; +use crate::ui::connect_dialog::{ConnectDialogMode, ConnectSubmission}; +use crate::ui::welcome_view::WelcomeView; +use crate::ui::workspace::{Workspace, WorkspaceInit, WorkspaceInput}; +use crate::workspace::{ + ApplicationInhibit, CloseDecision, CloseGuards, CloseReason, InhibitReason, WindowInhibitor, WorkspaceHandle, +}; + +glib::wrapper! { + pub struct SessionWindow(ObjectSubclass) + @extends adw::ApplicationWindow, gtk::ApplicationWindow, gtk::Window, gtk::Widget, + @implements gio::ActionGroup, gio::ActionMap, gtk::Accessible, gtk::Buildable, + gtk::ConstraintTarget, gtk::Native, gtk::Root, gtk::ShortcutManager; +} + +/// What the application hands each window it opens. +pub struct SessionWindowInit { + pub services: Rc, + pub host: Rc, + pub launch: WindowLaunch, +} + +/// What a window keeps from its init for as long as it is open. +pub(crate) struct WindowState { + services: Rc, + /// Reaches the application's other windows. + host: Rc, + context: Rc, +} + +impl Default for SessionWindow { + fn default() -> Self { + Self::new() + } +} + +impl SessionWindow { + pub fn new() -> Self { + glib::Object::new() + } + + /// Start the window on `init.launch`. Called once the window is + /// added to the application, which gives it the id its session is + /// claimed under. + pub fn start(&self, init: SessionWindowInit) { + let SessionWindowInit { services, host, launch } = init; + let imp = self.imp(); + let (context, prompts) = services.window_context(); + let (input, receiver) = crate::runtime::input_queue::(); + let _ = imp.input.set(input.clone()); + let (tasks, hold) = (services.tasks.clone(), services.hold()); + imp.pump.replace(Some(crate::runtime::pump_inputs_with_leftovers( + self, + receiver, + |window, message| window.handle_input(message), + move |message| { + if let Some(pending) = unadopted(message) { + discard_pending(pending, &tasks, &*hold); + } + }, + ))); + if crate::config::profile() == crate::config::Profile::Development { + self.add_css_class("devel"); + } + crate::ui::window_geometry::restore(self.upcast_ref(), &services.settings); + window_actions::install(self.upcast_ref(), &input); + self.install_notifier(&input); + imp.prompt_presenter + .replace(Some(crate::ui::credential_prompt::CredentialPromptPresenter::start( + prompts, self, + ))); + + let guards = CloseGuards::new(); + let window = self.downgrade(); + guards.connect_window( + self, + move || window.upgrade().and_then(|window| window.workspace_handle()), + SessionWindow::on_close_approved, + ); + let _ = imp.close_guards.set(guards); + imp.inhibitor + .replace(Some(WindowInhibitor::new(ApplicationInhibit::new(self)))); + + let list = services.saved_connections.clone(); + let welcome = WelcomeView::new(list.clone(), Some(services.tasks.clone())); + crate::ui::picker::install_picker_actions(&welcome, list.clone(), input.clone()); + let picker = PickerPage::new(&welcome, self.application().as_ref()); + let reset_input = input.clone(); + picker + .banner() + .connect_button_clicked(move |_| reset_input.deliver(SessionWindowInput::ResetConnectionList)); + let window = self.downgrade(); + imp.list_observer.set(Some(list.connect_state_changed(move |_| { + if let Some(window) = window.upgrade() { + window.show_connection_list_banner(); + } + }))); + imp.welcome.replace(Some(welcome)); + let _ = imp.picker.set(picker); + let _ = imp.state.set(WindowState { + services, + host, + context, + }); + + self.show_page(SessionPage::Picker); + self.show_connection_list_banner(); + input.deliver(SessionWindowInput::LoadConnections); + match launch { + WindowLaunch::Picker => {} + WindowLaunch::Connect(saved) => input.deliver(SessionWindowInput::OpenSaved(saved)), + WindowLaunch::OpenFile(file) => { + imp.opening_file.set(true); + imp.opened_for_file.set(true); + input.deliver(SessionWindowInput::OpenFile(file)); + } + WindowLaunch::Restore(key) => { + self.imp().restoring.replace(Some(key.clone())); + input.deliver(SessionWindowInput::Restore(key)); + } + } + } + + /// The banner over the picker that says the list cannot be read. + #[cfg(test)] + pub(crate) fn connection_list_banner(&self) -> Option { + self.imp().picker.get().map(PickerPage::banner) + } + + /// What the window's actions, pages and background work send it. + pub(crate) fn input(&self) -> Option> { + self.imp().input.get().cloned() + } + + pub(crate) fn send(&self, message: SessionWindowInput) { + if let Some(input) = self.imp().input.get() { + input.deliver(message); + } + } + + /// On the picker with nothing under way, so a file handed to the + /// application can open here. + pub(crate) fn is_idle(&self) -> bool { + let imp = self.imp(); + self.page_is(|page| matches!(page, SessionPage::Picker)) + && !imp.opening_file.get() + && !imp.dialog_opening.get() + && imp.dialog.try_borrow().is_ok_and(|dialog| dialog.is_none()) + } + + /// Whether the window's claim is a session to reopen: it shows the + /// session or is connecting it. A failed connect's claim is not. + pub(crate) fn keeps_session(&self) -> bool { + self.page_is(|page| matches!(page, SessionPage::Connecting { .. } | SessionPage::Workspace { .. })) + } + + /// Whether the window holds logout back now, for its unsaved + /// changes or for an export its workspace is writing. + pub(crate) fn is_inhibiting(&self) -> bool { + let exporting = self + .workspace() + .and_then(|workspace| workspace.exports()) + .is_some_and(|exports| exports.is_inhibiting()); + exporting + || self + .imp() + .inhibitor + .borrow() + .as_ref() + .is_some_and(WindowInhibitor::is_held) + } + + /// A test session has no session manager to grant an inhibition. + #[cfg(test)] + pub(crate) fn set_inhibitor(&self, inhibitor: WindowInhibitor) { + self.imp().inhibitor.replace(Some(inhibitor)); + } + + /// Stop the window's input and close what it still holds: the + /// session it shows, or the connect it runs. Runs once, from the + /// application as the window goes, or from dispose. + pub fn shutdown(&self) { + let imp = self.imp(); + if imp.shut_down.replace(true) { + return; + } + if let Some(pump) = imp.pump.take() { + pump.abort(); + } + if let Some(notifier) = imp.notifier.take() { + notifier.withdraw_all(); + } + if let Some(presenter) = imp.prompt_presenter.take() { + presenter.abort(); + } + self.release_session(); + drop(imp.dialog.take()); + if let Some(welcome) = imp.welcome.take() { + welcome.shutdown(); + } + if let (Some(observer), Some(state)) = (imp.list_observer.take(), imp.state.get()) { + state.services.saved_connections.disconnect_state_changed(observer); + } + drop(imp.inhibitor.take()); + } + + fn state(&self) -> Option<&WindowState> { + self.imp().state.get() + } + + fn close_guards(&self) -> Option<&Rc> { + self.imp().close_guards.get() + } + + fn page_is(&self, matches: impl FnOnce(&SessionPage) -> bool) -> bool { + self.imp().page.try_borrow().is_ok_and(|page| matches(&page)) + } + + /// The page's workspace, while the window shows one. + pub(crate) fn workspace(&self) -> Option { + match &*self.imp().page.try_borrow().ok()? { + SessionPage::Workspace { workspace, .. } => Some(workspace.clone()), + _ => None, + } + } + + /// What the close prompts ask about, with a value typed into a cell + /// counted among the unsaved changes. + fn workspace_handle(&self) -> Option { + let workspace = self.workspace()?; + workspace.commit_cell_edits(); + workspace.handle() + } + + fn handle_input(&self, message: SessionWindowInput) { + match message { + SessionWindowInput::OpenSaved(saved) => self.on_open_saved(*saved), + SessionWindowInput::OpenFile(file) => self.on_open_file(file), + SessionWindowInput::FileResolved(resolved) => self.on_file_resolved(*resolved), + SessionWindowInput::Restore(key) => self.on_restore(key), + SessionWindowInput::RestoreSaved { id, saved } => self.on_restore_saved(id, saved.map(|saved| *saved)), + SessionWindowInput::OpenConnectDialog => { + self.present_connect_dialog(ConnectDialogMode::Create, None, None); + } + SessionWindowInput::ConnectFromDialog(submission) => self.on_connect_from_dialog(*submission), + SessionWindowInput::DialogClosed => drop(self.imp().dialog.take()), + SessionWindowInput::CancelConnect => self.on_cancel_connect(), + SessionWindowInput::RetryConnect => self.on_retry_connect(), + SessionWindowInput::RetryWithNewSecret(kind) => self.on_retry_with_new_secret(kind), + SessionWindowInput::EditConnection => self.on_edit_connection(), + SessionWindowInput::EditSavedConnection(id) => self.on_edit_saved_connection(id), + SessionWindowInput::ConnectionSaved(id) => self.on_connection_saved(id), + SessionWindowInput::Disconnect => self.on_disconnect(), + SessionWindowInput::TeardownApproved => { + if self.tear_down() { + self.show_page(SessionPage::Picker); + tracing::info!("disconnected"); + } + } + SessionWindowInput::CloseCurrent => self.on_close_current(), + SessionWindowInput::Reconnect => { + if let SessionPage::Workspace { session, .. } = &*self.imp().page.borrow() { + session.signal(MonitorSignal::RetryNow); + } + } + SessionWindowInput::ResetWorkspaceLayout => { + if let Some(input) = self.workspace().and_then(|workspace| workspace.input()) { + input.deliver(WorkspaceInput::ResetWorkspaceLayout); + } + } + SessionWindowInput::CloseRequested(CloseReason::Quit(reply)) => { + if let Some(guards) = self.close_guards() { + guards.request_window_close(self, move |decision: CloseDecision| { + // A quit that went away no longer needs the answer. + let _ = reply.send(decision); + }); + } + } + // Only the workspace on screen speaks for the window: one + // torn down has nothing left to lose. + SessionWindowInput::DirtyChanged(dirty) => { + if self.page_is(|page| matches!(page, SessionPage::Workspace { .. })) { + self.imp().dirty.set(dirty); + self.refresh_inhibitor(); + } + } + SessionWindowInput::SavesInFlightChanged(count) => { + if self.page_is(|page| matches!(page, SessionPage::Workspace { .. })) { + self.imp().saving.set(count > 0); + self.refresh_inhibitor(); + } + } + SessionWindowInput::TitleChanged(title) => { + if self.page_is(|page| matches!(page, SessionPage::Workspace { .. })) { + self.set_title(Some(&title)); + } + } + SessionWindowInput::WorkCompleted(work) => self.on_work_completed(work), + SessionWindowInput::TabsChanged => self.on_tabs_changed(), + SessionWindowInput::FocusTab(tab) => self.on_focus_tab(tab), + SessionWindowInput::Command(command) => match *command { + SessionWindowCommand::Attempt { generation, result } => self.on_attempt(generation, result), + }, + SessionWindowInput::LocationChosen { generation, choice } => self.on_location_chosen(generation, choice), + SessionWindowInput::FileLocated { id, path } => self.on_file_located(id, path), + SessionWindowInput::LocatedSaved(located) => self.on_located_saved(*located), + SessionWindowInput::LoadConnections => self.on_load_connections(), + SessionWindowInput::ResetConnectionList => self.on_reset_connection_list(), + SessionWindowInput::SecretDeleteFailed(id, options) => self.on_secret_delete_failed(id, options), + SessionWindowInput::DeleteConnection(id) => self.on_delete_connection(id), + SessionWindowInput::ConfirmDelete(id, options) => self.on_confirm_delete(id, options), + SessionWindowInput::DuplicateConnection(id) => self.on_duplicate_connection(id), + SessionWindowInput::SetConnectionColor(id, color) => self.on_set_connection_color(id, color), + SessionWindowInput::SetConnectionGroup(id, group) => self.on_set_connection_group(id, group), + SessionWindowInput::ExportConnections => self.on_export_connections(), + SessionWindowInput::ImportConnections => self.on_import_connections(), + SessionWindowInput::ConnectionsFileRead { path, bytes } => self.on_connections_file_read(path, bytes), + SessionWindowInput::ShowToast(message) => self.show_toast(&message), + } + } + + fn show_toast(&self, message: &str) { + self.add_toast(crate::ui::plain_toast(message)); + } + + /// On the page on screen: a toast for a page left behind would never + /// show. + fn add_toast(&self, toast: adw::Toast) { + #[cfg(test)] + self.imp().toasts.borrow_mut().push(toast.clone()); + match self.imp().page_toasts.borrow().as_ref() { + Some(overlay) => overlay.add_toast(toast), + None => tracing::debug!(title = ?toast.title(), "a toast with no page to show it on"), + } + } + + /// Move to `page`. The page left behind drops with what it held, + /// which releases a claim. A workspace left behind is torn down + /// first, so its session is closed rather than cut off. + fn show_page(&self, page: SessionPage) { + self.tear_down(); + let imp = self.imp(); + let app = self.application(); + let mut can_edit = false; + let (child, toasts, shown): (Option, Option, ShownPage) = match &page { + SessionPage::Picker => { + self.set_title(Some(&crate::i18n::gettext("TablePro"))); + let picker = imp.picker.get(); + ( + picker.map(|picker| picker.clone().upcast()), + picker.map(PickerPage::toast_overlay), + ShownPage::Other, + ) + } + SessionPage::Connecting { source, .. } => { + let connecting = ConnectingPage::new(app.as_ref()); + let toasts = connecting.toast_overlay(); + ( + Some(connecting.clone().upcast()), + Some(toasts), + ShownPage::Connecting(connecting, source_name(source)), + ) + } + SessionPage::ConnectFailed { source, failure, .. } => { + let view = ConnectFailureView::new(failure, source); + can_edit = view.can_edit; + let failed = ConnectFailedPage::new(&view, &edit_label(failure), failure.reenter, app.as_ref()); + let toasts = failed.toast_overlay(); + (Some(failed.clone().upcast()), Some(toasts), ShownPage::Failed(failed)) + } + SessionPage::Workspace { workspace, .. } => ( + Some(workspace.clone().upcast()), + Some(workspace.toast_overlay()), + ShownPage::Other, + ), + }; + window_actions::sync(self.upcast_ref(), &page, can_edit); + if !matches!(page, SessionPage::Picker) { + imp.opened_for_file.set(false); + } + let left = imp.page.replace(page); + imp.page_toasts.replace(toasts); + self.show_crash_notice(); + // A Cancel or Retry left as the default would take Enter on a + // page that no longer shows it. + self.set_default_widget(None::<>k::Widget>); + self.set_content(child.as_ref()); + match shown { + ShownPage::Connecting(page, name) => page.show(&name), + ShownPage::Failed(page) => page.show(), + ShownPage::Other => {} + } + drop(left); + } + + /// A connect worked or failed. A late result, from an attempt the + /// user cancelled or replaced, is closed rather than adopted. + fn on_attempt( + &self, + generation: tablepro_session::connect::AttemptGeneration, + result: Result, + ) { + let imp = self.imp(); + let current = + imp.attempts.accepts(generation) && self.page_is(|page| matches!(page, SessionPage::Connecting { .. })); + if !current { + if let Ok(connected) = result { + self.discard_pending(connected.pending); + } + return; + } + let SessionPage::Connecting { claim, source } = imp.page.replace(SessionPage::Picker) else { + return; + }; + match result { + Ok(connected) => self.adopt(claim, source, connected), + Err(AttemptError::Cancelled) => { + let key = claim.key().clone(); + drop(claim); + self.show_page(SessionPage::Picker); + self.forget_session(&key); + } + Err(AttemptError::AskLocation(kind)) => { + imp.page.replace(SessionPage::Connecting { claim, source }); + self.ask_about_location(generation, &kind); + } + // A session restored at launch that no longer opens is not + // tried again at the next one; a Retry that works records it. + Err(AttemptError::Failed(failure)) => { + tracing::warn!(error = %failure.message, "connect failed"); + let key = claim.key().clone(); + let missing = match &source { + ConnectSource::Saved(saved) => failure.missing_file.clone().map(|missing| (saved.id, missing)), + _ => None, + }; + self.show_page(SessionPage::ConnectFailed { claim, source, failure }); + self.forget_session(&key); + if let Some((id, missing)) = missing { + self.offer_locate(id, missing); + } + } + } + } + + /// Start the session that connected and build its workspace. + fn adopt(&self, claim: SessionClaim, source: ConnectSource, connected: Connected) { + let Connected { + pending, + saved, + warnings, + session_only, + } = connected; + let (Some(state), Some(guards)) = (self.state(), self.close_guards()) else { + self.discard_pending(pending); + return; + }; + let key = claim.key().clone(); + let credentials = state.context.session_credentials(saved.as_ref(), &session_only); + let session = Session::start(pending, state.context.session_deps(credentials)); + claim.attach(&session); + self.remember_session(&key, saved.as_ref()); + if let ConnectSource::Intent(intent) = &source + && let Err(error) = state + .services + .settings + .set_connect_dialog_driver(&intent.request.metadata.driver_id) + { + tracing::warn!(%error, "could not remember the connect dialog driver"); + } + let workspace = Workspace::new(WorkspaceInit { + key: key.clone(), + session: session.clone(), + services: state.services.clone(), + context: state.context.clone(), + window: self.clone().upcast(), + close_guards: guards.clone(), + }); + self.connect_workspace(&workspace); + self.show_page(SessionPage::Workspace { + claim, + session, + workspace: workspace.clone(), + }); + workspace.start(); + drop(self.imp().dialog.take()); + for warning in warnings { + self.show_toast(&warning); + } + if let SessionKey::Saved(id) = key { + self.touch_last_opened(id); + } + } + + fn connect_workspace(&self, workspace: &Workspace) { + let Some(input) = self.input() else { + return; + }; + let dirty = input.clone(); + workspace.connect_dirty_changed(move |_, value| dirty.deliver(SessionWindowInput::DirtyChanged(value))); + let saves = input.clone(); + workspace.connect_saves_in_flight_changed(move |_, count| { + saves.deliver(SessionWindowInput::SavesInFlightChanged(count)); + }); + self.follow_workspace_tabs(workspace, &input); + workspace.connect_title_changed(move |_, title| input.deliver(SessionWindowInput::TitleChanged(title))); + } + + fn discard_pending(&self, pending: PendingSession) { + if let Some(state) = self.state() { + discard_pending(pending, &state.services.tasks, &*state.services.hold()); + } + } + + /// Claim the key, then connect, so a second window asking for the + /// same database brings the first forward instead of connecting. + fn claim(&self, key: SessionKey) -> Option { + let state = self.state()?; + match state.context.sessions.claim(key, self.id()) { + Ok(claim) => Some(claim), + Err(ClaimError::AlreadyOpen { window_id }) => { + // A window that stayed to say the owner could not be + // shown is the user's picker from then on. + let opened_for_file = self.imp().opened_for_file.replace(false); + if self.present_owner(window_id) && opened_for_file { + self.close(); + } + None + } + } + } + + /// False when the owner could not be brought forward, and this + /// window says so instead. + fn present_owner(&self, window_id: u32) -> bool { + let presented = self.state().is_some_and(|state| state.host.present_window(window_id)); + if !presented { + self.show_toast(&crate::i18n::gettext( + "This connection is already open in another window.", + )); + } + presented + } + + fn on_open_saved(&self, saved: SavedConnection) { + let Some(state) = self.state() else { + return; + }; + // A picker another window deleted the entry under still lists it. + let Some(saved) = self.saved_as_stored(&saved) else { + self.show_toast(&crate::i18n::gettext("The connection was deleted.")); + return; + }; + if !self.page_is(|page| matches!(page, SessionPage::Picker)) { + // This window has its session; another one opens this. + let key = SessionKey::Saved(saved.id); + if let Some(owner) = state.context.sessions.window_for(&key) + && state.host.present_window(owner) + { + return; + } + state.host.open_window(WindowLaunch::Connect(Box::new(saved))); + return; + } + if let Some(claim) = self.claim(SessionKey::Saved(saved.id)) { + self.begin_connect(claim, ConnectSource::Saved(Box::new(saved))); + } + } + + /// The file is resolved off the GTK thread: canonicalizing its path + /// and matching it against the saved connections read the disk. + fn on_open_file(&self, file: gio::File) { + let Some(state) = self.state() else { + return; + }; + if !self.page_is(|page| matches!(page, SessionPage::Picker)) { + state.host.open_window(WindowLaunch::OpenFile(file)); + return; + } + self.imp().opening_file.set(true); + let path = file.path(); + let uri = file.uri().to_string(); + let resolving_uri = uri.clone(); + let connections = state.services.storage.connections().clone(); + let drivers = state.services.drivers.clone(); + let resolving = state + .services + .tasks + .spawn_blocking_task(move || resolve_file_blocking(path, resolving_uri, &connections, &drivers)); + let input = self.input(); + glib::spawn_future_local(async move { + let resolved = resolving.await.unwrap_or_else(|failure| ResolvedFile { + uri, + target: Err(FileResolveError::Interrupted(failure)), + }); + if let Some(input) = input { + input.deliver(SessionWindowInput::FileResolved(Box::new(resolved))); + } + }); + } + + fn on_file_resolved(&self, resolved: ResolvedFile) { + self.imp().opening_file.set(false); + let ResolvedFile { uri, target } = resolved; + self.end_restore(&SessionKey::File(uri.clone())); + if !self.page_is(|page| matches!(page, SessionPage::Picker)) { + if let Some(state) = self.state() { + state.host.open_window(WindowLaunch::OpenFile(gio::File::for_uri(&uri))); + } + return; + } + // A file restored under a key it no longer resolves to would be + // reopened under both. + let arrived = SessionKey::File(uri.clone()); + match target { + Ok(FileTarget::Saved(saved)) => { + self.forget_unclaimed(&arrived); + self.on_open_saved(*saved); + } + Ok(FileTarget::File(identity)) => { + if identity.uri != uri { + self.forget_unclaimed(&arrived); + } + if let Some(claim) = self.claim(SessionKey::File(identity.uri.clone())) { + self.begin_connect(claim, ConnectSource::File(identity)); + } + } + Err(error) => { + tracing::warn!(%error, %uri, "could not open the file"); + self.forget_unclaimed(&arrived); + let Some(claim) = self.claim(SessionKey::File(uri.clone())) else { + return; + }; + let failure = ConnectFailure::new(file_failure_message(&uri, &error)); + self.show_page(SessionPage::ConnectFailed { + claim, + source: ConnectSource::File(FileIdentity::new(None, uri)), + failure, + }); + } + } + } + + fn on_connect_from_dialog(&self, submission: ConnectSubmission) { + let ConnectSubmission { request, pending_save } = submission; + match &*self.imp().page.borrow() { + SessionPage::Picker | SessionPage::ConnectFailed { .. } => {} + SessionPage::Connecting { .. } | SessionPage::Workspace { .. } => { + tracing::warn!("a connect from the dialog reached a window with a session"); + return; + } + } + // A failed connect's claim goes first: the dialog may be editing + // that same connection. + self.show_page(SessionPage::Picker); + let key = SessionKey::Saved(pending_save.connection.id); + let Some(state) = self.state() else { + return; + }; + match state.context.sessions.claim(key.clone(), self.id()) { + Ok(claim) => self.begin_connect( + claim, + ConnectSource::Intent(Box::new(ConnectIntent { + key, + request, + pending_save: Some(pending_save), + })), + ), + Err(ClaimError::AlreadyOpen { window_id }) => { + // The edits are kept for the next connect; the session + // open in the other window goes on as it was. + self.persist_pending_save(pending_save); + self.present_owner(window_id); + } + } + } + + fn begin_connect(&self, claim: SessionClaim, source: ConnectSource) { + self.start_connect(claim, source, None, CredentialDemand::default()); + } + + fn begin_connect_with(&self, claim: SessionClaim, source: ConnectSource, demand: CredentialDemand) { + self.start_connect(claim, source, None, demand); + } + + /// `answer` is how the user chose to open a database file on a + /// filesystem whose locking SQLite cannot trust, once asked. + fn start_connect( + &self, + claim: SessionClaim, + source: ConnectSource, + answer: Option, + demand: CredentialDemand, + ) { + let Some(state) = self.state() else { + return; + }; + let check = self.file_check(&source, answer); + let ticket = self.imp().attempts.begin(); + let generation = ticket.generation(); + let cancel = ticket.cancel().clone(); + let work = connect_work(state, &source, check, demand, cancel); + self.show_page(SessionPage::Connecting { claim, source }); + let input = self.input(); + let (tasks, hold) = (state.services.tasks.clone(), state.services.hold()); + crate::runtime::spawn_and_deliver(&state.services.tasks, work, move |outcome| { + let result = outcome.unwrap_or_else(|failure| { + Err(AttemptError::Failed(ConnectFailure::new( + crate::ui::error_text::task_failure_message(&failure), + ))) + }); + let attempt = SessionWindowCommand::Attempt { generation, result }; + let Some(input) = input else { + return; + }; + if let Err(unsent) = input.send(SessionWindowInput::Command(Box::new(attempt))) + && let Some(pending) = unadopted(unsent.0) + { + discard_pending(pending, &tasks, &*hold); + } + }); + } + + /// Stop the connect in progress, or leave a failed one, for the + /// picker. + fn on_cancel_connect(&self) { + if self.page_is(|page| matches!(page, SessionPage::Connecting { .. })) { + stop_connecting(&self.imp().attempts); + } + if self.page_is(|page| matches!(page, SessionPage::Connecting { .. } | SessionPage::ConnectFailed { .. })) { + let key = self.imp().page.borrow().key().cloned(); + self.show_page(SessionPage::Picker); + if let Some(key) = key { + self.forget_session(&key); + } + } + } + + fn on_retry_connect(&self) { + let imp = self.imp(); + if !self.page_is(|page| matches!(page, SessionPage::ConnectFailed { .. })) { + return; + } + let SessionPage::ConnectFailed { claim, source, .. } = imp.page.replace(SessionPage::Picker) else { + return; + }; + match source { + // The file is resolved again, since it may have moved. + ConnectSource::File(identity) => { + drop(claim); + self.show_page(SessionPage::Picker); + self.on_open_file(gio::File::for_uri(&identity.uri)); + } + ConnectSource::Saved(saved) => self.reconnect_saved(claim, *saved, CredentialDemand::default()), + source => self.begin_connect(claim, source), + } + } + + /// Connect a failed saved connection again, as it is saved now: the + /// entry may have been edited or deleted in another window since. + fn reconnect_saved(&self, claim: SessionClaim, saved: SavedConnection, demand: CredentialDemand) { + match self.saved_as_stored(&saved) { + Some(current) => self.begin_connect_with(claim, ConnectSource::Saved(Box::new(current)), demand), + None => { + drop(claim); + self.show_page(SessionPage::Picker); + self.show_toast(&crate::i18n::gettext("The connection was deleted.")); + } + } + } + + /// The failed page's Edit Connection. A saved connection opens as an + /// Edit of the entry as saved now, whose Save connects again; a new + /// one reopens the connect dialog on what was typed. + fn on_edit_connection(&self) { + let (saved, prefill, focus) = match &*self.imp().page.borrow() { + SessionPage::ConnectFailed { source, failure, .. } => ( + match source { + ConnectSource::Saved(saved) => Some((**saved).clone()), + _ => None, + }, + connections::prefill_for(source), + failure.remedy.map(crate::ui::connect_dialog::remedy_field), + ), + _ => return, + }; + match (saved, prefill) { + (Some(saved), _) => match self.saved_as_stored(&saved) { + Some(current) => self.present_connect_dialog(ConnectDialogMode::Edit(Box::new(current)), None, focus), + None => self.show_toast(&crate::i18n::gettext("The connection was deleted.")), + }, + (None, Some(prefill)) => self.present_connect_dialog(ConnectDialogMode::Create, Some(prefill), focus), + (None, None) => {} + } + } + + fn on_disconnect(&self) { + let (Some(handle), Some(guards)) = (self.workspace_handle(), self.close_guards()) else { + return; + }; + guards.begin_teardown(); + if guards.may_tear_down(&handle) { + self.send(SessionWindowInput::TeardownApproved); + return; + } + let input = self.input(); + guards.ask_tear_down(self.upcast_ref(), &handle, move |decision| { + if decision == CloseDecision::Approved + && let Some(input) = input + { + input.deliver(SessionWindowInput::TeardownApproved); + } + }); + } + + /// Ctrl+W: the workspace closes its current tab, and with no + /// workspace the window closes, through its close prompts. + fn on_close_current(&self) { + match self.workspace().and_then(|workspace| workspace.input()) { + Some(input) => input.deliver(WorkspaceInput::CloseActiveWorkspaceTab), + None => self.close(), + } + } + + /// The close prompts approved the window's close, which goes ahead + /// once this returns: what it holds is written and closed first. + fn on_close_approved(&self) { + if let Some(state) = self.state() { + crate::ui::window_geometry::persist(self.upcast_ref(), &state.services.settings); + } + self.release_session(); + } + + /// Close the session the window shows, or stop the connect it runs. + fn release_session(&self) { + self.abandon_restore(); + if self.tear_down() { + return; + } + if self.page_is(|page| matches!(page, SessionPage::Connecting { .. })) { + stop_connecting(&self.imp().attempts); + } + let left = match self.imp().page.try_borrow_mut() { + Ok(mut page) => std::mem::take(&mut *page), + Err(_) => { + tracing::warn!("the window closed while its page was in use"); + return; + } + }; + let key = left.key().cloned(); + drop(left); + if let Some(key) = key { + self.forget_session(&key); + } + } + + fn refresh_inhibitor(&self) { + let imp = self.imp(); + let reason = InhibitReason::for_state(imp.dirty.get(), imp.saving.get()); + if let Some(inhibitor) = imp.inhibitor.borrow_mut().as_mut() { + inhibitor.update(reason); + } + } + + #[cfg(test)] + pub(crate) fn page_name(&self) -> &'static str { + self.imp().page.borrow().name() + } +} + +/// The connection a message carries that its window will never adopt +/// now. It is closed rather than dropped, so the server sees it end. +fn unadopted(message: SessionWindowInput) -> Option { + match message { + SessionWindowInput::Command(command) => match *command { + SessionWindowCommand::Attempt { result, .. } => result.ok().map(|connected| connected.pending), + }, + _ => None, + } +} + +/// Throw away a connection nobody adopted, closing it under the close +/// deadline while the process stays alive for it. +fn discard_pending(pending: PendingSession, tasks: &tablepro_session::runtime::Tasks, hold: &dyn ProcessHold) { + crate::runtime::spawn_held(tasks, hold, async move { + let report = pending.discard().await; + tracing::debug!(?report, "closed a connection that was not adopted"); + }); +} + +/// Supersede the connect in flight, not only cancel it: a driver +/// blocked in a socket call can still connect afterwards, and that late +/// session must not be adopted. +fn stop_connecting(attempts: &tablepro_session::ConnectAttempts) { + drop(attempts.begin()); + attempts.cancel(); +} + +/// The connect a source makes, off the GTK thread, once `check` finds +/// its file safe to open. Reading the keyring, asking for what it lacks +/// and connecting all happen there; only the outcome comes back. +fn connect_work( + state: &WindowState, + source: &ConnectSource, + check: FileCheck, + demand: CredentialDemand, + cancel: tokio_util::sync::CancellationToken, +) -> BoxFuture<'static, Result> { + let connecting = connect_source(state, source, check.read_only(), demand, cancel.clone()); + check.guard(&state.services.tasks, cancel, connecting) +} + +/// `read_only` opens it read-only whatever the source says. +fn connect_source( + state: &WindowState, + source: &ConnectSource, + read_only: bool, + demand: CredentialDemand, + cancel: tokio_util::sync::CancellationToken, +) -> BoxFuture<'static, Result> { + match source { + ConnectSource::Saved(saved) => connect_saved(&state.context, saved, read_only, demand, cancel), + ConnectSource::Intent(intent) => connect_intent(state, intent, read_only, cancel), + ConnectSource::File(identity) => connect_file(&state.context, identity, read_only, cancel), + } +} + +fn connect_saved( + context: &SessionContext, + saved: &SavedConnection, + read_only: bool, + demand: CredentialDemand, + cancel: tokio_util::sync::CancellationToken, +) -> BoxFuture<'static, Result> { + let connector = context.connector.clone(); + let interaction = context.attended(); + let saved = saved.clone(); + let mut opened = saved.clone(); + opened.read_only |= read_only; + let drivers = context.drivers.clone(); + let credentials = context.credentials_for(Some(&saved)); + let server_name_shown = crate::ui::connect_dialog::driver_shows_server_name( + drivers + .get(&saved.driver_id) + .as_deref() + .map(DatabaseDriver::capabilities), + route(saved.ssh.is_some()), + ); + async move { + let connected = connect_flow::connect_saved( + &opened, + &drivers, + &*credentials, + &*connector, + interaction, + demand, + cancel, + ) + .await; + match connected { + Ok(login) => Ok(Connected { + pending: login.pending, + saved: Some(saved), + warnings: login + .refused + .iter() + .map(|(_, error)| crate::services::secret_save_report::not_saved_message(error)) + .collect(), + session_only: login.session_only, + }), + Err(error) => Err(match attempt_error(&error, server_name_shown) { + AttemptError::Failed(failure) => AttemptError::Failed(ConnectFailure { + reenter: secret_retry::refused_secret(&error, &saved), + ..failure + }), + cancelled => cancelled, + }), + } + } + .boxed() +} + +/// A connection typed into the connect dialog, saved first when the +/// dialog asked for that. +fn connect_intent( + state: &WindowState, + intent: &ConnectIntent, + read_only: bool, + cancel: tokio_util::sync::CancellationToken, +) -> BoxFuture<'static, Result> { + let context = &state.context; + let connector = context.connector.clone(); + let interaction = context.attended(); + let mut request = intent.request.clone(); + request.workspace_settings.read_only |= read_only; + let server_name_shown = match &request.target { + RequestTarget::Network { ssh, .. } => crate::ui::connect_dialog::driver_shows_server_name( + Some(request.driver.capabilities()), + route(ssh.is_some()), + ), + RequestTarget::File(_) => false, + }; + let Some(pending_save) = intent.pending_save.clone() else { + return async move { + connect_flow::connect_request(&*connector, request, interaction, cancel) + .await + .map(unsaved) + .map_err(|error| attempt_error(&error, server_name_shown)) + } + .boxed(); + }; + let secrets = context.secrets.clone(); + let writes = StoreConnectionWrites::new( + state.services.storage.connections().clone(), + state.services.tasks.clone(), + ); + async move { + let saving = connect_flow::connect_then_save( + &*connector, + request, + interaction, + cancel, + &*secrets, + &writes, + pending_save, + ) + .await; + match saving { + Ok((pending, report)) => Ok(Connected { + pending, + // A keyring refusal must not be silent: the + // connection opened, but the password will be + // asked for next time. + warnings: report + .secret_failures + .iter() + .map(|(_, error)| crate::services::secret_save_report::not_saved_message(error)) + .collect(), + session_only: report.secret_failures.iter().map(|(kind, _)| *kind).collect(), + saved: Some(report.saved), + }), + Err(connect_flow::SubmitError::Connect(error)) => Err(attempt_error(&error, server_name_shown)), + Err(connect_flow::SubmitError::Save(error)) => Err(AttemptError::Failed(ConnectFailure { + heading: Some(crate::i18n::gettext("Could Not Save the Connection")), + ..ConnectFailure::new(crate::ui::error_text::saved_connection_error_message( + &crate::services::saved_connection_error::SavedConnectionError::Storage(error), + )) + })), + } + } + .boxed() +} + +fn connect_file( + context: &SessionContext, + identity: &FileIdentity, + read_only: bool, + cancel: tokio_util::sync::CancellationToken, +) -> BoxFuture<'static, Result> { + let connector = context.connector.clone(); + let interaction = context.attended(); + let request = resolve_file_identity::file_request(identity, &context.drivers, read_only); + async move { + let request = request.map_err(|error| attempt_error(&error, false))?; + connect_flow::connect_request(&*connector, request, interaction, cancel) + .await + .map(unsaved) + .map_err(|error| attempt_error(&error, false)) + } + .boxed() +} + +fn unsaved(pending: PendingSession) -> Connected { + Connected { + pending, + saved: None, + warnings: Vec::new(), + session_only: Vec::new(), + } +} + +fn route(tunnelled: bool) -> TransportClass { + if tunnelled { + TransportClass::SshForward + } else { + TransportClass::DirectTcp + } +} + +/// `server_name_shown` says whether the connect dialog shows the server +/// name row for this connection, so a remedy never opens a hidden row. +fn attempt_error(error: &ConnectError, server_name_shown: bool) -> AttemptError { + match error { + ConnectError::Cancelled => AttemptError::Cancelled, + error => AttemptError::Failed(ConnectFailure { + message: crate::ui::error_text::connect_error_message(error), + remedy: crate::ui::error_text::connect_remedy(error, server_name_shown), + reenter: None, + missing_file: match error { + ConnectError::Driver(DriverError::FileNotFound { path }) => Some(path.clone()), + _ => None, + }, + heading: None, + }), + } +} + +/// What the connecting page names: the connection, or the file. +fn source_name(source: &ConnectSource) -> String { + match source { + ConnectSource::Saved(saved) => saved.name.clone(), + ConnectSource::Intent(intent) => intent.request.metadata.name.clone(), + ConnectSource::File(identity) => file_display_name(&identity.uri), + } +} + +fn file_display_name(uri: &str) -> String { + let file = gio::File::for_uri(uri); + file.basename() + .map_or_else(|| uri.to_owned(), |name| name.to_string_lossy().into_owned()) +} + +/// The failed page's Edit button: named after the setting that fixes a +/// TLS failure when there is one. +fn edit_label(failure: &ConnectFailure) -> String { + failure + .remedy + .map_or_else(|| crate::i18n::gettext("Edit Connection…"), |remedy| remedy.label()) +} + +/// A page that says what it shows once it is in the window. +enum ShownPage { + Connecting(ConnectingPage, String), + Failed(ConnectFailedPage), + Other, +} + +fn file_failure_message(uri: &str, error: &FileResolveError) -> String { + let name = gio::File::for_uri(uri).parse_name().to_string(); + match error { + FileResolveError::NotLocal => crate::i18n::gettext_f( + "{file} is not on this computer. Copy it to a local folder to open it.", + &[("file", &name)], + ), + FileResolveError::NotFound(_) => crate::i18n::gettext_f("{file} could not be found.", &[("file", &name)]), + FileResolveError::Interrupted(failure) => crate::i18n::gettext_f( + "{file} could not be opened. {reason}", + &[ + ("file", &name), + ("reason", &crate::ui::error_text::task_failure_message(failure)), + ], + ), + } +} diff --git a/linux/crates/app/src/ui/session_window/notification_request.rs b/linux/crates/app/src/ui/session_window/notification_request.rs new file mode 100644 index 0000000000..87ac60b293 --- /dev/null +++ b/linux/crates/app/src/ui/session_window/notification_request.rs @@ -0,0 +1,132 @@ +use uuid::Uuid; + +use crate::workspace::{CompletedWork, WorkKind, WorkOutcome}; + +/// A desktop notification about one tab's finished work. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct NotificationRequest { + pub id: String, + pub title: String, + pub body: String, + pub window_id: u32, + pub tab: Uuid, +} + +/// The notification for `work`, or `None` for work the user stopped. +/// +/// A notification can show on the lock screen, so it names the tab and +/// the connection and nothing else: no SQL, no rows, no error text. +pub(crate) fn notification_request( + window_id: u32, + work: &CompletedWork, + tab_label: &str, + connection_name: &str, +) -> Option { + let title = match (work.kind, work.outcome) { + (_, WorkOutcome::Cancelled) => return None, + (WorkKind::Query, WorkOutcome::Succeeded) => crate::i18n::gettext("Query Finished"), + (WorkKind::Query, WorkOutcome::Failed) => crate::i18n::gettext("Query Failed"), + (WorkKind::Save, WorkOutcome::Succeeded) => crate::i18n::gettext("Changes Saved"), + (WorkKind::Save, WorkOutcome::Failed) => crate::i18n::gettext("Changes Not Saved"), + (WorkKind::Export, WorkOutcome::Succeeded) => crate::i18n::gettext("Export Finished"), + (WorkKind::Export, WorkOutcome::Failed) => crate::i18n::gettext("Export Failed"), + }; + let body = match connection_name.is_empty() { + true => tab_label.to_owned(), + false => crate::i18n::gettext_f( + "{tab} · {connection}", + &[("tab", tab_label), ("connection", connection_name)], + ), + }; + Some(NotificationRequest { + id: notification_id(window_id, work.tab), + title, + body, + window_id, + tab: work.tab, + }) +} + +/// One per tab, so a newer notification replaces the tab's last one. +fn notification_id(window_id: u32, tab: Uuid) -> String { + format!("work-finished-{window_id}-{tab}") +} + +#[cfg(test)] +mod tests { + use super::*; + + fn work(kind: WorkKind, outcome: WorkOutcome) -> CompletedWork { + CompletedWork { + tab: Uuid::from_u128(7), + kind, + outcome, + } + } + + #[test] + fn notification_id_is_stable_per_window_and_tab() { + let (tab, other) = (Uuid::new_v4(), Uuid::new_v4()); + let id = |window_id, tab| { + let work = CompletedWork { + tab, + kind: WorkKind::Query, + outcome: WorkOutcome::Succeeded, + }; + notification_request(window_id, &work, "orders", "Prod") + .expect("a request") + .id + }; + + assert_eq!(id(3, tab), id(3, tab)); + assert_eq!(id(3, tab), format!("work-finished-3-{tab}")); + assert_ne!(id(3, tab), id(4, tab)); + assert_ne!(id(3, tab), id(3, other)); + let request = + notification_request(3, &work(WorkKind::Export, WorkOutcome::Failed), "orders", "Prod").expect("a request"); + assert_eq!((request.window_id, request.tab), (3, Uuid::from_u128(7))); + } + + #[test] + fn cancelled_work_produces_no_request() { + for kind in [WorkKind::Query, WorkKind::Save, WorkKind::Export] { + assert_eq!( + notification_request(1, &work(kind, WorkOutcome::Cancelled), "orders", "Prod"), + None, + "{kind:?}" + ); + } + } + + #[test] + fn request_body_contains_no_sql_or_error_text() { + let request = + notification_request(1, &work(WorkKind::Query, WorkOutcome::Failed), "Query 2", "Prod").expect("a request"); + + assert_eq!(request.body, "Query 2 · Prod"); + assert_eq!(request.title, "Query Failed"); + assert_eq!( + notification_request(1, &work(WorkKind::Save, WorkOutcome::Succeeded), "orders", "") + .expect("a request") + .body, + "orders", + "a missing connection name left a dangling separator" + ); + } + + #[test] + fn titles_per_kind_and_outcome() { + let title = |kind, outcome| { + notification_request(1, &work(kind, outcome), "orders", "Prod") + .expect("a request") + .title + }; + + assert_eq!(title(WorkKind::Query, WorkOutcome::Succeeded), "Query Finished"); + assert_eq!(title(WorkKind::Query, WorkOutcome::Failed), "Query Failed"); + assert_eq!(title(WorkKind::Save, WorkOutcome::Succeeded), "Changes Saved"); + assert_eq!(title(WorkKind::Save, WorkOutcome::Failed), "Changes Not Saved"); + assert_eq!(title(WorkKind::Export, WorkOutcome::Succeeded), "Export Finished"); + assert_eq!(title(WorkKind::Export, WorkOutcome::Failed), "Export Failed"); + } +} diff --git a/linux/crates/app/src/ui/session_window/notification_sink.rs b/linux/crates/app/src/ui/session_window/notification_sink.rs new file mode 100644 index 0000000000..4352381ff1 --- /dev/null +++ b/linux/crates/app/src/ui/session_window/notification_sink.rs @@ -0,0 +1,8 @@ +use super::notification_request::NotificationRequest; + +/// Where a window's notifications go: the desktop, or a test's record. +pub(crate) trait NotificationSink { + /// Show `request`, replacing the one with its id. + fn send(&self, request: &NotificationRequest); + fn withdraw(&self, id: &str); +} diff --git a/linux/crates/app/src/ui/session_window/notifications.rs b/linux/crates/app/src/ui/session_window/notifications.rs new file mode 100644 index 0000000000..ab23725d2c --- /dev/null +++ b/linux/crates/app/src/ui/session_window/notifications.rs @@ -0,0 +1,116 @@ +//! The window's side of the notifications about finished work: it hears +//! the workspace's tabs, decides whether the user saw the work end, and +//! withdraws a tab's notification once the user looks at the tab. + +use std::rc::Rc; + +use gtk4::prelude::*; +use gtk4::subclass::prelude::*; +use uuid::Uuid; + +use super::notification_sink::NotificationSink; +use super::work_notifier::WorkNotifier; +use super::{SessionWindow, SessionWindowInput}; +use crate::runtime::{DeliverInput, InputSender}; +use crate::ui::workspace::{Workspace, WorkspaceInput}; +use crate::workspace::CompletedWork; + +impl SessionWindow { + /// Called once the window is added to the application, which gives + /// it the id its notifications name. + pub(super) fn install_notifier(&self, input: &InputSender) { + if let Some(app) = self.application() { + self.set_notification_sink(default_sink(&app)); + } + let changed = input.clone(); + self.connect_is_active_notify(move |_| changed.deliver(SessionWindowInput::TabsChanged)); + } + + pub(super) fn set_notification_sink(&self, sink: Rc) { + let replaced = self.imp().notifier.replace(Some(WorkNotifier::new(self.id(), sink))); + if let Some(replaced) = replaced { + replaced.withdraw_all(); + } + } + + /// Count the window as active or not, whatever the display says. + #[cfg(test)] + pub(super) fn set_active_for_test(&self, active: bool) { + self.imp().active_for_test.set(Some(active)); + } + + fn window_is_active(&self) -> bool { + #[cfg(test)] + if let Some(active) = self.imp().active_for_test.get() { + return active; + } + self.is_active() + } + + pub(super) fn follow_workspace_tabs(&self, workspace: &Workspace, input: &InputSender) { + let completed = input.clone(); + workspace.connect_work_completed(move |_, work| completed.deliver(SessionWindowInput::WorkCompleted(work))); + let changed = input.clone(); + workspace.connect_tabs_changed(move |_| changed.deliver(SessionWindowInput::TabsChanged)); + } + + pub(super) fn on_work_completed(&self, work: CompletedWork) { + let Some(workspace) = self.workspace() else { + return; + }; + let Some(label) = workspace.notice_label(work.tab) else { + return; + }; + let active = self.window_is_active() && workspace.selected_tab() == Some(work.tab); + let connection = workspace.connection_name().unwrap_or_default(); + if let Some(notifier) = self.imp().notifier.borrow().as_ref() { + notifier.work_completed(&work, active, &label, &connection); + } + } + + pub(super) fn on_tabs_changed(&self) { + let notifier = self.imp().notifier.borrow(); + let Some(notifier) = notifier.as_ref().filter(|notifier| notifier.has_sent()) else { + return; + }; + let Some(workspace) = self.workspace() else { + return; + }; + if let Some(open) = workspace.open_tabs() { + notifier.withdraw_closed(|tab| open.contains(&tab)); + } + if self.window_is_active() + && let Some(tab) = workspace.selected_tab() + { + notifier.tab_became_active(tab); + } + } + + pub(super) fn on_focus_tab(&self, tab: Uuid) { + match self.workspace().and_then(|workspace| workspace.input()) { + Some(input) => input.deliver(WorkspaceInput::FocusTab(tab)), + None => tracing::debug!("a notification named a tab of a session the window no longer shows"), + } + } + + /// Every notification names a tab of the session going away. + pub(super) fn withdraw_notifications(&self) { + if let Some(notifier) = self.imp().notifier.borrow().as_ref() { + notifier.withdraw_all(); + } + } +} + +#[cfg(not(test))] +fn default_sink(app: >k4::Application) -> Rc { + Rc::new(super::application_notification_sink::ApplicationNotificationSink::new( + app, + )) +} + +/// The tests share a session bus with no notification server on it, +/// where GLib warns about the first notification it cannot send. +#[cfg(test)] +fn default_sink(_: >k4::Application) -> Rc { + Rc::new(super::work_notifier::tests::RecordingNotificationSink::default()) +} diff --git a/linux/crates/app/src/ui/session_window/picker_page.rs b/linux/crates/app/src/ui/session_window/picker_page.rs new file mode 100644 index 0000000000..d145ff56a1 --- /dev/null +++ b/linux/crates/app/src/ui/session_window/picker_page.rs @@ -0,0 +1,80 @@ +use gtk4 as gtk; +use gtk4::glib; +use gtk4::subclass::prelude::*; +use libadwaita as adw; +use libadwaita::prelude::*; + +mod imp { + use super::*; + use libadwaita::subclass::prelude::*; + + #[derive(Default, gtk::CompositeTemplate)] + #[template(resource = "/app/tablepro/TablePro/ui/picker-page.ui")] + pub struct PickerPage { + #[template_child] + pub toast_overlay: TemplateChild, + #[template_child] + pub header_bar: TemplateChild, + #[template_child] + pub new_connection_button: TemplateChild, + #[template_child] + pub connection_list_banner: TemplateChild, + #[template_child] + pub welcome_slot: TemplateChild, + } + + #[glib::object_subclass] + impl ObjectSubclass for PickerPage { + const NAME: &'static str = "TableProPickerPage"; + type Type = super::PickerPage; + type ParentType = adw::Bin; + + fn class_init(klass: &mut Self::Class) { + klass.bind_template(); + } + + fn instance_init(object: &glib::subclass::InitializingObject) { + object.init_template(); + } + } + + impl ObjectImpl for PickerPage { + fn constructed(&self) { + self.parent_constructed(); + crate::ui::a11y::describe_icon_button( + &self.new_connection_button.get(), + &crate::i18n::gettext("New Connection"), + None, + ); + } + } + impl WidgetImpl for PickerPage {} + impl BinImpl for PickerPage {} +} + +glib::wrapper! { + /// The saved connections, with a header bar of their own and the + /// banner that says when the list could not be read. It sits outside + /// any split view, so no empty sidebar can show while disconnected. + pub struct PickerPage(ObjectSubclass) + @extends adw::Bin, gtk::Widget, + @implements gtk::Accessible, gtk::Buildable, gtk::ConstraintTarget; +} + +impl PickerPage { + pub fn new(welcome: &impl IsA, app: Option<>k::Application>) -> Self { + let page: Self = glib::Object::new(); + let imp = page.imp(); + imp.welcome_slot.set_child(Some(welcome)); + imp.header_bar.pack_end(&crate::ui::shell::primary_menu_button(app)); + page + } + + pub fn toast_overlay(&self) -> adw::ToastOverlay { + self.imp().toast_overlay.get() + } + + pub fn banner(&self) -> adw::Banner { + self.imp().connection_list_banner.get() + } +} diff --git a/linux/crates/app/src/ui/session_window/resolve_file_identity.rs b/linux/crates/app/src/ui/session_window/resolve_file_identity.rs new file mode 100644 index 0000000000..589d2741c9 --- /dev/null +++ b/linux/crates/app/src/ui/session_window/resolve_file_identity.rs @@ -0,0 +1,106 @@ +//! What a database file handed to TablePro connects with, once it is +//! resolved off the GTK thread. + +use std::path::PathBuf; +use std::sync::Arc; + +use tablepro_core::{DatabaseDriver, DriverError, DriverRegistry, FileEndpoint, WorkspaceSettings}; +use tablepro_session::registry::FileIdentity; +use tablepro_session::{ConnectError, ConnectRequest, ConnectionMetadata, RequestTarget}; + +pub(crate) use crate::services::file_resolution::{FileResolveError, FileTarget, ResolvedFile, resolve_file_blocking}; + +/// The request that opens a file nobody saved, through the driver that +/// opens files. +pub(crate) fn file_request( + identity: &FileIdentity, + drivers: &DriverRegistry, + read_only: bool, +) -> Result { + let driver = file_driver(drivers).ok_or_else(|| ConnectError::UnknownDriver("sqlite".to_owned()))?; + let path = identity + .canonical_path + .clone() + .unwrap_or_else(|| PathBuf::from(&identity.uri)); + let name = path + .file_name() + .map_or_else(|| identity.uri.clone(), |name| name.to_string_lossy().into_owned()); + let endpoint = FileEndpoint::open_existing(path).map_err(DriverError::from)?; + Ok(ConnectRequest { + metadata: ConnectionMetadata { + id: uuid::Uuid::new_v4(), + name, + driver_id: driver.id().to_owned(), + }, + driver, + target: RequestTarget::File(endpoint), + workspace_settings: WorkspaceSettings::workspace(read_only), + }) +} + +fn file_driver(drivers: &DriverRegistry) -> Option> { + drivers + .get("sqlite") + .filter(|driver| driver.capabilities().is_file_based()) + .or_else(|| { + drivers + .iter() + .find(|driver| driver.capabilities().is_file_based()) + .cloned() + }) +} + +#[cfg(test)] +mod tests { + use std::time::Duration; + + use super::*; + use crate::services::file_resolution::resolve_file_identity_blocking; + + #[gtk4::test] + fn symlinked_path_canonicalises() { + let runtime = crate::test_support::test_runtime(); + let root = tempfile::tempdir().expect("tempdir"); + let database = root.path().join("app.db"); + std::fs::write(&database, b"").expect("seed"); + let link = root.path().join("link.db"); + std::os::unix::fs::symlink(&database, &link).expect("symlink"); + let uri = format!("file://{}", link.display()); + + let resolving = runtime + .tasks() + .spawn_blocking_task(move || resolve_file_identity_blocking(Some(&link), &uri)); + let identity = crate::test_support::finish_on_main(Duration::from_secs(10), resolving) + .expect("the resolve to end") + .expect("the task to finish") + .expect("the file resolves"); + + let canonical = std::fs::canonicalize(&database).expect("canonical"); + assert_eq!(identity.canonical_path.as_deref(), Some(canonical.as_path())); + assert!( + identity.uri.ends_with("/app.db"), + "the key followed the link, not the file: {}", + identity.uri + ); + runtime.shutdown(); + } + + #[test] + fn an_unsaved_file_opens_through_the_driver_that_opens_files() { + let mut drivers = DriverRegistry::new(); + drivers.register(Arc::new(tablepro_test_support::FakeDriver::connecting( + &tablepro_test_support::FakeDriver::FILE_CAPABILITIES, + tablepro_test_support::ConnectionScript::default(), + ))); + let identity = FileIdentity::new(Some(PathBuf::from("/srv/app.db")), "file:///srv/app.db".to_owned()); + + let request = file_request(&identity, &drivers, false).expect("a request"); + let read_only = file_request(&identity, &drivers, true).expect("a read-only request"); + + assert_eq!(request.metadata.name, "app.db"); + assert!(matches!(request.target, RequestTarget::File(_))); + assert!(!request.workspace_settings.read_only); + assert!(read_only.workspace_settings.read_only); + assert!(file_request(&identity, &DriverRegistry::new(), false).is_err()); + } +} diff --git a/linux/crates/app/src/ui/session_window/secret_retry.rs b/linux/crates/app/src/ui/session_window/secret_retry.rs new file mode 100644 index 0000000000..549f29b4ce --- /dev/null +++ b/linux/crates/app/src/ui/session_window/secret_retry.rs @@ -0,0 +1,488 @@ +//! A failed connect whose secret the server refused offers to take it +//! again, and the retry asks for it rather than reading the keyring's +//! copy, which is the one that was just refused. + +use gtk4::subclass::prelude::*; +use tablepro_core::credentials::SecretKind; +use tablepro_core::{AuthMode, DriverError, SshFailure, TransportError}; +use tablepro_session::{ConnectError, ConnectSource}; +use tablepro_storage::{CredentialDemand, SavedConnection, SavedSshAuth}; + +use super::SessionWindow; +use super::session_page::SessionPage; + +impl SessionWindow { + /// The failed page's Enter Password: connect again, asking for + /// `kind` whatever the keyring holds. A stale activation, from a + /// page that has moved on, does nothing. + pub(super) fn on_retry_with_new_secret(&self, kind: SecretKind) { + let imp = self.imp(); + let offered = self.page_is(|page| { + matches!( + page, + SessionPage::ConnectFailed { + source: ConnectSource::Saved(_), + failure, + .. + } if failure.reenter == Some(kind) + ) + }); + if !offered { + return; + } + let SessionPage::ConnectFailed { + claim, + source: ConnectSource::Saved(saved), + .. + } = imp.page.replace(SessionPage::Picker) + else { + return; + }; + self.reconnect_saved(claim, *saved, CredentialDemand::reenter(kind)); + } +} + +/// The secret of `saved` that `error` says the server refused, when the +/// user can type it in TablePro. +/// +/// A key's passphrase counts only when the keyring holds it: without +/// one, ssh asks for it at each connect anyway. A keyring that could +/// not be read refused nothing, so it is not offered: the retry's +/// prompt would say the server turned the secret down. +pub(super) fn refused_secret(error: &ConnectError, saved: &SavedConnection) -> Option { + match error { + ConnectError::Driver(DriverError::Auth { .. }) => { + (saved.auth_mode == AuthMode::Password).then_some(SecretKind::DatabasePassword) + } + ConnectError::Driver(DriverError::Transport(TransportError::Ssh(SshFailure::AuthenticationRejected { + .. + }))) => match saved.ssh.as_ref().map(|ssh| &ssh.auth)? { + SavedSshAuth::Password => Some(SecretKind::SshPassword), + SavedSshAuth::PrivateKey { + has_passphrase: true, .. + } => Some(SecretKind::SshPassphrase), + SavedSshAuth::PrivateKey { .. } | SavedSshAuth::Agent | SavedSshAuth::KeyboardInteractive => None, + }, + _ => None, + } +} + +/// The label of the failed page's button for typing `kind` again. +pub(super) fn reenter_label(kind: SecretKind) -> String { + match kind { + SecretKind::DatabasePassword => crate::i18n::gettext("Enter Password…"), + SecretKind::SshPassword => crate::i18n::gettext("Enter SSH Password…"), + SecretKind::SshPassphrase => crate::i18n::gettext("Enter Passphrase…"), + } +} + +/// The kind an action target names, as `SecretKind::attribute` spells +/// it. +pub(super) fn secret_kind_named(name: &str) -> Option { + SecretKind::ALL.into_iter().find(|kind| kind.attribute() == name) +} + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use tablepro_core::TlsConfig; + use tablepro_storage::{KeyringError, SavedSshConfig}; + use uuid::Uuid; + + use super::*; + + fn saved(auth_mode: AuthMode, ssh: Option) -> SavedConnection { + SavedConnection { + id: Uuid::new_v4(), + name: "Prod".to_owned(), + driver_id: "postgres".to_owned(), + host: "db.example.com".to_owned(), + port: 5432, + database: "shop".to_owned(), + username: "app".to_owned(), + tls: TlsConfig::verify_full(), + read_only: false, + auth_mode, + ssh: ssh.map(|auth| SavedSshConfig { + host: "bastion.example.com".to_owned(), + port: None, + username: Some("deploy".to_owned()), + jump_hosts: Vec::new(), + auth, + }), + last_opened_at: None, + color: None, + group: None, + } + } + + fn ssh_rejected() -> ConnectError { + ConnectError::Driver(DriverError::Transport(TransportError::Ssh( + SshFailure::AuthenticationRejected { + user: "deploy".to_owned(), + host: "bastion.example.com".to_owned(), + methods: vec!["password".to_owned()], + }, + ))) + } + + #[test] + fn a_refused_database_login_offers_the_database_password() { + let refused = ConnectError::Driver(DriverError::auth(None)); + + assert_eq!( + refused_secret(&refused, &saved(AuthMode::Password, None)), + Some(SecretKind::DatabasePassword) + ); + assert_eq!( + refused_secret(&refused, &saved(AuthMode::Kerberos, None)), + None, + "a Kerberos login has no password to type" + ); + } + + #[test] + fn a_refused_ssh_login_offers_the_secret_the_keyring_holds_for_it() { + let password = saved(AuthMode::Password, Some(SavedSshAuth::Password)); + let stored_passphrase = saved( + AuthMode::Password, + Some(SavedSshAuth::PrivateKey { + path: Some(PathBuf::from("/home/u/.ssh/id_ed25519")), + has_passphrase: true, + }), + ); + let asked_by_ssh = saved( + AuthMode::Password, + Some(SavedSshAuth::PrivateKey { + path: Some(PathBuf::from("/home/u/.ssh/id_ed25519")), + has_passphrase: false, + }), + ); + let agent = saved(AuthMode::Password, Some(SavedSshAuth::Agent)); + + assert_eq!( + refused_secret(&ssh_rejected(), &password), + Some(SecretKind::SshPassword) + ); + assert_eq!( + refused_secret(&ssh_rejected(), &stored_passphrase), + Some(SecretKind::SshPassphrase) + ); + assert_eq!(refused_secret(&ssh_rejected(), &asked_by_ssh), None); + assert_eq!(refused_secret(&ssh_rejected(), &agent), None); + } + + #[test] + fn an_unreadable_keyring_offers_nothing_to_type_again() { + let unreadable = ConnectError::Keyring(KeyringError::Secret { + kind: SecretKind::SshPassword, + error: tablepro_core::credentials::SecretError::Backend { + detail: "broken".to_owned(), + }, + }); + + assert_eq!( + refused_secret(&unreadable, &saved(AuthMode::Password, Some(SavedSshAuth::Password))), + None, + "a keyring read error was offered as a refused password" + ); + } + + #[test] + fn failures_a_secret_cannot_fix_offer_nothing() { + let lost = ConnectError::Driver(DriverError::ConnectionLost { + during: tablepro_core::LossPhase::Connect, + }); + + assert_eq!(refused_secret(&lost, &saved(AuthMode::Password, None)), None); + let pg_hba = ConnectError::Driver(DriverError::LoginRefused(Box::new( + tablepro_core::ServerDiagnostics::new(None, "no pg_hba.conf entry for host"), + ))); + assert_eq!( + refused_secret(&pg_hba, &saved(AuthMode::Password, None)), + None, + "a login the server's rules refused offered a new password" + ); + assert_eq!( + refused_secret(&ConnectError::Cancelled, &saved(AuthMode::Password, None)), + None + ); + } + + #[test] + fn every_kind_round_trips_through_its_action_target() { + for kind in SecretKind::ALL { + assert_eq!(secret_kind_named(kind.attribute()), Some(kind)); + } + assert_eq!(secret_kind_named("password"), None); + } +} + +#[cfg(test)] +mod window_tests { + use gtk4::glib; + use gtk4::prelude::*; + use libadwaita as adw; + use libadwaita::prelude::*; + use secrecy::{ExposeSecret, SecretString}; + use tablepro_core::Credentials; + use tablepro_core::credentials::{SecretError, SecretVault}; + use tablepro_session::RequestTarget; + use tablepro_session::fakes::{EstablishCall, FakeCredentialSource}; + use tablepro_test_support::Call; + + use super::*; + use crate::test_support::{descendants, finish_on_main, wait_until}; + use crate::ui::session_window::SessionWindowInput; + use crate::ui::session_window::connect_failed_page::ConnectFailedPage; + use crate::ui::session_window::tests::{Script, TestWindow, WAIT, refused}; + + fn open_prompt(window: &SessionWindow) -> Option { + window + .dialogs() + .iter::() + .filter_map(|item| item.ok()?.downcast::().ok()) + .next() + } + + /// Type `secret` into the credential prompt the window shows and + /// press its Connect, as the user would. Returns what the prompt + /// said. + fn answer_prompt(window: &SessionWindow, secret: &str, remember: bool) -> String { + let mut shown = None; + wait_until(WAIT, || { + shown = open_prompt(window); + shown.is_some() + }) + .expect("a credential prompt"); + let alert = shown.expect("the prompt"); + let body = alert.body().to_string(); + let rows = alert.extra_child().expect("the prompt's rows"); + for row in descendants(&rows) { + if let Some(entry) = row.downcast_ref::() { + entry.set_text(secret); + } else if let Some(switch) = row.downcast_ref::() { + switch.set_active(remember); + } + } + assert!( + crate::workspace::answer(window, "accept"), + "the prompt has no Connect button" + ); + body + } + + fn password_of(call: &EstablishCall) -> Option { + match &call.request.target { + RequestTarget::Network { + credentials: Credentials::Password { password, .. }, + .. + } => Some(password.expose_secret().to_owned()), + _ => None, + } + } + + fn failed_page(window: &SessionWindow) -> ConnectFailedPage { + window + .content() + .and_then(|child| child.downcast::().ok()) + .expect("the failed page") + } + + fn toasts(window: &SessionWindow) -> Vec { + window + .imp() + .toasts + .borrow() + .iter() + .filter_map(|toast| toast.title().map(|title| title.to_string())) + .collect() + } + + fn password_loads(test: &TestWindow) -> usize { + test.vault + .calls() + .iter() + .filter(|call| **call == Call::Load(test.saved.id, SecretKind::DatabasePassword)) + .count() + } + + #[gtk4::test] + fn a_connect_the_user_starts_may_ask_for_what_is_missing() { + let credentials = FakeCredentialSource::new(); + let test = TestWindow::with_script( + "Attended", + Script { + credentials: Some(credentials.clone()), + ..Script::default() + }, + ); + + test.window + .send(SessionWindowInput::OpenSaved(Box::new(test.saved.clone()))); + test.wait_for_page("workspace"); + + assert_eq!(credentials.refreshes(), [true]); + assert!(test.connector.calls().iter().all(|call| call.attended)); + } + + #[gtk4::test] + fn enter_password_asks_again_and_skips_the_refused_secret() { + let test = TestWindow::with_script( + "EnterPassword", + Script { + establish: vec![refused()], + network: true, + ..Script::default() + }, + ); + let (vault, id) = (test.vault.clone(), test.saved.id); + finish_on_main(WAIT, async move { + vault + .store(id, SecretKind::DatabasePassword, &SecretString::from("old"), "label") + .await + }) + .expect("the store to end") + .expect("the old password stored"); + test.window + .send(SessionWindowInput::OpenSaved(Box::new(test.saved.clone()))); + test.wait_for_page("connect-failed"); + let button = failed_page(&test.window) + .reenter_button() + .expect("an Enter Password button"); + assert_eq!(button.label().as_deref(), Some("Enter Password…")); + assert!( + test.window + .lookup_action("retry-with-new-secret") + .is_some_and(|action| action.is_enabled()) + ); + let loads = password_loads(&test); + + button.emit_clicked(); + let asked = answer_prompt(&test.window, "new", true); + test.wait_for_page("workspace"); + + assert!(asked.contains("refused"), "{asked}"); + assert_eq!(password_loads(&test), loads, "the refused password was read again"); + let calls = test.connector.calls(); + assert_eq!(password_of(&calls[0]).as_deref(), Some("old")); + assert_eq!(password_of(&calls[1]).as_deref(), Some("new")); + assert_eq!( + test.vault.stored_secret(id, SecretKind::DatabasePassword).as_deref(), + Some("new"), + "the password that worked was not remembered" + ); + } + + #[gtk4::test] + fn a_reconnect_uses_the_password_typed_without_remember() { + let test = TestWindow::with_script( + "ReenterReconnect", + Script { + establish: vec![refused()], + network: true, + ..Script::default() + }, + ); + let (vault, id) = (test.vault.clone(), test.saved.id); + finish_on_main(WAIT, async move { + vault + .store(id, SecretKind::DatabasePassword, &SecretString::from("old"), "label") + .await + }) + .expect("the store to end") + .expect("the old password stored"); + test.window + .send(SessionWindowInput::OpenSaved(Box::new(test.saved.clone()))); + test.wait_for_page("connect-failed"); + failed_page(&test.window) + .reenter_button() + .expect("an Enter Password button") + .emit_clicked(); + answer_prompt(&test.window, "new", false); + test.wait_for_page("workspace"); + + WidgetExt::activate_action(&test.window, "win.reconnect", None).expect("win.reconnect"); + wait_until(WAIT, || test.connector.calls().len() == 3).expect("the reconnect"); + + let calls = test.connector.calls(); + assert_eq!( + password_of(&calls[2]).as_deref(), + Some("new"), + "the reconnect logged in with the refused password" + ); + assert!(open_prompt(&test.window).is_none(), "the reconnect asked again"); + assert_eq!( + test.vault.stored_secret(id, SecretKind::DatabasePassword).as_deref(), + Some("old"), + "a password typed without Remember was kept" + ); + } + + #[gtk4::test] + fn a_failure_no_password_fixes_offers_no_enter_password() { + let lost = tablepro_session::fakes::FakeEstablish::Fail(ConnectError::Driver(DriverError::ConnectionLost { + during: tablepro_core::LossPhase::Connect, + })); + let test = TestWindow::with_script( + "NoEnterPassword", + Script { + establish: vec![lost], + network: true, + ..Script::default() + }, + ); + let (vault, id) = (test.vault.clone(), test.saved.id); + finish_on_main(WAIT, async move { + vault + .store(id, SecretKind::DatabasePassword, &SecretString::from("old"), "label") + .await + }) + .expect("the store to end") + .expect("the password stored"); + + test.window + .send(SessionWindowInput::OpenSaved(Box::new(test.saved.clone()))); + test.wait_for_page("connect-failed"); + + assert!(failed_page(&test.window).reenter_button().is_none()); + assert!( + test.window + .lookup_action("retry-with-new-secret") + .is_some_and(|action| !action.is_enabled()) + ); + } + + #[gtk4::test] + fn adoption_stores_remembered_secret_and_toasts_failure() { + let test = TestWindow::with_script( + "RememberRefused", + Script { + network: true, + ..Script::default() + }, + ); + test.vault.fail_store(SecretKind::DatabasePassword, SecretError::Locked); + + test.window + .send(SessionWindowInput::OpenSaved(Box::new(test.saved.clone()))); + let asked = answer_prompt(&test.window, "typed", true); + test.wait_for_page("workspace"); + + assert!(asked.contains("No password is saved"), "{asked}"); + assert_eq!(password_of(&test.connector.calls()[0]).as_deref(), Some("typed")); + assert!( + test.vault + .calls() + .contains(&Call::Store(test.saved.id, SecretKind::DatabasePassword)), + "the typed password was not offered to the keyring" + ); + wait_until(WAIT, || { + toasts(&test.window) + .iter() + .any(|toast| toast.starts_with("The keyring stayed locked, so the password was not saved")) + }) + .expect("the refused save to be reported"); + } +} diff --git a/linux/crates/app/src/ui/session_window/session_page.rs b/linux/crates/app/src/ui/session_window/session_page.rs new file mode 100644 index 0000000000..45c29823f0 --- /dev/null +++ b/linux/crates/app/src/ui/session_window/session_page.rs @@ -0,0 +1,67 @@ +use std::sync::Arc; + +use tablepro_session::{ConnectSource, Session, SessionClaim, SessionKey}; + +use super::session_window_command::ConnectFailure; +use crate::ui::workspace::Workspace; + +/// Which page a window shows, and what it holds for that page. The +/// claim on the session's key lives here, so leaving the page releases +/// it. +#[derive(Default)] +pub(crate) enum SessionPage { + /// The saved connections, to pick one from. + #[default] + Picker, + Connecting { + claim: SessionClaim, + source: ConnectSource, + }, + ConnectFailed { + claim: SessionClaim, + source: ConnectSource, + failure: ConnectFailure, + }, + Workspace { + claim: SessionClaim, + session: Arc, + workspace: Workspace, + }, +} + +impl SessionPage { + /// The key the page holds a claim on. + pub(crate) fn key(&self) -> Option<&SessionKey> { + match self { + Self::Picker => None, + Self::Connecting { claim, .. } | Self::ConnectFailed { claim, .. } | Self::Workspace { claim, .. } => { + Some(claim.key()) + } + } + } + + #[cfg(test)] + pub(crate) fn name(&self) -> &'static str { + match self { + Self::Picker => "picker", + Self::Connecting { .. } => "connecting", + Self::ConnectFailed { .. } => "connect-failed", + Self::Workspace { .. } => "workspace", + } + } +} + +impl std::fmt::Debug for SessionPage { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Picker => f.write_str("Picker"), + Self::Connecting { claim, .. } => f.debug_struct("Connecting").field("key", claim.key()).finish(), + Self::ConnectFailed { claim, failure, .. } => f + .debug_struct("ConnectFailed") + .field("key", claim.key()) + .field("failure", failure) + .finish(), + Self::Workspace { claim, .. } => f.debug_struct("Workspace").field("key", claim.key()).finish(), + } + } +} diff --git a/linux/crates/app/src/ui/session_window/session_restore.rs b/linux/crates/app/src/ui/session_window/session_restore.rs new file mode 100644 index 0000000000..f2dd688975 --- /dev/null +++ b/linux/crates/app/src/ui/session_window/session_restore.rs @@ -0,0 +1,327 @@ +//! Reopening a session that was open when the application last quit, +//! and keeping the record of open sessions in step with the window. + +use gtk4::glib; +use gtk4::subclass::prelude::*; +use tablepro_session::SessionKey; +use tablepro_storage::SavedConnection; +use uuid::Uuid; + +use super::{SessionWindow, SessionWindowInput}; +use crate::runtime::DeliverInput; + +impl SessionWindow { + /// A file opens as the file manager would open it. A saved + /// connection is looked up first, since it may have been deleted + /// since the quit. + pub(super) fn on_restore(&self, key: SessionKey) { + let id = match key { + SessionKey::File(uri) => { + self.on_open_file(gio::File::for_uri(&uri)); + return; + } + SessionKey::Saved(id) => id, + }; + let (Some(state), Some(input)) = (self.state(), self.input()) else { + return; + }; + let store = state.services.storage.connections().clone(); + let reading = state.services.tasks.spawn_blocking_task(move || store.load_blocking()); + glib::spawn_future_local(async move { + match reading.await { + Ok(Ok(connections)) => { + let saved = connections.iter().find(|saved| saved.id == id).cloned().map(Box::new); + input.deliver(SessionWindowInput::RestoreSaved { id, saved }); + } + // The key stays recorded: the list may read again once + // the user resets it, which the picker's banner offers. + Ok(Err(error)) => { + tracing::warn!(%error, "a session was not restored: the saved connections could not be read") + } + Err(failure) => { + tracing::warn!(%failure, "a session was not restored: the connections read task failed") + } + } + }); + } + + pub(super) fn on_restore_saved(&self, id: Uuid, saved: Option) { + self.end_restore(&SessionKey::Saved(id)); + match saved { + Some(saved) => self.on_open_saved(saved), + None => { + self.forget_session(&SessionKey::Saved(id)); + self.show_toast(&crate::i18n::gettext("The connection was deleted.")); + } + } + } + + /// Record the session the window adopted when a later launch can + /// open it again: a file, or a connection the saved list holds. One + /// that was never saved has nothing to reopen it from. + pub(super) fn remember_session(&self, key: &SessionKey, saved: Option<&SavedConnection>) { + let restorable = match key { + SessionKey::File(_) => true, + SessionKey::Saved(_) => saved.is_some(), + }; + if restorable && let Some(state) = self.state() { + state.services.open_sessions().add(key.clone()); + } + } + + pub(super) fn forget_session(&self, key: &SessionKey) { + if let Some(state) = self.state() { + state.services.open_sessions().remove(key); + } + } + + /// The restore has its answer, and the page the window shows next + /// speaks for the key. + pub(super) fn end_restore(&self, key: &SessionKey) { + let restoring = &self.imp().restoring; + if restoring.borrow().as_ref() == Some(key) { + restoring.replace(None); + } + } + + /// A window closed before its restore had an answer holds no claim + /// whose release would forget the key. + pub(super) fn abandon_restore(&self) { + if let Some(key) = self.imp().restoring.take() { + self.forget_unclaimed(&key); + } + } + + /// Forget a key this window has no claim on, unless another window + /// shows that session. + pub(super) fn forget_unclaimed(&self, key: &SessionKey) { + if let Some(state) = self.state() + && state.context.sessions.window_for(key).is_none() + { + state.services.open_sessions().remove(key); + } + } +} + +#[cfg(test)] +mod tests { + use std::time::Duration; + + use gtk4::prelude::*; + use tablepro_session::fakes::{FakeConnector, FakeEstablish}; + use tablepro_session::{PendingSave, SessionKey}; + use tablepro_test_support::ConnectionScript; + + use super::*; + use crate::application::WindowLaunch; + use crate::test_support::wait_until; + use crate::ui::connect_dialog::ConnectSubmission; + use crate::ui::session_window::tests::{Script, TestWindow, WAIT}; + use crate::workspace::OpenSessionsRecord; + + impl TestWindow { + fn recorded(&self) -> Vec { + self.services.open_sessions().keys() + } + + /// Open a window on `key` the way a launch restoring it does. + fn restored_window(&self, key: &SessionKey) -> SessionWindow { + self.services + .open_sessions() + .restore(OpenSessionsRecord::from_keys([key])); + self.app + .open_window(WindowLaunch::Restore(key.clone())) + .and_then(|window| window.downcast::().ok()) + .expect("a restored window") + } + + /// The URI a file under the test's directory is keyed by. + fn file_key(&self, name: &str) -> SessionKey { + let dir = std::fs::canonicalize(self.dir()).expect("the canonical directory"); + let uri = glib::filename_to_uri(dir.join(name), None).expect("a file URI"); + SessionKey::File(uri.to_string()) + } + } + + fn wait_for(window: &SessionWindow, page: &str) { + wait_until(WAIT, || window.page_name() == page) + .unwrap_or_else(|_| panic!("the window stayed on {}, not {page}", window.page_name())); + } + + fn submission(test: &TestWindow) -> ConnectSubmission { + let mut connection = test.saved.clone(); + connection.id = Uuid::new_v4(); + connection.name = "Submitted".to_owned(); + ConnectSubmission { + request: FakeConnector::request().expect("a request"), + pending_save: PendingSave { + connection, + secrets: Vec::new(), + forget: Vec::new(), + is_new: true, + }, + } + } + + #[gtk4::test] + fn adoption_adds_persisted_key_only() { + let test = TestWindow::picker("AdoptPersisted"); + let list = test.services.storage.paths().connections_file(); + // A directory where the list goes makes its write fail. + std::fs::remove_file(&list).expect("remove the list"); + std::fs::create_dir(&list).expect("block the list"); + let refused = submission(&test); + let refused_key = SessionKey::Saved(refused.pending_save.connection.id); + + test.window + .send(SessionWindowInput::ConnectFromDialog(Box::new(refused))); + test.wait_for_page("connect-failed"); + assert!( + !test.recorded().contains(&refused_key), + "a connection whose entry was not saved was recorded" + ); + + std::fs::remove_dir(&list).expect("unblock the list"); + test.window.send(SessionWindowInput::CancelConnect); + test.wait_for_page("picker"); + let saved = submission(&test); + let saved_key = SessionKey::Saved(saved.pending_save.connection.id); + test.window.send(SessionWindowInput::ConnectFromDialog(Box::new(saved))); + test.wait_for_page("workspace"); + + assert_eq!(test.recorded(), [saved_key]); + } + + #[gtk4::test] + fn an_opened_file_is_recorded_and_a_disconnect_forgets_it() { + let test = TestWindow::picker("RecordFile"); + std::fs::write(test.dir().join("local.db"), b"").expect("seed"); + let key = test.file_key("local.db"); + + test.window.send(SessionWindowInput::OpenFile(gio::File::for_path( + test.dir().join("local.db"), + ))); + test.wait_for_page("workspace"); + assert_eq!(test.recorded(), std::slice::from_ref(&key)); + + test.window.send(SessionWindowInput::Disconnect); + test.wait_for_page("picker"); + assert!(test.recorded().is_empty(), "a disconnected session stayed recorded"); + } + + #[gtk4::test] + fn a_closed_window_forgets_its_session() { + let test = TestWindow::connected("CloseForgets"); + assert_eq!(test.recorded(), [test.key()]); + + test.window.close(); + + wait_until(WAIT, || test.recorded().is_empty()).expect("the closed window's session to be forgotten"); + } + + #[gtk4::test] + fn a_restored_saved_connection_connects_and_stays_recorded() { + let test = TestWindow::picker("RestoreSaved"); + + let window = test.restored_window(&test.key()); + wait_for(&window, "workspace"); + + assert_eq!(test.services.sessions.window_for(&test.key()), Some(window.id())); + assert_eq!(test.recorded(), [test.key()]); + } + + #[gtk4::test] + fn cancel_connect_removes_key() { + let test = TestWindow::with_script( + "RestoreCancel", + Script { + establish: vec![FakeEstablish::Late { + after: Duration::from_millis(300), + script: ConnectionScript::default(), + }], + ..Script::default() + }, + ); + let window = test.restored_window(&test.key()); + wait_for(&window, "connecting"); + assert_eq!(test.recorded(), [test.key()]); + + window.send(SessionWindowInput::CancelConnect); + wait_for(&window, "picker"); + + assert!(test.recorded().is_empty(), "a cancelled restore stayed recorded"); + } + + #[gtk4::test] + fn a_deleted_saved_connection_is_forgotten_and_shows_the_picker() { + let test = TestWindow::picker("RestoreDeleted"); + let gone = SessionKey::Saved(Uuid::new_v4()); + + let window = test.restored_window(&gone); + wait_until(WAIT, || test.recorded().is_empty()).expect("the deleted connection to be forgotten"); + + assert_eq!(window.page_name(), "picker"); + assert!( + test.connector.calls().is_empty(), + "a deleted connection was connected to" + ); + } + + #[gtk4::test] + fn a_restored_window_closed_before_its_lookup_forgets_the_session() { + let test = TestWindow::picker("RestoreClosedEarly"); + + let window = test.restored_window(&test.key()); + window.close(); + + assert!( + test.recorded().is_empty(), + "the closed window's session stayed recorded" + ); + let _ = wait_until(Duration::from_millis(300), || { + test.services.sessions.window_for(&test.key()).is_some() + }); + assert_eq!( + test.services.sessions.window_for(&test.key()), + None, + "the closed window connected" + ); + } + + #[gtk4::test] + fn a_failed_open_of_a_file_another_window_shows_keeps_it_recorded() { + let test = TestWindow::picker("OpenGoneFile"); + let path = test.dir().join("local.db"); + std::fs::write(&path, b"").expect("seed"); + let key = test.file_key("local.db"); + test.window + .send(SessionWindowInput::OpenFile(gio::File::for_path(&path))); + test.wait_for_page("workspace"); + std::fs::remove_file(&path).expect("delete the file"); + let SessionKey::File(uri) = &key else { + panic!("{key:?} is not a file key"); + }; + test.window.set_visible(false); + + test.app.open_window(WindowLaunch::OpenFile(gio::File::for_uri(uri))); + wait_until(WAIT, || test.window.is_visible()).expect("the window showing the file to come forward"); + + assert_eq!(test.recorded(), [key]); + } + + #[gtk4::test] + fn a_missing_file_is_forgotten_and_a_retry_records_it_again() { + let test = TestWindow::picker("RestoreMissing"); + let key = test.file_key("later.db"); + + let window = test.restored_window(&key); + wait_for(&window, "connect-failed"); + assert!(test.recorded().is_empty(), "a file that is gone stayed recorded"); + + std::fs::write(test.dir().join("later.db"), b"").expect("create the file"); + window.send(SessionWindowInput::RetryConnect); + wait_for(&window, "workspace"); + + assert_eq!(test.recorded(), [key]); + } +} diff --git a/linux/crates/app/src/ui/session_window/session_teardown.rs b/linux/crates/app/src/ui/session_window/session_teardown.rs new file mode 100644 index 0000000000..8b5f7de86b --- /dev/null +++ b/linux/crates/app/src/ui/session_window/session_teardown.rs @@ -0,0 +1,216 @@ +//! The ordered teardown of a window's session, for a disconnect and an +//! approved close. + +use std::sync::Arc; +use std::time::Duration; + +use gtk4 as gtk; +use gtk4::glib; +use gtk4::prelude::*; +use gtk4::subclass::prelude::*; +use libadwaita::prelude::AdwApplicationWindowExt; +use tablepro_core::EditorSession; +use tablepro_session::Session; +use tablepro_session::runtime::{ProcessHold, Tasks}; + +use super::SessionWindow; +use super::session_page::SessionPage; + +impl SessionWindow { + /// Take the workspace down once its close prompts approved, and + /// close the session under one hold. False when the window shows + /// no workspace. + /// + /// The order matters. What the workspace shows is written before it + /// is freed; the workspace goes before the claim, so nothing of it + /// reads for a key another window can now claim; and the claim goes + /// before the close finishes, so the same connection can be picked + /// again at once. + pub(super) fn tear_down(&self) -> bool { + let imp = self.imp(); + let Some(state) = self.state() else { + return false; + }; + let Ok(mut page) = imp.page.try_borrow_mut() else { + tracing::warn!("the session was torn down while its page was in use"); + return false; + }; + if !matches!(*page, SessionPage::Workspace { .. }) { + return false; + } + let SessionPage::Workspace { + claim, + session, + workspace, + } = std::mem::take(&mut *page) + else { + return false; + }; + drop(page); + workspace.flush(); + let editor_sessions = workspace.take_editor_sessions(); + self.set_content(gtk::Widget::NONE); + imp.page_toasts.replace(None); + self.set_title(Some(&crate::i18n::gettext("TablePro"))); + workspace.shutdown(); + drop(workspace); + self.withdraw_notifications(); + let key = claim.key().clone(); + drop(claim); + // A quit freezes the record first, so only a disconnect or a + // window closed by hand takes the session off it. + self.forget_session(&key); + close_session( + session, + editor_sessions, + &*state.services.hold(), + &state.services.tasks, + state.context.close_timeout(), + ); + imp.dirty.set(false); + imp.saving.set(false); + self.refresh_inhibitor(); + super::window_actions::sync(self.upcast_ref(), &SessionPage::Picker, false); + true + } +} + +/// Close the session and every editor tab's connection together, under +/// the close deadline. One hold keeps the process alive until all of +/// them end, so quitting right after a disconnect closes them rather +/// than cutting them off. +fn close_session( + session: Arc, + editor_sessions: Vec>, + hold: &dyn ProcessHold, + tasks: &Tasks, + close_timeout: Duration, +) { + let hold = hold.acquire(); + let editors_closing = tablepro_session::close_editor_sessions(editor_sessions, close_timeout, tasks); + let closing = session.begin_shutdown(); + drop(session); + glib::spawn_future_local(async move { + let (closed, editors) = futures::future::join(closing, editors_closing).await; + match closed { + Ok(report) => tracing::debug!(?report, "the session closed"), + Err(failure) => tracing::warn!(%failure, "the session close task failed"), + } + match editors { + Ok(reports) => tracing::debug!(?reports, "the editor tabs' sessions closed"), + Err(failure) => tracing::warn!(%failure, "closing the editor tabs' sessions failed"), + } + drop(hold); + }); +} + +#[cfg(test)] +mod tests { + use std::time::Duration; + + use tablepro_core::EditorSession; + use tablepro_test_support::{CallCounters, CloseBehaviour, FakeEditorSession, FakeReply, FakeRun, Replies}; + + use std::cell::RefCell; + use std::rc::Rc; + + use tablepro_session::RegistryEvent; + + use super::*; + use crate::test_support::wait_until; + use crate::ui::session_window::SessionWindowInput; + use crate::ui::session_window::tests::{TestWindow, WAIT}; + + #[gtk4::test] + fn disconnect_runs_ordered_teardown() { + let test = TestWindow::connected("OrderedTeardown"); + let workspace = test.workspace().downgrade(); + let key = test.key(); + wait_until(WAIT, || test.hold.live() == 0).expect("the workspace's opening writes to land"); + // Each step records what had happened before it. Only the flush + // holds the process before the claim goes, and the close's hold + // comes after it. + let steps = Rc::new(RefCell::new(Vec::new())); + let (detached, hold) = (steps.clone(), test.hold.clone()); + test.workspace() + .connect_notify_local(Some("parent"), move |workspace, _| { + if workspace.parent().is_none() { + detached.borrow_mut().push(("detached", hold.live() > 0)); + } + }); + let (released, gone) = (steps.clone(), workspace.clone()); + let _subscription = test.services.sessions.subscribe(move |event| { + if matches!(event, RegistryEvent::Released { .. }) { + let shut_down = gone.upgrade().is_none_or(|workspace| workspace.handle().is_none()); + released.borrow_mut().push(("released", shut_down)); + } + }); + + test.window.send(SessionWindowInput::Disconnect); + test.wait_for_page("picker"); + + assert_eq!( + *steps.borrow(), + [("detached", true), ("released", true)], + "a step ran before the one it depends on" + ); + + let child = test.window.content(); + assert!( + child.is_some_and(|child| child.type_().name() == "TableProPickerPage"), + "the picker did not replace the workspace" + ); + let counters = test.connection().counters(); + wait_until(WAIT, || CallCounters::get(&counters.close) == 1).expect("the connection to close"); + crate::test_support::drain_main_context(); + assert_eq!( + CallCounters::get(&counters.close), + 1, + "the connection closed more than once" + ); + // GTK holds a widget that had the focus until the window paints + // again, and a delivery still in flight holds the workspace until + // its aborted task's result reaches the main context. + crate::test_support::after_next_frame(&test.window); + wait_until(WAIT, || workspace.upgrade().is_none()).expect("the workspace outlived its teardown"); + let claim = test + .services + .sessions + .claim(key, test.window.id() + 100) + .expect("the key is free again at once"); + drop(claim); + } + + #[gtk4::test] + fn disconnect_closes_editor_sessions_once() { + let delayed = || { + let mut runs = Replies::default(); + runs.always(FakeRun::completed(Vec::new())); + std::sync::Arc::new(FakeEditorSession::with_close( + runs, + CloseBehaviour::Delay(Duration::from_millis(100)), + )) + }; + let (first, second) = (delayed(), delayed()); + let test = TestWindow::connected_with("EditorSessions", |script| { + let (a, b): (Arc, Arc) = (first.clone(), second.clone()); + script.open_editor_session.once(FakeReply::ok(a)); + script.open_editor_session.once(FakeReply::ok(b)); + }); + test.run_in_new_editor_tab(); + test.run_in_new_editor_tab(); + wait_until(WAIT, || !first.texts().is_empty() && !second.texts().is_empty()) + .expect("both tabs to open their sessions"); + + test.window.send(SessionWindowInput::Disconnect); + test.wait_for_page("picker"); + assert!(test.hold.live() > 0, "nothing held the process for the closes"); + wait_until(WAIT, || test.hold.live() == 0).expect("the holds to end"); + + assert!( + first.is_closed() && second.is_closed(), + "the hold ended before both delayed closes did" + ); + assert_eq!((first.close_count(), second.close_count()), (1, 1)); + } +} diff --git a/linux/crates/app/src/ui/session_window/session_window_command.rs b/linux/crates/app/src/ui/session_window/session_window_command.rs new file mode 100644 index 0000000000..e88e1ff3e9 --- /dev/null +++ b/linux/crates/app/src/ui/session_window/session_window_command.rs @@ -0,0 +1,69 @@ +use tablepro_core::credentials::SecretKind; +use tablepro_session::PendingSession; +use tablepro_session::connect::AttemptGeneration; +use tablepro_storage::SavedConnection; + +use crate::ui::error_text::TlsRemedy; + +/// What background work a window started brings back to it. +#[derive(Debug)] +pub(crate) enum SessionWindowCommand { + /// A connect attempt ended. Its generation says which one, so a + /// result that lands after the user moved on is discarded. + Attempt { + generation: AttemptGeneration, + result: Result, + }, +} + +/// A connect that worked, waiting for the window to adopt it. +#[derive(Debug)] +pub(crate) struct Connected { + pub pending: PendingSession, + /// The saved entry it connected to, as saved, which a reconnect + /// reads its secrets for. `None` for a file nobody saved. + pub saved: Option, + /// Secrets the keyring refused to keep, one sentence each. + pub warnings: Vec, + /// Secrets typed for this login that the keyring does not hold. The + /// session reconnects with them rather than with the keyring's copy. + pub session_only: Vec, +} + +#[derive(Debug)] +pub(crate) enum AttemptError { + /// The user stopped it, which needs no error. + Cancelled, + /// The database file is on a filesystem whose locking SQLite cannot + /// trust, so the user is asked how to open it first. + AskLocation(crate::services::file_location::FileLocationKind), + Failed(ConnectFailure), +} + +/// What the failed page says, and the setting that would fix it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct ConnectFailure { + pub message: String, + pub remedy: Option, + /// The secret the server refused, which the user can type again in + /// place of the keyring's copy. + pub reenter: Option, + /// The database file the driver could not find, which the user can + /// locate again for a saved connection. + pub missing_file: Option, + /// The page's title when the connect itself worked and what came + /// after it failed. + pub heading: Option, +} + +impl ConnectFailure { + pub(crate) fn new(message: String) -> Self { + Self { + message, + remedy: None, + reenter: None, + missing_file: None, + heading: None, + } + } +} diff --git a/linux/crates/app/src/ui/session_window/session_window_input.rs b/linux/crates/app/src/ui/session_window/session_window_input.rs new file mode 100644 index 0000000000..dc682fc313 --- /dev/null +++ b/linux/crates/app/src/ui/session_window/session_window_input.rs @@ -0,0 +1,116 @@ +use tablepro_core::credentials::SecretKind; +use tablepro_session::SessionKey; +use tablepro_storage::{ConnectionColor, ConnectionGroupName, SavedConnection}; +use uuid::Uuid; + +use super::resolve_file_identity::ResolvedFile; +use super::session_window_command::SessionWindowCommand; +use super::sqlite_location_dialog::LocationChoice; +use crate::services::connection_deletion::DeletionOptions; +use crate::ui::connect_dialog::ConnectSubmission; +use crate::workspace::{CloseReason, CompletedWork}; + +#[derive(Debug)] +pub(crate) enum SessionWindowInput { + /// Open a saved connection: in this window from the picker, in + /// another one otherwise. + OpenSaved(Box), + /// Open a database file, as the file manager or the command line + /// hands it over. + OpenFile(gio::File), + /// The file resolved, off the GTK thread, to the session it opens. + FileResolved(Box), + /// Open a session that was open when the application last quit. + Restore(SessionKey), + /// The restored saved connection as the list holds it now, or `None` + /// once it is deleted. + RestoreSaved { + id: Uuid, + saved: Option>, + }, + OpenConnectDialog, + /// The connect dialog's Connect: connect, then save the entry. + ConnectFromDialog(Box), + DialogClosed, + /// Stop the connect in progress, or leave a failed one, for the + /// picker. + CancelConnect, + /// Connect again from where the failed connect came from, reading + /// the keyring again. + RetryConnect, + /// Connect a saved connection whose login was refused again, asking + /// for this secret instead of reading the keyring's copy. + RetryWithNewSecret(SecretKind), + /// Open the connect dialog on a connection that failed to connect, + /// with the focus on the setting that fixes it. + EditConnection, + /// Open the connect dialog on a saved connection to change it. + EditSavedConnection(Uuid), + /// The connect dialog saved its changes to a saved connection. + ConnectionSaved(Uuid), + /// Close the workspace through its prompts and return to the + /// picker. + Disconnect, + /// The prompts approved the disconnect. + TeardownApproved, + /// Close the workspace's current tab, or the window when it shows + /// no tab. + CloseCurrent, + /// The health banner's Retry: ask the session to reconnect now. + Reconnect, + /// The toast's Reset: move the workspace's unreadable saved tabs + /// aside. + ResetWorkspaceLayout, + /// The application asks the window to close. + CloseRequested(CloseReason), + /// The workspace gained or lost something a logout would lose. + DirtyChanged(bool), + /// The number of saves running in the workspace changed. + SavesInFlightChanged(usize), + TitleChanged(String), + /// A run, save or export ended in one of the workspace's tabs. + WorkCompleted(CompletedWork), + /// The tab in front, the open tabs or the window's focus changed. + TabsChanged, + /// A notification about the tab was clicked. + FocusTab(Uuid), + Command(Box), + /// The answer for a file on a filesystem whose locking SQLite cannot + /// trust, or ReadWrite when there was nothing to ask. + LocationChosen { + generation: tablepro_session::connect::AttemptGeneration, + choice: LocationChoice, + }, + /// The user found the file the saved connection `id` lost. + FileLocated { + id: Uuid, + path: std::path::PathBuf, + }, + /// The located file's path is saved; connect to it. + LocatedSaved(Box), + /// Read the connection list, which every picker follows from then on. + LoadConnections, + ResetConnectionList, + /// The keyring kept a deleted connection's secrets, so the entry + /// was kept too. The toast's Retry runs the same delete again. + SecretDeleteFailed(Uuid, DeletionOptions), + /// Ask whether to delete a saved connection. + DeleteConnection(Uuid), + /// The user confirmed the delete. + ConfirmDelete(Uuid, DeletionOptions), + DuplicateConnection(Uuid), + /// A colour picked from a connection row's menu, or `None` when the + /// user cleared it. + SetConnectionColor(Uuid, Option), + /// A group picked from a connection row's menu, or `None` when the + /// user took it out of one. + SetConnectionGroup(Uuid, Option), + ExportConnections, + ImportConnections, + /// The file the user picked to import, read off the GTK thread. + ConnectionsFileRead { + path: std::path::PathBuf, + bytes: Vec, + }, + ShowToast(String), +} diff --git a/linux/crates/app/src/ui/session_window/sqlite_location_dialog.rs b/linux/crates/app/src/ui/session_window/sqlite_location_dialog.rs new file mode 100644 index 0000000000..dac092df5e --- /dev/null +++ b/linux/crates/app/src/ui/session_window/sqlite_location_dialog.rs @@ -0,0 +1,369 @@ +//! The question asked before a SQLite file on a network or FUSE +//! filesystem opens read-write. + +use std::path::PathBuf; + +use futures::FutureExt; +use futures::future::BoxFuture; +use gtk4::glib; +use gtk4::prelude::*; +use gtk4::subclass::prelude::*; +use libadwaita as adw; +use libadwaita::prelude::*; +use tablepro_core::FileOpenMode; +use tablepro_session::connect::AttemptGeneration; +use tablepro_session::runtime::Tasks; +use tablepro_session::{ConnectSource, RequestTarget}; +use tablepro_storage::CredentialDemand; +use tokio_util::sync::CancellationToken; + +use super::session_page::SessionPage; +use super::session_window_command::{AttemptError, Connected}; +use super::{SessionWindow, SessionWindowInput}; +use crate::services::file_location::{self, FileLocationKind, LocationClassifier}; + +const CANCEL: &str = "cancel"; +const READ_ONLY: &str = "read-only"; +const READ_WRITE: &str = "read-write"; + +/// How the user chose to open a file the location check flagged. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum LocationChoice { + Cancel, + ReadOnly, + ReadWrite, +} + +/// What a connect knows about where its database file is. +#[derive(Debug)] +pub(crate) enum FileCheck { + /// Read the filesystem `path` is on before connecting, and stop to + /// ask when SQLite's locking cannot be trusted there. + Pending { + path: PathBuf, + classify: LocationClassifier, + }, + /// Nothing to check, or the user answered. + Settled { read_only: bool }, +} + +impl FileCheck { + pub(crate) fn read_only(&self) -> bool { + matches!(self, Self::Settled { read_only: true }) + } + + /// Run `connecting` once the file is known to be on a filesystem + /// whose locking SQLite can trust. The check reads the disk on the + /// spawner, and only a file that needs the question comes back to + /// the GTK thread before connecting. + pub(crate) fn guard( + self, + tasks: &Tasks, + cancel: CancellationToken, + connecting: BoxFuture<'static, Result>, + ) -> BoxFuture<'static, Result> { + let Self::Pending { path, classify } = self else { + return connecting; + }; + let checking = tasks.spawn_blocking_task(move || classify(&path)); + async move { + let kind = cancel + .run_until_cancelled(checking) + .await + .ok_or(AttemptError::Cancelled)? + .unwrap_or_else(|failure| { + tracing::warn!(%failure, "could not read the database file's filesystem"); + FileLocationKind::Unknown { fs_type: String::new() } + }); + if kind.requires_read_only_prompt() { + return Err(AttemptError::AskLocation(kind)); + } + connecting.await + } + .boxed() + } +} + +/// The question for a file at `kind`, or `None` when it opens without +/// one. +pub(crate) fn for_location(kind: &FileLocationKind) -> Option { + kind.requires_read_only_prompt().then(location_dialog) +} + +fn location_dialog() -> adw::AlertDialog { + let dialog = adw::AlertDialog::new( + Some(&crate::i18n::gettext("Database on a Network Filesystem")), + Some(&crate::i18n::gettext( + "SQLite can corrupt a database on a network or FUSE filesystem when more than one program writes to it.", + )), + ); + dialog.add_responses(&[ + (CANCEL, &crate::i18n::gettext("_Cancel")), + (READ_ONLY, &crate::i18n::gettext("Open _Read-Only")), + (READ_WRITE, &crate::i18n::gettext("Open Read-_Write")), + ]); + dialog.set_response_appearance(READ_ONLY, adw::ResponseAppearance::Suggested); + dialog.set_response_appearance(READ_WRITE, adw::ResponseAppearance::Destructive); + dialog.set_default_response(Some(READ_ONLY)); + dialog.set_close_response(CANCEL); + dialog +} + +impl SessionWindow { + /// The file a connect would open read-write. A file opened + /// read-only needs no question, nor does a new one, which no other + /// program writes to yet. + pub(super) fn file_to_check(&self, source: &ConnectSource) -> Option { + match source { + ConnectSource::File(identity) => identity.canonical_path.clone(), + ConnectSource::Saved(saved) => { + let file_based = self + .state()? + .context + .drivers + .get(&saved.driver_id) + .is_some_and(|driver| driver.capabilities().is_file_based()); + (file_based && !saved.read_only).then(|| PathBuf::from(&saved.database)) + } + ConnectSource::Intent(intent) => match &intent.request.target { + RequestTarget::File(endpoint) + if endpoint.mode() == FileOpenMode::OpenExisting + && !intent.request.workspace_settings.read_only => + { + Some(endpoint.path().to_path_buf()) + } + _ => None, + }, + } + } + + /// What a connect from `source` checks before it starts. `answer` + /// is how the user chose to open a file they were already asked + /// about. + pub(super) fn file_check(&self, source: &ConnectSource, answer: Option) -> FileCheck { + if let Some(read_only) = answer { + return FileCheck::Settled { read_only }; + } + match self.file_to_check(source) { + Some(path) => FileCheck::Pending { + path, + classify: self + .imp() + .classify_location + .get() + .unwrap_or(file_location::classify_blocking), + }, + None => FileCheck::Settled { read_only: false }, + } + } + + /// The connect stopped before connecting to ask about its file. The + /// connecting page stays behind the question. + pub(super) fn ask_about_location(&self, generation: AttemptGeneration, kind: &FileLocationKind) { + let Some(dialog) = for_location(kind) else { + self.send(SessionWindowInput::LocationChosen { + generation, + choice: LocationChoice::ReadWrite, + }); + return; + }; + let window = self.downgrade(); + glib::spawn_future_local(async move { + let Some(window) = window.upgrade() else { + return; + }; + let choice = match crate::workspace::respond(&dialog, window.upcast_ref()).await.as_str() { + READ_ONLY => LocationChoice::ReadOnly, + READ_WRITE => LocationChoice::ReadWrite, + _ => LocationChoice::Cancel, + }; + window.send(SessionWindowInput::LocationChosen { generation, choice }); + }); + } + + pub(super) fn on_location_chosen(&self, generation: AttemptGeneration, choice: LocationChoice) { + let asking = self.imp().attempts.accepts(generation) + && self.page_is(|page| matches!(page, SessionPage::Connecting { .. })); + if !asking { + return; + } + let read_only = match choice { + LocationChoice::Cancel => { + self.on_cancel_connect(); + return; + } + LocationChoice::ReadOnly => true, + LocationChoice::ReadWrite => false, + }; + let SessionPage::Connecting { claim, source } = self.imp().page.replace(SessionPage::Picker) else { + return; + }; + self.start_connect(claim, source, Some(read_only), CredentialDemand::default()); + } +} + +#[cfg(test)] +mod tests { + use std::path::Path; + + use tablepro_core::FileEndpoint; + + use super::super::tests::{TestWindow, WAIT, dialog_submission}; + use super::*; + use crate::test_support::wait_until; + use crate::workspace::{answer, visible_alert}; + + fn on_a_share(_path: &Path) -> FileLocationKind { + FileLocationKind::Remote { + fs_type: "nfs4".to_owned(), + } + } + + fn open_saved_on_a_share(name: &str) -> TestWindow { + let test = TestWindow::picker(name); + test.window.imp().classify_location.set(Some(on_a_share)); + test.window + .send(SessionWindowInput::OpenSaved(Box::new(test.saved.clone()))); + wait_until(WAIT, || visible_alert(&test.window).is_some()).expect("the location question"); + test + } + + #[gtk4::test] + fn default_response_is_read_only() { + adw::init().expect("libadwaita initialises under the test backend"); + + let dialog = for_location(&on_a_share(Path::new("/mnt/share/app.db"))).expect("a question"); + + assert_eq!(dialog.default_response().as_deref(), Some(READ_ONLY)); + assert_eq!(dialog.close_response(), CANCEL); + assert_eq!( + dialog.response_appearance(READ_ONLY), + adw::ResponseAppearance::Suggested + ); + assert_eq!( + dialog.response_appearance(READ_WRITE), + adw::ResponseAppearance::Destructive + ); + assert!(for_location(&FileLocationKind::Local).is_none()); + } + + #[gtk4::test] + fn portal_path_without_host_path_offers_read_only_default() { + adw::init().expect("libadwaita initialises under the test backend"); + let portal = FileLocationKind::portal(None, |_| true, |_| FileLocationKind::Local); + + let dialog = for_location(&portal).expect("a question for a portal file"); + + assert_eq!(dialog.default_response().as_deref(), Some(READ_ONLY)); + } + + #[gtk4::test] + fn a_file_on_a_share_opens_read_only_when_asked() { + let test = open_saved_on_a_share("ShareReadOnly"); + assert!( + test.connector.calls().is_empty(), + "the connect did not wait for the answer" + ); + + assert!(answer(&test.window, READ_ONLY)); + test.wait_for_page("workspace"); + + let calls = test.connector.calls(); + assert_eq!(calls.len(), 1); + assert!(calls[0].request.workspace_settings.read_only); + } + + #[gtk4::test] + fn a_file_on_a_share_opens_read_write_when_asked() { + let test = open_saved_on_a_share("ShareReadWrite"); + + assert!(answer(&test.window, READ_WRITE)); + test.wait_for_page("workspace"); + + assert!(!test.connector.calls()[0].request.workspace_settings.read_only); + } + + #[gtk4::test] + fn cancel_at_the_question_connects_nothing_and_frees_the_connection() { + let test = open_saved_on_a_share("ShareCancel"); + + assert!(answer(&test.window, CANCEL)); + test.wait_for_page("picker"); + crate::test_support::drain_main_context(); + + assert!(test.connector.calls().is_empty()); + assert_eq!(test.services.sessions.window_for(&test.key()), None); + } + + #[gtk4::test] + fn a_read_only_connection_is_not_asked_about() { + let test = TestWindow::picker("ShareSavedReadOnly"); + test.window.imp().classify_location.set(Some(on_a_share)); + let mut saved = test.saved.clone(); + saved.read_only = true; + test.services + .storage + .connections() + .upsert_blocking(saved.clone()) + .expect("save the entry read-only"); + + test.window.send(SessionWindowInput::OpenSaved(Box::new(saved))); + test.wait_for_page("workspace"); + + assert!(visible_alert(&test.window).is_none()); + } + + #[gtk4::test] + fn a_file_handed_over_from_a_share_is_asked_about() { + let test = TestWindow::picker("ShareFile"); + test.window.imp().classify_location.set(Some(on_a_share)); + let dir = tempfile::tempdir().expect("tempdir"); + let database = dir.path().join("app.db"); + std::fs::write(&database, b"").expect("seed"); + + test.window + .send(SessionWindowInput::OpenFile(gio::File::for_path(&database))); + wait_until(WAIT, || visible_alert(&test.window).is_some()).expect("the location question"); + assert!(test.connector.calls().is_empty()); + assert!(answer(&test.window, READ_ONLY)); + test.wait_for_page("workspace"); + + let calls = test.connector.calls(); + assert_eq!(calls.len(), 1); + assert!(calls[0].request.workspace_settings.read_only); + } + + #[gtk4::test] + fn an_existing_file_from_the_connect_dialog_is_asked_about() { + let test = TestWindow::picker("ShareDialog"); + test.window.imp().classify_location.set(Some(on_a_share)); + let endpoint = FileEndpoint::open_existing(PathBuf::from("/mnt/share/app.db")).expect("an endpoint"); + + test.window + .send(SessionWindowInput::ConnectFromDialog(Box::new(dialog_submission( + endpoint, + )))); + wait_until(WAIT, || visible_alert(&test.window).is_some()).expect("the location question"); + assert!(test.connector.calls().is_empty()); + assert!(answer(&test.window, READ_ONLY)); + test.wait_for_page("workspace"); + + assert!(test.connector.calls()[0].request.workspace_settings.read_only); + } + + #[gtk4::test] + fn a_new_file_from_the_connect_dialog_is_not_asked_about() { + let test = TestWindow::picker("ShareDialogNew"); + test.window.imp().classify_location.set(Some(on_a_share)); + let endpoint = FileEndpoint::create_new(PathBuf::from("/mnt/share/new.db")).expect("an endpoint"); + + test.window + .send(SessionWindowInput::ConnectFromDialog(Box::new(dialog_submission( + endpoint, + )))); + test.wait_for_page("workspace"); + + assert!(visible_alert(&test.window).is_none()); + assert!(!test.connector.calls()[0].request.workspace_settings.read_only); + } +} diff --git a/linux/crates/app/src/ui/session_window/tests/mod.rs b/linux/crates/app/src/ui/session_window/tests/mod.rs new file mode 100644 index 0000000000..c4059403d8 --- /dev/null +++ b/linux/crates/app/src/ui/session_window/tests/mod.rs @@ -0,0 +1,1105 @@ +mod notification; +mod open_file; + +use std::rc::Rc; +use std::sync::Arc; +use std::time::Duration; + +use gtk4::prelude::*; +use libadwaita as adw; +use tablepro_core::{AuthMode, DriverError, DriverRegistry, LivenessPolicy, TlsConfig}; +use tablepro_session::fakes::{FakeConnector, FakeCredentialSource, FakeEstablish}; +use tablepro_session::operation::DefaultDeadlines; +use tablepro_session::runtime::CountingHold; +use tablepro_session::{ConnectError, CredentialSource, SessionKey}; +use tablepro_storage::{SavedConnection, StoragePaths}; +use tablepro_test_support::{CallCounters, ConnectionScript, FakeConnection, FakeDriver, FakeReply, FakeSecretVault}; + +use super::*; +use crate::application::{AppServicesParts, TableProApplication}; +use crate::runtime::Delivery; +use crate::services::session_context::CredentialSourceFactory; +use crate::storage::AppStorage; +use crate::test_support::{MemorySettings, assert_labelled, descendants, drain_main_context, wait_until}; + +pub(crate) const WAIT: Duration = Duration::from_secs(10); + +/// Every saved connection, whatever it is, reads its secrets from one +/// fake, so a test can count the reads. +struct FakeSources(FakeCredentialSource); + +impl CredentialSourceFactory for FakeSources { + fn for_connection( + &self, + _saved: Option<&SavedConnection>, + _session_only: &[tablepro_core::credentials::SecretKind], + ) -> Arc { + Arc::new(self.0.clone()) + } +} + +/// What a scripted world can change before the application starts. +#[derive(Default)] +pub(crate) struct Script { + pub connection: ConnectionScript, + /// What each connect gets, before `connection` from then on. + pub establish: Vec, + pub credentials: Option, + pub ssh: Option, + /// The saved connection logs in to a server with a password, rather + /// than opening a file. + pub network: bool, + /// The saved connection reaches its server through this SSH tunnel, + /// logging in to the database as the system user. + pub tunnel: Option, + /// The connection list on disk cannot be read, so it holds nothing. + pub unreadable_list: bool, + /// The size the window opens at, as the settings remember it. + pub window_size: Option<(i32, i32)>, + /// Stands in for the file manager. + pub revealer: Option>, +} + +/// A TablePro over fakes with one window open on the picker and `saved` +/// in its connection list. The application has an id of its own, so it +/// never meets another test's, or a TablePro the developer has open, on +/// the session bus. +pub(crate) struct TestWindow { + pub app: TableProApplication, + pub window: SessionWindow, + pub services: Rc, + pub hold: Rc, + pub connector: FakeConnector, + pub saved: SavedConnection, + /// The keyring every saved connection reads, unless the script + /// brings its own credentials. + pub vault: Arc, + runtime: Option, + _settings: MemorySettings, + _dir: tempfile::TempDir, +} + +impl TestWindow { + pub(crate) fn picker(name: &str) -> Self { + Self::with_script(name, Script::default()) + } + + pub(crate) fn with_script(name: &str, script: Script) -> Self { + init_gtk(); + let runtime = crate::test_support::test_runtime(); + let settings = MemorySettings::new(); + let dir = tempfile::tempdir().expect("a temporary directory"); + let paths = StoragePaths::under(dir.path(), "tablepro", crate::config::APP_ID); + let list_file = paths.connections_file(); + let vault = Arc::new(FakeSecretVault::new()); + let storage = AppStorage::new(paths, vault.clone(), &runtime.tasks()); + let Script { + mut connection, + establish, + credentials, + ssh, + network, + tunnel, + unreadable_list, + window_size, + revealer, + } = script; + if let Some(size) = window_size { + remember_window_size(&settings, size); + } + let network = network || tunnel.is_some(); + let saved = scripted_connection(network, tunnel, dir.path()); + if unreadable_list { + write_unreadable_list(&list_file); + } else { + storage + .connections() + .upsert_blocking(saved.clone()) + .expect("save the test's connection"); + } + connection.probe.always(FakeReply::ok(())); + connection.list_schemas.always(FakeReply::ok(Vec::new())); + connection.list_objects.always(FakeReply::ok(Vec::new())); + let mut drivers = DriverRegistry::new(); + let capabilities = if network { + &FakeDriver::NETWORK_CAPABILITIES + } else { + &FakeDriver::FILE_CAPABILITIES + }; + drivers.register(Arc::new(FakeDriver::connecting(capabilities, connection.clone()))); + let connector = FakeConnector::new(establish).always(FakeEstablish::Connect(connection)); + let liveness = LivenessPolicy::DESKTOP; + let hold = Rc::new(CountingHold::new()); + let parts = AppServicesParts { + drivers, + connector: Arc::new(connector.clone()), + deadlines: Arc::new(DefaultDeadlines::new(liveness)), + liveness, + settings: settings.get().clone(), + storage, + tasks: runtime.tasks(), + }; + let mut services = scripted_services(parts, hold.clone(), credentials, ssh); + if let Some(revealer) = revealer { + services.file_revealer = revealer; + } + let (app, window) = open_picker_window(name, services); + let services = app.services().expect("the application's services"); + let test = Self { + app, + window, + services, + hold, + connector, + saved, + vault, + runtime: Some(runtime), + _settings: settings, + _dir: dir, + }; + test.wait_for_page("picker"); + test + } + + /// A window that opened the saved connection and shows its + /// workspace. + pub(crate) fn connected(name: &str) -> Self { + Self::connected_with(name, |_| {}) + } + + pub(crate) fn connected_with(name: &str, script: impl FnOnce(&mut ConnectionScript)) -> Self { + let mut connection = ConnectionScript::default(); + script(&mut connection); + let test = Self::with_script( + name, + Script { + connection, + ..Script::default() + }, + ); + test.window + .send(SessionWindowInput::OpenSaved(Box::new(test.saved.clone()))); + test.wait_for_page("workspace"); + test + } + + pub(crate) fn wait_for_page(&self, name: &str) { + wait_until(WAIT, || self.window.page_name() == name) + .unwrap_or_else(|_| panic!("the window stayed on {}, not {name}", self.window.page_name())); + } + + pub(crate) fn workspace(&self) -> Workspace { + self.window.workspace().expect("the window shows a workspace") + } + + pub(crate) fn key(&self) -> SessionKey { + SessionKey::Saved(self.saved.id) + } + + /// Where the test keeps its files, the fake database among them. + pub(crate) fn dir(&self) -> &std::path::Path { + self._dir.path() + } + + /// The connection the last connect built. + pub(crate) fn connection(&self) -> Arc { + self.connector.connections().pop().expect("a connection") + } + + /// Open an editor tab through its window action and run what it + /// holds, which opens the tab's own editor session. + pub(crate) fn run_in_new_editor_tab(&self) { + let before = editors(&self.window); + WidgetExt::activate_action(&self.window, "win.new-editor-tab", None).expect("win.new-editor-tab"); + wait_until(WAIT, || editors(&self.window).len() == before.len() + 1).expect("a new editor tab"); + let editor = editors(&self.window) + .into_iter() + .find(|editor| !before.contains(editor)) + .expect("the new editor"); + editor.run(); + wait_until(WAIT, || !editor.is_running()).expect("the run to end"); + } +} + +fn init_gtk() { + adw::init().expect("libadwaita initialises under the test backend"); + crate::register_test_resources(); + if let Some(settings) = gtk4::Settings::default() { + settings.set_gtk_enable_animations(false); + } +} + +fn remember_window_size(settings: &MemorySettings, (width, height): (i32, i32)) { + settings + .get() + .set_window_geometry(tablepro_storage::WindowGeometry { + width, + height, + maximized: false, + }) + .expect("store the window size"); +} + +/// The connection the script's world has saved: through `tunnel` when +/// it has one, to a server when `network`, else to a file under `dir`. +fn scripted_connection( + network: bool, + tunnel: Option, + dir: &std::path::Path, +) -> SavedConnection { + match tunnel { + Some(tunnel) => SavedConnection { + ssh: Some(tunnel), + auth_mode: AuthMode::Kerberos, + ..network_connection() + }, + None if network => network_connection(), + None => saved_connection(&dir.join("fake.db")), + } +} + +fn write_unreadable_list(list_file: &std::path::Path) { + if let Some(parent) = list_file.parent() { + std::fs::create_dir_all(parent).expect("the config directory"); + } + std::fs::write(list_file, b"{ not json").expect("the unreadable list"); +} + +fn scripted_services( + parts: AppServicesParts, + hold: Rc, + credentials: Option, + ssh: Option, +) -> AppServices { + let mut services = AppServices::new(parts).with_hold(hold); + if let Some(credentials) = credentials { + services = services.with_credentials(Rc::new(FakeSources(credentials))); + } + if let Some(ssh) = ssh { + services = services.with_ssh(ssh); + } + services +} + +fn open_picker_window(name: &str, services: AppServices) -> (TableProApplication, SessionWindow) { + let app = TableProApplication::new(services); + app.set_application_id(Some(&format!("{}.Test.Window{name}", crate::config::APP_ID))); + app.register(gio::Cancellable::NONE).expect("register the application"); + let window = app + .open_window(WindowLaunch::Picker) + .and_then(|window| window.downcast::().ok()) + .expect("a session window"); + (app, window) +} + +impl Drop for TestWindow { + fn drop(&mut self) { + for window in self.app.windows() { + crate::test_support::drop_focus(&window); + window.destroy(); + } + drain_main_context(); + crate::test_support::end_application(&self.app); + if let Some(runtime) = self.runtime.take() { + runtime.shutdown(); + } + } +} + +pub(crate) fn editors(window: &SessionWindow) -> Vec { + descendants(window) + .into_iter() + .filter_map(|widget| widget.downcast::().ok()) + .collect() +} + +fn saved_connection(database: &std::path::Path) -> SavedConnection { + SavedConnection { + id: uuid::Uuid::new_v4(), + name: "Fake".to_owned(), + driver_id: "fake".to_owned(), + host: String::new(), + port: 0, + database: database.display().to_string(), + username: String::new(), + tls: TlsConfig::verify_full(), + read_only: false, + auth_mode: AuthMode::Password, + ssh: None, + last_opened_at: None, + color: None, + group: None, + } +} + +/// A server on this machine, so no reachability check runs. +fn network_connection() -> SavedConnection { + SavedConnection { + id: uuid::Uuid::new_v4(), + name: "Server".to_owned(), + driver_id: "fake".to_owned(), + host: "127.0.0.1".to_owned(), + port: 5432, + database: "shop".to_owned(), + username: "app".to_owned(), + tls: TlsConfig::verify_full(), + read_only: false, + auth_mode: AuthMode::Password, + ssh: None, + last_opened_at: None, + color: None, + group: None, + } +} + +/// The connect dialog's Connect on a new entry for the file `endpoint`. +pub(crate) fn dialog_submission(endpoint: tablepro_core::FileEndpoint) -> ConnectSubmission { + let mut request = FakeConnector::request().expect("a request"); + let mut connection = saved_connection(endpoint.path()); + connection.id = request.metadata.id; + request.target = tablepro_session::RequestTarget::File(endpoint); + ConnectSubmission { + request, + pending_save: tablepro_session::PendingSave { + connection, + secrets: Vec::new(), + forget: Vec::new(), + is_new: true, + }, + } +} + +pub(crate) fn refused() -> FakeEstablish { + FakeEstablish::Fail(ConnectError::Driver(DriverError::Auth { diagnostics: None })) +} + +#[gtk4::test] +fn a_connected_window_shows_the_workspace_and_claims_its_key() { + let test = TestWindow::connected("Connects"); + + assert_eq!(test.services.sessions.window_for(&test.key()), Some(test.window.id())); + assert_eq!(test.services.sessions.live_count(), 1); + assert!( + test.window + .lookup_action("disconnect") + .is_some_and(|action| action.is_enabled()) + ); +} + +#[gtk4::test] +fn the_header_icon_buttons_are_labelled() { + let test = TestWindow::connected("Labels"); + let picker = test.window.imp().picker.get().expect("the picker").clone(); + + // The tab overview's own header is libadwaita's, outside the + // workspace's breakpoint bin. + let panes = test.workspace().imp().breakpoint_bin.get(); + for root in [picker.upcast::(), panes.upcast()] { + let headers = descendants(&root) + .into_iter() + .filter(|widget| widget.is::()); + let buttons: Vec = headers + .flat_map(|header| descendants(&header)) + // A menu button's own toggle, and the tab button's inner + // button, are labelled through their outer widget. + .filter(|widget| { + let inner = widget + .parent() + .is_some_and(|parent| parent.is::() || parent.is::()); + widget.is::() + || widget.is::() + || (widget.is::() && !inner) + || widget + .downcast_ref::() + .is_some_and(|button| button.icon_name().is_some() && !inner) + }) + .collect(); + assert!(!buttons.is_empty(), "no header buttons under {}", root.type_().name()); + for button in buttons { + assert_labelled(&button).unwrap_or_else(|problem| panic!("{problem:?}: {:?}", button.tooltip_text())); + } + } +} + +fn shows_text(root: &impl IsA, text: &str) -> bool { + descendants(root) + .iter() + .filter_map(|widget| widget.downcast_ref::()) + .any(|label| label.label() == text) +} + +#[gtk4::test] +fn toast_routes_to_current_page_overlay() { + let test = TestWindow::picker("ToastRoutes"); + let picker = test.window.imp().picker.get().expect("the picker").clone(); + + test.window.show_toast("on the picker"); + drain_main_context(); + assert!(shows_text(&picker.toast_overlay(), "on the picker")); + + test.window + .send(SessionWindowInput::OpenSaved(Box::new(test.saved.clone()))); + test.wait_for_page("workspace"); + test.window.show_toast("on the workspace"); + drain_main_context(); + + let workspace = test.workspace(); + assert!(shows_text(&workspace.toast_overlay(), "on the workspace")); + assert!(!shows_text(&picker.toast_overlay(), "on the workspace")); + assert!( + workspace + .toast_overlay() + .ancestor(adw::TabOverview::static_type()) + .is_some(), + "the workspace's toasts sit above the tab overview" + ); +} + +#[gtk4::test] +fn the_connect_dialog_opens_once_over_its_own_window() { + let test = TestWindow::picker("ConnectDialog"); + + test.window.send(SessionWindowInput::OpenConnectDialog); + test.window.send(SessionWindowInput::OpenConnectDialog); + wait_until(WAIT, || test.window.imp().dialog.borrow().is_some()).expect("the connect dialog"); + drain_main_context(); + + let dialogs = descendants(&test.window) + .into_iter() + .filter(|widget| widget.is::()) + .count(); + assert_eq!(dialogs, 1); +} + +#[gtk4::test] +fn claiming_open_key_presents_owner_window() { + let test = TestWindow::connected("Owner"); + let other = test + .app + .open_window(WindowLaunch::Picker) + .and_then(|window| window.downcast::().ok()) + .expect("a second window"); + wait_until(WAIT, || other.page_name() == "picker").expect("the second window's picker"); + test.window.set_visible(false); + + other.send(SessionWindowInput::OpenSaved(Box::new(test.saved.clone()))); + wait_until(WAIT, || test.window.is_visible()).expect("the owner to come forward"); + + assert_eq!(other.page_name(), "picker", "the second window connected too"); + assert_eq!(test.connector.calls().len(), 1, "a second connection was built"); + assert_eq!(test.services.sessions.window_for(&test.key()), Some(test.window.id())); +} + +#[gtk4::test] +fn f002_cancel_connect_discards_late_attempt() { + let test = TestWindow::with_script( + "CancelLate", + Script { + establish: vec![FakeEstablish::Late { + after: Duration::from_millis(300), + script: ConnectionScript::default(), + }], + ..Script::default() + }, + ); + test.window + .send(SessionWindowInput::OpenSaved(Box::new(test.saved.clone()))); + test.wait_for_page("connecting"); + // A cancel before the driver is asked builds no connection at all. + wait_until(WAIT, || !test.connector.calls().is_empty()).expect("the connect to reach the driver"); + + test.window.send(SessionWindowInput::CancelConnect); + test.wait_for_page("picker"); + assert_eq!( + test.services.sessions.window_for(&test.key()), + None, + "the claim outlived the cancel" + ); + wait_until(WAIT, || { + test.connector + .connections() + .first() + .is_some_and(|connection| connection.is_closed()) + }) + .expect("the late connection to close"); + drain_main_context(); + + assert_eq!(test.window.page_name(), "picker", "the late connect was adopted"); + assert_eq!(test.services.sessions.live_count(), 0); +} + +#[gtk4::test] +fn a_connect_that_lands_after_its_window_closed_is_closed() { + let test = TestWindow::picker("LandsAfterClose"); + test.window + .send(SessionWindowInput::OpenSaved(Box::new(test.saved.clone()))); + test.wait_for_page("connecting"); + // The connect ends on the runtime while the main loop waits, so its + // answer reaches the window only after the window is gone. + let deadline = std::time::Instant::now() + WAIT; + while test.connector.connections().is_empty() && std::time::Instant::now() < deadline { + std::thread::sleep(Duration::from_millis(10)); + } + std::thread::sleep(Duration::from_millis(100)); + + test.window.destroy(); + let connection = test.connection(); + wait_until(WAIT, || connection.is_closed()).expect("the late connection to close"); + + assert_eq!(test.services.sessions.live_count(), 0); +} + +#[gtk4::test] +fn a_connect_still_queued_when_its_window_shuts_down_is_closed() { + let test = TestWindow::picker("QueuedAtShutdown"); + let request = FakeConnector::request().expect("a request"); + let (pending, connection) = FakeConnector::pending_session(request, ConnectionScript::default()); + let attempt = SessionWindowCommand::Attempt { + generation: tablepro_session::connect::AttemptGeneration::FIRST, + result: Ok(Connected { + pending, + saved: None, + warnings: Vec::new(), + session_only: Vec::new(), + }), + }; + + test.window.send(SessionWindowInput::Command(Box::new(attempt))); + test.window.shutdown(); + + wait_until(WAIT, || connection.is_closed()).expect("the queued connection to close"); +} + +#[gtk4::test] +fn a_failed_connect_shows_why_and_choose_connection_returns_to_the_picker() { + let test = TestWindow::with_script( + "Failed", + Script { + establish: vec![refused()], + ..Script::default() + }, + ); + + test.window + .send(SessionWindowInput::OpenSaved(Box::new(test.saved.clone()))); + test.wait_for_page("connect-failed"); + let page = test + .window + .content() + .and_then(|child| child.downcast::().ok()) + .expect("the failed page"); + assert!(page.message().is_some_and(|message| !message.is_empty())); + assert!( + test.window + .lookup_action("retry-connect") + .is_some_and(|action| action.is_enabled()) + ); + assert_eq!(test.services.sessions.window_for(&test.key()), Some(test.window.id())); + + WidgetExt::activate_action(&test.window, "win.cancel-connect", None).expect("win.cancel-connect"); + test.wait_for_page("picker"); + + assert_eq!(test.services.sessions.window_for(&test.key()), None); +} + +#[gtk4::test] +fn connecting_page_uses_spinner_paintable_and_cancel_action() { + let test = TestWindow::with_script( + "ConnectingPage", + Script { + establish: vec![FakeEstablish::UntilCancelled], + ..Script::default() + }, + ); + + test.window + .send(SessionWindowInput::OpenSaved(Box::new(test.saved.clone()))); + test.wait_for_page("connecting"); + + let page = test + .window + .content() + .and_downcast::() + .expect("the connecting page"); + assert!( + page.status_page() + .paintable() + .and_downcast::() + .is_some(), + "the status page draws no spinner" + ); + assert_eq!(page.status_page().title(), "Connecting to Fake"); + let cancel = page.cancel_button(); + assert_eq!(cancel.action_name().as_deref(), Some("win.cancel-connect")); + assert_eq!(test.window.default_widget(), Some(cancel.upcast())); + assert_eq!(test.window.title().as_deref(), Some("Connecting")); + assert_eq!( + page.announced(), + [( + "Connecting to Fake".to_owned(), + gtk4::AccessibleAnnouncementPriority::Medium + )] + ); + + WidgetExt::activate_action(&test.window, "win.cancel-connect", None).expect("win.cancel-connect"); + test.wait_for_page("picker"); + assert_eq!( + test.window.default_widget(), + None, + "Enter still reached the gone Cancel" + ); +} + +#[gtk4::test] +fn failed_page_offers_retry_edit_and_choose() { + let test = TestWindow::with_script( + "FailedPage", + Script { + establish: vec![refused()], + ..Script::default() + }, + ); + + test.window + .send(SessionWindowInput::OpenSaved(Box::new(test.saved.clone()))); + test.wait_for_page("connect-failed"); + + let page = test + .window + .content() + .and_downcast::() + .expect("the failed page"); + let status = page.status_page(); + assert_eq!(status.icon_name().as_deref(), Some("network-error-symbolic")); + assert_eq!(status.title(), "Could Not Connect"); + let [retry, edit, choose] = page.buttons(); + assert_eq!(retry.action_name().as_deref(), Some("win.retry-connect")); + assert_eq!(edit.action_name().as_deref(), Some("win.edit-connection")); + assert_eq!(choose.action_name().as_deref(), Some("win.cancel-connect")); + assert!(edit.is_visible(), "a saved connection offered no Edit Connection"); + assert!(choose.has_css_class("flat")); + assert!( + test.window + .lookup_action("edit-connection") + .is_some_and(|action| action.is_enabled()) + ); + let default = test.window.default_widget().expect("a default button"); + assert!( + default.is_visible() && default.has_css_class("suggested-action"), + "Enter does not take the suggested way on" + ); + assert_eq!(test.window.title().as_deref(), Some("Could Not Connect")); + let message = page.message().expect("the reason"); + assert_eq!( + page.announced(), + [( + format!("Could Not Connect. {message}"), + gtk4::AccessibleAnnouncementPriority::High + )] + ); +} + +#[gtk4::test] +fn a_file_that_failed_offers_no_edit() { + let test = TestWindow::picker("FailedFile"); + + test.window.send(SessionWindowInput::OpenFile(gio::File::for_path( + test.dir().join("gone.db"), + ))); + test.wait_for_page("connect-failed"); + + let page = test + .window + .content() + .and_downcast::() + .expect("the failed page"); + assert!(!page.buttons()[1].is_visible()); + assert!( + test.window + .lookup_action("edit-connection") + .is_some_and(|action| !action.is_enabled()) + ); +} + +#[gtk4::test] +fn edit_connection_opens_edit_dialog_and_retries_on_save() { + let test = TestWindow::with_script( + "EditRetries", + Script { + establish: vec![refused()], + network: true, + ..Script::default() + }, + ); + let (vault, id) = (test.vault.clone(), test.saved.id); + crate::test_support::finish_on_main(WAIT, async move { + tablepro_core::credentials::SecretVault::store( + &*vault, + id, + tablepro_core::credentials::SecretKind::DatabasePassword, + &secrecy::SecretString::from("s3cret"), + "label", + ) + .await + }) + .expect("the store to end") + .expect("the password stored"); + test.window + .send(SessionWindowInput::OpenSaved(Box::new(test.saved.clone()))); + test.wait_for_page("connect-failed"); + + WidgetExt::activate_action(&test.window, "win.edit-connection", None).expect("win.edit-connection"); + let mut shown = None; + wait_until(WAIT, || { + shown = test + .window + .visible_dialog() + .and_downcast::(); + shown.is_some() + }) + .expect("the connect dialog"); + let dialog = shown.expect("the dialog"); + assert_eq!(dialog.editing().map(|saved| saved.id), Some(test.saved.id)); + assert_eq!(test.connector.calls().len(), 1); + dialog.imp().port_row.set_value(6543.0); + + dialog.imp().connect_button.emit_clicked(); + + test.wait_for_page("workspace"); + let calls = test.connector.calls(); + assert_eq!(calls.len(), 2, "Save did not connect again"); + let tablepro_session::RequestTarget::Network { endpoint, .. } = &calls[1].request.target else { + panic!("expected a network target"); + }; + assert_eq!(endpoint.port(), 6543, "the retry connected to the entry as it was"); + assert_eq!(test.window.visible_dialog(), None, "the dialog stayed open"); +} + +#[gtk4::test] +fn a_close_while_the_page_is_read_does_not_panic() { + let test = TestWindow::with_script( + "CloseWhileRead", + Script { + establish: vec![refused()], + ..Script::default() + }, + ); + test.window + .send(SessionWindowInput::OpenSaved(Box::new(test.saved.clone()))); + test.wait_for_page("connect-failed"); + + let page = test.window.imp().page.borrow(); + test.window.release_session(); + drop(page); + + assert_eq!(test.window.page_name(), "connect-failed"); +} + +#[gtk4::test] +fn retry_rereads_credentials() { + let credentials = FakeCredentialSource::new(); + let test = TestWindow::with_script( + "Retry", + Script { + establish: vec![refused()], + credentials: Some(credentials.clone()), + ..Script::default() + }, + ); + test.window + .send(SessionWindowInput::OpenSaved(Box::new(test.saved.clone()))); + test.wait_for_page("connect-failed"); + let before = credentials.refreshes().len(); + + test.window.send(SessionWindowInput::RetryConnect); + test.wait_for_page("workspace"); + + assert_eq!(credentials.refreshes().len(), before + 1); +} + +#[gtk4::test] +fn retry_connects_to_the_entry_as_saved_now() { + let test = TestWindow::with_script( + "RetryEdited", + Script { + establish: vec![refused()], + ..Script::default() + }, + ); + let store = test.services.storage.connections().clone(); + test.window + .send(SessionWindowInput::OpenSaved(Box::new(test.saved.clone()))); + test.wait_for_page("connect-failed"); + let mut renamed = test.saved.clone(); + renamed.name = "Renamed".to_owned(); + store.upsert_blocking(renamed).expect("edit the entry"); + + test.window.send(SessionWindowInput::RetryConnect); + test.wait_for_page("workspace"); + + let names: Vec = test + .connector + .calls() + .iter() + .map(|call| call.request.metadata.name.clone()) + .collect(); + assert_eq!(names, ["Fake", "Renamed"]); +} + +#[gtk4::test] +fn retry_after_the_entry_was_deleted_returns_to_the_picker() { + let test = TestWindow::with_script( + "RetryDeleted", + Script { + establish: vec![refused()], + ..Script::default() + }, + ); + let store = test.services.storage.connections().clone(); + test.window + .send(SessionWindowInput::OpenSaved(Box::new(test.saved.clone()))); + test.wait_for_page("connect-failed"); + store.remove_blocking(test.saved.id).expect("delete the entry"); + + test.window.send(SessionWindowInput::RetryConnect); + test.wait_for_page("picker"); + + assert_eq!(test.connector.calls().len(), 1, "the deleted entry was connected to"); + assert_eq!(test.services.sessions.window_for(&test.key()), None); +} + +#[gtk4::test] +fn missing_file_goes_to_connect_failed() { + let test = TestWindow::picker("MissingFile"); + let missing = test._dir.path().join("gone.db"); + + test.window + .send(SessionWindowInput::OpenFile(gio::File::for_path(&missing))); + test.wait_for_page("connect-failed"); + + let message = test + .window + .content() + .and_then(|child| child.downcast::().ok()) + .and_then(|page| page.message()) + .expect("the failed page's message"); + assert!(message.contains("gone.db"), "{message}"); + assert!(test.connector.calls().is_empty(), "a missing file was connected to"); +} + +/// The page reads its description as markup, where the `&` would +/// leave the reason empty. +#[gtk4::test] +fn a_reason_with_an_ampersand_shows_on_the_failed_page() { + let test = TestWindow::picker("MissingAmpersand"); + let missing = test._dir.path().join("R&D").join("gone.db"); + + test.window + .send(SessionWindowInput::OpenFile(gio::File::for_path(&missing))); + test.wait_for_page("connect-failed"); + + let message = test + .window + .content() + .and_then(|child| child.downcast::().ok()) + .and_then(|page| page.message()) + .expect("the failed page's message"); + assert!(message.contains("R&D"), "{message}"); +} + +#[gtk4::test] +fn a_file_whose_resolve_never_ran_goes_to_connect_failed() { + let mut test = TestWindow::picker("ResolveStopped"); + let database = test._dir.path().join("local.db"); + // With the runtime gone, the resolve is cancelled before it starts. + if let Some(runtime) = test.runtime.take() { + runtime.shutdown(); + } + + test.window + .send(SessionWindowInput::OpenFile(gio::File::for_path(&database))); + test.wait_for_page("connect-failed"); + + assert!( + test.connector.calls().is_empty(), + "a file nobody resolved was connected to" + ); +} + +#[gtk4::test] +fn an_unsaved_file_opens_a_session_of_its_own() { + let test = TestWindow::picker("File"); + let database = test._dir.path().join("local.db"); + std::fs::write(&database, b"").expect("seed"); + + test.window + .send(SessionWindowInput::OpenFile(gio::File::for_path(&database))); + test.wait_for_page("workspace"); + + let uri = glib::filename_to_uri(std::fs::canonicalize(&database).expect("canonical"), None) + .expect("a uri") + .to_string(); + assert_eq!( + test.services.sessions.window_for(&SessionKey::File(uri)), + Some(test.window.id()) + ); +} + +#[gtk4::test] +fn a_file_window_that_could_not_show_the_owner_stays_open_from_then_on() { + let test = TestWindow::connected("OpenFileOwnerGone"); + let database = test._dir.path().join("local.db"); + std::fs::write(&database, b"").expect("seed"); + let uri = glib::filename_to_uri(std::fs::canonicalize(&database).expect("canonical"), None) + .expect("a uri") + .to_string(); + // An owner the application cannot bring forward. + let _held = test + .services + .sessions + .claim(SessionKey::File(uri), test.window.id() + 100) + .expect("claim the file"); + let extra = test + .app + .open_window(WindowLaunch::OpenFile(gio::File::for_path(&database))) + .and_then(|window| window.downcast::().ok()) + .expect("a window for the file"); + wait_until(WAIT, || !extra.imp().opening_file.get()).expect("the file to resolve"); + drain_main_context(); + assert_eq!(extra.page_name(), "picker"); + test.window.set_visible(false); + + extra.send(SessionWindowInput::OpenSaved(Box::new(test.saved.clone()))); + wait_until(WAIT, || test.window.is_visible()).expect("the connection's window to come forward"); + drain_main_context(); + + assert_eq!(test.app.windows().len(), 2, "the picker closed under the user"); + assert_eq!(extra.page_name(), "picker"); +} + +#[gtk4::test] +fn f002_late_show_create_reply_after_disconnect_is_dropped() { + let test = TestWindow::connected_with("LateShowCreate", |script| { + script.dialect = Arc::new(tablepro_driver_postgres::PgDialect::new( + &tablepro_core::ServerInfo::new("PostgreSQL", "18.0", 18, 0, 0), + )); + script.fetch_table_definition.always(FakeReply::Late { + after: Duration::from_millis(300), + outcome: Ok(users_table()), + }); + }); + let counters = test.connection().counters(); + let workspace = test.workspace().downgrade(); + let input = test.workspace().input().expect("the workspace's input"); + input.deliver(WorkspaceInput::ShowCreateTableForExisting { + schema: Some("public".to_owned()), + table: "users".to_owned(), + }); + wait_until(WAIT, || CallCounters::get(&counters.fetch_table_definition) == 1) + .expect("the definition was asked for"); + + test.window.send(SessionWindowInput::Disconnect); + test.wait_for_page("picker"); + drain_main_context(); + // The reply comes back through the workspace's input, which the + // teardown closes before the reply is due. + assert_eq!( + input.try_deliver(WorkspaceInput::RefreshPage), + Delivery::ReceiverGone, + "the torn-down workspace still takes input" + ); + let _ = wait_until(Duration::from_millis(600), || false); + drain_main_context(); + + assert!(workspace.upgrade().is_none(), "the workspace outlived its teardown"); + assert_eq!(test.window.page_name(), "picker"); + assert!(editors(&test.window).is_empty(), "the late reply opened an editor tab"); +} + +#[gtk4::test] +fn an_edit_kept_for_its_next_connect_is_written_under_a_hold() { + let test = TestWindow::picker("PersistHeld"); + wait_until(WAIT, || test.hold.live() == 0).expect("nothing else to hold the process"); + let mut edited = test.saved.clone(); + edited.name = "Edited".to_owned(); + + test.window.persist_pending_save(tablepro_session::PendingSave { + connection: edited, + secrets: Vec::new(), + forget: Vec::new(), + is_new: false, + }); + assert_eq!(test.hold.live(), 1, "the write started without holding the process"); + wait_until(WAIT, || test.hold.live() == 0).expect("the hold to end"); + + let stored = test + .services + .storage + .connections() + .load_blocking() + .expect("the connection list"); + assert!( + stored.iter().any(|saved| saved.name == "Edited"), + "the hold ended before the write" + ); +} + +#[gtk4::test] +fn quit_closes_a_connected_window_and_its_session() { + let test = TestWindow::connected("Quit"); + let counters = test.connection().counters(); + + let decision = crate::test_support::finish_on_main(WAIT, crate::application::QuitSequence::run(test.app.clone())) + .expect("the quit to finish"); + + assert_eq!(decision, CloseDecision::Approved); + wait_until(WAIT, || CallCounters::get(&counters.close) == 1).expect("the connection to close"); + assert!(test.app.windows().is_empty()); + assert_eq!(test.services.sessions.live_count(), 0); +} + +#[expect( + clippy::disallowed_methods, + reason = "a test stands in for the catalogue a driver would read" +)] +fn users_table() -> tablepro_core::ddl::TableDefinition { + use tablepro_core::column::{ + CatalogType, ColumnDefault, ColumnKind, ColumnType, ReadForm, SqlTypeExpr, TextLength, + }; + use tablepro_core::ddl::{ColumnAttributes, ColumnDefinition, TableDefinition}; + use tablepro_core::meta::TableRef; + + let column = ColumnDefinition::new( + tablepro_core::ColumnInfo { + name: "id".to_owned(), + column_type: ColumnType { + name: "text".to_owned(), + kind: ColumnKind::Text { + length: TextLength::Unbounded, + }, + catalog: Some(CatalogType { + declaration: SqlTypeExpr::from_catalog_text("text"), + cast_target: SqlTypeExpr::from_catalog_text("text"), + }), + dynamic_storage: false, + read_form: ReadForm::Native, + }, + nullable: false, + primary_key: true, + is_auto_increment: false, + is_generated: false, + default: ColumnDefault::None, + comment: None, + }, + ColumnAttributes::default(), + ); + TableDefinition { + table: TableRef::new(Some("public".to_owned()), "users"), + columns: vec![column], + primary_key: None, + indexes: Vec::new(), + foreign_keys: Vec::new(), + inbound_foreign_keys: Vec::new(), + source: None, + } +} diff --git a/linux/crates/app/src/ui/session_window/tests/notification.rs b/linux/crates/app/src/ui/session_window/tests/notification.rs new file mode 100644 index 0000000000..ba756c6653 --- /dev/null +++ b/linux/crates/app/src/ui/session_window/tests/notification.rs @@ -0,0 +1,637 @@ +use std::cell::RefCell; +use std::rc::Rc; +use std::sync::Arc; + +use gtk4::glib; +use gtk4::prelude::*; +use gtk4::subclass::prelude::*; +use libadwaita::prelude::AlertDialogExt; +use tablepro_core::column::{ColumnType, ResultColumn}; +use tablepro_core::export::CsvOptions; +use tablepro_core::meta::TableRef; +use tablepro_core::{DriverError, EditorSession, Value, WriteKind, WriteReport}; +use tablepro_test_support::{FakeEditorSession, FakeReply, FakeRun, Replies}; +use uuid::Uuid; + +use super::super::SessionWindowInput; +use super::super::application_notification_sink::ApplicationNotificationSink; +use super::super::notification_request::NotificationRequest; +use super::super::work_notifier::tests::{Notice, RecordingNotificationSink}; +use super::{TestWindow, WAIT, editors}; +use crate::runtime::DeliverInput; +use crate::services::change_origins::ChangeOrigins; +use crate::services::change_tracker::RowKey; +use crate::services::save_batch::SaveTarget; +use crate::services::tracker_edit::TrackerEdit; +use crate::test_support::{drain_main_context, wait_until}; +use crate::ui::editor::SqlEditor; +use crate::ui::export::{ExportFormat, ExportJob, ExportSource, RowFormat}; +use crate::ui::workspace::{OpenMode, WorkspaceInput}; +use crate::workspace::test_workspace::integer; +use crate::workspace::{RollbackCause, SaveOutcome, SaveSettled, WorkspaceEvent, answer, visible_alert}; + +/// A connected window counted as the active one, whose notifications +/// go to a record. +struct Notified { + test: TestWindow, + sink: Rc, +} + +/// A connected window counted as the active one, whose every editor run +/// ends as `run` does and every save commits. +fn active_window(name: &str, run: FakeRun) -> TestWindow { + let mut runs = Replies::default(); + runs.always(run); + let session: Arc = Arc::new(FakeEditorSession::new(runs)); + let test = TestWindow::connected_with(name, |script| { + script.open_editor_session.always(FakeReply::ok(session)); + script + .write_batch + .always(FakeReply::ok(WriteReport::new(vec![Some(1)]))); + }); + test.window.set_active_for_test(true); + test +} + +/// Open an editor tab, which comes to the front. +fn open_editor_tab(test: &TestWindow) -> (Uuid, SqlEditor) { + let before = editors(&test.window); + WidgetExt::activate_action(&test.window, "win.new-editor-tab", None).expect("win.new-editor-tab"); + wait_until(WAIT, || editors(&test.window).len() == before.len() + 1).expect("a new editor tab"); + let editor = editors(&test.window) + .into_iter() + .find(|editor| !before.contains(editor)) + .expect("the new editor"); + let tab = test.workspace().selected_tab().expect("the new tab in front"); + (tab, editor) +} + +/// Run the editor's script and let the window hear how it ended. +fn run(editor: &SqlEditor) { + editor.run(); + wait_until(WAIT, || !editor.is_running()).expect("the run to end"); + drain_main_context(); +} + +impl Notified { + fn new(name: &str, run: FakeRun) -> Self { + let test = active_window(name, run); + let sink = Rc::new(RecordingNotificationSink::default()); + test.window.set_notification_sink(sink.clone()); + Self { test, sink } + } + + fn completing(name: &str) -> Self { + Self::new(name, FakeRun::completed(Vec::new())) + } + + fn open_editor_tab(&self) -> (Uuid, SqlEditor) { + open_editor_tab(&self.test) + } + + /// Open table `name` in a tab of its own, which comes to the front. + fn open_table_tab(&self, name: &str) -> Uuid { + let workspace = self.test.workspace(); + let before = workspace.selected_tab(); + workspace + .input() + .expect("the workspace's input") + .deliver(WorkspaceInput::SelectTable { + schema: None, + name: name.to_owned(), + open_mode: OpenMode::NewTab, + }); + let mut table = None; + wait_until(WAIT, || { + table = workspace.selected_tab().filter(|tab| Some(*tab) != before); + table.is_some() + }) + .expect("the table's tab"); + table.expect("the table's tab") + } + + fn run(&self, editor: &SqlEditor) { + run(editor); + } + + fn id(&self, tab: Uuid) -> String { + format!("work-finished-{}-{tab}", self.test.window.id()) + } + + fn wait_for_sent(&self, count: usize) { + wait_until(WAIT, || self.sink.sent().len() >= count).expect("the notifications"); + drain_main_context(); + } +} + +#[gtk4::test] +fn inactive_tab_completion_sends_once() { + let notified = Notified::completing("NotifyInactiveTab"); + let (first, editor) = notified.open_editor_tab(); + notified.open_editor_tab(); + + notified.run(&editor); + notified.wait_for_sent(1); + + let window_id = notified.test.window.id(); + assert_eq!( + notified.sink.notices(), + [Notice::Sent(NotificationRequest { + id: notified.id(first), + title: "Query Finished".to_owned(), + body: "Query 1 · Fake".to_owned(), + window_id, + tab: first, + })], + "the tab is named by its script, or the run was told twice" + ); +} + +#[gtk4::test] +fn active_tab_completion_sends_nothing() { + let notified = Notified::completing("NotifyActiveTab"); + let (tab, editor) = notified.open_editor_tab(); + + notified.run(&editor); + // The same tab in front, with the user away from the window. + notified.test.window.set_active_for_test(false); + notified.run(&editor); + notified.wait_for_sent(1); + + let sent = notified.sink.sent(); + assert_eq!(sent.len(), 1, "the run the user watched was notified: {sent:?}"); + assert_eq!(sent[0].tab, tab); +} + +#[gtk4::test] +fn a_failed_run_says_so_without_its_error() { + let notified = Notified::new( + "NotifyFailedRun", + FakeRun::failed(DriverError::server("password authentication failed for hunter2")), + ); + let (tab, editor) = notified.open_editor_tab(); + notified.open_editor_tab(); + + notified.run(&editor); + notified.wait_for_sent(1); + + let sent = notified.sink.sent(); + assert_eq!(sent.len(), 1); + assert_eq!((sent[0].tab, sent[0].title.as_str()), (tab, "Query Failed")); + assert_eq!(sent[0].body, "Query 1 · Fake"); +} + +#[gtk4::test] +fn a_stopped_run_sends_nothing() { + let notified = Notified::new("NotifyStoppedRun", FakeRun::until_stopped()); + let (_, editor) = notified.open_editor_tab(); + notified.open_editor_tab(); + + editor.run(); + wait_until(WAIT, || editor.is_running()).expect("the run to start"); + editor.stop(); + wait_until(WAIT, || !editor.is_running()).expect("the run to stop"); + drain_main_context(); + + assert!(notified.sink.notices().is_empty(), "{:?}", notified.sink.notices()); +} + +#[gtk4::test] +fn activating_tab_withdraws() { + let notified = Notified::completing("NotifyActivateTab"); + let (first, editor) = notified.open_editor_tab(); + notified.open_editor_tab(); + notified.run(&editor); + notified.wait_for_sent(1); + + let view = notified.test.workspace().imp().tab_view.get(); + view.set_selected_page(&view.nth_page(0)); + + wait_until(WAIT, || notified.sink.withdrawn() == [notified.id(first)]).expect("the notification to go"); +} + +#[gtk4::test] +fn the_window_coming_forward_withdraws_the_tab_in_front() { + let notified = Notified::completing("NotifyWindowActive"); + notified.test.window.set_active_for_test(false); + let (tab, editor) = notified.open_editor_tab(); + notified.run(&editor); + notified.wait_for_sent(1); + assert!(notified.sink.withdrawn().is_empty()); + + notified.test.window.set_active_for_test(true); + notified.test.window.notify("is-active"); + + wait_until(WAIT, || notified.sink.withdrawn() == [notified.id(tab)]).expect("the notification to go"); +} + +#[gtk4::test] +fn closing_a_tab_withdraws_its_notification() { + let notified = Notified::completing("NotifyCloseTab"); + let (first, editor) = notified.open_editor_tab(); + notified.open_editor_tab(); + notified.run(&editor); + notified.wait_for_sent(1); + + let view = notified.test.workspace().imp().tab_view.get(); + view.close_page(&view.nth_page(0)); + + wait_until(WAIT, || notified.sink.withdrawn() == [notified.id(first)]).expect("the notification to go"); +} + +#[gtk4::test] +fn teardown_withdraws_all() { + let notified = Notified::completing("NotifyTeardown"); + notified.test.window.set_active_for_test(false); + let (first, editor) = notified.open_editor_tab(); + notified.run(&editor); + let (second, editor) = notified.open_editor_tab(); + notified.run(&editor); + notified.wait_for_sent(2); + + notified.test.window.send(SessionWindowInput::Disconnect); + notified.test.wait_for_page("picker"); + + let mut withdrawn = notified.sink.withdrawn(); + withdrawn.sort(); + let mut expected = vec![notified.id(first), notified.id(second)]; + expected.sort(); + assert_eq!(withdrawn, expected); +} + +#[gtk4::test] +fn a_failed_save_in_a_background_tab_names_only_the_table() { + let notified = Notified::completing("NotifySave"); + let workspace = notified.test.workspace(); + let table = notified.open_table_tab("orders"); + notified.open_editor_tab(); + + let input = workspace.input().expect("the workspace's input"); + input.deliver(WorkspaceInput::Workspace(WorkspaceEvent::SaveSettled { + settled: Box::new(SaveSettled { + tab: table, + kind: WriteKind::RowChanges, + outcome: SaveOutcome::RolledBack(RollbackCause::Driver(DriverError::server( + "duplicate key value (id)=(42)", + ))), + failing: None, + saved_table_name: None, + origins: ChangeOrigins::default(), + }), + in_prompt: false, + })); + notified.wait_for_sent(1); + + let sent = notified.sink.sent(); + assert_eq!(sent.len(), 1); + assert_eq!( + (sent[0].tab, sent[0].title.as_str(), sent[0].body.as_str()), + (table, "Changes Not Saved", "orders · Fake") + ); +} + +#[gtk4::test] +fn an_export_from_a_background_tab_notifies() { + let notified = Notified::completing("NotifyExport"); + let (first, _) = notified.open_editor_tab(); + notified.open_editor_tab(); + let exports = notified.test.workspace().exports().expect("the workspace's exports"); + let columns: Arc<[ResultColumn]> = vec![ResultColumn::new("n", ColumnType::untyped_text("text"))].into(); + + let _running = exports + .begin(ExportJob { + destination: gio::File::for_path(notified.test.dir().join("rows.csv")), + format: ExportFormat::Rows(RowFormat::Csv(CsvOptions::default())), + source: ExportSource::Rows { + columns, + rows: Vec::new(), + }, + expected_rows: None, + tab: Some(first), + }) + .expect("started"); + notified.wait_for_sent(1); + + let sent = notified.sink.sent(); + assert_eq!( + (sent[0].tab, sent[0].title.as_str(), sent[0].body.as_str()), + (first, "Export Finished", "Query 1 · Fake") + ); +} + +#[gtk4::test] +fn a_background_tab_saved_by_its_close_prompt_is_not_notified() { + let notified = Notified::completing("NotifyPromptSave"); + let workspace = notified.test.workspace(); + let table = notified.open_table_tab("orders"); + let view = workspace.imp().tab_view.get(); + let page = view.selected_page().expect("the table's page"); + let tracker = workspace.row_tracker(table).expect("the table's tracker"); + let _ = tracker.set_target(SaveTarget { + table: TableRef::unqualified("orders"), + columns: vec![integer("id", true), integer("n", false)], + }); + let _ = tracker.apply(TrackerEdit::Cell { + row_key: RowKey::from_pk_values(&[Value::Int(1)]).expect("a key"), + col: 1, + original: Value::Int(1), + value: Value::Int(2), + }); + notified.open_editor_tab(); + + view.close_page(&page); + wait_until(WAIT, || { + visible_alert(¬ified.test.window).is_some_and(|alert| alert.heading().as_deref() == Some("Save Changes?")) + }) + .expect("the close prompt"); + assert!(answer(¬ified.test.window, "save")); + wait_until(WAIT, || { + workspace.open_tabs().is_some_and(|open| !open.contains(&table)) + }) + .expect("the saved tab to close"); + drain_main_context(); + + assert!(notified.sink.notices().is_empty(), "{:?}", notified.sink.notices()); +} + +#[gtk4::test] +fn an_editor_tab_named_by_its_script_is_notified_by_its_number() { + let notified = Notified::completing("NotifyScriptTab"); + let (first, editor) = notified.open_editor_tab(); + let view = notified.test.workspace().imp().tab_view.get(); + let page = view.selected_page().expect("the editor's page"); + notified.open_editor_tab(); + + editor + .buffer() + .set_text("UPDATE users SET password = 'hunter2' WHERE id = 1;"); + wait_until(WAIT, || page.title().starts_with("UPDATE")).expect("the tab named by its script"); + notified.run(&editor); + notified.wait_for_sent(1); + + let sent = notified.sink.sent(); + assert_eq!( + (sent[0].tab, sent[0].title.as_str(), sent[0].body.as_str()), + (first, "Query Finished", "Query 1 · Fake"), + "the notification showed the script" + ); +} + +#[gtk4::test] +fn focus_tab_action_selects_tab_and_presents_window() { + let notified = Notified::completing("NotifyFocusTab"); + let (first, _) = notified.open_editor_tab(); + let (second, _) = notified.open_editor_tab(); + let workspace = notified.test.workspace(); + assert_eq!(workspace.selected_tab(), Some(second)); + notified.test.window.set_visible(false); + + notified.test.app.activate_action( + "focus-tab", + Some(&(notified.test.window.id(), first.to_string()).to_variant()), + ); + + wait_until(WAIT, || workspace.selected_tab() == Some(first)).expect("the tab to come to the front"); + assert!(notified.test.window.is_visible(), "the window was not presented"); +} + +#[gtk4::test] +fn focus_tab_action_for_a_gone_window_or_tab_brings_tablepro_forward() { + let notified = Notified::completing("NotifyFocusGone"); + let (tab, _) = notified.open_editor_tab(); + let workspace = notified.test.workspace(); + let window_id = notified.test.window.id(); + + for (target_window, target_tab) in [ + (window_id + 100, Uuid::new_v4().to_string()), + (window_id, "not a tab".to_owned()), + (window_id, Uuid::new_v4().to_string()), + ] { + notified.test.window.set_visible(false); + notified + .test + .app + .activate_action("focus-tab", Some(&(target_window, target_tab).to_variant())); + wait_until(WAIT, || notified.test.window.is_visible()).expect("TablePro to come forward"); + drain_main_context(); + assert_eq!(workspace.selected_tab(), Some(tab)); + } +} + +/// A notification server on the session bus, as GNOME Shell or a +/// notification daemon is, on a connection of its own. Every test on +/// the bus can reach it, so a test reads only its own application's +/// calls. +struct NotificationServer { + connection: gio::DBusConnection, + calls: Rc>>, + registration: Option, +} + +#[derive(Debug, Clone)] +enum ServerCall { + Notify { + /// The application id, from the `desktop-entry` hint. + app: String, + id: u32, + summary: String, + body: String, + actions: Vec, + }, + Close(u32), +} + +const NOTIFICATIONS: &str = "org.freedesktop.Notifications"; +const NOTIFICATIONS_PATH: &str = "/org/freedesktop/Notifications"; +const NOTIFICATIONS_XML: &str = r#" + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +"#; + +impl NotificationServer { + fn start() -> Self { + let address = + gio::dbus_address_get_for_bus_sync(gio::BusType::Session, gio::Cancellable::NONE).expect("the session bus"); + let connection = gio::DBusConnection::for_address_sync( + &address, + gio::DBusConnectionFlags::AUTHENTICATION_CLIENT | gio::DBusConnectionFlags::MESSAGE_BUS_CONNECTION, + None, + gio::Cancellable::NONE, + ) + .expect("a connection of the server's own"); + let node = gio::DBusNodeInfo::for_xml(NOTIFICATIONS_XML).expect("the interface description"); + let interface = node.lookup_interface(NOTIFICATIONS).expect("the interface"); + let calls = Rc::new(RefCell::new(Vec::new())); + let log = calls.clone(); + let next_id = std::cell::Cell::new(0u32); + let registration = connection + .register_object(NOTIFICATIONS_PATH, &interface) + .method_call(move |_, _, _, _, method, parameters, invocation| match method { + "Notify" => { + let text = |index| parameters.child_value(index).get::().unwrap_or_default(); + // A replaced notification keeps its id. + let replaces = parameters.child_value(1).get::().unwrap_or_default(); + let id = match replaces { + 0 => { + next_id.set(next_id.get() + 1); + next_id.get() + } + id => id, + }; + let app = glib::VariantDict::new(Some(¶meters.child_value(6))) + .lookup_value("desktop-entry", Some(glib::VariantTy::STRING)) + .and_then(|entry| entry.get::()) + .unwrap_or_default(); + log.borrow_mut().push(ServerCall::Notify { + app, + id, + summary: text(3), + body: text(4), + actions: parameters.child_value(5).get::>().unwrap_or_default(), + }); + invocation.return_value(Some(&(id,).to_variant())); + } + "CloseNotification" => { + let id = parameters.child_value(0).get::().unwrap_or_default(); + log.borrow_mut().push(ServerCall::Close(id)); + invocation.return_value(None); + } + "GetCapabilities" => { + invocation.return_value(Some(&(vec!["actions".to_owned(), "body".to_owned()],).to_variant())); + } + _ => invocation.return_value(Some(&("Fake", "TablePro", "1", "1.2").to_variant())), + }) + .build() + .expect("the server's object"); + let owner = connection + .call_sync( + Some("org.freedesktop.DBus"), + "/org/freedesktop/DBus", + "org.freedesktop.DBus", + "RequestName", + Some(&(NOTIFICATIONS, 4u32).to_variant()), + Some(glib::VariantTy::new("(u)").expect("a reply type")), + gio::DBusCallFlags::NONE, + -1, + gio::Cancellable::NONE, + ) + .expect("the server's name"); + assert_eq!( + owner.child_value(0).get::(), + Some(1), + "another server owns the name" + ); + Self { + connection, + calls, + registration: Some(registration), + } + } + + /// The calls about `app_id`'s notifications. + fn calls_from(&self, app_id: &str) -> Vec { + let calls = self.calls.borrow(); + let ours: Vec = calls + .iter() + .filter_map(|call| match call { + ServerCall::Notify { app, id, .. } if app == app_id => Some(*id), + _ => None, + }) + .collect(); + calls + .iter() + .filter(|call| match call { + ServerCall::Notify { app, .. } => app == app_id, + ServerCall::Close(id) => ours.contains(id), + }) + .cloned() + .collect() + } + + /// What clicking notification `id` sends. + fn click(&self, id: u32) { + self.connection + .emit_signal( + None, + NOTIFICATIONS_PATH, + NOTIFICATIONS, + "ActionInvoked", + Some(&(id, "default").to_variant()), + ) + .expect("the click"); + } +} + +impl Drop for NotificationServer { + fn drop(&mut self) { + if let Some(registration) = self.registration.take() { + let _ = self.connection.unregister_object(registration); + } + let _ = self.connection.close_sync(gio::Cancellable::NONE); + } +} + +#[gtk4::test] +fn a_notification_reaches_the_desktop_and_a_click_brings_its_tab_forward() { + let server = NotificationServer::start(); + let test = active_window("NotifyDesktop", FakeRun::completed(Vec::new())); + test.window + .set_notification_sink(Rc::new(ApplicationNotificationSink::new(test.app.upcast_ref()))); + let app_id = test + .app + .application_id() + .expect("the test's application id") + .to_string(); + let (first, editor) = open_editor_tab(&test); + open_editor_tab(&test); + + run(&editor); + wait_until(WAIT, || !server.calls_from(&app_id).is_empty()).expect("the notification on the bus"); + let ServerCall::Notify { + id, + summary, + body, + actions, + .. + } = server.calls_from(&app_id).remove(0) + else { + panic!("{:?}", server.calls_from(&app_id)); + }; + assert_eq!((summary.as_str(), body.as_str()), ("Query Finished", "Query 1 · Fake")); + assert!(actions.iter().any(|action| action == "default"), "{actions:?}"); + + server.click(id); + + wait_until(WAIT, || test.workspace().selected_tab() == Some(first)).expect("the click to bring the tab forward"); +} diff --git a/linux/crates/app/src/ui/session_window/tests/open_file.rs b/linux/crates/app/src/ui/session_window/tests/open_file.rs new file mode 100644 index 0000000000..e4232ae32d --- /dev/null +++ b/linux/crates/app/src/ui/session_window/tests/open_file.rs @@ -0,0 +1,80 @@ +//! Files the application hands to its windows, from Open With or the +//! command line. + +use gtk4::prelude::*; + +use super::super::*; +use super::{TestWindow, WAIT}; +use crate::test_support::wait_until; + +/// An empty file, which SQLite reads as an empty database. +fn database(dir: &tempfile::TempDir, name: &str) -> gio::File { + let file = gio::File::for_path(dir.path().join(name)); + file.replace_contents(b"", None, false, gio::FileCreateFlags::NONE, gio::Cancellable::NONE) + .expect("seed the database file"); + file +} + +fn other_windows(test: &TestWindow) -> Vec { + test.app + .windows() + .into_iter() + .filter_map(|window| window.downcast::().ok()) + .filter(|window| window != &test.window) + .collect() +} + +#[gtk4::test] +fn an_opened_file_goes_to_the_idle_window_then_its_owner_then_a_new_window() { + let test = TestWindow::picker("OpenFiles"); + let dir = tempfile::tempdir().expect("tempdir"); + let first = database(&dir, "first.sqlite3"); + let second = database(&dir, "second.sqlite3"); + + test.app.open(std::slice::from_ref(&first), ""); + test.wait_for_page("workspace"); + assert_eq!(test.app.windows().len(), 1, "the idle picker did not take the file"); + + test.window.set_visible(false); + test.app.open(&[first], ""); + wait_until(WAIT, || test.window.is_visible()).expect("the file's window to come forward"); + assert_eq!(test.app.windows().len(), 1, "a second window opened on the same file"); + assert_eq!(test.connector.calls().len(), 1, "the open file was connected to again"); + + test.app.open(&[second], ""); + wait_until(WAIT, || { + other_windows(&test) + .iter() + .any(|window| window.page_name() == "workspace") + }) + .expect("a new window on the second file"); + assert_eq!(test.app.windows().len(), 2); +} + +#[gtk4::test] +fn a_window_opened_for_a_file_another_window_has_closes_again() { + let test = TestWindow::picker("OpenFileTwice"); + let dir = tempfile::tempdir().expect("tempdir"); + let file = database(&dir, "app.sqlite3"); + test.window.send(SessionWindowInput::OpenFile(file.clone())); + test.wait_for_page("workspace"); + test.window.set_visible(false); + + assert!(test.app.open_window(WindowLaunch::OpenFile(file)).is_some()); + wait_until(WAIT, || test.app.windows().len() == 1).expect("the extra window to close"); + + assert!(test.window.is_visible(), "the file's window stayed hidden"); + assert_eq!(test.connector.calls().len(), 1); +} + +#[gtk4::test] +fn a_window_resolving_a_file_takes_no_other() { + let test = TestWindow::picker("OpenFileBusy"); + assert!(test.window.is_idle()); + + test.window.imp().opening_file.set(true); + assert!(!test.window.is_idle()); + test.window.imp().opening_file.set(false); + test.window.send(SessionWindowInput::OpenConnectDialog); + wait_until(WAIT, || !test.window.is_idle()).expect("the connect dialog to count as busy"); +} diff --git a/linux/crates/app/src/ui/session_window/window_actions.rs b/linux/crates/app/src/ui/session_window/window_actions.rs new file mode 100644 index 0000000000..0db12abf8c --- /dev/null +++ b/linux/crates/app/src/ui/session_window/window_actions.rs @@ -0,0 +1,185 @@ +//! The window's own actions. Their keys are application accelerators, +//! from the accelerator table. +//! +//! The actions go on the window's own action map, which GTK already +//! exports as `win`: a second group inserted under that name would hide +//! it from the application's accelerators and from D-Bus. + +use gtk4::glib; +use gtk4::prelude::*; +use libadwaita as adw; + +use super::SessionWindowInput; +use super::session_page::SessionPage; +use crate::runtime::{DeliverInput, InputSender}; + +/// Builds the input an action sends. +type ActionInput = fn() -> SessionWindowInput; + +/// The window's actions and the input each one sends. A closure holds +/// only the window's input sender, so an action that outlives the window +/// drops its input rather than reaching a window that is gone. +const ACTIONS: [(&str, ActionInput); 10] = [ + ("new-connection", || SessionWindowInput::OpenConnectDialog), + ("close-current", || SessionWindowInput::CloseCurrent), + ("disconnect", || SessionWindowInput::Disconnect), + ("reconnect", || SessionWindowInput::Reconnect), + ("cancel-connect", || SessionWindowInput::CancelConnect), + ("retry-connect", || SessionWindowInput::RetryConnect), + ("edit-connection", || SessionWindowInput::EditConnection), + ("reset-workspace-layout", || SessionWindowInput::ResetWorkspaceLayout), + ("export-connections", || SessionWindowInput::ExportConnections), + ("import-connections", || SessionWindowInput::ImportConnections), +]; + +pub(super) fn install(window: &adw::ApplicationWindow, input: &InputSender) { + let entries = ACTIONS.map(|(name, message)| { + let input = input.clone(); + gio::ActionEntry::::builder(name) + .activate(move |_, _, _| input.deliver(message())) + .build() + }); + window.add_action_entries(entries); + window.add_action_entries([retry_with_new_secret(input.clone())]); +} + +/// Takes the secret to ask for as its target, named the way the keyring +/// attribute names it. +fn retry_with_new_secret(input: InputSender) -> gio::ActionEntry { + gio::ActionEntry::builder("retry-with-new-secret") + .parameter_type(Some(glib::VariantTy::STRING)) + .activate(move |_, _, target| { + if let Some(kind) = target + .and_then(|target| target.str()) + .and_then(super::secret_retry::secret_kind_named) + { + input.deliver(SessionWindowInput::RetryWithNewSecret(kind)); + } + }) + .build() +} + +/// Enable each action on the page it means something on, so its menu +/// item and button are insensitive elsewhere. +pub(super) fn sync(window: &adw::ApplicationWindow, page: &SessionPage, editable: bool) { + let connected = matches!(page, SessionPage::Workspace { .. }); + let failed = matches!(page, SessionPage::ConnectFailed { .. }); + let reenter = matches!(page, SessionPage::ConnectFailed { failure, .. } if failure.reenter.is_some()); + for (name, enabled) in [ + ("disconnect", connected), + ("reconnect", connected), + ("reset-workspace-layout", connected), + ( + "cancel-connect", + matches!(page, SessionPage::Connecting { .. }) || failed, + ), + ("retry-connect", failed), + ("retry-with-new-secret", reenter), + ("edit-connection", failed && editable), + ] { + if let Some(action) = window.lookup_action(name).and_downcast::() { + action.set_enabled(enabled); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::runtime::Delivery; + use crate::test_support::wait_until; + use crate::ui::session_window::tests::{TestWindow, WAIT, editors}; + + #[gtk4::test] + fn actions_live_on_application_window_map() { + let window = crate::test_support::OwnedWindow::new(adw::ApplicationWindow::builder().build()); + let (input, mut receiver) = crate::runtime::input_queue::(); + + install(&window, &input); + sync(&window, &SessionPage::Picker, false); + + assert!( + window + .lookup_action("disconnect") + .is_some_and(|action| !action.is_enabled()) + ); + for moved in ["quit", "about", "preferences"] { + assert!( + window.lookup_action(moved).is_none(), + "win.{moved} is still on the window" + ); + } + WidgetExt::activate_action(&*window, "win.export-connections", None) + .expect("win resolves to the window's own map"); + assert!(matches!(receiver.try_recv(), Ok(SessionWindowInput::ExportConnections))); + } + + #[gtk4::test] + fn action_after_workspace_drop_does_not_panic() { + let test = TestWindow::connected("ActionAfterDrop"); + let action = test + .window + .lookup_action("reopen-closed-tab") + .expect("the workspace's reopen action"); + let input = test.workspace().input().expect("the workspace's input"); + + test.window.send(SessionWindowInput::Disconnect); + test.wait_for_page("picker"); + crate::test_support::drain_main_context(); + + assert!( + test.window.lookup_action("reopen-closed-tab").is_none(), + "the action outlived its workspace" + ); + action.activate(None); + assert_eq!( + input.try_deliver(crate::ui::workspace::WorkspaceInput::RefreshPage), + Delivery::ReceiverGone + ); + } + + /// An accelerator for an action nobody adds does nothing, silently. + #[gtk4::test] + fn application_accelerators_name_actions_a_connected_window_has() { + use crate::application::{ACCELERATORS, AcceleratorScope}; + let test = TestWindow::connected("AcceleratorActions"); + + for entry in ACCELERATORS + .iter() + .filter(|entry| entry.scope == AcceleratorScope::Application) + { + let found = match entry.action.split_once('.') { + Some(("win", name)) => test.window.lookup_action(name).is_some(), + Some(("app", name)) => test.app.lookup_action(name).is_some(), + _ => false, + }; + assert!(found, "{} has an accelerator and no action", entry.action); + } + } + + #[gtk4::test] + fn close_current_closes_a_window_with_no_workspace() { + let test = TestWindow::picker("CloseCurrentPicker"); + + WidgetExt::activate_action(&test.window, "win.close-current", None).expect("win.close-current"); + + wait_until(WAIT, || test.app.windows().is_empty()).expect("the picker's window to close"); + } + + #[gtk4::test] + fn close_current_closes_the_tab_and_still_works_after_a_disconnect() { + let test = TestWindow::connected("CloseCurrentTab"); + WidgetExt::activate_action(&test.window, "win.new-editor-tab", None).expect("win.new-editor-tab"); + wait_until(WAIT, || editors(&test.window).len() == 1).expect("an editor tab"); + + WidgetExt::activate_action(&test.window, "win.close-current", None).expect("win.close-current"); + wait_until(WAIT, || editors(&test.window).is_empty()).expect("the tab to close"); + assert_eq!(test.app.windows().len(), 1, "the window closed with its tab"); + + test.window.send(SessionWindowInput::Disconnect); + test.wait_for_page("picker"); + WidgetExt::activate_action(&test.window, "win.close-current", None).expect("win.close-current on the picker"); + + wait_until(WAIT, || test.app.windows().is_empty()).expect("the window to close"); + } +} diff --git a/linux/crates/app/src/ui/session_window/work_notifier.rs b/linux/crates/app/src/ui/session_window/work_notifier.rs new file mode 100644 index 0000000000..b475f21c7a --- /dev/null +++ b/linux/crates/app/src/ui/session_window/work_notifier.rs @@ -0,0 +1,187 @@ +use std::cell::RefCell; +use std::collections::HashMap; +use std::rc::Rc; + +use uuid::Uuid; + +use super::notification_request::notification_request; +use super::notification_sink::NotificationSink; +use crate::workspace::CompletedWork; + +/// A window's notifications about work that ended in a tab the user was +/// not looking at, as GNOME Console sends them: no time threshold, one +/// per tab, withdrawn once the user looks at the tab. +pub(crate) struct WorkNotifier { + window_id: u32, + sink: Rc, + /// The id of each tab's notification on screen. + sent: RefCell>, +} + +impl WorkNotifier { + pub(crate) fn new(window_id: u32, sink: Rc) -> Self { + Self { + window_id, + sink, + sent: RefCell::default(), + } + } + + /// `tab_active` when the window is the active one and the tab is in + /// front: the user saw it end. + pub(crate) fn work_completed( + &self, + work: &CompletedWork, + tab_active: bool, + tab_label: &str, + connection_name: &str, + ) { + if tab_active { + return; + } + let Some(request) = notification_request(self.window_id, work, tab_label, connection_name) else { + return; + }; + self.sink.send(&request); + self.sent.borrow_mut().insert(request.tab, request.id); + } + + pub(crate) fn has_sent(&self) -> bool { + !self.sent.borrow().is_empty() + } + + pub(crate) fn tab_became_active(&self, tab: Uuid) { + let sent = self.sent.borrow_mut().remove(&tab); + if let Some(id) = sent { + self.sink.withdraw(&id); + } + } + + /// Withdraw the notifications of tabs that closed: there is nothing + /// left for them to bring forward. + pub(crate) fn withdraw_closed(&self, is_open: impl Fn(Uuid) -> bool) { + let closed: Vec = { + let mut sent = self.sent.borrow_mut(); + let tabs: Vec = sent.keys().copied().filter(|tab| !is_open(*tab)).collect(); + tabs.iter().filter_map(|tab| sent.remove(tab)).collect() + }; + for id in closed { + self.sink.withdraw(&id); + } + } + + pub(crate) fn withdraw_all(&self) { + let sent = std::mem::take(&mut *self.sent.borrow_mut()); + for id in sent.into_values() { + self.sink.withdraw(&id); + } + } +} + +impl std::fmt::Debug for WorkNotifier { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("WorkNotifier") + .field("window_id", &self.window_id) + .field("sent", &self.sent.borrow().len()) + .finish_non_exhaustive() + } +} + +#[cfg(test)] +pub(crate) mod tests { + use super::*; + use crate::ui::session_window::notification_request::NotificationRequest; + use crate::workspace::{WorkKind, WorkOutcome}; + + #[derive(Debug, Clone, PartialEq, Eq)] + pub(crate) enum Notice { + Sent(NotificationRequest), + Withdrawn(String), + } + + /// Keeps what a window sent and withdrew, in order. + #[derive(Debug, Default)] + pub(crate) struct RecordingNotificationSink { + notices: RefCell>, + } + + impl RecordingNotificationSink { + pub(crate) fn notices(&self) -> Vec { + self.notices.borrow().clone() + } + + pub(crate) fn sent(&self) -> Vec { + self.notices + .borrow() + .iter() + .filter_map(|notice| match notice { + Notice::Sent(request) => Some(request.clone()), + Notice::Withdrawn(_) => None, + }) + .collect() + } + + pub(crate) fn withdrawn(&self) -> Vec { + self.notices + .borrow() + .iter() + .filter_map(|notice| match notice { + Notice::Withdrawn(id) => Some(id.clone()), + Notice::Sent(_) => None, + }) + .collect() + } + } + + impl NotificationSink for RecordingNotificationSink { + fn send(&self, request: &NotificationRequest) { + self.notices.borrow_mut().push(Notice::Sent(request.clone())); + } + + fn withdraw(&self, id: &str) { + self.notices.borrow_mut().push(Notice::Withdrawn(id.to_owned())); + } + } + + fn finished(tab: Uuid) -> CompletedWork { + CompletedWork { + tab, + kind: WorkKind::Export, + outcome: WorkOutcome::Succeeded, + } + } + + fn notifier() -> (WorkNotifier, Rc) { + let sink = Rc::new(RecordingNotificationSink::default()); + (WorkNotifier::new(9, sink.clone()), sink) + } + + #[test] + fn a_tab_withdrawn_once_is_not_withdrawn_again() { + let (notifier, sink) = notifier(); + let tab = Uuid::new_v4(); + + notifier.tab_became_active(tab); + notifier.work_completed(&finished(tab), false, "orders", "Prod"); + notifier.tab_became_active(tab); + notifier.tab_became_active(tab); + notifier.withdraw_all(); + + assert_eq!(sink.sent().len(), 1); + assert_eq!(sink.withdrawn(), [format!("work-finished-9-{tab}")]); + } + + #[test] + fn only_closed_tabs_lose_their_notification() { + let (notifier, sink) = notifier(); + let (open, closed) = (Uuid::new_v4(), Uuid::new_v4()); + notifier.work_completed(&finished(open), false, "orders", "Prod"); + notifier.work_completed(&finished(closed), false, "users", "Prod"); + + notifier.withdraw_closed(|tab| tab == open); + + assert_eq!(sink.withdrawn(), [format!("work-finished-9-{closed}")]); + notifier.withdraw_all(); + assert_eq!(sink.withdrawn().last(), Some(&format!("work-finished-9-{open}"))); + } +} diff --git a/linux/crates/app/src/ui/shell/mod.rs b/linux/crates/app/src/ui/shell/mod.rs new file mode 100644 index 0000000000..6dc6e9532e --- /dev/null +++ b/linux/crates/app/src/ui/shell/mod.rs @@ -0,0 +1,5 @@ +//! What every page of a window shares in its header bar. + +mod primary_menu_button; + +pub(crate) use primary_menu_button::{TAB_OVERVIEW_MENU, app_menu, primary_menu_button}; diff --git a/linux/crates/app/src/ui/shell/primary_menu_button.rs b/linux/crates/app/src/ui/shell/primary_menu_button.rs new file mode 100644 index 0000000000..d72e9026e3 --- /dev/null +++ b/linux/crates/app/src/ui/shell/primary_menu_button.rs @@ -0,0 +1,47 @@ +use gtk4 as gtk; +use gtk4::prelude::*; + +/// The ids of the menus in `gtk/menus.ui`. +const PRIMARY_MENU: &str = "primary_menu"; +pub(crate) const TAB_OVERVIEW_MENU: &str = "tab_overview_menu"; + +/// A menu GtkApplication read from `gtk/menus.ui` at startup. A missing +/// one is a broken build, not a state the user can fix. +pub(crate) fn app_menu(app: Option<>k::Application>, id: &str) -> Option { + let menu = app.and_then(|app| app.menu_by_id(id)); + if menu.is_none() { + tracing::error!( + id, + "the application has no such menu; is gtk/menus.ui in the resources?" + ); + } + menu.map(|menu| menu.upcast()) +} + +/// The header bar's main menu, which F10 opens. +pub(crate) fn primary_menu_button(app: Option<>k::Application>) -> gtk::MenuButton { + let button = gtk::MenuButton::builder() + .icon_name(crate::ui::icons::OPEN_MENU) + .primary(true) + .build(); + crate::ui::a11y::describe_icon_button(&button, &crate::i18n::gettext("Main Menu"), None); + match app_menu(app, PRIMARY_MENU) { + Some(menu) => button.set_menu_model(Some(&menu)), + None => button.set_sensitive(false), + } + button +} + +#[cfg(test)] +mod tests { + use super::*; + + #[gtk4::test] + fn without_its_menu_the_button_is_insensitive() { + let button = primary_menu_button(None); + + assert!(!button.is_sensitive()); + assert!(button.is_primary()); + assert_eq!(button.tooltip_text().as_deref(), Some("Main Menu")); + } +} diff --git a/linux/crates/app/src/ui/ssh_inputs.rs b/linux/crates/app/src/ui/ssh_inputs.rs new file mode 100644 index 0000000000..36784746aa --- /dev/null +++ b/linux/crates/app/src/ui/ssh_inputs.rs @@ -0,0 +1,444 @@ +use std::path::{Path, PathBuf}; + +use secrecy::{ExposeSecret, SecretString}; +use tablepro_ssh::{SshAuth, SshConfig, SshDestination}; +use tablepro_storage::{SavedSshAuth, SavedSshConfig}; + +pub use crate::services::ssh_environment::SshEnvironment; + +/// How the SSH server is logged in to, in the order the combo shows +/// them. OpenSSH does the work for each; the app only picks which one +/// it tries first. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum SshAuthChoice { + Agent, + PrivateKey, + Password, + KeyboardInteractive, +} + +impl SshAuthChoice { + pub const ALL: [Self; 4] = [Self::Agent, Self::PrivateKey, Self::Password, Self::KeyboardInteractive]; + + pub fn label(self) -> String { + match self { + Self::Agent => crate::i18n::gettext("SSH agent"), + Self::PrivateKey => crate::i18n::gettext("Private key"), + Self::Password => crate::i18n::gettext("Password"), + Self::KeyboardInteractive => crate::i18n::gettext("Keyboard-interactive"), + } + } + + /// What the choice does, for the combo row's subtitle. + pub fn description(self) -> String { + match self { + Self::Agent => crate::i18n::gettext("Keys from the SSH agent and your SSH configuration"), + Self::PrivateKey => crate::i18n::gettext("A key file, unlocked with its passphrase if it has one"), + Self::Password => crate::i18n::gettext("The SSH server's own password"), + Self::KeyboardInteractive => crate::i18n::gettext("The SSH server asks its own questions, such as a code"), + } + } + + pub fn index(self) -> u32 { + Self::ALL.iter().position(|choice| *choice == self).unwrap_or(0) as u32 + } + + pub fn from_index(index: u32) -> Self { + Self::ALL.get(index as usize).copied().unwrap_or(Self::Agent) + } + + /// An agent that is running already holds the keys the user logs in + /// with everywhere else, so it goes first; a key on disk comes next. + pub fn default_for(environment: &SshEnvironment) -> Self { + if environment.agent { + Self::Agent + } else if environment.default_key.is_some() { + Self::PrivateKey + } else { + Self::Password + } + } + + fn of_saved(auth: &SavedSshAuth) -> Self { + match auth { + SavedSshAuth::Agent => Self::Agent, + SavedSshAuth::PrivateKey { .. } => Self::PrivateKey, + SavedSshAuth::Password => Self::Password, + SavedSshAuth::KeyboardInteractive => Self::KeyboardInteractive, + } + } +} + +/// The SSH rows as typed. Port, username and jump hosts may be empty: +/// OpenSSH then takes them from the user's SSH configuration, which is +/// also where a host alias is resolved. +#[derive(Debug, Clone)] +pub struct SshFormState { + pub host: String, + pub port: String, + pub user: String, + pub jump_hosts: String, + pub auth: SshAuthChoice, + pub key_path: String, + pub password: SecretString, + pub passphrase: SecretString, +} + +impl SshFormState { + pub fn new(environment: &SshEnvironment) -> Self { + Self { + host: String::new(), + port: String::new(), + user: String::new(), + jump_hosts: String::new(), + auth: SshAuthChoice::default_for(environment), + key_path: environment + .default_key + .as_deref() + .map(|path| path.to_string_lossy().into_owned()) + .unwrap_or_default(), + password: SecretString::from(String::new()), + passphrase: SecretString::from(String::new()), + } + } + + /// The rows a saved SSH section fills. Secrets stay in the keyring. + pub fn from_saved(saved: &SavedSshConfig) -> Self { + let key_path = match &saved.auth { + SavedSshAuth::PrivateKey { path, .. } => path.as_deref().map(Path::to_string_lossy).unwrap_or_default(), + _ => Default::default(), + }; + Self { + host: saved.host.clone(), + port: saved.port.map(|port| port.to_string()).unwrap_or_default(), + user: saved.username.clone().unwrap_or_default(), + jump_hosts: saved.jump_hosts.join(", "), + auth: SshAuthChoice::of_saved(&saved.auth), + key_path: key_path.into_owned(), + password: SecretString::from(String::new()), + passphrase: SecretString::from(String::new()), + } + } + + /// What is saved, which never holds a secret. + pub fn saved(&self) -> Result { + let host = self.host.trim(); + if host.is_empty() { + return Err(SshInputError::HostRequired); + } + let port = match self.port.trim() { + "" => None, + text => Some( + text.parse::() + .ok() + .filter(|port| *port != 0) + .ok_or(SshInputError::PortOutOfRange)?, + ), + }; + let user = Some(self.user.trim().to_owned()).filter(|user| !user.is_empty()); + if let Some(user) = &user + && let Err(failure) = SshDestination::new("host", None, Some(user.clone())) + { + return Err(SshInputError::UserInvalid(failure.to_string())); + } + if let Err(failure) = SshDestination::new(host, port, None) { + return Err(SshInputError::HostInvalid(failure.to_string())); + } + let jump_hosts = match self.jump_hosts.trim() { + "" => Vec::new(), + list => { + SshDestination::parse_jump_list(list) + .map_err(|failure| SshInputError::JumpHostsInvalid(failure.to_string()))?; + vec![list.to_owned()] + } + }; + let auth = match self.auth { + SshAuthChoice::Agent => SavedSshAuth::Agent, + SshAuthChoice::Password => SavedSshAuth::Password, + SshAuthChoice::KeyboardInteractive => SavedSshAuth::KeyboardInteractive, + SshAuthChoice::PrivateKey => { + let path = self.key_path.trim(); + if path.is_empty() { + return Err(SshInputError::KeyPathRequired); + } + SavedSshAuth::PrivateKey { + path: Some(PathBuf::from(path)), + has_passphrase: !self.passphrase.expose_secret().is_empty(), + } + } + }; + Ok(SavedSshConfig { + host: host.to_owned(), + port, + username: user, + jump_hosts, + auth, + }) + } + + /// The configuration a connect uses, the entry that is saved, and + /// which secret the keyring should keep. + pub fn inputs(&self) -> Result { + let saved = self.saved()?; + let mut cfg = tablepro_session::connect::ssh_config(&saved) + .map_err(|failure| SshInputError::HostInvalid(failure.to_string()))?; + let secret_to_store = fill_secret(&mut cfg, self.password.clone(), self.passphrase.clone()); + Ok(SshInputs { + cfg, + saved, + secret_to_store, + }) + } +} + +#[derive(Clone)] +pub struct SshInputs { + pub cfg: SshConfig, + pub saved: SavedSshConfig, + pub secret_to_store: SshSecretToStore, +} + +#[derive(Clone)] +pub enum SshSecretToStore { + Password(SecretString), + Passphrase(SecretString), + None, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SshInputError { + HostRequired, + HostInvalid(String), + PortOutOfRange, + UserInvalid(String), + JumpHostsInvalid(String), + KeyPathRequired, +} + +impl SshInputError { + pub fn message(&self) -> String { + match self { + Self::HostRequired => crate::i18n::gettext("Enter the SSH server's host name or its alias."), + Self::HostInvalid(detail) => { + crate::i18n::gettext_f("The SSH host cannot be used: {detail}", &[("detail", detail)]) + } + Self::PortOutOfRange => crate::i18n::gettext("The SSH port must be a number from 1 to 65535."), + Self::UserInvalid(detail) => { + crate::i18n::gettext_f("The SSH username cannot be used: {detail}", &[("detail", detail)]) + } + Self::JumpHostsInvalid(detail) => { + crate::i18n::gettext_f("The jump hosts cannot be used: {detail}", &[("detail", detail)]) + } + Self::KeyPathRequired => crate::i18n::gettext("Choose the private key file."), + } + } + + /// Whether the row is only empty. An empty row is not marked as an + /// error before the user has typed anything into it. + pub fn is_missing(&self) -> bool { + matches!(self, Self::HostRequired | Self::KeyPathRequired) + } +} + +/// Put the typed secret into the configuration the rows produced, and +/// say which one the keyring should keep. A key with no passphrase typed +/// keeps none, so ssh asks for it itself if the key needs one. +fn fill_secret(cfg: &mut SshConfig, password: SecretString, passphrase: SecretString) -> SshSecretToStore { + match &mut cfg.auth { + SshAuth::Password { password: slot } => { + *slot = password.clone(); + SshSecretToStore::Password(password) + } + SshAuth::PrivateKey { passphrase: slot, .. } => { + if passphrase.expose_secret().is_empty() { + return SshSecretToStore::None; + } + *slot = Some(passphrase.clone()); + SshSecretToStore::Passphrase(passphrase) + } + SshAuth::Agent | SshAuth::KeyboardInteractive => SshSecretToStore::None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn rows(auth: SshAuthChoice) -> SshFormState { + SshFormState { + host: " bastion.example.com ".to_owned(), + port: "2222".to_owned(), + user: "deploy".to_owned(), + jump_hosts: String::new(), + auth, + key_path: "/home/deploy/.ssh/id_ed25519".to_owned(), + password: SecretString::from("s3cret"), + passphrase: SecretString::from(""), + } + } + + #[test] + fn every_auth_choice_round_trips_through_its_index() { + for choice in SshAuthChoice::ALL { + assert_eq!(SshAuthChoice::from_index(choice.index()), choice); + } + assert_eq!(SshAuthChoice::from_index(99), SshAuthChoice::Agent); + } + + #[test] + fn agent_is_the_default_when_one_runs_then_a_key_then_a_password() { + let key = Some(PathBuf::from("/home/u/.ssh/id_ed25519")); + let cases = [ + (true, key.clone(), SshAuthChoice::Agent), + (true, None, SshAuthChoice::Agent), + (false, key, SshAuthChoice::PrivateKey), + (false, None, SshAuthChoice::Password), + ]; + for (agent, default_key, expected) in cases { + let environment = SshEnvironment { agent, default_key }; + assert_eq!(SshAuthChoice::default_for(&environment), expected, "{environment:?}"); + } + } + + #[test] + fn port_user_and_jump_hosts_may_come_from_the_ssh_config() { + let alias = SshFormState { + host: "prod-bastion".to_owned(), + port: String::new(), + user: " ".to_owned(), + ..rows(SshAuthChoice::Agent) + }; + + let saved = alias.saved().expect("an alias alone is enough"); + + assert_eq!(saved.host, "prod-bastion"); + assert_eq!(saved.port, None); + assert_eq!(saved.username, None); + assert!(saved.jump_hosts.is_empty()); + assert_eq!(saved.auth, SavedSshAuth::Agent); + } + + #[test] + fn ssh_key_auth_requires_path() { + let missing = SshFormState { + key_path: " ".to_owned(), + ..rows(SshAuthChoice::PrivateKey) + }; + assert_eq!(missing.saved(), Err(SshInputError::KeyPathRequired)); + + let key = rows(SshAuthChoice::PrivateKey).saved().expect("a key login"); + assert_eq!( + key.auth, + SavedSshAuth::PrivateKey { + path: Some(PathBuf::from("/home/deploy/.ssh/id_ed25519")), + has_passphrase: false, + } + ); + } + + #[test] + fn a_bad_row_names_the_row_it_is_in() { + let cases = [ + ( + SshFormState { + host: String::new(), + ..rows(SshAuthChoice::Agent) + }, + "host required", + ), + ( + SshFormState { + port: "70000".to_owned(), + ..rows(SshAuthChoice::Agent) + }, + "port", + ), + ( + SshFormState { + port: "0".to_owned(), + ..rows(SshAuthChoice::Agent) + }, + "port", + ), + ( + SshFormState { + user: "-oProxyCommand=x".to_owned(), + ..rows(SshAuthChoice::Agent) + }, + "user", + ), + ( + SshFormState { + jump_hosts: "jump1,,jump2".to_owned(), + ..rows(SshAuthChoice::Agent) + }, + "jump", + ), + ]; + for (state, row) in cases { + let error = state.saved().expect_err(row); + let matches = match row { + "host required" => error == SshInputError::HostRequired, + "port" => error == SshInputError::PortOutOfRange, + "user" => matches!(error, SshInputError::UserInvalid(_)), + _ => matches!(error, SshInputError::JumpHostsInvalid(_)), + }; + assert!(matches, "{row}: {error:?}"); + } + } + + #[test] + fn a_saved_section_fills_the_rows_it_came_from() { + let typed = SshFormState { + jump_hosts: "ops@jump1:2200, jump2".to_owned(), + ..rows(SshAuthChoice::KeyboardInteractive) + }; + let saved = typed.saved().expect("valid rows"); + + let refilled = SshFormState::from_saved(&saved); + + assert_eq!(refilled.saved(), Ok(saved)); + assert_eq!(refilled.auth, SshAuthChoice::KeyboardInteractive); + assert!( + refilled.password.expose_secret().is_empty(), + "a secret came back from the entry" + ); + } + + #[test] + fn a_password_login_carries_and_keeps_the_typed_password() { + let inputs = rows(SshAuthChoice::Password).inputs().expect("valid rows"); + + let SshAuth::Password { password } = &inputs.cfg.auth else { + panic!("expected password auth"); + }; + assert_eq!(password.expose_secret(), "s3cret"); + assert!(matches!(inputs.secret_to_store, SshSecretToStore::Password(_))); + } + + #[test] + fn a_key_with_no_passphrase_typed_keeps_none() { + let without = rows(SshAuthChoice::PrivateKey).inputs().expect("valid rows"); + assert!(matches!(without.secret_to_store, SshSecretToStore::None)); + + let with = SshFormState { + passphrase: SecretString::from("phrase"), + ..rows(SshAuthChoice::PrivateKey) + } + .inputs() + .expect("valid rows"); + let SshAuth::PrivateKey { passphrase, .. } = &with.cfg.auth else { + panic!("expected key auth"); + }; + assert_eq!(passphrase.as_ref().map(ExposeSecret::expose_secret), Some("phrase")); + assert!(matches!(with.secret_to_store, SshSecretToStore::Passphrase(_))); + } + + #[test] + fn agent_and_keyboard_interactive_keep_no_secret() { + for auth in [SshAuthChoice::Agent, SshAuthChoice::KeyboardInteractive] { + let inputs = rows(auth).inputs().expect("valid rows"); + assert!(matches!(inputs.secret_to_store, SshSecretToStore::None), "{auth:?}"); + } + } +} diff --git a/linux/crates/app/src/ui/ssh_section.rs b/linux/crates/app/src/ui/ssh_section.rs new file mode 100644 index 0000000000..d3827dcdf8 --- /dev/null +++ b/linux/crates/app/src/ui/ssh_section.rs @@ -0,0 +1,329 @@ +use gtk4::glib; +use gtk4::prelude::*; +use gtk4::subclass::prelude::*; +use libadwaita as adw; +use libadwaita::prelude::*; +use secrecy::SecretString; + +use super::connect_dialog::{FormErrors, FormField, show_problem}; +use super::ssh_inputs::{SshAuthChoice, SshEnvironment, SshFormState}; + +mod imp { + use std::cell::Cell; + use std::sync::OnceLock; + + use glib::subclass::Signal; + use libadwaita::subclass::prelude::*; + + use super::*; + + #[derive(Default, gtk4::CompositeTemplate)] + #[template(resource = "/app/tablepro/TablePro/ui/ssh-section.ui")] + pub struct SshSection { + #[template_child] + pub expander: TemplateChild, + #[template_child] + pub host_row: TemplateChild, + #[template_child] + pub port_row: TemplateChild, + #[template_child] + pub user_row: TemplateChild, + #[template_child] + pub jump_row: TemplateChild, + #[template_child] + pub auth_row: TemplateChild, + #[template_child] + pub auth_names: TemplateChild, + #[template_child] + pub password_row: TemplateChild, + #[template_child] + pub key_row: TemplateChild, + #[template_child] + pub key_browse_button: TemplateChild, + #[template_child] + pub passphrase_row: TemplateChild, + /// Set while the code writes the rows, so its own writes are not + /// reported as the user changing them. + pub updating: Cell, + } + + #[glib::object_subclass] + impl ObjectSubclass for SshSection { + const NAME: &'static str = "TableProSshSection"; + type Type = super::SshSection; + type ParentType = adw::PreferencesGroup; + + fn class_init(klass: &mut Self::Class) { + klass.bind_template(); + } + + fn instance_init(object: &glib::subclass::InitializingObject) { + object.init_template(); + } + } + + impl ObjectImpl for SshSection { + fn signals() -> &'static [Signal] { + static SIGNALS: OnceLock> = OnceLock::new(); + SIGNALS.get_or_init(|| vec![Signal::builder("changed").build()]) + } + + fn constructed(&self) { + self.parent_constructed(); + let section = self.obj(); + let labels: Vec = SshAuthChoice::ALL.iter().map(|choice| choice.label()).collect(); + let labels: Vec<&str> = labels.iter().map(String::as_str).collect(); + self.auth_names.splice(0, self.auth_names.n_items(), &labels); + self.key_browse_button.set_icon_name(crate::ui::icons::DOCUMENT_OPEN); + crate::ui::combo_value::show_whole_value(&self.auth_row); + section.refresh_auth_rows(); + + let weak = section.downgrade(); + self.expander.connect_enable_expansion_notify(move |_| { + if let Some(section) = weak.upgrade() { + section.emit_changed(); + } + }); + let weak = section.downgrade(); + self.auth_row.connect_selected_notify(move |_| { + if let Some(section) = weak.upgrade() { + section.refresh_auth_rows(); + section.emit_changed(); + } + }); + let entries: [&adw::EntryRow; 4] = [&self.host_row, &self.port_row, &self.user_row, &self.jump_row]; + for row in entries { + let weak = section.downgrade(); + row.connect_changed(move |_| { + if let Some(section) = weak.upgrade() { + section.emit_changed(); + } + }); + } + let key_row: &adw::EntryRow = &self.key_row; + let weak = section.downgrade(); + key_row.connect_changed(move |_| { + if let Some(section) = weak.upgrade() { + section.emit_changed(); + } + }); + for row in [&self.password_row, &self.passphrase_row] { + let weak = section.downgrade(); + row.connect_changed(move |_| { + if let Some(section) = weak.upgrade() { + section.emit_changed(); + } + }); + } + let weak = section.downgrade(); + self.key_browse_button.connect_clicked(move |_| { + if let Some(section) = weak.upgrade() { + glib::spawn_future_local(async move { section.choose_key().await }); + } + }); + } + } + + impl WidgetImpl for SshSection {} + impl PreferencesGroupImpl for SshSection {} +} + +glib::wrapper! { + /// The optional SSH tunnel. OpenSSH reads the user's own SSH + /// configuration, so a host alias and an empty port or username + /// work the way they do in a terminal. + pub struct SshSection(ObjectSubclass) + @extends adw::PreferencesGroup, gtk4::Widget, + @implements gtk4::Accessible, gtk4::Buildable, gtk4::ConstraintTarget; +} + +impl Default for SshSection { + fn default() -> Self { + glib::Object::new() + } +} + +impl SshSection { + pub fn is_enabled(&self) -> bool { + self.imp().expander.enables_expansion() + } + + /// Start from what this machine has: the agent when one runs, else + /// the first key in ~/.ssh. + pub fn apply_environment(&self, environment: &SshEnvironment) { + self.set_rows(&SshFormState::new(environment)); + } + + /// The rows as typed, or `None` while the tunnel is off. + pub fn form_state(&self) -> Option { + if !self.is_enabled() { + return None; + } + let imp = self.imp(); + Some(SshFormState { + host: imp.host_row.text().to_string(), + port: imp.port_row.text().to_string(), + user: imp.user_row.text().to_string(), + jump_hosts: imp.jump_row.text().to_string(), + auth: SshAuthChoice::from_index(imp.auth_row.selected()), + key_path: imp.key_row.text().to_string(), + password: SecretString::from(imp.password_row.text().to_string()), + passphrase: SecretString::from(imp.passphrase_row.text().to_string()), + }) + } + + /// Fill the rows and turn the tunnel on, or leave the rows as they + /// are and turn it off. + pub fn set_state(&self, state: Option<&SshFormState>) { + let imp = self.imp(); + match state { + Some(state) => { + self.set_rows(state); + imp.expander.set_enable_expansion(true); + } + None => imp.expander.set_enable_expansion(false), + } + } + + pub fn show_problems(&self, errors: &FormErrors) { + let imp = self.imp(); + let message = |field| errors.shown_on(field).map(|problem| problem.message.as_str()); + show_problem(&*imp.host_row, message(FormField::SshHost)); + show_problem(&*imp.port_row, message(FormField::SshPort)); + show_problem(&*imp.user_row, message(FormField::SshUser)); + show_problem(&*imp.jump_row, message(FormField::SshJumpHosts)); + show_problem(&*imp.key_row, message(FormField::SshKeyPath)); + } + + pub fn reveal(&self, field: FormField) { + let imp = self.imp(); + imp.expander.set_expanded(true); + let row: gtk4::Widget = match field { + FormField::SshPort => imp.port_row.get().upcast(), + FormField::SshUser => imp.user_row.get().upcast(), + FormField::SshJumpHosts => imp.jump_row.get().upcast(), + FormField::SshKeyPath => imp.key_row.get().upcast(), + _ => imp.host_row.get().upcast(), + }; + row.grab_focus(); + } + + pub fn connect_changed(&self, f: F) -> glib::SignalHandlerId { + self.connect_closure( + "changed", + false, + glib::closure_local!(move |section: &Self| { + f(section); + }), + ) + } + + fn set_rows(&self, state: &SshFormState) { + let imp = self.imp(); + imp.updating.set(true); + imp.host_row.set_text(&state.host); + imp.port_row.set_text(&state.port); + imp.user_row.set_text(&state.user); + imp.jump_row.set_text(&state.jump_hosts); + imp.auth_row.set_selected(state.auth.index()); + imp.key_row.set_text(&state.key_path); + imp.updating.set(false); + self.refresh_auth_rows(); + self.emit_changed(); + } + + fn emit_changed(&self) { + if !self.imp().updating.get() { + self.emit_by_name::<()>("changed", &[]); + } + } + + fn refresh_auth_rows(&self) { + let imp = self.imp(); + let auth = SshAuthChoice::from_index(imp.auth_row.selected()); + imp.auth_row.set_subtitle(&auth.description()); + imp.password_row.set_visible(auth == SshAuthChoice::Password); + imp.key_row.set_visible(auth == SshAuthChoice::PrivateKey); + imp.passphrase_row.set_visible(auth == SshAuthChoice::PrivateKey); + } + + async fn choose_key(&self) { + let filter = gtk4::FileFilter::new(); + filter.set_name(Some(&crate::i18n::gettext("SSH keys"))); + for pattern in ["id_*", "*.pem", "*.key"] { + filter.add_pattern(pattern); + } + filter.add_mime_type("application/x-pem-file"); + let any = gtk4::FileFilter::new(); + any.set_name(Some(&crate::i18n::gettext("All files"))); + any.add_pattern("*"); + let filters = gio::ListStore::new::(); + filters.append(&filter); + filters.append(&any); + let dialog = gtk4::FileDialog::builder() + .title(crate::i18n::gettext("Choose Private Key")) + .modal(true) + .filters(&filters) + .default_filter(&filter) + .build(); + // Asked through GIO, which reads the disk on its own threads. + let ssh_dir = gio::File::for_path(glib::home_dir().join(".ssh")); + let is_dir = ssh_dir + .query_info_future("standard::type", gio::FileQueryInfoFlags::NONE, glib::Priority::DEFAULT) + .await + .is_ok_and(|info| info.file_type() == gio::FileType::Directory); + if is_dir { + dialog.set_initial_folder(Some(&ssh_dir)); + } + let parent = self.root().and_downcast::(); + if let Some(path) = dialog + .open_future(parent.as_ref()) + .await + .ok() + .and_then(|file| file.path()) + { + self.imp().key_row.set_text(&path.to_string_lossy()); + } + } +} + +#[cfg(test)] +impl SshSection { + /// Every text row in the section, with whether Enter submits from + /// it. + pub(crate) fn rows_activating_default(&self) -> Vec<(&'static str, bool)> { + let imp = self.imp(); + vec![ + ("host", imp.host_row.activates_default()), + ("port", imp.port_row.activates_default()), + ("user", imp.user_row.activates_default()), + ("jump hosts", imp.jump_row.activates_default()), + ("password", imp.password_row.activates_default()), + ("key path", imp.key_row.activates_default()), + ("passphrase", imp.passphrase_row.activates_default()), + ] + } + + pub(crate) fn rows(&self) -> Vec { + let imp = self.imp(); + vec![ + imp.expander.get().upcast(), + imp.host_row.get().upcast(), + imp.port_row.get().upcast(), + imp.user_row.get().upcast(), + imp.jump_row.get().upcast(), + imp.auth_row.get().upcast(), + imp.password_row.get().upcast(), + imp.key_row.get().upcast(), + imp.passphrase_row.get().upcast(), + ] + } + + pub(crate) fn set_host(&self, host: &str) { + self.imp().host_row.set_text(host); + } + + pub(crate) fn enable(&self) { + self.imp().expander.set_enable_expansion(true); + } +} diff --git a/linux/crates/app/src/ui/structure_tab/column_handlers.rs b/linux/crates/app/src/ui/structure_tab/column_handlers.rs new file mode 100644 index 0000000000..db4dbd8e77 --- /dev/null +++ b/linux/crates/app/src/ui/structure_tab/column_handlers.rs @@ -0,0 +1,80 @@ +use tablepro_core::ddl::{DraftColumn, DraftType}; + +use super::draft_column_object::{DraftColumnObject, UnparsedDefault}; +use super::{ColumnField, ColumnsInput, StructureEffects, StructureModel, items}; + +impl StructureModel { + pub(super) fn on_columns(&mut self, input: ColumnsInput, fx: &mut StructureEffects) { + match input { + ColumnsInput::ColumnEdited { column, field } => self.edit_column(&column, field, fx), + ColumnsInput::AddColumn => { + let column_type = self + .context + .borrow() + .ddl() + .map_or("text", |ddl| ddl.default_column_type()) + .to_owned(); + let column = DraftColumn::new(self.placeholder_column_name(), DraftType::Typed(column_type)); + self.columns.append(&DraftColumnObject::new(column)); + self.recompute_dirty_state(fx); + } + ColumnsInput::RemoveColumn(column) => { + if let Some(position) = self.columns.find(&column) { + self.columns.remove(position); + self.recompute_dirty_state(fx); + } + } + } + } + + fn edit_column(&mut self, column: &DraftColumnObject, field: ColumnField, fx: &mut StructureEffects) { + // A row of a column the draft no longer holds. + if self.columns.find(column).is_none() { + return; + } + match field { + ColumnField::Name(name) => column.update(|draft| draft.name = name), + ColumnField::Type(text) => column.update(|draft| { + // Retyping the declaration keeps the catalogue's type, + // which is never parsed again. + draft.column_type = match draft.original.as_ref() { + Some(original) if DraftType::Catalog(original.info.column_type.clone()).text() == text => { + DraftType::Catalog(original.info.column_type.clone()) + } + _ => DraftType::Typed(text), + }; + }), + ColumnField::Nullable(nullable) => column.update(|draft| draft.nullable = nullable), + // The row's Nullable and Auto Increment switches follow the + // key and send their own edits. + ColumnField::PrimaryKey(primary_key) => column.update(|draft| draft.primary_key = primary_key), + ColumnField::AutoIncrement(auto_increment) => column.update(|draft| draft.auto_increment = auto_increment), + ColumnField::Default(text) => { + let parsed = self.context.borrow().parse_default(&column.column(), &text); + match parsed { + Ok(default) => { + column.set_unparsed_default(None); + column.update(|draft| draft.default = default); + } + // No edit of the column, but the tracker has to learn + // of it, or a close prompt would save without it. + Err(error) => column.set_unparsed_default(Some(UnparsedDefault { text, error })), + } + } + ColumnField::Comment(comment) => column.update(|draft| draft.comment = comment), + } + self.recompute_dirty_state(fx); + } + + /// `column_N` with the first N that no column of the draft is named. + fn placeholder_column_name(&self) -> String { + let names: Vec = items::(&self.columns) + .iter() + .map(DraftColumnObject::name) + .collect(); + (1_usize..) + .map(|n| format!("column_{n}")) + .find(|candidate| !names.iter().any(|name| name.eq_ignore_ascii_case(candidate))) + .unwrap_or_default() + } +} diff --git a/linux/crates/app/src/ui/structure_tab/columns.rs b/linux/crates/app/src/ui/structure_tab/columns.rs new file mode 100644 index 0000000000..23b8e09c2c --- /dev/null +++ b/linux/crates/app/src/ui/structure_tab/columns.rs @@ -0,0 +1,359 @@ +//! The column rows of the Structure tab's Columns page. + +use std::cell::{Cell, RefCell}; + +use gtk4 as gtk; +use gtk4::gio; +use libadwaita as adw; +use libadwaita::prelude::*; +use tablepro_core::ddl::{ColumnCapabilities, DraftColumn, DraftType, Permission}; +use tablepro_core::dialect::SqlDialect; + +use super::context::StructureContext; +use super::draft_column_object::DraftColumnObject; +use super::{ColumnField, ColumnsInput, StructureTabSender}; +use crate::ui::a11y::describe_icon_button; +use crate::ui::error_text::refusal_text; +use crate::ui::handler_guard::HandlerGuard; + +/// Sends a row's edits as edits of its column, while the column lives. +#[derive(Clone)] +struct ColumnEdits { + column: glib::WeakRef, + sender: StructureTabSender, +} + +impl ColumnEdits { + fn send(&self, field: ColumnField) { + if let Some(column) = self.column.upgrade() { + self.sender.input(ColumnsInput::ColumnEdited { column, field }); + } + } +} + +/// Turn a control off with the reason, in its tooltip and for screen +/// readers, when the engine refuses the change it makes. +pub(super) fn gate(widget: &impl IsA, permission: &Permission) { + if let Permission::Refused(reason) = permission { + let text = refusal_text(reason); + widget.set_sensitive(false); + widget.set_tooltip_text(Some(&text)); + widget + .upcast_ref::() + .update_property(&[gtk::accessible::Property::Description(&text)]); + } +} + +/// A key column takes no NULL on any engine, so while the column is in +/// the key its Nullable switch is off and stays off. +fn hold_not_null(row: &adw::SwitchRow, key: bool) { + row.set_sensitive(!key); + let widget = row.upcast_ref::(); + if key { + let text = crate::i18n::gettext("A primary key column can't take NULL."); + row.set_tooltip_text(Some(&text)); + widget.update_property(&[gtk::accessible::Property::Description(&text)]); + } else { + row.set_tooltip_text(None); + widget.reset_property(gtk::AccessibleProperty::Description); + } +} + +/// Mark a field whose text the engine's parser refused, or clear it. +fn mark_invalid(row: &adw::EntryRow, error: Option<&str>) { + match error { + Some(message) => { + row.add_css_class("error"); + row.set_tooltip_text(Some(message)); + } + None => { + row.remove_css_class("error"); + row.set_tooltip_text(None); + } + } + crate::ui::a11y::show_invalid(row, error); +} + +/// Whether the engine's parser takes `text` as one type. The +/// catalogue's own declaration, typed back as it was, is never parsed +/// again. +fn type_error(dialect: Option<&dyn SqlDialect>, column: &DraftColumn, text: &str) -> Option { + let declared = column + .original + .as_ref() + .is_some_and(|original| DraftType::Catalog(original.info.column_type.clone()).text() == text); + if declared { + return None; + } + let dialect = dialect?; + dialect.parse_type(text).err().map(|error| error.to_string()) +} + +/// Mark the Default field with the error the column holds, now and each +/// time it changes. The column outlives its row when the list builds +/// its rows again, so the handler goes with the row. +fn follow_default_error(column: &DraftColumnObject, row: &adw::EntryRow) { + mark_invalid(row, column.default_error().as_deref()); + let weak_row = row.downgrade(); + let handler = column.connect_default_error_notify(move |column| { + if let Some(row) = weak_row.upgrade() { + mark_invalid(&row, column.default_error().as_deref()); + } + }); + let guard = RefCell::new(Some(HandlerGuard::new(column, handler))); + row.connect_destroy(move |_| drop(guard.take())); +} + +/// The row of one column. Each control takes its value from the column +/// before its handler is connected, so building a row sends no edit. +pub(super) fn build_column_row( + column: &DraftColumnObject, + context: &StructureContext, + sender: &StructureTabSender, +) -> adw::ExpanderRow { + let draft = column.column(); + let permissions = context.column_permissions(&draft); + let edits = ColumnEdits { + column: column.downgrade(), + sender: sender.clone(), + }; + let row = adw::ExpanderRow::builder().use_markup(false).build(); + column.bind_property("name", &row, "title").sync_create().build(); + column.bind_property("summary", &row, "subtitle").sync_create().build(); + + // On the header, so it shows whether or not the row is expanded. + row.add_suffix(&remove_button(column, sender, &permissions.drop)); + row.add_row(&name_row(&draft, &permissions.rename, edits.clone())); + row.add_row(&type_row(&draft, context, &permissions.alter_type, edits.clone())); + let nullable_row = nullable_row(&draft, &permissions.alter_nullability, edits.clone()); + row.add_row(&nullable_row); + row.add_row(&default_row( + column, + &draft, + context, + &permissions.alter_default, + edits.clone(), + )); + let (pk_row, auto_row) = key_rows(&draft, context, &permissions, &nullable_row, &edits); + row.add_row(&pk_row); + row.add_row(&auto_row); + row.add_row(&comment_row(&draft, &permissions.alter_comment, edits)); + row +} + +fn remove_button(column: &DraftColumnObject, sender: &StructureTabSender, permission: &Permission) -> gtk::Button { + let remove_button = gtk::Button::builder() + .icon_name(crate::ui::icons::USER_TRASH) + .valign(gtk::Align::Center) + .build(); + remove_button.add_css_class("flat"); + describe_icon_button(&remove_button, &crate::i18n::gettext("Remove Column"), None); + remove_button.set_widget_name("col-remove"); + gate(&remove_button, permission); + let column_for_remove = column.downgrade(); + let sender_for_remove = sender.clone(); + remove_button.connect_clicked(move |_| { + if let Some(column) = column_for_remove.upgrade() { + sender_for_remove.input(ColumnsInput::RemoveColumn(column)); + } + }); + remove_button +} + +fn name_row(draft: &DraftColumn, permission: &Permission, edits: ColumnEdits) -> adw::EntryRow { + let name_row = adw::EntryRow::builder().title(crate::i18n::gettext("Name")).build(); + name_row.set_text(&draft.name); + name_row.set_widget_name("col-name"); + gate(&name_row, permission); + name_row.connect_changed(move |entry| edits.send(ColumnField::Name(entry.text().to_string()))); + name_row +} + +/// Free text, so `decimal(10,2)` or an enum type need no list; the +/// menu only suggests. +fn type_row( + draft: &DraftColumn, + context: &StructureContext, + permission: &Permission, + edits: ColumnEdits, +) -> adw::EntryRow { + let type_row = adw::EntryRow::builder().title(crate::i18n::gettext("Type")).build(); + type_row.set_text(draft.column_type.text()); + type_row.set_widget_name("col-type"); + // Before the gate, whose reason the tooltip has to keep. + mark_invalid( + &type_row, + type_error(context.dialect.as_deref(), draft, draft.column_type.text()).as_deref(), + ); + gate(&type_row, permission); + let dialect_for_type = context.dialect.clone(); + type_row.connect_changed(move |entry| { + let text = entry.text().to_string(); + let error = edits + .column + .upgrade() + .and_then(|column| type_error(dialect_for_type.as_deref(), &column.column(), &text)); + mark_invalid(entry, error.as_deref()); + edits.send(ColumnField::Type(text)); + }); + if type_row.is_sensitive() { + type_row.add_suffix(&build_type_suggestions_button(context.type_suggestions(), &type_row)); + } + type_row +} + +fn nullable_row(draft: &DraftColumn, permission: &Permission, edits: ColumnEdits) -> adw::SwitchRow { + let nullable_row = adw::SwitchRow::builder() + .title(crate::i18n::gettext("Nullable")) + .active(draft.nullable) + .build(); + nullable_row.set_widget_name("col-nullable"); + gate(&nullable_row, permission); + nullable_row.connect_active_notify(move |switch| edits.send(ColumnField::Nullable(switch.is_active()))); + nullable_row +} + +/// SQL, as the engine's parser reads it: a quoted literal or an +/// expression. Empty means no DEFAULT clause. Text the parser refused +/// stays as the user typed it. +fn default_row( + column: &DraftColumnObject, + draft: &DraftColumn, + context: &StructureContext, + permission: &Permission, + edits: ColumnEdits, +) -> adw::EntryRow { + let default_row = adw::EntryRow::builder() + .title(crate::i18n::gettext("Default value")) + .build(); + let default_text = column + .unparsed_default() + .map_or_else(|| context.default_text(draft), |unparsed| unparsed.text); + default_row.set_text(&default_text); + default_row.set_widget_name("col-default"); + follow_default_error(column, &default_row); + gate(&default_row, permission); + default_row.connect_changed(move |entry| edits.send(ColumnField::Default(entry.text().to_string()))); + default_row +} + +/// The Primary Key and Auto Increment switches. Joining the key turns +/// `nullable_row` off, and leaving it gives back what it showed before. +fn key_rows( + draft: &DraftColumn, + context: &StructureContext, + permissions: &ColumnCapabilities, + nullable_row: &adw::SwitchRow, + edits: &ColumnEdits, +) -> (adw::SwitchRow, adw::SwitchRow) { + let pk_row = adw::SwitchRow::builder() + .title(crate::i18n::gettext("Primary Key")) + .active(draft.primary_key) + .build(); + pk_row.set_widget_name("col-pk"); + gate(&pk_row, &permissions.alter_primary_key); + + let auto_row = adw::SwitchRow::builder() + .title(crate::i18n::gettext("Auto Increment")) + .active(draft.auto_increment) + .build(); + auto_row.set_widget_name("col-auto"); + // Numbering goes off with the key, on an engine that numbers only a + // key column. + let follows_key = !context.capabilities().auto_increment_outside_primary_key; + match &permissions.alter_auto_increment { + Permission::Allowed if follows_key => { + pk_row + .bind_property("active", &auto_row, "sensitive") + .sync_create() + .build(); + } + Permission::Allowed => {} + refused => gate(&auto_row, refused), + } + + // A refused nullability change keeps the reason it already shows. + let hold = permissions.alter_nullability.is_allowed(); + if hold { + hold_not_null(nullable_row, draft.primary_key); + } + let edits_for_pk = edits.clone(); + // The row owns both switches, so this holds them weakly. + let nullable_for_pk = nullable_row.downgrade(); + let auto_for_pk = auto_row.downgrade(); + // Given back when the column leaves the key. + let nullable_before_key = Cell::new(draft.nullable); + pk_row.connect_active_notify(move |switch| { + let key = switch.is_active(); + edits_for_pk.send(ColumnField::PrimaryKey(key)); + // Each switch sends its own edit, so the column holds what the + // row shows. + if hold && let Some(nullable) = nullable_for_pk.upgrade() { + if key { + nullable_before_key.set(nullable.is_active()); + nullable.set_active(false); + } else { + nullable.set_active(nullable_before_key.get()); + } + hold_not_null(&nullable, key); + } + if !key + && follows_key + && let Some(auto) = auto_for_pk.upgrade() + { + auto.set_active(false); + } + }); + + let edits_for_auto = edits.clone(); + auto_row.connect_active_notify(move |switch| edits_for_auto.send(ColumnField::AutoIncrement(switch.is_active()))); + (pk_row, auto_row) +} + +/// Empty means the column carries no description; clearing one the +/// server had drops it. +fn comment_row(draft: &DraftColumn, permission: &Permission, edits: ColumnEdits) -> adw::EntryRow { + let comment_row = adw::EntryRow::builder().title(crate::i18n::gettext("Comment")).build(); + comment_row.set_text(draft.comment.as_deref().unwrap_or("")); + comment_row.set_widget_name("col-comment"); + gate(&comment_row, permission); + comment_row.connect_changed(move |entry| { + let text = entry.text().to_string(); + let value = if text.is_empty() { None } else { Some(text) }; + edits.send(ColumnField::Comment(value)); + }); + comment_row +} + +/// A picked type is written into `target`, whose `changed` handler +/// takes it like typed text. Each button has its own action group, so +/// two columns' menus never share the `types.apply` action. +fn build_type_suggestions_button(suggestions: &'static [&'static str], target: &adw::EntryRow) -> gtk::MenuButton { + let action_group = gio::SimpleActionGroup::new(); + let apply_action = gio::SimpleAction::new("apply", Some(&String::static_variant_type())); + // The entry owns this button and its actions. + let target_for_action = target.downgrade(); + apply_action.connect_activate(move |_, param| { + if let (Some(s), Some(target)) = (param.and_then(|v| v.get::()), target_for_action.upgrade()) { + target.set_text(&s); + } + }); + action_group.add_action(&apply_action); + + let menu = gio::Menu::new(); + for ty in suggestions { + let item = gio::MenuItem::new(Some(ty), None); + item.set_action_and_target_value(Some("types.apply"), Some(&ty.to_variant())); + menu.append_item(&item); + } + + let button = gtk::MenuButton::builder() + .icon_name(crate::ui::icons::PAN_DOWN) + .valign(gtk::Align::Center) + .menu_model(&menu) + .build(); + button.add_css_class("flat"); + describe_icon_button(&button, &crate::i18n::gettext("Suggested Types"), None); + button.insert_action_group("types", Some(&action_group)); + button +} diff --git a/linux/crates/app/src/ui/structure_tab/columns_input.rs b/linux/crates/app/src/ui/structure_tab/columns_input.rs new file mode 100644 index 0000000000..f82536b66a --- /dev/null +++ b/linux/crates/app/src/ui/structure_tab/columns_input.rs @@ -0,0 +1,24 @@ +use super::draft_column_object::DraftColumnObject; + +/// What changes the draft's columns. +#[derive(Debug)] +pub(crate) enum ColumnsInput { + ColumnEdited { + column: DraftColumnObject, + field: ColumnField, + }, + AddColumn, + RemoveColumn(DraftColumnObject), +} + +#[derive(Debug, Clone)] +pub enum ColumnField { + Name(String), + Type(String), + Nullable(bool), + PrimaryKey(bool), + AutoIncrement(bool), + /// SQL text; empty is no default. + Default(String), + Comment(Option), +} diff --git a/linux/crates/app/src/ui/structure_tab/context.rs b/linux/crates/app/src/ui/structure_tab/context.rs new file mode 100644 index 0000000000..fc311ff0d0 --- /dev/null +++ b/linux/crates/app/src/ui/structure_tab/context.rs @@ -0,0 +1,148 @@ +//! What the Structure tab knows about the table it edits: whether it +//! exists, the definition the last read returned, and the dialect whose +//! DDL rules decide which changes each row offers. + +use std::sync::Arc; + +use tablepro_core::column::{ColumnDefault, ColumnType}; +use tablepro_core::ddl::{ + ColumnCapabilities, DdlCapabilities, DdlDialect, DraftColumn, Permission, RefusalReason, TableDefinition, + default_text, parse_default, primary_key_permission, +}; +use tablepro_core::dialect::SqlDialect; + +use super::StructureMode; + +/// The lists build their rows from this while the tab changes their +/// stores, so it lives apart from the model the tab is changing. +pub(super) struct StructureContext { + pub mode: StructureMode, + /// The connection's dialect, which plans the DDL. `None` until the + /// first read, and the SQL cannot be written before then. + pub dialect: Option>, + /// The table as the last read returned it, which Save plans against + /// and Discard restores. `None` for a new table. + pub definition: Option, +} + +impl StructureContext { + pub(super) fn ddl(&self) -> Option<&dyn DdlDialect> { + self.dialect.as_deref().and_then(|dialect| dialect.ddl()) + } + + /// The definition Save diffs the draft against: none for a new table. + pub(super) fn original(&self) -> Option<&TableDefinition> { + match self.mode { + StructureMode::New => None, + StructureMode::Edit => self.definition.as_ref(), + } + } + + pub(super) fn capabilities(&self) -> DdlCapabilities { + let Some(ddl) = self.ddl() else { + return DdlCapabilities::default(); + }; + match self.original() { + Some(definition) => ddl.table_capabilities(definition), + None => *ddl.capabilities(), + } + } + + pub(super) fn type_suggestions(&self) -> &'static [&'static str] { + self.ddl().map(|ddl| ddl.type_suggestions()).unwrap_or_default() + } + + /// Which changes the row of `column` offers. A column the user added + /// in this tab is only a draft, so everything about it can change, + /// apart from joining the primary key of a table whose key is held. + pub(super) fn column_permissions(&self, column: &DraftColumn) -> ColumnCapabilities { + let capabilities = self.capabilities(); + let (Some(ddl), Some(definition), Some(original)) = (self.ddl(), self.original(), column.original.as_ref()) + else { + let primary_key = match self.original() { + Some(definition) => primary_key_permission(&capabilities, definition), + None => Permission::Allowed, + }; + return ColumnCapabilities { + rename: Permission::Allowed, + drop: Permission::Allowed, + alter_type: Permission::Allowed, + alter_nullability: Permission::Allowed, + alter_default: Permission::Allowed, + alter_primary_key: primary_key, + alter_auto_increment: Permission::Allowed, + alter_comment: Permission::unless(capabilities.column_comments, RefusalReason::EngineUnsupported), + }; + }; + ddl.column_capabilities(definition, original) + } + + /// A draft's indexes and foreign keys are only entries in it, so the + /// engine's limits apply to a table that exists. + pub(super) fn table_permission(&self, allowed: bool) -> Permission { + match self.mode { + StructureMode::New => Permission::Allowed, + StructureMode::Edit => Permission::unless(allowed, RefusalReason::EngineUnsupported), + } + } + + pub(super) fn can_add_column(&self) -> bool { + match self.mode { + StructureMode::New => true, + StructureMode::Edit => self.capabilities().add_column, + } + } + + pub(super) fn can_add_index(&self) -> bool { + self.capabilities().create_index + } + + /// A new table declares its keys inside CREATE TABLE, which every + /// engine that creates tables takes. + pub(super) fn can_add_foreign_key(&self) -> bool { + let capabilities = self.capabilities(); + let allowed = match self.mode { + StructureMode::New => capabilities.create_table, + StructureMode::Edit => capabilities.add_foreign_key, + }; + allowed && self.ddl().is_some() + } + + /// The type a default's literal is read against: the column's own, or + /// the one the user typed once it parses. + fn default_column_type(&self, column: &DraftColumn) -> Option { + let dialect = self.dialect.as_deref()?; + column.column_type.column_type(dialect, &column.name).ok() + } + + /// The default as SQL the user could type back in. + pub(super) fn default_text(&self, column: &DraftColumn) -> String { + match (self.dialect.as_deref(), self.default_column_type(column)) { + (Some(dialect), Some(column_type)) => default_text(dialect, &column.default, &column_type), + _ => match &column.default { + ColumnDefault::None => String::new(), + ColumnDefault::Literal(value) => tablepro_core::export::value_to_text(value).unwrap_or_default(), + ColumnDefault::Expression(expression) => expression.as_sql().to_owned(), + }, + } + } + + /// The default the user typed, as the engine's parser reads it. The + /// text the row showed for the loaded default gives that default + /// back, so seeing it again is no edit. + pub(super) fn parse_default(&self, column: &DraftColumn, text: &str) -> Result { + if let Some(original) = column.original.as_ref().filter(|original| { + let loaded = DraftColumn::from_definition((*original).clone()); + self.default_text(&loaded) == text + }) { + return Ok(original.info.default.clone()); + } + let Some(dialect) = self.dialect.as_deref() else { + return Err(crate::i18n::gettext("Connect to a database to check the default.")); + }; + let column_type = self + .default_column_type(column) + .unwrap_or_else(|| ColumnType::untyped_text(column.column_type.text())); + parse_default(dialect, text, &column_type).map_err(|error| error.to_string()) + } +} diff --git a/linux/crates/app/src/ui/structure_tab/draft_column_object.rs b/linux/crates/app/src/ui/structure_tab/draft_column_object.rs new file mode 100644 index 0000000000..b3cde852aa --- /dev/null +++ b/linux/crates/app/src/ui/structure_tab/draft_column_object.rs @@ -0,0 +1,82 @@ +//! A column of the draft, as the Columns list holds it. + +use gtk4::glib; +use gtk4::subclass::prelude::*; +use tablepro_core::ddl::DraftColumn; + +use super::draft_column_object_imp as imp; + +glib::wrapper! { + /// The row bound to a column follows its `name` and `summary`, and + /// the tab finds the column by this object rather than by position. + pub struct DraftColumnObject(ObjectSubclass); +} + +/// Default text the engine's parser refused. The column keeps the +/// default it had, and Save waits until the text parses. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct UnparsedDefault { + pub text: String, + pub error: String, +} + +impl DraftColumnObject { + pub(crate) fn new(column: DraftColumn) -> Self { + let object: Self = glib::Object::new(); + object.imp().column.replace(column); + object + } + + pub(crate) fn column(&self) -> DraftColumn { + self.imp().column.borrow().clone() + } + + /// Change the column, and tell the rows bound to it when its name or + /// summary changed. + pub(crate) fn update(&self, change: impl FnOnce(&mut DraftColumn)) { + let (name, summary) = (self.name(), self.summary()); + change(&mut self.imp().column.borrow_mut()); + // The borrow has ended by now, since a bound row reads the + // column back as it hears of the change. + if self.name() != name { + self.notify_name(); + } + if self.summary() != summary { + self.notify_summary(); + } + } + + pub(crate) fn unparsed_default(&self) -> Option { + self.imp().unparsed_default.borrow().clone() + } + + pub(crate) fn set_unparsed_default(&self, unparsed: Option) { + if *self.imp().unparsed_default.borrow() == unparsed { + return; + } + self.imp().unparsed_default.replace(unparsed); + self.notify_default_error(); + } +} + +/// The collapsed row's summary, such as `varchar(255) · NOT NULL · +/// Primary key`. +pub(super) fn column_summary(column: &DraftColumn) -> String { + let mut parts: Vec = Vec::new(); + let type_text = column.column_type.text(); + if !type_text.is_empty() { + parts.push(type_text.to_owned()); + } + parts.push(if column.nullable { + crate::i18n::gettext("nullable") + } else { + crate::i18n::gettext("NOT NULL") + }); + if column.primary_key { + parts.push(crate::i18n::gettext("Primary key")); + } + if column.auto_increment { + parts.push(crate::i18n::gettext("auto-increment")); + } + parts.join(" · ") +} diff --git a/linux/crates/app/src/ui/structure_tab/draft_column_object_imp.rs b/linux/crates/app/src/ui/structure_tab/draft_column_object_imp.rs new file mode 100644 index 0000000000..f1eb635667 --- /dev/null +++ b/linux/crates/app/src/ui/structure_tab/draft_column_object_imp.rs @@ -0,0 +1,64 @@ +use std::cell::RefCell; +use std::marker::PhantomData; + +use gtk4::glib; +use gtk4::prelude::*; +use gtk4::subclass::prelude::*; +use tablepro_core::ddl::{DraftColumn, DraftType}; + +use super::draft_column_object::{UnparsedDefault, column_summary}; + +#[derive(glib::Properties)] +#[properties(wrapper_type = super::draft_column_object::DraftColumnObject)] +pub struct DraftColumnObject { + pub(super) column: RefCell, + pub(super) unparsed_default: RefCell>, + #[property(get = Self::name)] + name: PhantomData, + /// What the collapsed row says under the name. + #[property(get = Self::summary)] + summary: PhantomData, + /// Why the engine's parser refused the default the user typed. + #[property(get = Self::default_error)] + default_error: PhantomData>, +} + +/// What an object made by `glib::Object::new` rather than +/// `DraftColumnObject::new` holds: a column with no name or type. +impl Default for DraftColumnObject { + fn default() -> Self { + Self { + column: RefCell::new(DraftColumn::new(String::new(), DraftType::Typed(String::new()))), + unparsed_default: RefCell::default(), + name: PhantomData, + summary: PhantomData, + default_error: PhantomData, + } + } +} + +#[glib::object_subclass] +impl ObjectSubclass for DraftColumnObject { + const NAME: &'static str = "TableProDraftColumnObject"; + type Type = super::draft_column_object::DraftColumnObject; +} + +#[glib::derived_properties] +impl ObjectImpl for DraftColumnObject {} + +impl DraftColumnObject { + fn name(&self) -> String { + self.column.borrow().name.clone() + } + + fn summary(&self) -> String { + column_summary(&self.column.borrow()) + } + + fn default_error(&self) -> Option { + self.unparsed_default + .borrow() + .as_ref() + .map(|unparsed| unparsed.error.clone()) + } +} diff --git a/linux/crates/app/src/ui/structure_tab/fks.rs b/linux/crates/app/src/ui/structure_tab/fks.rs new file mode 100644 index 0000000000..82367e227d --- /dev/null +++ b/linux/crates/app/src/ui/structure_tab/fks.rs @@ -0,0 +1,65 @@ +//! The foreign key rows of the Structure tab's Foreign Keys page. + +use gtk4 as gtk; +use libadwaita as adw; +use libadwaita::prelude::*; + +use tablepro_core::ddl::Permission; + +use super::columns::gate; +use super::foreign_key_object::ForeignKeyObject; +use super::referential_action_label::action_text; +use super::{ForeignKeysInput, StructureTabSender}; + +/// The subtitle reads the whole relationship at a glance: +/// `col_a, col_b → other_table (ref_a, ref_b)`. `drafted` is a key the +/// user added in this tab rather than one read from the table. +pub(super) fn build_fk_row( + object: &ForeignKeyObject, + removable: &Permission, + drafted: bool, + sender: &StructureTabSender, +) -> adw::ActionRow { + let fk = object.info(); + let qualified_ref = match &fk.ref_schema { + Some(s) if !s.is_empty() => format!("{s}.{}", fk.ref_table), + _ => fk.ref_table.clone(), + }; + let mut subtitle = format!( + "{} → {qualified_ref} ({})", + fk.columns.join(", "), + fk.ref_columns.join(", "), + ); + if fk.on_delete.is_some() || fk.on_update.is_some() { + subtitle.push_str(&format!( + " · ON DELETE {} · ON UPDATE {}", + action_text(fk.on_delete, drafted), + action_text(fk.on_update, drafted), + )); + } + + let row = adw::ActionRow::builder() + .use_markup(false) + .title(fk.name.as_str()) + .subtitle(subtitle) + .build(); + + let remove_button = gtk::Button::builder() + .icon_name(crate::ui::icons::USER_TRASH) + .valign(gtk::Align::Center) + .build(); + remove_button.add_css_class("flat"); + crate::ui::a11y::describe_icon_button(&remove_button, &crate::i18n::gettext("Remove Foreign Key"), None); + remove_button.set_widget_name("fk-remove"); + gate(&remove_button, removable); + let key_for_remove = object.downgrade(); + let sender_for_remove = sender.clone(); + remove_button.connect_clicked(move |_| { + if let Some(key) = key_for_remove.upgrade() { + sender_for_remove.input(ForeignKeysInput::RemoveForeignKey(key)); + } + }); + row.add_suffix(&remove_button); + + row +} diff --git a/linux/crates/app/src/ui/structure_tab/foreign_key_dialog.rs b/linux/crates/app/src/ui/structure_tab/foreign_key_dialog.rs new file mode 100644 index 0000000000..e1ca41850c --- /dev/null +++ b/linux/crates/app/src/ui/structure_tab/foreign_key_dialog.rs @@ -0,0 +1,792 @@ +//! Add Foreign Key: a name, the referenced table picked from the +//! catalog, the columns of the key each paired with a column of that +//! table, and what deleting or updating a referenced row does. + +use std::cell::{Cell, RefCell}; +use std::rc::Rc; +use std::sync::Arc; + +use gtk4 as gtk; +use gtk4::gio; +use gtk4::prelude::*; +use libadwaita as adw; +use libadwaita::prelude::*; +use tablepro_core::ForeignKeyInfo; +use tablepro_core::ddl::{DdlDialect, ReferentialAction}; +use tablepro_core::meta::{ObjectScope, TableRef}; +use tablepro_session::runtime::{TaskFailure, Tasks}; +use tablepro_session::{OperationError, Session}; + +use super::foreign_key_draft::{ForeignKeyDraft, ForeignKeyDraftError}; +use super::form_dialog::{FormDialog, give_state, mark_invalid}; +use super::referential_action_label::{action_at, action_labels}; +use super::table_choice_object::{TableChoiceObject, table_choices}; + +pub(super) struct ForeignKeyDialogInit { + /// The draft's columns, in table order. + pub columns: Vec, + /// The names the table's foreign keys already take. + pub existing: Vec, + /// The actions the engine takes, in the order it offers them. + pub actions: &'static [ReferentialAction], + /// Where the referenced table can be (`reference_scope`). + pub scope: ObjectScope, + pub session: Arc, + pub tasks: Tasks, +} + +/// The dialog, which owns the form's state (`give_state`). +pub(super) struct ForeignKeyDialog { + dialog: adw::Dialog, + #[cfg(test)] + inner: Rc, +} + +/// The pair rows as built: the key's columns and the referenced table's, +/// once read. A pair chosen in a row changes neither. +type PairShape = (Vec, Option>); + +struct Inner { + form: FormDialog, + name_row: adw::EntryRow, + table_row: adw::ComboRow, + table_spinner: adw::Spinner, + tables: gio::ListStore, + column_checks: Vec<(String, gtk::CheckButton)>, + pairs_group: adw::PreferencesGroup, + pair_rows: RefCell>, + shown_pairs: RefCell>, + columns: Vec, + existing: Vec, + scope: ObjectScope, + session: Arc, + tasks: Tasks, + draft: RefCell, + /// The chosen table's columns, once read. + referenced_columns: RefCell>>, + /// Counts the tables chosen, so the columns of one chosen earlier, + /// read after it, are dropped. + choice: Cell, + /// A read of the catalog that failed, which the form says first. + load_error: RefCell>, + name_edited: Cell, + columns_edited: Cell, + on_add: Box, +} + +impl ForeignKeyDialog { + pub(super) fn new(init: ForeignKeyDialogInit, on_add: impl Fn(ForeignKeyInfo) + 'static) -> Self { + let ForeignKeyDialogInit { + columns, + existing, + actions, + scope, + session, + tasks, + } = init; + let (form, dialog) = FormDialog::new(&crate::i18n::gettext("Add Foreign Key"), &crate::i18n::gettext("_Add")); + let details = adw::PreferencesGroup::new(); + let name_row = FormDialog::entry_row(&crate::i18n::gettext("Name")); + // The form opens in its first field, where Enter submits. Left to + // the dialog, GTK 4.24 focuses Cancel, and Enter there cancels. + dialog.set_focus(Some(&name_row)); + details.add(&name_row); + + let tables = gio::ListStore::new::(); + let (table_row, table_spinner) = table_row(&tables); + let references = adw::PreferencesGroup::new(); + references.add(&table_row); + + let (checklist, column_checks) = column_checklist(&columns); + let pairs_group = adw::PreferencesGroup::builder() + .title(crate::i18n::gettext("Referenced Columns")) + .visible(false) + .build(); + let actions_group = ActionRows::new(actions); + + for group in [&details, &references, &checklist, &pairs_group, &actions_group.group] { + form.page.add(group); + } + + let inner = Rc::new(Inner { + form, + name_row, + table_row, + table_spinner, + tables, + column_checks, + pairs_group, + pair_rows: RefCell::default(), + shown_pairs: RefCell::default(), + columns, + existing, + scope, + session, + tasks, + draft: RefCell::default(), + referenced_columns: RefCell::default(), + choice: Cell::new(0), + load_error: RefCell::default(), + name_edited: Cell::new(false), + columns_edited: Cell::new(false), + on_add: Box::new(on_add), + }); + inner.follow_edits(&actions_group); + inner.load_tables(); + inner.refresh(); + give_state(&dialog, inner.clone()); + Self { + dialog, + #[cfg(test)] + inner, + } + } + + pub(super) fn present(&self, parent: >k::Widget) { + self.dialog.present(Some(parent)); + } +} + +/// Off, with a spinner, until the catalog's tables are read. +fn table_row(tables: &gio::ListStore) -> (adw::ComboRow, adw::Spinner) { + let table_row = adw::ComboRow::builder() + .title(crate::i18n::gettext("Referenced table")) + .model(tables) + .enable_search(true) + .sensitive(false) + .build(); + table_row.set_search_match_mode(gtk::StringFilterMatchMode::Substring); + table_row.set_expression(Some(>k::PropertyExpression::new( + TableChoiceObject::static_type(), + None::<>k::Expression>, + "label", + ))); + let table_spinner = adw::Spinner::new(); + table_row.add_suffix(&table_spinner); + (table_row, table_spinner) +} + +fn column_checklist(columns: &[String]) -> (adw::PreferencesGroup, Vec<(String, gtk::CheckButton)>) { + let checklist = adw::PreferencesGroup::builder() + .title(crate::i18n::gettext("Columns")) + .description(crate::i18n::gettext( + "The columns of this table that hold the reference.", + )) + .build(); + let column_checks = columns + .iter() + .map(|column| { + let row = adw::ActionRow::builder().title(column).use_markup(false).build(); + let check = gtk::CheckButton::builder().valign(gtk::Align::Center).build(); + row.add_prefix(&check); + row.set_activatable_widget(Some(&check)); + checklist.add(&row); + (column.clone(), check) + }) + .collect(); + (checklist, column_checks) +} + +/// What deleting or updating a referenced row does. +struct ActionRows { + group: adw::PreferencesGroup, + on_delete: adw::ComboRow, + on_update: adw::ComboRow, + actions: &'static [ReferentialAction], +} + +impl ActionRows { + fn new(actions: &'static [ReferentialAction]) -> Self { + let labels = action_labels(actions); + let labels: Vec<&str> = labels.iter().map(String::as_str).collect(); + let on_delete = adw::ComboRow::builder() + .title(crate::i18n::gettext("On delete")) + .model(>k::StringList::new(&labels)) + .build(); + let on_update = adw::ComboRow::builder() + .title(crate::i18n::gettext("On update")) + .model(>k::StringList::new(&labels)) + .build(); + let group = adw::PreferencesGroup::builder() + .title(crate::i18n::gettext("Actions")) + .build(); + group.add(&on_delete); + group.add(&on_update); + Self { + group, + on_delete, + on_update, + actions, + } + } +} + +impl Inner { + /// Every control writes into the draft, and Add submits it. + fn follow_edits(self: &Rc, action_rows: &ActionRows) { + let weak = Rc::downgrade(self); + self.name_row.connect_changed(move |row| { + if let Some(inner) = weak.upgrade() { + inner.name_edited.set(true); + inner.draft.borrow_mut().name = row.text().to_string(); + inner.refresh(); + } + }); + for (column, check) in &self.column_checks { + let (weak, column) = (Rc::downgrade(self), column.clone()); + check.connect_toggled(move |check| { + if let Some(inner) = weak.upgrade() { + inner.columns_edited.set(true); + let referenced = inner.referenced_columns.borrow().clone().unwrap_or_default(); + inner + .draft + .borrow_mut() + .set_checked(&inner.columns, &column, check.is_active(), &referenced); + inner.refresh(); + } + }); + } + let actions = action_rows.actions; + for (row, on_delete) in [(&action_rows.on_delete, true), (&action_rows.on_update, false)] { + let weak = Rc::downgrade(self); + row.connect_selected_notify(move |row| { + if let Some(inner) = weak.upgrade() { + let action = action_at(actions, row.selected()); + let mut draft = inner.draft.borrow_mut(); + match on_delete { + true => draft.on_delete = action, + false => draft.on_update = action, + } + } + }); + } + let weak = Rc::downgrade(self); + self.form.submit.connect_clicked(move |_| { + if let Some(inner) = weak.upgrade() { + inner.submit(); + } + }); + } + + fn submit(&self) { + let valid = self.draft.borrow().validate(&self.existing); + if let Ok(key) = valid { + (self.on_add)(key); + self.form.close(); + } + } + + /// The catalog's tables, read once as the dialog opens. + fn load_tables(self: &Rc) { + let (session, scope) = (self.session.clone(), self.scope.clone()); + let weak = Rc::downgrade(self); + let _delivery = + crate::runtime::spawn_and_deliver(&self.tasks, async move { session.objects(scope).await }, move |read| { + if let Some(inner) = weak.upgrade() { + inner.tables_read(read); + } + }); + } + + fn tables_read( + self: &Rc, + read: Result, Arc>, TaskFailure>, + ) { + self.table_spinner.set_visible(false); + match settled(read) { + Ok(objects) => { + // The first table is no more the user's choice than any + // other, so the row starts on none. + let choices: Vec = std::iter::once(TableChoiceObject::none()) + .chain(table_choices(&objects)) + .collect(); + self.tables.splice(0, self.tables.n_items(), &choices); + self.table_row.set_selected(0); + self.table_row.set_sensitive(true); + let weak = Rc::downgrade(self); + self.table_row.connect_selected_item_notify(move |row| { + if let Some(inner) = weak.upgrade() { + let table = row + .selected_item() + .and_downcast::() + .and_then(|choice| choice.table().cloned()); + inner.choose_table(table); + } + }); + } + Err(error) => { + self.load_error.replace(Some(crate::i18n::gettext_f( + "Couldn't read the tables: {error}", + &[("error", &error)], + ))); + } + } + self.refresh(); + } + + /// The key's columns lose their partners until the new table's + /// columns are read, then pair with the ones of the same name. + fn choose_table(self: &Rc, table: Option) { + let choice = self.choice.get() + 1; + self.choice.set(choice); + self.referenced_columns.replace(None); + self.load_error.replace(None); + { + let mut draft = self.draft.borrow_mut(); + draft.referenced = table.clone(); + draft.pair_with(&[]); + } + self.table_spinner.set_visible(table.is_some()); + if let Some(table) = table { + let session = self.session.clone(); + let weak = Rc::downgrade(self); + let read_table = table.clone(); + let _delivery = crate::runtime::spawn_and_deliver( + &self.tasks, + async move { session.columns(read_table).await }, + move |read| { + if let Some(inner) = weak.upgrade() + && inner.choice.get() == choice + { + inner.columns_read(&table, read); + } + }, + ); + } + self.refresh(); + } + + fn columns_read( + self: &Rc, + table: &TableRef, + read: Result, Arc>, TaskFailure>, + ) { + self.table_spinner.set_visible(false); + match settled(read) { + Ok(columns) => { + let names: Vec = columns.iter().map(|column| column.name.clone()).collect(); + self.draft.borrow_mut().pair_with(&names); + self.referenced_columns.replace(Some(names)); + } + Err(error) => { + self.load_error.replace(Some(crate::i18n::gettext_f( + "Couldn't read the columns of {table}: {error}", + &[("table", &table.name), ("error", &error)], + ))); + } + } + self.refresh(); + } + + fn refresh(self: &Rc) { + self.show_pairs(); + let errors = match self.draft.borrow().validate(&self.existing) { + Ok(_) => Vec::new(), + Err(errors) => errors, + }; + let loaded = self.referenced_columns.borrow().is_some(); + let shown: Vec<&ForeignKeyDraftError> = errors + .iter() + .filter(|error| match error { + ForeignKeyDraftError::NameMissing | ForeignKeyDraftError::NameTaken => self.name_edited.get(), + ForeignKeyDraftError::NoTable | ForeignKeyDraftError::NoPairs => self.columns_edited.get(), + ForeignKeyDraftError::UnpairedColumn(_) => loaded, + }) + .collect(); + let problem_of = |about: &dyn Fn(&ForeignKeyDraftError) -> bool| { + shown.iter().find(|error| about(error)).map(|error| error.message()) + }; + mark_invalid( + &self.name_row, + problem_of(&|error| { + matches!( + error, + ForeignKeyDraftError::NameMissing | ForeignKeyDraftError::NameTaken + ) + }) + .as_deref(), + ); + mark_invalid( + &self.table_row, + problem_of(&|error| matches!(error, ForeignKeyDraftError::NoTable)).as_deref(), + ); + for (column, row) in self.pair_rows.borrow().iter() { + let unpaired = problem_of( + &|error| matches!(error, ForeignKeyDraftError::UnpairedColumn(unpaired) if unpaired == column), + ); + mark_invalid(row, unpaired.as_deref()); + } + let problem = self + .load_error + .borrow() + .clone() + .or_else(|| shown.first().map(|error| error.message())); + self.form.show_state(errors.is_empty(), problem); + } + + /// A row per column of the key, choosing among the referenced + /// table's columns, built again only when the key's columns or the + /// table's change. + fn show_pairs(self: &Rc) { + let pairs = self.draft.borrow().pairs.clone(); + let referenced = self.referenced_columns.borrow().clone(); + let shape: PairShape = ( + pairs.iter().map(|(column, _)| column.clone()).collect(), + referenced.clone(), + ); + if self.shown_pairs.borrow().as_ref() == Some(&shape) { + return; + } + for (_, row) in self.pair_rows.borrow_mut().drain(..) { + self.pairs_group.remove(&row); + } + let candidates = referenced.unwrap_or_default(); + // A column pairs with nothing until the user or a matching name + // pairs it, never with whichever column comes first. + let none = crate::i18n::gettext("None"); + let labels: Vec<&str> = std::iter::once(none.as_str()) + .chain(candidates.iter().map(String::as_str)) + .collect(); + let rows: Vec<(String, adw::ComboRow)> = pairs + .iter() + .map(|(column, partner)| { + let row = adw::ComboRow::builder() + .title(crate::i18n::gettext_f("{column} references", &[("column", column)])) + .use_markup(false) + .model(>k::StringList::new(&labels)) + .sensitive(shape.1.is_some()) + .build(); + let selected = partner + .as_ref() + .and_then(|partner| candidates.iter().position(|candidate| candidate == partner)) + .and_then(|position| u32::try_from(position + 1).ok()) + .unwrap_or(0); + row.set_selected(selected); + let (weak, column_for_choice, candidates) = (Rc::downgrade(self), column.clone(), candidates.clone()); + row.connect_selected_notify(move |row| { + if let Some(inner) = weak.upgrade() { + let partner = row + .selected() + .checked_sub(1) + .and_then(|position| usize::try_from(position).ok()) + .and_then(|position| candidates.get(position).cloned()); + inner.draft.borrow_mut().pair(&column_for_choice, partner); + inner.refresh(); + } + }); + self.pairs_group.add(&row); + (column.clone(), row) + }) + .collect(); + self.pairs_group.set_visible(!rows.is_empty()); + self.pair_rows.replace(rows); + self.shown_pairs.replace(Some(shape)); + } +} + +/// Where a key of a table in `schema` can find its referenced table: any +/// schema, or the table's own where the engine looks there alone. +pub(super) fn reference_scope(ddl: &dyn DdlDialect, schema: Option<&str>) -> ObjectScope { + match (ddl.references_other_schemas(), schema) { + (true, _) => ObjectScope::AllSchemas, + (false, Some(schema)) => ObjectScope::Schema(schema.to_owned()), + (false, None) => ObjectScope::DefaultSchema, + } +} + +/// A read that never answered reads like the operation it ran. +fn settled(read: Result>, TaskFailure>) -> Result { + match read { + Ok(Ok(value)) => Ok(value), + Ok(Err(error)) => Err(crate::ui::error_text::operation_error_message(&error)), + Err(failure) => Err(crate::ui::error_text::operation_error_message(&OperationError::from( + failure, + ))), + } +} + +#[cfg(test)] +mod tests { + use tablepro_core::column::{ColumnDefault, ColumnType}; + use tablepro_core::meta::{ObjectInfo, ObjectKind}; + use tablepro_core::{ColumnInfo, DriverError}; + use tablepro_test_support::{ConnectionScript, FakeReply}; + + use super::super::form_dialog::tests::{WAIT, window}; + use super::*; + use crate::test_support::{FakeSession, OwnedWindow, drain_main_context, wait_until}; + + struct Harness { + dialog: Option, + added: Rc>>, + window: OwnedWindow, + _fake: FakeSession, + runtime: Option, + } + + impl Harness { + fn open(script: ConnectionScript) -> Self { + let window = window(); + let runtime = crate::test_support::test_runtime(); + let fake = FakeSession::start(script, [], runtime.tasks()); + let added = Rc::new(RefCell::new(Vec::new())); + let sink = added.clone(); + let dialog = ForeignKeyDialog::new( + ForeignKeyDialogInit { + columns: vec!["id".to_owned(), "account_id".to_owned(), "owner_id".to_owned()], + existing: vec!["fk_taken".to_owned()], + actions: &ReferentialAction::ALL, + scope: ObjectScope::AllSchemas, + session: fake.session.clone(), + tasks: runtime.tasks(), + }, + move |key| sink.borrow_mut().push(key), + ); + dialog.present(window.upcast_ref()); + Self { + dialog: Some(dialog), + added, + window, + _fake: fake, + runtime: Some(runtime), + } + } + + fn inner(&self) -> &Rc { + &self.dialog.as_ref().expect("the dialog is open").inner + } + + fn wait_for_tables(&self) { + let inner = self.inner().clone(); + wait_until(WAIT, || inner.table_row.is_sensitive()).expect("the tables to be read"); + } + + fn check(&self, column: &str) { + let check = self + .inner() + .column_checks + .iter() + .find(|(name, _)| name == column) + .map(|(_, check)| check.clone()) + .expect("a check for the column"); + check.set_active(true); + } + + fn pair_row(&self, column: &str) -> adw::ComboRow { + self.inner() + .pair_rows + .borrow() + .iter() + .find(|(name, _)| name == column) + .map(|(_, row)| row.clone()) + .expect("a pair row for the column") + } + } + + impl Drop for Harness { + fn drop(&mut self) { + if let Some(dialog) = self.dialog.take() { + dialog.dialog.force_close(); + } + self.window.destroy(); + drain_main_context(); + if let Some(runtime) = self.runtime.take() { + runtime.shutdown(); + } + } + } + + fn object(name: &str, kind: ObjectKind) -> ObjectInfo { + ObjectInfo { + schema: Some("public".to_owned()), + name: name.to_owned(), + kind, + } + } + + fn column(name: &str) -> ColumnInfo { + ColumnInfo { + name: name.to_owned(), + column_type: ColumnType::untyped_text("integer"), + nullable: true, + primary_key: false, + is_auto_increment: false, + is_generated: false, + default: ColumnDefault::None, + comment: None, + } + } + + fn catalog() -> ConnectionScript { + let mut script = ConnectionScript::default(); + script.list_objects.always(FakeReply::ok(vec![ + object("users", ObjectKind::Table), + object("active_users", ObjectKind::View), + object("accounts", ObjectKind::Table), + ])); + script + .fetch_columns + .always(FakeReply::ok(vec![column("id"), column("owner_id")])); + script + } + + fn table_labels(inner: &Inner) -> Vec { + (0..inner.tables.n_items()) + .filter_map(|position| inner.tables.item(position).and_downcast::()) + .map(|choice| choice.label()) + .collect() + } + + #[gtk4::test] + fn opens_with_the_cursor_in_the_name() { + let harness = Harness::open(catalog()); + let name_row = harness.inner().name_row.clone(); + wait_until(WAIT, || name_row.is_mapped()).expect("the form never appeared"); + + let text = gtk::prelude::EditableExt::delegate(&name_row).expect("an AdwEntryRow delegates to a GtkText"); + wait_until(WAIT, || text.is_focus()).expect("the form opened with the focus elsewhere"); + } + + #[gtk4::test] + fn referenced_table_combo_searches_catalog_tables() { + let harness = Harness::open(catalog()); + + harness.wait_for_tables(); + let inner = harness.inner(); + + assert_eq!(table_labels(inner), ["None", "accounts", "users"]); + assert!(inner.table_row.enables_search()); + assert_eq!( + inner.table_row.search_match_mode(), + gtk::StringFilterMatchMode::Substring + ); + assert!(inner.table_row.expression().is_some()); + assert_eq!(inner.table_row.selected(), 0); + assert!(!inner.table_spinner.is_visible()); + assert_eq!(inner.draft.borrow().referenced, None); + } + + #[gtk4::test] + fn choosing_table_loads_columns_and_autopairs_same_name() { + let harness = Harness::open(catalog()); + harness.wait_for_tables(); + let inner = harness.inner().clone(); + inner.name_row.set_text("fk_owner"); + harness.check("owner_id"); + assert!(!harness.pair_row("owner_id").is_sensitive()); + assert_eq!( + inner.form.caption().as_deref(), + Some("Choose the table the key references.") + ); + + inner.table_row.set_selected(1); + wait_until(WAIT, || inner.referenced_columns.borrow().is_some()).expect("the columns to be read"); + + let pair = harness.pair_row("owner_id"); + assert!(pair.is_sensitive()); + assert_eq!( + pair.selected_item() + .and_downcast::() + .map(|item| item.string().to_string()) + .as_deref(), + Some("owner_id") + ); + assert!(inner.form.submit.is_sensitive()); + inner.form.submit.emit_clicked(); + let added = harness.added.borrow(); + assert_eq!(added.len(), 1); + assert_eq!(added[0].columns, ["owner_id"]); + assert_eq!(added[0].ref_columns, ["owner_id"]); + assert_eq!( + (added[0].ref_schema.as_deref(), added[0].ref_table.as_str()), + (Some("public"), "accounts") + ); + assert_eq!((added[0].on_delete, added[0].on_update), (None, None)); + } + + #[gtk4::test] + fn a_column_with_no_partner_waits_for_one() { + let harness = Harness::open(catalog()); + harness.wait_for_tables(); + let inner = harness.inner().clone(); + inner.name_row.set_text("fk_account"); + harness.check("account_id"); + inner.table_row.set_selected(1); + wait_until(WAIT, || inner.referenced_columns.borrow().is_some()).expect("the columns to be read"); + + let pair = harness.pair_row("account_id"); + assert_eq!(pair.selected(), 0); + assert!(pair.has_css_class("error")); + let field = pair.upcast_ref::(); + assert!(gtk::test_accessible_has_state(field, gtk::AccessibleState::Invalid)); + assert!(gtk::test_accessible_has_property( + field, + gtk::AccessibleProperty::Description + )); + assert_eq!( + crate::ui::a11y::announced_in(&*harness.window) + .last() + .map(|(said, _)| said.as_str()), + Some("Choose the column “account_id” references.") + ); + assert!(!inner.form.submit.is_sensitive()); + assert_eq!( + inner.form.caption().as_deref(), + Some("Choose the column “account_id” references.") + ); + + pair.set_selected(1); + + assert!(!pair.has_css_class("error")); + assert!(!gtk::test_accessible_has_state( + pair.upcast_ref::(), + gtk::AccessibleState::Invalid + )); + assert!(inner.form.submit.is_sensitive()); + assert_eq!( + inner.draft.borrow().pairs, + [("account_id".to_owned(), Some("id".to_owned()))] + ); + } + + #[test] + fn a_sqlite_key_picks_its_table_from_its_own_schema() { + use tablepro_core::dialect::SqlDialect; + + let sqlite = tablepro_driver_sqlite::SqliteDialect::new(tablepro_driver_sqlite::SqliteVersion::new(3, 45, 0)); + let postgres = + tablepro_driver_postgres::PgDialect::new(&tablepro_core::ServerInfo::new("PostgreSQL", "18.0", 18, 0, 0)); + let sqlite = sqlite.ddl().expect("SQLite plans DDL"); + let postgres = postgres.ddl().expect("PostgreSQL plans DDL"); + + assert_eq!( + reference_scope(sqlite, Some("aux")), + ObjectScope::Schema("aux".to_owned()) + ); + assert_eq!(reference_scope(sqlite, None), ObjectScope::DefaultSchema); + assert_eq!(reference_scope(postgres, Some("sales")), ObjectScope::AllSchemas); + } + + #[gtk4::test] + fn a_catalog_that_cannot_be_read_says_so() { + let mut script = ConnectionScript::default(); + script + .list_objects + .always(FakeReply::err(DriverError::server("permission denied"))); + let harness = Harness::open(script); + let inner = harness.inner().clone(); + + wait_until(WAIT, || inner.form.caption().is_some()).expect("the failure to show"); + + assert!( + inner + .form + .caption() + .is_some_and(|caption| caption.starts_with("Couldn't read the tables:")), + "{:?}", + inner.form.caption() + ); + assert!(!inner.table_row.is_sensitive()); + assert!(!inner.table_spinner.is_visible()); + } +} diff --git a/linux/crates/app/src/ui/structure_tab/foreign_key_draft.rs b/linux/crates/app/src/ui/structure_tab/foreign_key_draft.rs new file mode 100644 index 0000000000..b122072093 --- /dev/null +++ b/linux/crates/app/src/ui/structure_tab/foreign_key_draft.rs @@ -0,0 +1,222 @@ +//! A foreign key as the Add Foreign Key dialog holds it, checked on +//! every change. + +use tablepro_core::ForeignKeyInfo; +use tablepro_core::ddl::ReferentialAction; +use tablepro_core::meta::TableRef; + +use super::index_draft::same_name; + +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub(crate) struct ForeignKeyDraft { + pub name: String, + /// Each column of the key, in the table's column order, with the + /// referenced column it pairs with once one is chosen. + pub pairs: Vec<(String, Option)>, + pub referenced: Option, + /// `None` leaves the action to the database, and the DDL says + /// nothing about it. + pub on_delete: Option, + pub on_update: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum ForeignKeyDraftError { + NameMissing, + NameTaken, + NoTable, + NoPairs, + UnpairedColumn(String), +} + +impl ForeignKeyDraftError { + pub(crate) fn message(&self) -> String { + match self { + Self::NameMissing => crate::i18n::gettext("Foreign key name is required."), + Self::NameTaken => crate::i18n::gettext("The table already has a foreign key with this name."), + Self::NoTable => crate::i18n::gettext("Choose the table the key references."), + Self::NoPairs => crate::i18n::gettext("Choose at least one column."), + Self::UnpairedColumn(column) => { + crate::i18n::gettext_f("Choose the column “{column}” references.", &[("column", column)]) + } + } + } +} + +impl ForeignKeyDraft { + /// The key, or every problem in the order the form shows its + /// fields. `existing` are the names the table's foreign keys already + /// take. + pub(crate) fn validate(&self, existing: &[String]) -> Result> { + let name = self.name.trim(); + let mut errors = Vec::new(); + if name.is_empty() { + errors.push(ForeignKeyDraftError::NameMissing); + } else if existing.iter().any(|taken| same_name(taken, name)) { + errors.push(ForeignKeyDraftError::NameTaken); + } + if self.referenced.is_none() { + errors.push(ForeignKeyDraftError::NoTable); + } + if self.pairs.is_empty() { + errors.push(ForeignKeyDraftError::NoPairs); + } + errors.extend( + self.pairs + .iter() + .filter(|(_, referenced)| referenced.is_none()) + .map(|(column, _)| ForeignKeyDraftError::UnpairedColumn(column.clone())), + ); + let Some(referenced) = self.referenced.as_ref().filter(|_| errors.is_empty()) else { + return Err(errors); + }; + Ok(ForeignKeyInfo { + name: name.to_owned(), + columns: self.pairs.iter().map(|(column, _)| column.clone()).collect(), + ref_schema: referenced.schema.clone(), + ref_table: referenced.name.clone(), + ref_columns: self + .pairs + .iter() + .filter_map(|(_, referenced)| referenced.clone()) + .collect(), + on_delete: self.on_delete, + on_update: self.on_update, + }) + } + + /// Add `column` to the key or take it out, keeping the key in the + /// order of `columns`. An added column pairs with the column of the + /// same name in `referenced_columns`, if there is one. + pub(crate) fn set_checked( + &mut self, + columns: &[String], + column: &str, + checked: bool, + referenced_columns: &[String], + ) { + let present = self.pairs.iter().any(|(paired, _)| paired == column); + match (checked, present) { + (true, false) => { + let partner = same_column(referenced_columns, column); + self.pairs.push((column.to_owned(), partner)); + self.pairs + .sort_by_key(|(paired, _)| columns.iter().position(|candidate| candidate == paired)); + } + (false, true) => self.pairs.retain(|(paired, _)| paired != column), + _ => {} + } + } + + pub(crate) fn pair(&mut self, column: &str, referenced: Option) { + if let Some((_, partner)) = self.pairs.iter_mut().find(|(paired, _)| paired == column) { + *partner = referenced; + } + } + + /// The columns of a newly chosen table: each column of the key pairs + /// with the one of the same name there, or with none. + pub(crate) fn pair_with(&mut self, referenced_columns: &[String]) { + for (column, partner) in &mut self.pairs { + *partner = same_column(referenced_columns, column); + } + } +} + +/// The exact name first, then one that differs only in case. +fn same_column(candidates: &[String], column: &str) -> Option { + candidates + .iter() + .find(|candidate| *candidate == column) + .or_else(|| candidates.iter().find(|candidate| same_name(candidate, column))) + .cloned() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn names(names: &[&str]) -> Vec { + names.iter().map(|name| (*name).to_owned()).collect() + } + + #[test] + fn fk_requires_table_and_complete_pairs() { + let columns = names(&["id", "account_id", "owner"]); + let mut draft = ForeignKeyDraft::default(); + + assert_eq!( + draft.validate(&[]), + Err(vec![ + ForeignKeyDraftError::NameMissing, + ForeignKeyDraftError::NoTable, + ForeignKeyDraftError::NoPairs, + ]) + ); + + draft.name = "fk_account".to_owned(); + draft.set_checked(&columns, "account_id", true, &[]); + draft.set_checked(&columns, "owner", true, &[]); + assert_eq!( + draft.validate(&names(&["FK_ACCOUNT"])), + Err(vec![ + ForeignKeyDraftError::NameTaken, + ForeignKeyDraftError::NoTable, + ForeignKeyDraftError::UnpairedColumn("account_id".to_owned()), + ForeignKeyDraftError::UnpairedColumn("owner".to_owned()), + ]) + ); + + draft.referenced = Some(TableRef::new(Some("billing".to_owned()), "accounts")); + draft.pair_with(&names(&["id", "Owner"])); + assert_eq!( + draft.validate(&[]), + Err(vec![ForeignKeyDraftError::UnpairedColumn("account_id".to_owned())]) + ); + + draft.pair("account_id", Some("id".to_owned())); + let key = draft.validate(&[]).expect("a complete key"); + assert_eq!(key.columns, ["account_id", "owner"]); + assert_eq!(key.ref_columns, ["id", "Owner"]); + assert_eq!( + (key.ref_schema.as_deref(), key.ref_table.as_str()), + (Some("billing"), "accounts") + ); + } + + #[test] + fn fk_pairs_keep_source_order() { + let columns = names(&["a", "b", "c"]); + let referenced = names(&["c", "a"]); + let mut draft = ForeignKeyDraft::default(); + + draft.set_checked(&columns, "c", true, &referenced); + draft.set_checked(&columns, "a", true, &referenced); + draft.set_checked(&columns, "b", true, &referenced); + draft.set_checked(&columns, "b", false, &referenced); + + assert_eq!( + draft.pairs, + [ + ("a".to_owned(), Some("a".to_owned())), + ("c".to_owned(), Some("c".to_owned())), + ] + ); + } + + #[test] + fn database_default_action_is_none() { + let mut draft = ForeignKeyDraft { + name: "fk".to_owned(), + referenced: Some(TableRef::unqualified("parent")), + ..ForeignKeyDraft::default() + }; + draft.set_checked(&names(&["parent_id"]), "parent_id", true, &names(&["parent_id"])); + draft.on_update = Some(ReferentialAction::Cascade); + + let key = draft.validate(&[]).expect("a complete key"); + + assert_eq!(key.on_delete, None); + assert_eq!(key.on_update, Some(ReferentialAction::Cascade)); + } +} diff --git a/linux/crates/app/src/ui/structure_tab/foreign_key_handlers.rs b/linux/crates/app/src/ui/structure_tab/foreign_key_handlers.rs new file mode 100644 index 0000000000..2116e988eb --- /dev/null +++ b/linux/crates/app/src/ui/structure_tab/foreign_key_handlers.rs @@ -0,0 +1,19 @@ +use super::foreign_key_object::ForeignKeyObject; +use super::{ForeignKeysInput, StructureEffects, StructureModel}; + +impl StructureModel { + pub(super) fn on_foreign_keys(&mut self, input: ForeignKeysInput, fx: &mut StructureEffects) { + match input { + ForeignKeysInput::AddForeignKey(key) => { + self.foreign_keys.append(&ForeignKeyObject::new(key)); + self.recompute_dirty_state(fx); + } + ForeignKeysInput::RemoveForeignKey(key) => { + if let Some(position) = self.foreign_keys.find(&key) { + self.foreign_keys.remove(position); + self.recompute_dirty_state(fx); + } + } + } + } +} diff --git a/linux/crates/app/src/ui/structure_tab/foreign_key_object.rs b/linux/crates/app/src/ui/structure_tab/foreign_key_object.rs new file mode 100644 index 0000000000..03e9ec6921 --- /dev/null +++ b/linux/crates/app/src/ui/structure_tab/foreign_key_object.rs @@ -0,0 +1,53 @@ +//! A foreign key of the draft, as the Foreign Keys list holds it. + +use std::cell::OnceCell; + +use gtk4::glib; +use gtk4::subclass::prelude::*; +use tablepro_core::ForeignKeyInfo; + +mod imp { + use super::*; + + #[derive(Default)] + pub struct ForeignKeyObject { + pub info: OnceCell, + } + + #[glib::object_subclass] + impl ObjectSubclass for ForeignKeyObject { + const NAME: &'static str = "TableProForeignKeyObject"; + type Type = super::ForeignKeyObject; + } + + impl ObjectImpl for ForeignKeyObject {} +} + +glib::wrapper! { + /// The tab finds the key by this object rather than by position. + pub struct ForeignKeyObject(ObjectSubclass); +} + +/// What an object made by `glib::Object::new` rather than +/// `ForeignKeyObject::new` reads as: a key of nothing. +static UNSET: ForeignKeyInfo = ForeignKeyInfo { + name: String::new(), + columns: Vec::new(), + ref_schema: None, + ref_table: String::new(), + ref_columns: Vec::new(), + on_delete: None, + on_update: None, +}; + +impl ForeignKeyObject { + pub(crate) fn new(info: ForeignKeyInfo) -> Self { + let object: Self = glib::Object::new(); + let _ = object.imp().info.set(info); + object + } + + pub(crate) fn info(&self) -> &ForeignKeyInfo { + self.imp().info.get().unwrap_or(&UNSET) + } +} diff --git a/linux/crates/app/src/ui/structure_tab/foreign_keys_input.rs b/linux/crates/app/src/ui/structure_tab/foreign_keys_input.rs new file mode 100644 index 0000000000..81077c0d33 --- /dev/null +++ b/linux/crates/app/src/ui/structure_tab/foreign_keys_input.rs @@ -0,0 +1,10 @@ +use tablepro_core::ForeignKeyInfo; + +use super::foreign_key_object::ForeignKeyObject; + +/// What changes the draft's foreign keys. +#[derive(Debug)] +pub(crate) enum ForeignKeysInput { + AddForeignKey(ForeignKeyInfo), + RemoveForeignKey(ForeignKeyObject), +} diff --git a/linux/crates/app/src/ui/structure_tab/form_dialog.rs b/linux/crates/app/src/ui/structure_tab/form_dialog.rs new file mode 100644 index 0000000000..7d53c77244 --- /dev/null +++ b/linux/crates/app/src/ui/structure_tab/form_dialog.rs @@ -0,0 +1,237 @@ +//! The frame the Add Index and Add Foreign Key forms share: an +//! `adw::Dialog` with Cancel and the suggested submit in its header bar +//! and the fields on a preferences page, as in GNOME Settings' Add +//! dialogs. The HIG keeps AlertDialog for confirmations. +//! +//! The form is checked on every change. Submit is on only while it is +//! valid, so Enter, which reaches it as the default widget, does +//! nothing until then; the first problem the user can act on shows +//! under the fields. + +use std::rc::Rc; + +use gtk4 as gtk; +use gtk4::glib; +use gtk4::prelude::*; +use libadwaita as adw; +use libadwaita::prelude::*; + +pub(super) struct FormDialog { + /// Weak, since the dialog owns the state the form lives in + /// (`give_state`), and a strong reference back would keep both. + dialog: glib::WeakRef, + pub page: adw::PreferencesPage, + pub submit: gtk::Button, + caption: gtk::Label, +} + +impl FormDialog { + /// The form, and the dialog it is drawn in, which nothing else holds + /// until it is presented. + pub(super) fn new(title: &str, submit_label: &str) -> (Self, adw::Dialog) { + let dialog = adw::Dialog::builder() + .title(title) + .content_width(420) + .content_height(560) + .build(); + let header = adw::HeaderBar::builder() + .show_start_title_buttons(false) + .show_end_title_buttons(false) + .build(); + let cancel = gtk::Button::with_mnemonic(&crate::i18n::gettext("_Cancel")); + let submit = gtk::Button::with_mnemonic(submit_label); + submit.add_css_class("suggested-action"); + submit.set_sensitive(false); + header.pack_start(&cancel); + header.pack_end(&submit); + + let page = adw::PreferencesPage::new(); + let caption = gtk::Label::builder() + .xalign(0.0) + .wrap(true) + .margin_top(6) + .margin_bottom(12) + .margin_start(12) + .margin_end(12) + .visible(false) + .build(); + caption.add_css_class("caption"); + caption.add_css_class("error"); + + let toolbar = adw::ToolbarView::new(); + toolbar.add_top_bar(&header); + toolbar.set_content(Some(&page)); + toolbar.add_bottom_bar(&caption); + dialog.set_child(Some(&toolbar)); + // A row reaches this only with activates-default set, which is + // why every entry row of a form sets it. + dialog.set_default_widget(Some(&submit)); + + let dialog_for_cancel = dialog.downgrade(); + cancel.connect_clicked(move |_| { + if let Some(dialog) = dialog_for_cancel.upgrade() { + dialog.close(); + } + }); + let form = Self { + dialog: dialog.downgrade(), + page, + submit, + caption, + }; + (form, dialog) + } + + /// An entry row that submits the form on Enter. A name typed into it + /// is never markup. + pub(super) fn entry_row(title: &str) -> adw::EntryRow { + adw::EntryRow::builder() + .title(title) + .use_markup(false) + .activates_default(true) + .build() + } + + /// Submit on only for a valid form, and `problem` under the fields. + /// A new problem is said: Enter does nothing until it is fixed, and + /// the caption sits below the field that has the focus. + pub(super) fn show_state(&self, valid: bool, problem: Option) { + self.submit.set_sensitive(valid); + match problem { + Some(problem) => { + let news = !self.caption.is_visible() || self.caption.label() != problem; + self.caption.set_label(&problem); + self.caption.set_visible(true); + if news { + crate::ui::a11y::announce(&self.caption, &problem, gtk::AccessibleAnnouncementPriority::Medium); + } + } + None => self.caption.set_visible(false), + } + } + + #[cfg(test)] + pub(super) fn caption(&self) -> Option { + self.caption.is_visible().then(|| self.caption.label().to_string()) + } + + pub(super) fn close(&self) { + if let Some(dialog) = self.dialog.upgrade() { + dialog.close(); + } + } +} + +/// The dialog keeps `state` until GTK lets the dialog go, once it closes +/// or with its window. The form's handlers hold the state weakly, and +/// the row that opens a form keeps nothing, so without this every +/// control would stop answering as soon as the form opened. +pub(super) fn give_state(dialog: &adw::Dialog, state: Rc) { + // Never disconnected: it runs once, as the dialog is disposed. + let _owner = dialog.add_weak_ref_notify_local(move || drop(state)); +} + +/// The `error` style on a field the problem shown is about, and the +/// problem on the field a screen reader is on. +pub(super) fn mark_invalid(widget: &impl IsA, problem: Option<&str>) { + match problem { + Some(_) => widget.add_css_class("error"), + None => widget.remove_css_class("error"), + } + crate::ui::a11y::show_invalid(widget, problem); +} + +/// A GtkPopoverMenu reads an underscore in a label as a mnemonic, so a +/// column name keeps its underscores by doubling them. +pub(super) fn menu_label(text: &str) -> String { + text.replace('_', "__") +} + +#[cfg(test)] +pub(super) mod tests { + use std::time::Duration; + + use super::*; + use crate::test_support::OwnedWindow; + + pub(crate) const WAIT: Duration = Duration::from_secs(5); + + /// The GtkText inside an AdwEntryRow. Enter reaches the row through + /// this delegate, so that is where a test presses it. + pub(crate) fn press_enter(row: &adw::EntryRow) { + let text = gtk::prelude::EditableExt::delegate(row) + .and_then(|delegate| delegate.downcast::().ok()) + .expect("an AdwEntryRow delegates to a GtkText"); + text.emit_activate(); + } + + /// A mapped window: GtkButton refuses to activate an unrealized + /// button, so a form that is not on screen would pass an Enter test + /// for the wrong reason. + pub(crate) fn window() -> OwnedWindow { + adw::init().expect("libadwaita initialises under the test backend"); + OwnedWindow::presented(adw::Window::new()) + } + + #[test] + fn a_column_name_keeps_its_underscores_in_a_menu() { + assert_eq!(menu_label("account_id"), "account__id"); + } + + /// A form handed to its dialog as the add dialogs hand theirs: the + /// state holds the form, and the form its dialog. + fn open_form(window: &adw::Window) -> (glib::WeakRef, std::rc::Weak) { + let (form, dialog) = FormDialog::new("Add Index", "_Add"); + let state = Rc::new(form); + let held = Rc::downgrade(&state); + give_state(&dialog, state); + dialog.present(Some(window.upcast_ref::())); + crate::test_support::drain_main_context(); + (dialog.downgrade(), held) + } + + #[gtk4::test] + fn an_open_form_keeps_its_state_until_it_closes() { + let window = window(); + let (dialog, held) = open_form(&window); + + assert!(held.upgrade().is_some(), "the state went while the form was open"); + dialog.upgrade().expect("the open form").force_close(); + + crate::test_support::wait_until(WAIT, || held.upgrade().is_none()).expect("a closed form kept its state"); + assert!(dialog.upgrade().is_none(), "a closed form stayed alive"); + } + + #[gtk4::test] + fn a_form_lets_go_of_its_state_with_its_window() { + let window = window(); + let (dialog, held) = open_form(&window); + + // AdwWindow lets its dialogs go as it is finalized, not as it is + // destroyed, so the test's own reference has to go too. + window.destroy(); + drop(window); + + crate::test_support::wait_until(WAIT, || held.upgrade().is_none()) + .expect("a form torn down with its window kept its state"); + assert!( + dialog.upgrade().is_none(), + "a form torn down with its window stayed alive" + ); + } + + #[gtk4::test] + fn a_form_dialog_starts_with_submit_off_and_as_its_default() { + adw::init().expect("libadwaita initialises under the test backend"); + let (form, dialog) = FormDialog::new("Add Index", "_Add"); + + assert_eq!(dialog.default_widget().as_ref(), Some(form.submit.upcast_ref())); + assert!(!form.submit.is_sensitive()); + assert_eq!(form.caption(), None); + form.show_state(false, Some("Index name is required.".to_owned())); + assert_eq!(form.caption().as_deref(), Some("Index name is required.")); + form.show_state(true, None); + assert!(form.submit.is_sensitive()); + assert_eq!(form.caption(), None); + } +} diff --git a/linux/crates/app/src/ui/structure_tab/imp.rs b/linux/crates/app/src/ui/structure_tab/imp.rs new file mode 100644 index 0000000000..5c26e20be4 --- /dev/null +++ b/linux/crates/app/src/ui/structure_tab/imp.rs @@ -0,0 +1,137 @@ +use std::cell::{Cell, RefCell}; +use std::collections::VecDeque; +use std::sync::OnceLock; + +use glib::subclass::Signal; +use gtk4 as gtk; +use gtk4::glib; +use gtk4::prelude::*; +use gtk4::subclass::prelude::*; +use libadwaita as adw; +use libadwaita::subclass::prelude::*; +use tablepro_session::runtime::WatchForwarder; + +use super::{StructureModel, StructureTabInput}; + +#[derive(Default, gtk::CompositeTemplate)] +#[template(resource = "/app/tablepro/TablePro/ui/structure-tab.ui")] +pub struct StructureTab { + #[template_child] + pub stale_banner: TemplateChild, + #[template_child] + pub inner_stack: TemplateChild, + #[template_child] + pub name_row: TemplateChild, + #[template_child] + pub name_entry: TemplateChild, + #[template_child] + pub columns_box: TemplateChild, + #[template_child] + pub columns_list: TemplateChild, + #[template_child] + pub add_column_list: TemplateChild, + #[template_child] + pub add_column_row: TemplateChild, + #[template_child] + pub indexes_box: TemplateChild, + #[template_child] + pub indexes_list: TemplateChild, + #[template_child] + pub add_index_list: TemplateChild, + #[template_child] + pub add_index_row: TemplateChild, + #[template_child] + pub fks_box: TemplateChild, + #[template_child] + pub fks_list: TemplateChild, + #[template_child] + pub add_fk_list: TemplateChild, + #[template_child] + pub add_fk_row: TemplateChild, + #[template_child] + pub copy_sql_button: TemplateChild, + #[template_child] + pub sql_view: TemplateChild, + #[template_child] + pub error_status: TemplateChild, + #[template_child] + pub retry_button: TemplateChild, + #[template_child] + pub pending_label: TemplateChild, + #[template_child] + pub drop_button: TemplateChild, + #[template_child] + pub discard_button: TemplateChild, + #[template_child] + pub save_button: TemplateChild, + #[template_child] + pub saving_spinner: TemplateChild, + #[template_child] + pub stop_button: TemplateChild, + + pub(super) model: RefCell>, + pub(super) queue: RefCell>, + pub(super) dispatching: Cell, + /// Delivers each reconnect, for as long as the tab is open. + pub(super) link_watch: RefCell>, +} + +#[glib::object_subclass] +impl ObjectSubclass for StructureTab { + const NAME: &'static str = "TableProStructureTab"; + type Type = super::StructureTab; + type ParentType = adw::Bin; + + fn class_init(klass: &mut Self::Class) { + sourceview5::View::ensure_type(); + klass.bind_template(); + } + + fn instance_init(object: &glib::subclass::InitializingObject) { + object.init_template(); + } +} + +impl ObjectImpl for StructureTab { + fn constructed(&self) { + self.parent_constructed(); + crate::ui::a11y::describe_icon_button(&self.copy_sql_button.get(), &crate::i18n::gettext("Copy SQL"), None); + } + + fn signals() -> &'static [Signal] { + static SIGNALS: OnceLock> = OnceLock::new(); + SIGNALS.get_or_init(|| { + vec![ + Signal::builder("dirty-changed") + .param_types([bool::static_type()]) + .build(), + Signal::builder("save-requested").build(), + Signal::builder("stop-save-requested").build(), + Signal::builder("drop-table-requested") + .param_types([String::static_type(), String::static_type()]) + .build(), + Signal::builder("toast").param_types([String::static_type()]).build(), + Signal::builder("loading-changed") + .param_types([bool::static_type()]) + .build(), + Signal::builder("alert") + .param_types([String::static_type(), String::static_type()]) + .build(), + ] + }) + } + + /// The model holds a handler on the app-wide style manager, which + /// would otherwise outlive the tab. + fn dispose(&self) { + self.link_watch.take(); + if let Ok(mut model) = self.model.try_borrow_mut() + && let Some(mut model) = model.take() + { + model.sequencer.cancel_all(); + } + } +} + +impl WidgetImpl for StructureTab {} +impl BinImpl for StructureTab {} diff --git a/linux/crates/app/src/ui/structure_tab/index_dialog.rs b/linux/crates/app/src/ui/structure_tab/index_dialog.rs new file mode 100644 index 0000000000..54df15c48b --- /dev/null +++ b/linux/crates/app/src/ui/structure_tab/index_dialog.rs @@ -0,0 +1,495 @@ +//! Add Index: a name, Unique, and the key columns in order, each with +//! its own menu to sort it descending, move it or remove it. + +use std::cell::{Cell, RefCell}; +use std::rc::{Rc, Weak}; + +use gtk4 as gtk; +use gtk4::prelude::*; +use gtk4::{gio, glib}; +use libadwaita as adw; +use libadwaita::prelude::*; +use tablepro_core::IndexInfo; + +use super::form_dialog::{FormDialog, give_state, mark_invalid, menu_label}; +use super::index_draft::{DraftKey, IndexDraft, IndexDraftError}; + +/// The dialog, which owns the form's state (`give_state`). +pub(super) struct IndexDialog { + dialog: adw::Dialog, + #[cfg(test)] + inner: Rc, +} + +struct Inner { + form: FormDialog, + name_row: adw::EntryRow, + keys_group: adw::PreferencesGroup, + add_key_button: gtk::MenuButton, + add_key_menu: gio::Menu, + key_rows: RefCell>, + /// The keys the rows show, so a change that leaves them alone does + /// not build them again. + shown_keys: RefCell>>, + /// The draft's columns, in table order. + columns: Vec, + /// The names the table's indexes already take. + existing: Vec, + draft: RefCell, + /// An empty name is a problem to point out only once the user has + /// typed one. + name_edited: Cell, + refresh_queued: Cell, + on_add: Box, +} + +impl IndexDialog { + pub(super) fn new(columns: Vec, existing: Vec, on_add: impl Fn(IndexInfo) + 'static) -> Self { + let (form, dialog) = FormDialog::new(&crate::i18n::gettext("Add Index"), &crate::i18n::gettext("_Add")); + let details = adw::PreferencesGroup::new(); + let name_row = FormDialog::entry_row(&crate::i18n::gettext("Name")); + // The form opens in its first field, where Enter submits. Left to + // the dialog, GTK 4.24 focuses Cancel, and Enter there cancels. + dialog.set_focus(Some(&name_row)); + let unique_row = adw::SwitchRow::builder() + .title(crate::i18n::gettext("Unique")) + .subtitle(crate::i18n::gettext("Refuse rows that repeat the key columns' values")) + .build(); + details.add(&name_row); + details.add(&unique_row); + + let add_key_menu = gio::Menu::new(); + let add_key_button = gtk::MenuButton::builder() + .icon_name(crate::ui::icons::LIST_ADD) + .menu_model(&add_key_menu) + .valign(gtk::Align::Center) + .build(); + add_key_button.add_css_class("flat"); + crate::ui::a11y::describe_icon_button(&add_key_button, &crate::i18n::gettext("Add Key Column"), None); + let keys_group = adw::PreferencesGroup::builder() + .title(crate::i18n::gettext("Key Columns")) + .header_suffix(&add_key_button) + .build(); + form.page.add(&details); + form.page.add(&keys_group); + + let inner = Rc::new(Inner { + form, + name_row: name_row.clone(), + keys_group, + add_key_button, + add_key_menu, + key_rows: RefCell::default(), + shown_keys: RefCell::default(), + columns, + existing, + draft: RefCell::default(), + name_edited: Cell::new(false), + refresh_queued: Cell::new(false), + on_add: Box::new(on_add), + }); + + let actions = gio::SimpleActionGroup::new(); + let add_key = gio::SimpleAction::new("add-key", Some(glib::VariantTy::STRING)); + let weak = Rc::downgrade(&inner); + add_key.connect_activate(move |_, parameter| { + if let (Some(inner), Some(column)) = (weak.upgrade(), parameter.and_then(|value| value.str())) { + inner.draft.borrow_mut().add_key(column); + inner.refresh(); + } + }); + actions.add_action(&add_key); + dialog.insert_action_group("index", Some(&actions)); + + let weak = Rc::downgrade(&inner); + name_row.connect_changed(move |row| { + if let Some(inner) = weak.upgrade() { + inner.name_edited.set(true); + inner.draft.borrow_mut().name = row.text().to_string(); + inner.refresh(); + } + }); + let weak = Rc::downgrade(&inner); + unique_row.connect_active_notify(move |row| { + if let Some(inner) = weak.upgrade() { + inner.draft.borrow_mut().unique = row.is_active(); + } + }); + let weak = Rc::downgrade(&inner); + inner.form.submit.connect_clicked(move |_| { + if let Some(inner) = weak.upgrade() { + inner.submit(); + } + }); + inner.refresh(); + give_state(&dialog, inner.clone()); + Self { + dialog, + #[cfg(test)] + inner, + } + } + + pub(super) fn present(&self, parent: >k::Widget) { + self.dialog.present(Some(parent)); + } +} + +impl Inner { + fn submit(&self) { + let valid = self.draft.borrow().validate(&self.existing); + if let Ok(index) = valid { + (self.on_add)(index); + self.form.close(); + } + } + + fn refresh(self: &Rc) { + let errors = match self.draft.borrow().validate(&self.existing) { + Ok(_) => Vec::new(), + Err(errors) => errors, + }; + let name_error = errors + .iter() + .copied() + .find(|error| matches!(error, IndexDraftError::NameMissing | IndexDraftError::NameTaken)) + .filter(|_| self.name_edited.get()); + let problem = name_error.map(IndexDraftError::message); + mark_invalid(&self.name_row, problem.as_deref()); + // An empty key list says so in the group's description. + self.form.show_state(errors.is_empty(), problem); + self.show_keys(); + } + + /// The menu header and the rows of the keys, built again only when + /// the keys changed. + fn show_keys(self: &Rc) { + let keys = self.draft.borrow().keys.clone(); + if self.shown_keys.borrow().as_ref() == Some(&keys) { + return; + } + for row in self.key_rows.borrow_mut().drain(..) { + self.keys_group.remove(&row); + } + let rows: Vec = keys + .iter() + .enumerate() + .map(|(position, key)| self.key_row(position, key, keys.len())) + .collect(); + for row in &rows { + self.keys_group.add(row); + } + self.key_rows.replace(rows); + self.keys_group.set_description( + keys.is_empty() + .then(|| crate::i18n::gettext("Add the columns the index sorts by.")) + .as_deref(), + ); + + self.add_key_menu.remove_all(); + let unused: Vec = self + .draft + .borrow() + .unused(&self.columns) + .into_iter() + .map(str::to_owned) + .collect(); + for column in &unused { + let item = gio::MenuItem::new(Some(&menu_label(column)), None); + item.set_action_and_target_value(Some("index.add-key"), Some(&column.to_variant())); + self.add_key_menu.append_item(&item); + } + self.add_key_button.set_sensitive(!unused.is_empty()); + self.shown_keys.replace(Some(keys)); + } + + fn key_row(self: &Rc, position: usize, key: &DraftKey, count: usize) -> adw::ActionRow { + let row = adw::ActionRow::builder().title(&key.column).use_markup(false).build(); + if key.descending { + row.set_subtitle(&crate::i18n::gettext("Descending")); + } + let order = gio::Menu::new(); + order.append(Some(&crate::i18n::gettext("Sort _Descending")), Some("key.descending")); + let moves = gio::Menu::new(); + moves.append(Some(&crate::i18n::gettext("Move _Up")), Some("key.move-up")); + moves.append(Some(&crate::i18n::gettext("Move D_own")), Some("key.move-down")); + let removal = gio::Menu::new(); + removal.append(Some(&crate::i18n::gettext("_Remove")), Some("key.remove")); + let menu = gio::Menu::new(); + menu.append_section(None, &order); + menu.append_section(None, &moves); + menu.append_section(None, &removal); + let button = gtk::MenuButton::builder() + .icon_name(crate::ui::icons::VIEW_MORE) + .menu_model(&menu) + .valign(gtk::Align::Center) + .build(); + button.add_css_class("flat"); + crate::ui::a11y::describe_icon_button(&button, &crate::i18n::gettext("Key Options"), None); + row.add_suffix(&button); + + let actions = gio::SimpleActionGroup::new(); + let descending = gio::SimpleAction::new_stateful("descending", None, &key.descending.to_variant()); + let weak = Rc::downgrade(self); + descending.connect_activate(move |action, _| { + let next = !action.state().and_then(|state| state.get::()).unwrap_or(false); + action.set_state(&next.to_variant()); + edit_later(&weak, move |draft| draft.set_descending(position, next)); + }); + actions.add_action(&descending); + let steps = [ + ("move-up", position > 0, Edit::Up), + ("move-down", position + 1 < count, Edit::Down), + ("remove", true, Edit::Remove), + ]; + for (name, enabled, edit) in steps { + let action = gio::SimpleAction::new(name, None); + action.set_enabled(enabled); + let weak = Rc::downgrade(self); + action.connect_activate(move |_, _| { + edit_later(&weak, move |draft| match edit { + Edit::Up => draft.move_key(position, true), + Edit::Down => draft.move_key(position, false), + Edit::Remove => draft.remove_key(position), + }); + }); + actions.add_action(&action); + } + row.insert_action_group("key", Some(&actions)); + row + } +} + +#[derive(Debug, Clone, Copy)] +enum Edit { + Up, + Down, + Remove, +} + +/// The draft changes at once, and the rows a moment later: the menu the +/// edit came from belongs to a row that is about to be replaced, and is +/// still closing. +fn edit_later(inner: &Weak, edit: impl FnOnce(&mut IndexDraft)) { + let Some(inner) = inner.upgrade() else { + return; + }; + edit(&mut inner.draft.borrow_mut()); + if inner.refresh_queued.replace(true) { + return; + } + let weak = Rc::downgrade(&inner); + glib::idle_add_local_once(move || { + if let Some(inner) = weak.upgrade() { + inner.refresh_queued.set(false); + inner.refresh(); + } + }); +} + +#[cfg(test)] +mod tests { + use std::time::Duration; + + use super::super::form_dialog::tests::{WAIT, press_enter, window}; + use super::*; + use crate::test_support::{drain_main_context, wait_until}; + + fn dialog(added: &Rc>>) -> IndexDialog { + let sink = added.clone(); + IndexDialog::new( + vec!["id".to_owned(), "email".to_owned(), "created_at".to_owned()], + vec!["users_pkey".to_owned()], + move |index| sink.borrow_mut().push(index), + ) + } + + fn add_key(dialog: &IndexDialog, column: &str) { + assert!( + dialog + .dialog + .activate_action("index.add-key", Some(&column.to_variant())) + .is_ok(), + "no index.add-key action" + ); + } + + fn key_titles(dialog: &IndexDialog) -> Vec { + dialog + .inner + .key_rows + .borrow() + .iter() + .map(|row| row.title().to_string()) + .collect() + } + + #[gtk4::test] + fn add_button_insensitive_until_valid() { + adw::init().expect("libadwaita initialises under the test backend"); + let added = Rc::new(RefCell::new(Vec::new())); + let dialog = dialog(&added); + let submit = dialog.inner.form.submit.clone(); + + assert!(!submit.is_sensitive()); + dialog.inner.name_row.set_text("by_email"); + assert!(!submit.is_sensitive(), "an index without keys can be added"); + add_key(&dialog, "email"); + assert!(submit.is_sensitive()); + dialog.inner.name_row.set_text("USERS_PKEY"); + assert!(!submit.is_sensitive(), "a name the table already has can be added"); + assert_eq!( + dialog.inner.form.caption().as_deref(), + Some("The table already has an index with this name.") + ); + } + + #[gtk4::test] + fn empty_name_row_has_error_class() { + adw::init().expect("libadwaita initialises under the test backend"); + let added = Rc::new(RefCell::new(Vec::new())); + let dialog = dialog(&added); + let name_row = dialog.inner.name_row.clone(); + + assert!(!name_row.has_css_class("error"), "an untouched form opens marked wrong"); + assert_eq!(dialog.inner.form.caption(), None); + name_row.set_text("by_email"); + name_row.set_text(""); + + assert!(name_row.has_css_class("error")); + assert_eq!(dialog.inner.form.caption().as_deref(), Some("Index name is required.")); + // The focus is in the row's text field, under which no caption shows. + let field = name_row.delegate().expect("the row's text field"); + assert!(gtk::test_accessible_has_state(&field, gtk::AccessibleState::Invalid)); + assert!(gtk::test_accessible_has_property( + &field, + gtk::AccessibleProperty::Description + )); + name_row.set_text("by_email"); + assert!(!name_row.has_css_class("error")); + assert!(!gtk::test_accessible_has_state(&field, gtk::AccessibleState::Invalid)); + } + + #[gtk4::test] + fn the_add_menu_offers_only_unused_columns() { + adw::init().expect("libadwaita initialises under the test backend"); + let added = Rc::new(RefCell::new(Vec::new())); + let dialog = dialog(&added); + let labels = |dialog: &IndexDialog| -> Vec { + let menu = &dialog.inner.add_key_menu; + (0..menu.n_items()) + .filter_map(|item| menu.item_attribute_value(item, "label", Some(glib::VariantTy::STRING))) + .filter_map(|label| label.str().map(str::to_owned)) + .collect() + }; + + assert_eq!(labels(&dialog), ["id", "email", "created__at"]); + add_key(&dialog, "id"); + add_key(&dialog, "email"); + add_key(&dialog, "created_at"); + + assert!(labels(&dialog).is_empty()); + assert!(!dialog.inner.add_key_button.is_sensitive()); + } + + #[gtk4::test] + fn move_up_reorders_keys() { + let window = window(); + let added = Rc::new(RefCell::new(Vec::new())); + let dialog = dialog(&added); + dialog.present(window.upcast_ref()); + add_key(&dialog, "email"); + add_key(&dialog, "created_at"); + let second = dialog.inner.key_rows.borrow()[1].clone(); + + assert!(second.activate_action("key.move-up", None).is_ok()); + drain_main_context(); + let first = dialog.inner.key_rows.borrow()[0].clone(); + assert!(first.activate_action("key.descending", None).is_ok()); + drain_main_context(); + + assert_eq!(key_titles(&dialog), ["created_at", "email"]); + dialog.inner.name_row.set_text("by_created"); + dialog.inner.form.submit.emit_clicked(); + let index = added.borrow().first().cloned().expect("the index was added"); + assert_eq!( + index + .keys + .iter() + .map(|key| (key.column_name().unwrap_or_default().to_owned(), key.descending())) + .collect::>(), + [("created_at".to_owned(), true), ("email".to_owned(), false)] + ); + let first = dialog.inner.key_rows.borrow()[0].clone(); + assert_eq!(first.subtitle().as_deref(), Some("Descending")); + } + + #[gtk4::test] + fn removing_the_last_key_leaves_the_form_invalid() { + adw::init().expect("libadwaita initialises under the test backend"); + let added = Rc::new(RefCell::new(Vec::new())); + let dialog = dialog(&added); + dialog.inner.name_row.set_text("by_email"); + add_key(&dialog, "email"); + let row = dialog.inner.key_rows.borrow()[0].clone(); + + assert!(row.activate_action("key.remove", None).is_ok()); + drain_main_context(); + + assert!(key_titles(&dialog).is_empty()); + assert!(!dialog.inner.form.submit.is_sensitive()); + assert_eq!( + dialog.inner.keys_group.description().as_deref(), + Some("Add the columns the index sorts by.") + ); + } + + #[gtk4::test] + fn opens_with_the_cursor_in_the_name() { + let window = window(); + let added = Rc::new(RefCell::new(Vec::new())); + let dialog = dialog(&added); + dialog.present(window.upcast_ref()); + let name_row = dialog.inner.name_row.clone(); + wait_until(WAIT, || name_row.is_mapped()).expect("the form never appeared"); + + let text = gtk::prelude::EditableExt::delegate(&name_row).expect("an AdwEntryRow delegates to a GtkText"); + wait_until(WAIT, || text.is_focus()).expect("the form opened with the focus elsewhere"); + } + + #[gtk4::test] + fn enter_on_valid_form_submits() { + let window = window(); + let added = Rc::new(RefCell::new(Vec::new())); + let dialog = dialog(&added); + dialog.present(window.upcast_ref()); + let name_row = dialog.inner.name_row.clone(); + wait_until(WAIT, || name_row.is_mapped()).expect("the form never appeared"); + name_row.set_text("by_email"); + add_key(&dialog, "email"); + + press_enter(&name_row); + + wait_until(WAIT, || added.borrow().len() == 1).expect("Enter did not add the index"); + assert_eq!(added.borrow()[0].name, "by_email"); + } + + #[gtk4::test] + fn enter_on_invalid_form_does_nothing() { + let window = window(); + let added = Rc::new(RefCell::new(Vec::new())); + let dialog = dialog(&added); + dialog.present(window.upcast_ref()); + let name_row = dialog.inner.name_row.clone(); + wait_until(WAIT, || name_row.is_mapped()).expect("the form never appeared"); + name_row.set_text("by_email"); + + press_enter(&name_row); + + assert!( + wait_until(Duration::from_secs(1), || !added.borrow().is_empty()).is_err(), + "Enter added an index with no keys" + ); + assert!(name_row.is_mapped(), "Enter closed an invalid form"); + } +} diff --git a/linux/crates/app/src/ui/structure_tab/index_draft.rs b/linux/crates/app/src/ui/structure_tab/index_draft.rs new file mode 100644 index 0000000000..e24fe07eb9 --- /dev/null +++ b/linux/crates/app/src/ui/structure_tab/index_draft.rs @@ -0,0 +1,200 @@ +//! An index as the Add Index dialog holds it, checked on every change. + +use tablepro_core::IndexInfo; +use tablepro_core::meta::IndexKey; + +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub(crate) struct IndexDraft { + pub name: String, + pub unique: bool, + /// In key order, each column at most once. + pub keys: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct DraftKey { + pub column: String, + pub descending: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum IndexDraftError { + NameMissing, + NameTaken, + NoKeys, +} + +impl IndexDraftError { + pub(crate) fn message(self) -> String { + match self { + Self::NameMissing => crate::i18n::gettext("Index name is required."), + Self::NameTaken => crate::i18n::gettext("The table already has an index with this name."), + Self::NoKeys => crate::i18n::gettext("Add at least one key column."), + } + } +} + +impl IndexDraft { + /// The index, or every problem in the order the form shows its + /// fields. `existing` are the names the table's indexes already + /// take. + pub(crate) fn validate(&self, existing: &[String]) -> Result> { + let name = self.name.trim(); + let mut errors = Vec::new(); + if name.is_empty() { + errors.push(IndexDraftError::NameMissing); + } else if existing.iter().any(|taken| same_name(taken, name)) { + errors.push(IndexDraftError::NameTaken); + } + if self.keys.is_empty() { + errors.push(IndexDraftError::NoKeys); + } + if !errors.is_empty() { + return Err(errors); + } + Ok(IndexInfo { + name: name.to_owned(), + keys: self + .keys + .iter() + .map(|key| IndexKey::Column { + name: key.column.clone(), + descending: key.descending, + }) + .collect(), + unique: self.unique, + primary: false, + predicate: None, + }) + } + + /// The columns of `columns` no key uses yet, in table order. + pub(crate) fn unused<'a>(&self, columns: &'a [String]) -> Vec<&'a str> { + columns + .iter() + .map(String::as_str) + .filter(|column| !self.keys.iter().any(|key| key.column == *column)) + .collect() + } + + pub(crate) fn add_key(&mut self, column: &str) { + if !self.keys.iter().any(|key| key.column == column) { + self.keys.push(DraftKey { + column: column.to_owned(), + descending: false, + }); + } + } + + pub(crate) fn remove_key(&mut self, position: usize) { + if position < self.keys.len() { + self.keys.remove(position); + } + } + + pub(crate) fn set_descending(&mut self, position: usize, descending: bool) { + if let Some(key) = self.keys.get_mut(position) { + key.descending = descending; + } + } + + /// Swap the key at `position` with the one before it, or after it + /// when `up` is false. A key at the end it moves toward stays. + pub(crate) fn move_key(&mut self, position: usize, up: bool) { + let other = match up { + true => position.checked_sub(1), + false => position.checked_add(1).filter(|other| *other < self.keys.len()), + }; + if let Some(other) = other + && position < self.keys.len() + { + self.keys.swap(position, other); + } + } +} + +/// Engines differ on whether names fold case, so two that differ only +/// in case are taken as the same. +pub(crate) fn same_name(left: &str, right: &str) -> bool { + left.trim().to_lowercase() == right.trim().to_lowercase() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn draft(name: &str, keys: &[&str]) -> IndexDraft { + let mut draft = IndexDraft { + name: name.to_owned(), + ..IndexDraft::default() + }; + for key in keys { + draft.add_key(key); + } + draft + } + + #[test] + fn index_requires_name_and_key() { + assert_eq!( + draft(" ", &[]).validate(&[]), + Err(vec![IndexDraftError::NameMissing, IndexDraftError::NoKeys]) + ); + assert_eq!(draft("by_email", &[]).validate(&[]), Err(vec![IndexDraftError::NoKeys])); + assert_eq!( + draft("", &["email"]).validate(&[]), + Err(vec![IndexDraftError::NameMissing]) + ); + let index = draft(" by_email ", &["email"]).validate(&[]).expect("a valid index"); + assert_eq!(index.name, "by_email"); + assert!(!index.primary && index.predicate.is_none()); + } + + #[test] + fn index_name_unique_case_insensitively() { + let existing = vec!["By_Email".to_owned()]; + + assert_eq!( + draft("by_email", &["email"]).validate(&existing), + Err(vec![IndexDraftError::NameTaken]) + ); + assert_eq!( + draft("BY_EMAIL", &["email"]).validate(&existing), + Err(vec![IndexDraftError::NameTaken]) + ); + assert!(draft("by_email_2", &["email"]).validate(&existing).is_ok()); + } + + #[test] + fn key_order_is_preserved() { + let mut draft = draft("by_name", &["last", "first", "last"]); + draft.set_descending(1, true); + + let keys = draft.validate(&[]).expect("a valid index").keys; + assert_eq!( + keys, + [ + IndexKey::Column { + name: "last".to_owned(), + descending: false + }, + IndexKey::Column { + name: "first".to_owned(), + descending: true + }, + ] + ); + + draft.move_key(1, true); + draft.move_key(0, true); + draft.move_key(1, false); + assert_eq!( + draft.keys.iter().map(|key| key.column.as_str()).collect::>(), + ["first", "last"] + ); + let columns = ["first".to_owned(), "middle".to_owned(), "last".to_owned()]; + assert_eq!(draft.unused(&columns), ["middle"]); + draft.remove_key(0); + assert_eq!(draft.unused(&columns), ["first", "middle"]); + } +} diff --git a/linux/crates/app/src/ui/structure_tab/index_handlers.rs b/linux/crates/app/src/ui/structure_tab/index_handlers.rs new file mode 100644 index 0000000000..28d47be261 --- /dev/null +++ b/linux/crates/app/src/ui/structure_tab/index_handlers.rs @@ -0,0 +1,19 @@ +use super::index_object::IndexObject; +use super::{IndexesInput, StructureEffects, StructureModel}; + +impl StructureModel { + pub(super) fn on_indexes(&mut self, input: IndexesInput, fx: &mut StructureEffects) { + match input { + IndexesInput::AddIndex(index) => { + self.indexes.append(&IndexObject::new(index)); + self.recompute_dirty_state(fx); + } + IndexesInput::RemoveIndex(index) => { + if let Some(position) = self.indexes.find(&index) { + self.indexes.remove(position); + self.recompute_dirty_state(fx); + } + } + } + } +} diff --git a/linux/crates/app/src/ui/structure_tab/index_object.rs b/linux/crates/app/src/ui/structure_tab/index_object.rs new file mode 100644 index 0000000000..360a6f4946 --- /dev/null +++ b/linux/crates/app/src/ui/structure_tab/index_object.rs @@ -0,0 +1,51 @@ +//! An index of the draft, as the Indexes list holds it. + +use std::cell::OnceCell; + +use gtk4::glib; +use gtk4::subclass::prelude::*; +use tablepro_core::IndexInfo; + +mod imp { + use super::*; + + #[derive(Default)] + pub struct IndexObject { + pub info: OnceCell, + } + + #[glib::object_subclass] + impl ObjectSubclass for IndexObject { + const NAME: &'static str = "TableProIndexObject"; + type Type = super::IndexObject; + } + + impl ObjectImpl for IndexObject {} +} + +glib::wrapper! { + /// The tab finds the index by this object rather than by position. + pub struct IndexObject(ObjectSubclass); +} + +/// What an object made by `glib::Object::new` rather than +/// `IndexObject::new` reads as: an index of nothing. +static UNSET: IndexInfo = IndexInfo { + name: String::new(), + keys: Vec::new(), + unique: false, + primary: false, + predicate: None, +}; + +impl IndexObject { + pub(crate) fn new(info: IndexInfo) -> Self { + let object: Self = glib::Object::new(); + let _ = object.imp().info.set(info); + object + } + + pub(crate) fn info(&self) -> &IndexInfo { + self.imp().info.get().unwrap_or(&UNSET) + } +} diff --git a/linux/crates/app/src/ui/structure_tab/indexes.rs b/linux/crates/app/src/ui/structure_tab/indexes.rs new file mode 100644 index 0000000000..5cd2dbbaa5 --- /dev/null +++ b/linux/crates/app/src/ui/structure_tab/indexes.rs @@ -0,0 +1,150 @@ +//! The index rows of the Structure tab's Indexes page. + +use gtk4 as gtk; +use libadwaita as adw; +use libadwaita::prelude::*; + +use tablepro_core::IndexInfo; +use tablepro_core::ddl::Permission; +use tablepro_core::meta::IndexKey; + +use super::columns::gate; +use super::index_object::IndexObject; +use super::{IndexesInput, StructureTabSender}; + +fn index_badge(label: &str, accent_class: &str) -> gtk::Label { + let badge = gtk::Label::builder().label(label).valign(gtk::Align::Center).build(); + badge.add_css_class("caption"); + badge.add_css_class(accent_class); + badge +} + +pub(super) fn build_index_row( + object: &IndexObject, + removable: &Permission, + sender: &StructureTabSender, +) -> adw::ActionRow { + let idx = object.info(); + let row = adw::ActionRow::builder() + .use_markup(false) + .title(idx.name.as_str()) + .subtitle(index_key_summary(idx)) + .build(); + + if idx.unique { + row.add_suffix(&index_badge(&crate::i18n::gettext("UNIQUE"), "dim-label")); + } + if idx.primary { + row.add_suffix(&index_badge(&crate::i18n::gettext("PRIMARY"), "accent")); + } + + let remove_button = gtk::Button::builder() + .icon_name(crate::ui::icons::USER_TRASH) + .valign(gtk::Align::Center) + .build(); + remove_button.add_css_class("flat"); + crate::ui::a11y::describe_icon_button(&remove_button, &crate::i18n::gettext("Remove Index"), None); + remove_button.set_widget_name("index-remove"); + // The primary key's constraint owns its index. + if idx.primary { + remove_button.set_sensitive(false); + remove_button.set_tooltip_text(Some(&crate::i18n::gettext( + "Primary-key index can't be dropped here; clear the PK on the column.", + ))); + } else { + gate(&remove_button, removable); + } + let index_for_remove = object.downgrade(); + let sender_for_remove = sender.clone(); + remove_button.connect_clicked(move |_| { + if let Some(index) = index_for_remove.upgrade() { + sender_for_remove.input(IndexesInput::RemoveIndex(index)); + } + }); + row.add_suffix(&remove_button); + + row +} + +/// The keys an index covers, as the subtitle reads them: each column or +/// expression in key order, `DESC` where it sorts descending, and the +/// WHERE of a partial index after them. +/// +/// No keys at all means the driver returned a malformed index, so the +/// subtitle says so rather than staying empty. +fn index_key_summary(index: &IndexInfo) -> String { + if index.keys.is_empty() { + return crate::i18n::gettext("no keys"); + } + let keys: Vec = index + .keys + .iter() + .map(|key| { + let text = match key { + IndexKey::Column { name, .. } => name.as_str(), + IndexKey::Expression { sql, .. } => sql.as_str(), + }; + match key.descending() { + true => format!("{text} DESC"), + false => text.to_owned(), + } + }) + .collect(); + let keys = keys.join(", "); + match index.predicate.as_deref() { + Some(predicate) => format!("{keys} WHERE {predicate}"), + None => keys, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn index(keys: Vec, predicate: Option<&str>) -> IndexInfo { + IndexInfo { + name: "by_email".to_owned(), + keys, + unique: false, + primary: false, + predicate: predicate.map(str::to_owned), + } + } + + #[test] + fn the_summary_reads_each_key_in_order_with_its_direction() { + let summary = index_key_summary(&index( + vec![ + IndexKey::Column { + name: "email".to_owned(), + descending: false, + }, + IndexKey::Expression { + sql: "lower(name)".to_owned(), + descending: true, + }, + ], + None, + )); + + assert_eq!(summary, "email, lower(name) DESC"); + } + + #[test] + fn a_partial_index_shows_its_predicate() { + let summary = index_key_summary(&index( + vec![IndexKey::Column { + name: "email".to_owned(), + descending: false, + }], + Some("deleted_at IS NULL"), + )); + + assert_eq!(summary, "email WHERE deleted_at IS NULL"); + } + + #[test] + fn an_index_without_keys_says_so() { + assert_eq!(index_key_summary(&index(Vec::new(), None)), "no keys"); + } +} diff --git a/linux/crates/app/src/ui/structure_tab/indexes_input.rs b/linux/crates/app/src/ui/structure_tab/indexes_input.rs new file mode 100644 index 0000000000..fa8627c8d5 --- /dev/null +++ b/linux/crates/app/src/ui/structure_tab/indexes_input.rs @@ -0,0 +1,10 @@ +use tablepro_core::IndexInfo; + +use super::index_object::IndexObject; + +/// What changes the draft's indexes. +#[derive(Debug)] +pub(crate) enum IndexesInput { + AddIndex(IndexInfo), + RemoveIndex(IndexObject), +} diff --git a/linux/crates/app/src/ui/structure_tab/layout.rs b/linux/crates/app/src/ui/structure_tab/layout.rs new file mode 100644 index 0000000000..2e40246ea0 --- /dev/null +++ b/linux/crates/app/src/ui/structure_tab/layout.rs @@ -0,0 +1,296 @@ +use std::cell::RefCell; +use std::rc::Rc; +use std::sync::Arc; + +use gtk4 as gtk; +use gtk4::prelude::*; +use gtk4::subclass::prelude::*; +use gtk4::{gio, glib}; +use libadwaita as adw; +use tablepro_session::Session; +use tablepro_session::runtime::Tasks; + +use super::columns::build_column_row; +use super::context::StructureContext; +use super::draft_column_object::DraftColumnObject; +use super::fks::build_fk_row; +use super::foreign_key_dialog::{ForeignKeyDialog, ForeignKeyDialogInit, reference_scope}; +use super::foreign_key_object::ForeignKeyObject; +use super::index_dialog::IndexDialog; +use super::index_object::IndexObject; +use super::indexes::build_index_row; +use super::structure_tab_widgets::StructureTabWidgets; +use super::{ + ColumnsInput, ForeignKeysInput, IndexesInput, LoadInput, SaveInput, StructureMode, StructureTab, StructureTabInput, + StructureTabOutput, TableInput, apply_sql_scheme, items, +}; +use crate::ui::handler_guard::HandlerGuard; +use crate::ui::tab_load::StaleBanner; + +pub(super) struct LayoutInit<'a> { + pub(super) table: &'a str, + /// The table's schema, which a foreign key may have to reference + /// within. + pub(super) schema: Option, + pub(super) mode: StructureMode, + /// The connection refuses writes, so Drop is not offered. + pub(super) read_only: bool, + pub(super) session: &'a Arc, + pub(super) tasks: &'a Tasks, +} + +/// The draft's columns, indexes and foreign keys, which the pages' +/// lists show. +pub(super) struct DraftStores<'a> { + pub(super) columns: &'a gio::ListStore, + pub(super) indexes: &'a gio::ListStore, + pub(super) foreign_keys: &'a gio::ListStore, +} + +/// The tab as it opens, each page bound to the draft's stores and each +/// control sending its input. Gives back the widgets the handlers drive. +pub(super) fn build( + tab: &StructureTab, + init: LayoutInit<'_>, + context: &Rc>, + stores: &DraftStores<'_>, +) -> StructureTabWidgets { + let imp = tab.imp(); + let (sql_buffer, theme_handler) = sql_preview_page(tab); + let name_changed = name_row(tab, init.table, init.mode); + toolbar(tab, init.mode, init.read_only); + let stale_banner = load_pages(tab, init.mode); + columns_page(tab, context, stores.columns); + indexes_page(tab, context, stores); + foreign_keys_page(tab, context, stores, init.schema, init.session, init.tasks); + StructureTabWidgets { + inner_stack: imp.inner_stack.get(), + error_status: imp.error_status.get(), + stale_banner, + name_entry: imp.name_entry.get(), + name_row: imp.name_row.get(), + name_changed, + columns_box: imp.columns_box.get(), + indexes_box: imp.indexes_box.get(), + fks_box: imp.fks_box.get(), + add_column_row: imp.add_column_row.get(), + add_index_row: imp.add_index_row.get(), + add_fk_row: imp.add_fk_row.get(), + sql_buffer, + pending_label: imp.pending_label.get(), + save_button: imp.save_button.get(), + discard_button: imp.discard_button.get(), + drop_button: imp.drop_button.get(), + saving_spinner: imp.saving_spinner.get(), + stop_button: imp.stop_button.get(), + _theme_handler: theme_handler, + } +} + +/// The preview's buffer, highlighted as SQL in the desktop's light or +/// dark scheme, and the handler that follows the scheme. +fn sql_preview_page(tab: &StructureTab) -> (sourceview5::Buffer, HandlerGuard) { + let imp = tab.imp(); + let sql_buffer = match sourceview5::LanguageManager::default().language("sql") { + Some(language) => sourceview5::Buffer::with_language(&language), + None => sourceview5::Buffer::new(None), + }; + imp.sql_view.set_buffer(Some(&sql_buffer)); + apply_sql_scheme(&sql_buffer); + let style_manager = adw::StyleManager::default(); + let buffer_for_theme = sql_buffer.downgrade(); + let theme_handler = HandlerGuard::new( + &style_manager, + style_manager.connect_dark_notify(move |_| { + if let Some(buffer) = buffer_for_theme.upgrade() { + apply_sql_scheme(&buffer); + } + }), + ); + // Saves the user clicking into the preview, selecting all and + // copying every time they want the DDL in another tool. + let buffer_for_copy = sql_buffer.downgrade(); + let tab_for_copy = tab.downgrade(); + imp.copy_sql_button.connect_clicked(move |button| { + if let Some(buffer) = buffer_for_copy.upgrade() { + let text = buffer.text(&buffer.start_iter(), &buffer.end_iter(), false); + crate::ui::clipboard::copy_text(button, text.to_string(), |message| { + if let Some(tab) = tab_for_copy.upgrade() { + tab.emit_output(StructureTabOutput::ShowToast(message.to_owned())); + } + }); + } + }); + (sql_buffer, theme_handler) +} + +/// The new table's name, and the entry's handler, which the tab blocks +/// while it writes the name itself. +fn name_row(tab: &StructureTab, table: &str, mode: StructureMode) -> glib::SignalHandlerId { + let imp = tab.imp(); + imp.name_entry.set_text(table); + imp.name_row.set_visible(matches!(mode, StructureMode::New)); + let sender = tab.sender(); + imp.name_entry.connect_changed(move |entry| { + sender.input(TableInput::TableNameEdited(entry.text().to_string())); + }) +} + +/// A read-only connection is offered nothing that writes, as the +/// sidebar's table menu offers no Drop Table on one. +fn toolbar(tab: &StructureTab, mode: StructureMode, read_only: bool) { + let imp = tab.imp(); + imp.drop_button + .set_visible(matches!(mode, StructureMode::Edit) && !read_only); + let on_click = |button: >k::Button, msg: fn() -> StructureTabInput| { + let sender = tab.sender(); + button.connect_clicked(move |_| sender.input(msg())); + }; + on_click(&imp.save_button, || SaveInput::Save.into()); + on_click(&imp.discard_button, || SaveInput::ConfirmDiscard.into()); + on_click(&imp.drop_button, || TableInput::DropTableRequested.into()); + on_click(&imp.stop_button, || SaveInput::StopSaving.into()); +} + +/// Without a retry a transient network blip on a remote database would +/// force the user to close the tab and open it again. +fn load_pages(tab: &StructureTab, mode: StructureMode) -> StaleBanner { + let imp = tab.imp(); + imp.inner_stack.set_visible_child_name(match mode { + StructureMode::New => "editor", + StructureMode::Edit => "loading", + }); + let sender = tab.sender(); + imp.retry_button + .connect_clicked(move |_| sender.input(LoadInput::Retry)); + let sender = tab.sender(); + StaleBanner::new(imp.stale_banner.get(), move || sender.input(LoadInput::Retry)) +} + +/// Each list builds a row for an item from the item and the context +/// alone, since the stores change while the tab's model is borrowed. +fn columns_page(tab: &StructureTab, context: &Rc>, columns: &gio::ListStore) { + let imp = tab.imp(); + let (context, sender) = (context.clone(), tab.sender()); + bind_list(&imp.columns_list, columns, move |column: &DraftColumnObject| { + build_column_row(column, &context.borrow(), &sender) + }); + let sender = tab.sender(); + imp.add_column_row + .connect_activated(move |_| sender.input(ColumnsInput::AddColumn)); + hide_with_add_row(&imp.add_column_row, &imp.add_column_list); +} + +/// The dialog takes the draft as it is when it opens: its columns, and +/// the names its indexes already take. +fn indexes_page(tab: &StructureTab, context: &Rc>, stores: &DraftStores<'_>) { + let imp = tab.imp(); + let (context, sender) = (context.clone(), tab.sender()); + bind_list(&imp.indexes_list, stores.indexes, move |index: &IndexObject| { + let context = context.borrow(); + let removable = context.table_permission(context.capabilities().drop_index); + build_index_row(index, &removable, &sender) + }); + let (columns, indexes, sender) = (stores.columns.downgrade(), stores.indexes.downgrade(), tab.sender()); + imp.add_index_row.connect_activated(move |row| { + let (Some(columns), Some(indexes)) = (columns.upgrade(), indexes.upgrade()) else { + return; + }; + let existing = items::(&indexes) + .iter() + .map(|index| index.info().name.clone()) + .collect(); + let sender = sender.clone(); + IndexDialog::new(column_names(&columns), existing, move |index| { + sender.input(IndexesInput::AddIndex(index)); + }) + .present(row.upcast_ref()); + }); + hide_with_add_row(&imp.add_index_row, &imp.add_index_list); +} + +/// The dialog takes the draft as it is when it opens: its columns, and +/// the names its foreign keys already take. `schema` is the table's, +/// which a foreign key may have to reference within. +fn foreign_keys_page( + tab: &StructureTab, + context: &Rc>, + stores: &DraftStores<'_>, + schema: Option, + session: &Arc, + tasks: &Tasks, +) { + let imp = tab.imp(); + let (context_for_keys, sender) = (context.clone(), tab.sender()); + bind_list(&imp.fks_list, stores.foreign_keys, move |key: &ForeignKeyObject| { + let context = context_for_keys.borrow(); + let removable = context.table_permission(context.capabilities().drop_foreign_key); + let drafted = context + .original() + .is_none_or(|definition| !definition.foreign_keys.contains(key.info())); + build_fk_row(key, &removable, drafted, &sender) + }); + let (columns, keys, context, sender) = ( + stores.columns.downgrade(), + stores.foreign_keys.downgrade(), + context.clone(), + tab.sender(), + ); + let (session, tasks) = (session.clone(), tasks.clone()); + imp.add_fk_row.connect_activated(move |row| { + let engine = context + .borrow() + .ddl() + .map(|ddl| (ddl.referential_actions(), reference_scope(ddl, schema.as_deref()))); + let (Some(columns), Some(keys), Some((actions, scope))) = (columns.upgrade(), keys.upgrade(), engine) else { + return; + }; + let init = ForeignKeyDialogInit { + columns: column_names(&columns), + existing: items::(&keys) + .iter() + .map(|key| key.info().name.clone()) + .collect(), + actions, + scope, + session: session.clone(), + tasks: tasks.clone(), + }; + let sender = sender.clone(); + ForeignKeyDialog::new(init, move |key| sender.input(ForeignKeysInput::AddForeignKey(key))) + .present(row.upcast_ref()); + }); + hide_with_add_row(&imp.add_fk_row, &imp.add_fk_list); +} + +/// An add row that is hidden would leave its list's frame behind. +fn hide_with_add_row(row: &adw::ButtonRow, list: >k::ListBox) { + row.bind_property("visible", list, "visible").sync_create().build(); +} + +/// Show `store` in `list`, a row for each item built by `build`. An empty +/// boxed list would still draw its frame, so the list hides while the +/// store is empty. +fn bind_list(list: >k::ListBox, store: &gio::ListStore, build: impl Fn(&T) -> W + 'static) +where + T: IsA, + W: IsA, +{ + store + .bind_property("n-items", list, "visible") + .transform_to(|_, items: u32| Some(items > 0)) + .sync_create() + .build(); + list.bind_model(Some(store), move |item| match item.downcast_ref::() { + Some(item) => build(item).upcast(), + // The store takes only `T`. + None => gtk::ListBoxRow::new().upcast(), + }); +} + +fn column_names(store: &gio::ListStore) -> Vec { + items::(store) + .iter() + .map(DraftColumnObject::name) + .collect() +} diff --git a/linux/crates/app/src/ui/structure_tab/load_handlers.rs b/linux/crates/app/src/ui/structure_tab/load_handlers.rs new file mode 100644 index 0000000000..82d6ddde9c --- /dev/null +++ b/linux/crates/app/src/ui/structure_tab/load_handlers.rs @@ -0,0 +1,170 @@ +use std::sync::Arc; + +use gtk4::glib; +use tablepro_core::ddl::TableDraft; +use tablepro_session::LinkGeneration; + +use super::structure_fetch::fetch_structure; +use super::{ + LoadInput, LoadedStructure, StructureCommand, StructureEffects, StructureMode, StructureModel, + StructureRequestKind, StructureTabOutput, +}; +use crate::runtime::{RequestTicket, Stamped}; +use crate::ui::tab_load::{ContentPage, LoadFailure, LoadPresentation}; + +impl StructureModel { + pub(super) fn on_load(&mut self, input: LoadInput, fx: &mut StructureEffects) { + match input { + LoadInput::Start => { + if self.mode() == StructureMode::Edit { + self.read_definition(fx); + } + self.sync_add_rows(); + self.regenerate_sql_preview(&self.pending()); + } + LoadInput::Command(command) => self.on_command(*command, fx), + LoadInput::LinkChanged(generation) => self.on_link_changed(generation, fx), + LoadInput::SchemaChanged => { + // Reading the table again replaces the draft, so a tab + // with edits of its own keeps them until Discard. + if self.mode() == StructureMode::Edit { + match self.last_dirty { + true => self.definition_stale = true, + false => self.read_definition(fx), + } + } + } + LoadInput::Retry => self.read_definition(fx), + } + } + + /// Read the table's definition. A definition on screen stays there + /// while it is read, and through a failure. + pub(super) fn read_definition(&mut self, fx: &mut StructureEffects) { + let presentation = self.load.begin(); + self.present(presentation, fx); + self.fetch_definition(); + } + + /// Show where the read stands: the spinner, the editor or the error + /// page, the banner over a definition that could not be read again, + /// and the tab bar's spinner. + fn present(&mut self, presentation: LoadPresentation, fx: &mut StructureEffects) { + match &presentation.page { + ContentPage::Spinner => self.widgets.inner_stack.set_visible_child_name("loading"), + ContentPage::Content => self.widgets.inner_stack.set_visible_child_name("editor"), + ContentPage::Error(failure) => { + // Inline on the status page: a modal alert would say the + // same and make the user dismiss it before seeing the + // page. The page reads its description as markup. + self.widgets + .error_status + .set_description(Some(&glib::markup_escape_text(&failure.message()))); + self.widgets.inner_stack.set_visible_child_name("error"); + } + } + self.widgets.stale_banner.show(presentation.stale.as_ref()); + if self.tab_loading != presentation.tab_loading { + self.tab_loading = presentation.tab_loading; + fx.output(StructureTabOutput::LoadingChanged(presentation.tab_loading)); + } + } + + /// One read brings columns, indexes and foreign keys from the same + /// moment, so the tab rebuilds once. + fn fetch_definition(&mut self) { + self.issued_on + .insert(StructureRequestKind::Definition, self.session.link_generation()); + let RequestTicket { stamp, cancel } = self.sequencer.issue(StructureRequestKind::Definition); + let work = fetch_structure(self.session.clone(), self.table.clone(), cancel); + let tab = self.this.clone(); + let _delivery = crate::runtime::spawn_and_deliver(&self.tasks, work, move |joined| { + let value = joined.unwrap_or_else(|failure| { + Err(LoadFailure::Definition(Arc::new( + tablepro_session::OperationError::from(failure), + ))) + }); + if let Some(tab) = tab.upgrade() { + tab.send(LoadInput::Command(Box::new(StructureCommand::Loaded(Stamped { + stamp, + value, + })))); + } + }); + } + + fn on_loaded(&mut self, loaded: LoadedStructure, fx: &mut StructureEffects) { + let draft = TableDraft::from_definition(&loaded.definition); + { + let mut context = self.context.borrow_mut(); + context.dialect = Some(loaded.dialect); + context.definition = Some(loaded.definition); + } + self.show_draft(draft); + self.sync_add_rows(); + let presentation = self.load.succeed(); + self.present(presentation, fx); + self.definition_stale = false; + // The definition is current again, so the diff counts. + self.refetching = false; + self.sync_editable(); + self.recompute_dirty_state(fx); + } + + /// With a definition on screen it stays, under the banner. A read + /// after a committed save keeps the draft locked: the definition on + /// screen is from before the save, so the draft's diff against it + /// would offer the saved changes again. + fn on_load_failed(&mut self, failure: LoadFailure, fx: &mut StructureEffects) { + let presentation = self.load.fail(failure); + let content_kept = !matches!(presentation.page, ContentPage::Error(_)); + self.present(presentation, fx); + if content_kept { + return; + } + // A retry from the error page must not find dirty tracking still + // frozen. + self.refetching = false; + self.sync_editable(); + } + + fn on_command(&mut self, command: StructureCommand, fx: &mut StructureEffects) { + let StructureCommand::Loaded(Stamped { stamp, value }) = command; + if !self.sequencer.accept(StructureRequestKind::Definition, stamp) { + return; + } + self.failed.record(StructureRequestKind::Definition, &value); + match value { + Ok(loaded) => self.on_loaded(loaded, fx), + Err(failure) => { + self.on_load_failed(failure, fx); + let issued = self.issued_on.get(&StructureRequestKind::Definition).copied(); + if issued.is_some_and(|issued| self.session.link_generation() > issued) { + self.retry_failed(fx); + } + } + } + } + + /// The session reconnected: a draft takes the new connection's + /// dialect, and a read that failed is asked again. + fn on_link_changed(&mut self, generation: LinkGeneration, fx: &mut StructureEffects) { + if generation <= self.link_generation { + return; + } + self.link_generation = generation; + if self.mode() == StructureMode::New { + self.context.borrow_mut().dialect = self.session.current_dialect(); + self.rebuild_rows(); + self.sync_add_rows(); + self.recompute_dirty_state(fx); + } + self.retry_failed(fx); + } + + fn retry_failed(&mut self, fx: &mut StructureEffects) { + if self.failed.take().contains(&StructureRequestKind::Definition) { + self.read_definition(fx); + } + } +} diff --git a/linux/crates/app/src/ui/structure_tab/load_input.rs b/linux/crates/app/src/ui/structure_tab/load_input.rs new file mode 100644 index 0000000000..723d02ece5 --- /dev/null +++ b/linux/crates/app/src/ui/structure_tab/load_input.rs @@ -0,0 +1,19 @@ +use tablepro_session::LinkGeneration; + +use super::StructureCommand; + +/// What reads the table's definition, or reads it again. +#[derive(Debug)] +pub(crate) enum LoadInput { + /// The tab is built: read the table, or draw an empty draft. + Start, + /// A reply to the tab's read, boxed: it holds the whole definition, + /// which every other input would otherwise carry room for. + Command(Box), + /// A reconnect replaced the session's connection. + LinkChanged(LinkGeneration), + /// The table's definition changed elsewhere. + SchemaChanged, + /// Try Again on the error page, or Retry on the banner. + Retry, +} diff --git a/linux/crates/app/src/ui/structure_tab/mod.rs b/linux/crates/app/src/ui/structure_tab/mod.rs new file mode 100644 index 0000000000..336c9f2a46 --- /dev/null +++ b/linux/crates/app/src/ui/structure_tab/mod.rs @@ -0,0 +1,601 @@ +//! The Structure workspace tab: CREATE, DROP and ALTER TABLE, indexes +//! and foreign keys. +//! +//! Layout (adw::ToolbarView, no internal HeaderBar): +//! +//! ┌─ Content ────────────────────────────────────────────────┐ +//! │ (New mode) AdwPreferencesGroup { name entry } │ +//! │ Centred AdwViewSwitcher: Columns | Indexes | FKs | SQL │ +//! │ ┌─ ViewStack ─────────────────────────────────────────┐ │ +//! │ │ Columns: boxed-list of AdwExpanderRow (per column, │ │ +//! │ │ header = Name + summary, body = Name/Type/Null/ │ │ +//! │ │ Default/PK/AutoInc rows), then an Add Column row │ │ +//! │ │ Indexes: boxed-list (Name, Cols, Unique, trash) │ │ +//! │ │ FKs: boxed-list (Name, Cols, Refs, RefCols, trash) │ │ +//! │ │ SQL Preview: SourceView5 (read-only, sql highlight) │ │ +//! │ └─────────────────────────────────────────────────────┘ │ +//! ├─ ActionBar { pending count | Discard | Save | Drop } ────┤ +//! └──────────────────────────────────────────────────────────┘ +//! +//! The tab edits a draft started from the table's definition as one +//! read returned it. The draft's columns, indexes and foreign keys are +//! the items of three list stores, each shown by a list bound to it, so +//! an edit changes one item and its row, and every other row stays as +//! it is. Every edit diffs the draft against the definition, plans the +//! change through the connection's `DdlDialect` for the preview, and +//! hands the pending structure to the tab's tracker, where the window's +//! close and save paths read it. Each control is on only where the +//! dialect allows its change, with the reason in its tooltip where it +//! does not. Discard puts the definition's items back in the stores; +//! there is no per-change undo. +//! +//! The tab reads the table's definition itself, one request at a time, +//! and reads it again once the session reconnects if the last read +//! failed. A change to the table from elsewhere reads it again too, +//! unless the draft has edits: then Discard reads it, and a plan that +//! writes the table again from the old definition refuses to save. A +//! read that fails with a definition on screen leaves it there under a +//! banner with Retry. +//! +//! An input belongs to the group of state it changes, and each group is +//! handled in its own `*_handlers` module. + +mod column_handlers; +mod columns; +mod columns_input; +mod context; +mod draft_column_object; +mod draft_column_object_imp; +mod fks; +mod foreign_key_dialog; +mod foreign_key_draft; +mod foreign_key_handlers; +mod foreign_key_object; +mod foreign_keys_input; +mod form_dialog; +mod imp; +mod index_dialog; +mod index_draft; +mod index_handlers; +mod index_object; +mod indexes; +mod indexes_input; +mod layout; +mod load_handlers; +mod load_input; +mod referential_action_label; +pub(crate) mod review_dialog; +mod save_gate; +mod save_handlers; +mod save_input; +mod sql_preview; +mod structure_actions; +mod structure_command; +mod structure_fetch; +mod structure_tab_input; +mod structure_tab_widgets; +mod table_choice_object; +mod table_handlers; +mod table_input; +#[cfg(test)] +mod tests; + +use std::cell::RefCell; +use std::collections::{BTreeMap, VecDeque}; +use std::rc::Rc; +use std::sync::Arc; + +use gtk4 as gtk; +use gtk4::prelude::*; +use gtk4::subclass::prelude::*; +use gtk4::{gio, glib}; +use libadwaita as adw; +use sourceview5::prelude::BufferExt; +use tablepro_core::ddl::{DraftColumn, TableChanges, TableDraft, diff_table}; +use tablepro_core::meta::TableRef; +use tablepro_session::runtime::{Tasks, WatchForwarder}; +use tablepro_session::{LinkGeneration, Session}; + +pub(crate) use self::columns_input::{ColumnField, ColumnsInput}; +use self::context::StructureContext; +use self::draft_column_object::DraftColumnObject; +use self::foreign_key_object::ForeignKeyObject; +pub(crate) use self::foreign_keys_input::ForeignKeysInput; +use self::index_object::IndexObject; +pub(crate) use self::indexes_input::IndexesInput; +pub(crate) use self::load_input::LoadInput; +pub(crate) use self::save_input::SaveInput; +pub(crate) use self::structure_command::{LoadedStructure, StructureCommand, StructureRequestKind}; +pub(crate) use self::structure_tab_input::StructureTabInput; +use self::structure_tab_widgets::StructureTabWidgets; +pub(crate) use self::table_input::TableInput; +use crate::runtime::{FailedRequests, RequestSequencer}; +use crate::services::pending_structure::PendingStructure; +use crate::services::structure_tracker_handle::StructureTrackerHandle; +use crate::ui::tab_load::{ContentLoad, FirstLoad, start_first_load}; + +glib::wrapper! { + pub struct StructureTab(ObjectSubclass) + @extends adw::Bin, gtk::Widget, + @implements gtk::Accessible, gtk::Buildable, gtk::ConstraintTarget; +} + +/// Whether the Structure tab is editing an existing table or +/// drafting a brand-new one. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum StructureMode { + New, + Edit, +} + +pub struct StructureTabInit { + pub tracker: StructureTrackerHandle, + pub schema: Option, + pub table: String, + pub mode: StructureMode, + /// The session the tab reads through. A draft takes its dialect + /// from it for the SQL preview. + pub session: Arc, + pub tasks: Tasks, + pub first_load: FirstLoad, +} + +/// Holds the tab weakly, so a row's or a dialog's closure never keeps a +/// closed tab alive. +#[derive(Clone)] +pub(crate) struct StructureTabSender { + tab: glib::WeakRef, +} + +impl StructureTabSender { + pub(crate) fn input(&self, msg: impl Into) { + if let Some(tab) = self.tab.upgrade() { + tab.send(msg); + } + } +} + +impl StructureTab { + pub fn new(init: StructureTabInit) -> Self { + let tab: Self = glib::Object::new(); + let imp = tab.imp(); + let read_only = init.session.read_only(); + let context = Rc::new(RefCell::new(StructureContext { + mode: init.mode, + dialect: match init.mode { + // A draft has no table to read the dialect back with. + StructureMode::New => init.session.current_dialect(), + StructureMode::Edit => None, + }, + definition: None, + })); + let columns = gio::ListStore::new::(); + let indexes = gio::ListStore::new::(); + let foreign_keys = gio::ListStore::new::(); + let widgets = layout::build( + &tab, + layout::LayoutInit { + table: &init.table, + schema: init.schema.clone(), + mode: init.mode, + read_only, + session: &init.session, + tasks: &init.tasks, + }, + &context, + &layout::DraftStores { + columns: &columns, + indexes: &indexes, + foreign_keys: &foreign_keys, + }, + ); + tab.install_actions(); + + let link_changes = init.session.link_changes(); + let model = StructureModel { + this: tab.downgrade(), + link_generation: init.session.link_generation(), + session: init.session, + tasks: init.tasks, + sequencer: RequestSequencer::new(), + failed: FailedRequests::new(), + issued_on: BTreeMap::new(), + tracker: init.tracker, + context, + table: TableRef::new(init.schema, init.table), + columns, + indexes, + foreign_keys, + read_only, + saving: false, + last_dirty: false, + refetching: false, + load: match init.mode { + // A new table's draft is the content from the start. + StructureMode::New => ContentLoad::Loaded, + StructureMode::Edit => ContentLoad::default(), + }, + tab_loading: false, + definition_stale: false, + widgets, + }; + imp.model.replace(Some(model)); + imp.link_watch.replace(Some(WatchForwarder::new( + link_changes, + { + let tab = tab.downgrade(); + move |generation: LinkGeneration| { + if let Some(tab) = tab.upgrade() { + tab.send(LoadInput::LinkChanged(generation)); + } + } + }, + |future| { + glib::spawn_future_local(future); + }, + ))); + start_first_load(&tab, init.first_load, { + let tab = tab.downgrade(); + move || { + if let Some(tab) = tab.upgrade() { + tab.send(LoadInput::Start); + } + } + }); + tab + } + + pub(crate) fn sender(&self) -> StructureTabSender { + StructureTabSender { tab: self.downgrade() } + } + + /// Hand the tab an input. One that arrives while another is being + /// handled, from a widget signal the handler set off, waits for it + /// to finish rather than running inside it. + pub(crate) fn send(&self, msg: impl Into) { + let imp = self.imp(); + imp.queue.borrow_mut().push_back(msg.into()); + if imp.dispatching.replace(true) { + return; + } + loop { + let next = imp.queue.borrow_mut().pop_front(); + let Some(msg) = next else { + break; + }; + let mut effects = StructureEffects::default(); + { + let mut model = imp.model.borrow_mut(); + let Some(model) = model.as_mut() else { + imp.queue.borrow_mut().clear(); + break; + }; + model.update(msg, &mut effects); + } + for output in effects.outputs { + self.emit_output(output); + } + imp.queue.borrow_mut().extend(effects.inputs); + } + imp.dispatching.set(false); + } + + /// Stop the read the tab has running and stop following reconnects + /// now, rather than at dispose, which waits for the last reference + /// to the widget. + pub fn shutdown(&self) { + let imp = self.imp(); + imp.link_watch.take(); + if let Ok(mut model) = imp.model.try_borrow_mut() + && let Some(model) = model.as_mut() + { + model.sequencer.cancel_all(); + } + } + + pub fn connect_dirty_changed(&self, f: F) -> glib::SignalHandlerId { + self.connect_closure( + "dirty-changed", + false, + glib::closure_local!(move |tab: &Self, dirty: bool| f(tab, dirty)), + ) + } + + /// Save asked the window to write what the tab's tracker holds. + pub fn connect_save_requested(&self, f: F) -> glib::SignalHandlerId { + self.connect_closure("save-requested", false, glib::closure_local!(move |tab: &Self| f(tab))) + } + + /// Stop Saving asked the window to stop the tab's save. + pub fn connect_stop_save_requested(&self, f: F) -> glib::SignalHandlerId { + self.connect_closure( + "stop-save-requested", + false, + glib::closure_local!(move |tab: &Self| f(tab)), + ) + } + + pub fn connect_drop_table_requested, String) + 'static>( + &self, + f: F, + ) -> glib::SignalHandlerId { + self.connect_closure( + "drop-table-requested", + false, + glib::closure_local!(move |tab: &Self, schema: Option, table: String| f(tab, schema, table)), + ) + } + + pub fn connect_toast(&self, f: F) -> glib::SignalHandlerId { + self.connect_closure( + "toast", + false, + glib::closure_local!(move |tab: &Self, text: String| f(tab, text)), + ) + } + + pub fn connect_alert(&self, f: F) -> glib::SignalHandlerId { + self.connect_closure( + "alert", + false, + glib::closure_local!(move |tab: &Self, title: String, body: String| f(tab, title, body)), + ) + } + + /// A read of the table's definition started or ended. + pub fn connect_loading_changed(&self, f: F) -> glib::SignalHandlerId { + self.connect_closure( + "loading-changed", + false, + glib::closure_local!(move |tab: &Self, loading: bool| f(tab, loading)), + ) + } + + /// A read of the table's definition is running. + pub fn is_loading(&self) -> bool { + self.imp() + .model + .try_borrow() + .ok() + .and_then(|model| model.as_ref().map(|model| model.tab_loading)) + .unwrap_or(false) + } + + fn emit_output(&self, output: StructureTabOutput) { + match output { + StructureTabOutput::DirtyChanged(dirty) => self.emit_by_name::<()>("dirty-changed", &[&dirty]), + StructureTabOutput::SaveRequested => self.emit_by_name::<()>("save-requested", &[]), + StructureTabOutput::StopSaveRequested => self.emit_by_name::<()>("stop-save-requested", &[]), + StructureTabOutput::DropTableRequested { schema, table } => { + self.emit_by_name::<()>("drop-table-requested", &[&schema, &table]); + } + StructureTabOutput::ShowToast(text) => self.emit_by_name::<()>("toast", &[&text]), + StructureTabOutput::ShowAlert { title, body } => self.emit_by_name::<()>("alert", &[&title, &body]), + StructureTabOutput::LoadingChanged(loading) => self.emit_by_name::<()>("loading-changed", &[&loading]), + } + } +} + +#[derive(Default)] +pub(crate) struct StructureEffects { + outputs: Vec, + inputs: VecDeque, +} + +impl StructureEffects { + fn output(&mut self, output: StructureTabOutput) { + self.outputs.push(output); + } + + fn input(&mut self, input: impl Into) { + self.inputs.push_back(input.into()); + } +} + +pub(crate) struct StructureModel { + this: glib::WeakRef, + session: Arc, + tasks: Tasks, + sequencer: RequestSequencer, + failed: FailedRequests, + /// The connection each running read was issued on. A read that + /// fails after the connection was replaced is asked again at once, + /// since the reconnect it would wait for has already landed. + issued_on: BTreeMap, + /// The newest connection the tab has heard of. + link_generation: LinkGeneration, + tracker: StructureTrackerHandle, + /// Whether the table exists, its definition and the dialect, shared + /// with the lists, which read it as they build rows. + context: Rc>, + /// The table the draft names, renamed as the user types a new + /// table's name. + table: TableRef, + /// The draft's columns, indexes and foreign keys, which the lists + /// show. An edit finds its item here by identity. + columns: gio::ListStore, + indexes: gio::ListStore, + foreign_keys: gio::ListStore, + /// The connection refuses writes, so Drop is not offered. + read_only: bool, + /// A save of the tab is running, so Save and Discard stay off. + saving: bool, + + last_dirty: bool, + /// True between `SaveCompleted` and the read that follows. The draft + /// is already committed while the definition still holds the + /// pre-save state, so diffing them would surface phantom pending + /// changes; `recompute_dirty_state` waits it out. + refetching: bool, + /// Where the read of the definition stands. A failed read with a + /// definition on screen leaves it there under the banner. + load: ContentLoad, + /// What the tab bar was last told, so it hears only changes. + tab_loading: bool, + /// The table changed after `definition` was read, while the draft + /// had edits that a read would replace. Discard reads it again + /// rather than restoring it. + definition_stale: bool, + widgets: StructureTabWidgets, +} + +#[derive(Debug)] +pub(crate) enum StructureTabOutput { + DirtyChanged(bool), + /// Save asked the window to write the tracker's pending structure. + SaveRequested, + StopSaveRequested, + DropTableRequested { + schema: Option, + table: String, + }, + ShowToast(String), + ShowAlert { + title: String, + body: String, + }, + /// A read of the definition started or ended, for the tab bar. + LoadingChanged(bool), +} + +impl StructureModel { + fn mode(&self) -> StructureMode { + self.context.borrow().mode + } + + /// The draft as the lists hold it. + fn draft(&self) -> TableDraft { + TableDraft { + table: self.table.clone(), + columns: draft_columns(&self.columns), + indexes: items::(&self.indexes) + .iter() + .map(|index| index.info().clone()) + .collect(), + foreign_keys: items::(&self.foreign_keys) + .iter() + .map(|key| key.info().clone()) + .collect(), + } + } + + /// What Save would write: a new table, or the changes between the + /// definition and the draft. + fn pending(&self) -> PendingStructure { + let draft = self.draft(); + let original = self.context.borrow().original().cloned(); + let changes = match &original { + Some(original) => diff_table(original, &draft), + None => TableChanges::default(), + }; + let unparsed_defaults = items::(&self.columns) + .iter() + .filter(|column| column.unparsed_default().is_some()) + .map(DraftColumnObject::name) + .collect(); + PendingStructure { + original, + draft, + changes, + unparsed_defaults, + } + } + + /// The action bar, the preview and the tracker after an edit. The + /// tracker holds the pending structure so the window's close and save + /// paths read it without the tab. + /// + /// Waits while `refetching`: between `SaveCompleted` and the read + /// after it, the definition is stale. + fn recompute_dirty_state(&mut self, fx: &mut StructureEffects) { + if self.refetching { + return; + } + let pending = self.pending(); + let dirty = pending.has_changes(); + self.refresh_buttons(pending.change_count(), pending.unparsed_defaults.is_empty()); + self.regenerate_sql_preview(&pending); + self.tracker.set_pending(Some(pending)); + if self.last_dirty != dirty { + self.last_dirty = dirty; + fx.output(StructureTabOutput::DirtyChanged(dirty)); + } + } + + fn regenerate_sql_preview(&self, pending: &PendingStructure) { + let text = sql_preview::preview_text(&self.context.borrow(), pending); + self.widgets.sql_buffer.set_text(&text); + } + + /// The Add rows the engine allows on the table as it stands. + fn sync_add_rows(&self) { + let context = self.context.borrow(); + self.widgets.add_column_row.set_visible(context.can_add_column()); + self.widgets.add_index_row.set_visible(context.can_add_index()); + self.widgets.add_fk_row.set_visible(context.can_add_foreign_key()); + } + + /// Put `draft` in the lists. Each store changes once, so each list + /// builds its rows once. + fn show_draft(&mut self, draft: TableDraft) { + self.table = draft.table; + replace_items(&self.columns, draft.columns.into_iter().map(DraftColumnObject::new)); + replace_items(&self.indexes, draft.indexes.into_iter().map(IndexObject::new)); + replace_items( + &self.foreign_keys, + draft.foreign_keys.into_iter().map(ForeignKeyObject::new), + ); + } + + /// The lists build every row again, keeping the items, for rows + /// whose controls depend on a context that changed. + fn rebuild_rows(&self) { + replace_items(&self.columns, items::(&self.columns)); + replace_items(&self.indexes, items::(&self.indexes)); + replace_items(&self.foreign_keys, items::(&self.foreign_keys)); + } + + fn update(&mut self, msg: StructureTabInput, fx: &mut StructureEffects) { + if msg.edits_draft() && self.edits_locked() { + self.refuse_edit(&msg, fx); + return; + } + match msg { + StructureTabInput::Load(input) => self.on_load(input, fx), + StructureTabInput::Columns(input) => self.on_columns(input, fx), + StructureTabInput::Indexes(input) => self.on_indexes(input, fx), + StructureTabInput::ForeignKeys(input) => self.on_foreign_keys(input, fx), + StructureTabInput::Table(input) => self.on_table(input, fx), + StructureTabInput::Save(input) => self.on_save(input, fx), + } + } +} + +fn items>(store: &gio::ListStore) -> Vec { + (0..store.n_items()) + .filter_map(|position| store.item(position).and_downcast::()) + .collect() +} + +fn draft_columns(store: &gio::ListStore) -> Vec { + items::(store) + .iter() + .map(DraftColumnObject::column) + .collect() +} + +fn replace_items>(store: &gio::ListStore, items: impl IntoIterator) { + let items: Vec = items.into_iter().collect(); + store.splice(0, store.n_items(), &items); +} + +fn apply_sql_scheme(buffer: &sourceview5::Buffer) { + let scheme_name = if adw::StyleManager::default().is_dark() { + "Adwaita-dark" + } else { + "Adwaita" + }; + if let Some(scheme) = sourceview5::StyleSchemeManager::default().scheme(scheme_name) { + buffer.set_style_scheme(Some(&scheme)); + } +} diff --git a/linux/crates/app/src/ui/structure_tab/referential_action_label.rs b/linux/crates/app/src/ui/structure_tab/referential_action_label.rs new file mode 100644 index 0000000000..243c73943b --- /dev/null +++ b/linux/crates/app/src/ui/structure_tab/referential_action_label.rs @@ -0,0 +1,64 @@ +//! How the Structure tab names a foreign key's actions. + +use tablepro_core::ddl::ReferentialAction; + +/// The On Delete and On Update choices: the database's own default, then +/// each action the engine takes, in its order. +pub(super) fn action_labels(actions: &[ReferentialAction]) -> Vec { + std::iter::once(crate::i18n::gettext("Database Default")) + .chain(actions.iter().map(|action| action.as_sql().to_owned())) + .collect() +} + +/// The action a choice stands for. The first choice leaves it to the +/// database; one past the end can only be a stale selection. +pub(super) fn action_at(actions: &[ReferentialAction], position: u32) -> Option { + let index = usize::try_from(position.checked_sub(1)?).ok()?; + actions.get(index).copied() +} + +/// A key the user drafted left an unset action to the database. One +/// read from the database with no action is one the driver could not +/// name, so the row says so rather than claim the default. +pub(super) fn action_text(action: Option, drafted: bool) -> String { + match (action, drafted) { + (Some(action), _) => action.as_sql().to_owned(), + (None, true) => crate::i18n::gettext("database default"), + (None, false) => crate::i18n::gettext("not reported"), + } +} + +#[cfg(test)] +mod tests { + use tablepro_core::ddl::DdlDialect; + + use super::*; + + #[test] + fn each_choice_decodes_to_the_action_it_is_labelled_with() { + let mssql = tablepro_driver_mssql::MssqlDialect::new(16); + let actions = mssql.referential_actions(); + + let labels = action_labels(actions); + + assert_eq!(labels[0], "Database Default"); + assert_eq!(action_at(actions, 0), None); + assert!(!labels.iter().any(|label| label == "RESTRICT"), "{labels:?}"); + for (position, label) in labels.iter().enumerate().skip(1) { + let position = u32::try_from(position).expect("a short list"); + assert_eq!( + action_at(actions, position).map(ReferentialAction::as_sql), + Some(label.as_str()) + ); + } + let past_the_end = u32::try_from(labels.len()).expect("a short list"); + assert_eq!(action_at(actions, past_the_end), None); + } + + #[test] + fn an_unset_action_reads_by_where_the_key_came_from() { + assert_eq!(action_text(Some(ReferentialAction::SetNull), false), "SET NULL"); + assert_eq!(action_text(None, true), "database default"); + assert_eq!(action_text(None, false), "not reported"); + } +} diff --git a/linux/crates/app/src/ui/structure_tab/review_dialog.rs b/linux/crates/app/src/ui/structure_tab/review_dialog.rs new file mode 100644 index 0000000000..78577408e6 --- /dev/null +++ b/linux/crates/app/src/ui/structure_tab/review_dialog.rs @@ -0,0 +1,296 @@ +//! The review a structure save asks for before it runs: what it loses, +//! whether it can stop halfway, and the SQL itself. + +use std::cell::RefCell; + +use gtk4 as gtk; +use gtk4::prelude::*; +use libadwaita as adw; +use libadwaita::prelude::*; +use tablepro_core::WriteAtomicity; +use tablepro_core::ddl::{ChangeReview, DestructiveChange, RecreatedObject, SchemaObjectKind, TableRebuild}; + +use crate::ui::handler_guard::HandlerGuard; +use crate::workspace::EngineProfile; + +const CANCEL: &str = "cancel"; +const APPLY: &str = "apply"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ReviewDecision { + Apply, + Cancel, +} + +/// Cancel is the default, so Enter never applies a change that loses +/// data. +pub(crate) async fn confirm( + parent: >k::Widget, + table: &str, + review: &ChangeReview, + engine: &EngineProfile, +) -> ReviewDecision { + let dialog = build(table, review, engine); + match crate::workspace::respond(&dialog, parent).await.as_str() { + APPLY => ReviewDecision::Apply, + _ => ReviewDecision::Cancel, + } +} + +pub(crate) fn build(table: &str, review: &ChangeReview, engine: &EngineProfile) -> adw::AlertDialog { + let count = review.operations; + let heading = crate::i18n::ngettext_f( + "Apply {count} Change to “{table}”?", + "Apply {count} Changes to “{table}”?", + u32::try_from(count).unwrap_or(u32::MAX), + &[("count", &count.to_string()), ("table", table)], + ); + let dialog = adw::AlertDialog::new(Some(&heading), Some(&body(review, engine))); + // A table or column name is never markup. + dialog.set_heading_use_markup(false); + dialog.set_body_use_markup(false); + dialog.add_response(CANCEL, &crate::i18n::gettext("_Cancel")); + dialog.add_response(APPLY, &crate::i18n::gettext("_Apply")); + let appearance = match review.destructive.is_empty() { + true => adw::ResponseAppearance::Suggested, + false => adw::ResponseAppearance::Destructive, + }; + dialog.set_response_appearance(APPLY, appearance); + dialog.set_default_response(Some(CANCEL)); + dialog.set_close_response(CANCEL); + dialog.set_prefer_wide_layout(true); + dialog.set_extra_child(Some(&sql_view(&review.sql(), &dialog))); + dialog +} + +fn body(review: &ChangeReview, engine: &EngineProfile) -> String { + let mut lines: Vec = review.destructive.iter().map(destructive_text).collect(); + if engine.ddl_writes == WriteAtomicity::StatementByStatement && review.statements.len() > 1 { + lines.push(crate::i18n::gettext_f( + "{engine} applies each statement separately. If one fails, the statements before it stay applied.", + &[("engine", &engine.name)], + )); + } + lines.join("\n") +} + +fn destructive_text(change: &DestructiveChange) -> String { + match change { + DestructiveChange::RebuildTable(rebuild) => rebuild_text(rebuild), + DestructiveChange::DropColumn { column } => { + crate::i18n::gettext_f("Drops column “{column}” and the data in it.", &[("column", column)]) + } + DestructiveChange::ChangeColumnType { column, from, to } => crate::i18n::gettext_f( + "Changes column “{column}” from {from} to {to}. A value the new type can't hold is converted or refused.", + &[("column", column), ("from", from), ("to", to)], + ), + DestructiveChange::SetNotNull { column } => crate::i18n::gettext_f( + "Makes column “{column}” NOT NULL. A NULL already in it is converted or refused.", + &[("column", column)], + ), + DestructiveChange::DropIndex { index } => crate::i18n::gettext_f("Drops index “{index}”.", &[("index", index)]), + DestructiveChange::DropForeignKey { key } => { + crate::i18n::gettext_f("Drops foreign key “{key}”.", &[("key", key)]) + } + } +} + +fn rebuild_text(rebuild: &TableRebuild) -> String { + let mut sentences = vec![crate::i18n::gettext( + "Rebuilds the table: copies its rows into a new table and drops the old one.", + )]; + if !rebuild.recreated.is_empty() { + let objects: Vec = rebuild.recreated.iter().map(object_text).collect(); + sentences.push(crate::i18n::gettext_f( + "Drops and creates again: {objects}.", + &[("objects", &objects.join(", "))], + )); + } + if rebuild.renumbers_rowids { + sentences.push(crate::i18n::gettext("The rows get new rowids.")); + } + if let Some(column) = &rebuild.numbered_column { + sentences.push(crate::i18n::gettext_f( + "“{column}” becomes the rowid, so the save stops if any row has no value in it.", + &[("column", column)], + )); + } + sentences.join(" ") +} + +fn object_text(object: &RecreatedObject) -> String { + let name = [("name", object.name.as_str())]; + match object.kind { + SchemaObjectKind::Index => crate::i18n::gettext_f("index “{name}”", &name), + SchemaObjectKind::Trigger => crate::i18n::gettext_f("trigger “{name}”", &name), + SchemaObjectKind::View => crate::i18n::gettext_f("view “{name}”", &name), + } +} + +fn sql_view(sql: &str, dialog: &adw::AlertDialog) -> gtk::ScrolledWindow { + let buffer = match sourceview5::LanguageManager::default().language("sql") { + Some(language) => sourceview5::Buffer::with_language(&language), + None => sourceview5::Buffer::new(None), + }; + buffer.set_text(sql); + super::apply_sql_scheme(&buffer); + let style_manager = adw::StyleManager::default(); + let buffer_for_theme = buffer.downgrade(); + let theme_handler = RefCell::new(Some(HandlerGuard::new( + &style_manager, + style_manager.connect_dark_notify(move |_| { + if let Some(buffer) = buffer_for_theme.upgrade() { + super::apply_sql_scheme(&buffer); + } + }), + ))); + dialog.connect_closed(move |_| { + theme_handler.borrow_mut().take(); + }); + + let view = sourceview5::View::with_buffer(&buffer); + view.set_editable(false); + view.set_cursor_visible(false); + view.set_monospace(true); + view.set_wrap_mode(gtk::WrapMode::WordChar); + view.set_top_margin(8); + view.set_bottom_margin(8); + view.set_left_margin(8); + view.set_right_margin(8); + view.upcast_ref::() + .update_property(&[gtk::accessible::Property::Label(&crate::i18n::gettext("SQL to run"))]); + gtk::ScrolledWindow::builder() + .child(&view) + .has_frame(true) + .hscrollbar_policy(gtk::PolicyType::Never) + .min_content_height(160) + .max_content_height(320) + .propagate_natural_height(true) + .build() +} + +#[cfg(test)] +mod tests { + use tablepro_core::ddl::{DdlPlan, TableChanges}; + + use super::*; + + fn engine(ddl_writes: WriteAtomicity) -> EngineProfile { + EngineProfile { + name: "MySQL".to_owned(), + ddl_writes, + } + } + + fn review(statements: &[&str], destructive: Vec) -> ChangeReview { + let plan = DdlPlan::from_sql(statements.iter().map(|statement| (*statement).to_owned())); + ChangeReview { + destructive, + ..ChangeReview::review(&TableChanges::default(), &plan) + } + } + + fn sql_child(dialog: &adw::AlertDialog) -> String { + let view = dialog + .extra_child() + .and_then(|child| child.downcast::().ok()) + .and_then(|scrolled| scrolled.child()) + .and_then(|child| child.downcast::().ok()) + .expect("the SQL view"); + assert!(!view.is_editable()); + let buffer = view.buffer(); + buffer.text(&buffer.start_iter(), &buffer.end_iter(), false).to_string() + } + + #[gtk4::test] + fn table_name_with_markup_chars_renders_literally() { + adw::init().expect("libadwaita initialises under the test backend"); + let mut reviewed = review( + &["ALTER TABLE t DROP COLUMN \"\""], + vec![DestructiveChange::DropColumn { + column: "".to_owned(), + }], + ); + reviewed.operations = 1; + + let dialog = build("a & b", &reviewed, &engine(WriteAtomicity::Transactional)); + + assert_eq!(dialog.heading().as_deref(), Some("Apply 1 Change to “a & b”?")); + assert!(!dialog.is_heading_use_markup()); + assert!(!dialog.is_body_use_markup()); + assert_eq!(dialog.body(), "Drops column “” and the data in it."); + assert_eq!(sql_child(&dialog), "ALTER TABLE t DROP COLUMN \"\";"); + assert_eq!(dialog.response_appearance(APPLY), adw::ResponseAppearance::Destructive); + assert_eq!(dialog.default_response().as_deref(), Some(CANCEL)); + assert_eq!(dialog.close_response(), CANCEL); + assert!(dialog.prefers_wide_layout()); + } + + #[gtk4::test] + fn a_save_that_can_stop_halfway_says_so_and_suggests_apply() { + adw::init().expect("libadwaita initialises under the test backend"); + let reviewed = review(&["ALTER TABLE a", "CREATE INDEX b ON a (c)"], Vec::new()); + + let separately = build("a", &reviewed, &engine(WriteAtomicity::StatementByStatement)); + let atomic = build("a", &reviewed, &engine(WriteAtomicity::Transactional)); + + assert_eq!( + separately.body(), + "MySQL applies each statement separately. If one fails, the statements before it stay applied." + ); + assert_eq!( + separately.response_appearance(APPLY), + adw::ResponseAppearance::Suggested + ); + assert_eq!(atomic.body(), ""); + } + + #[test] + fn a_new_not_null_says_what_happens_to_nulls() { + assert_eq!( + destructive_text(&DestructiveChange::SetNotNull { + column: "email".to_owned() + }), + "Makes column “email” NOT NULL. A NULL already in it is converted or refused." + ); + } + + #[test] + fn a_rebuild_names_what_it_makes_again() { + let text = rebuild_text(&TableRebuild { + recreated: vec![ + RecreatedObject { + kind: SchemaObjectKind::Trigger, + name: "stamp".to_owned(), + }, + RecreatedObject { + kind: SchemaObjectKind::View, + name: "big".to_owned(), + }, + ], + renumbers_rowids: true, + numbered_column: None, + }); + + assert_eq!( + text, + "Rebuilds the table: copies its rows into a new table and drops the old one. Drops and creates again: \ + trigger “stamp”, view “big”. The rows get new rowids." + ); + } + + #[test] + fn a_rebuild_says_a_numbered_column_must_hold_values() { + let text = rebuild_text(&TableRebuild { + recreated: Vec::new(), + renumbers_rowids: true, + numbered_column: Some("code".to_owned()), + }); + + assert_eq!( + text, + "Rebuilds the table: copies its rows into a new table and drops the old one. The rows get new rowids. \ + “code” becomes the rowid, so the save stops if any row has no value in it." + ); + } +} diff --git a/linux/crates/app/src/ui/structure_tab/save_gate.rs b/linux/crates/app/src/ui/structure_tab/save_gate.rs new file mode 100644 index 0000000000..6fbefaaa64 --- /dev/null +++ b/linux/crates/app/src/ui/structure_tab/save_gate.rs @@ -0,0 +1,134 @@ +use crate::services::pending_structure::{PendingStructure, SaveBlocker}; + +/// What Save does with the pending structure as it stands. The draft +/// is checked before the changes, so a draft that cannot be written +/// says why even when it changes nothing. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) enum SaveGate { + Enabled, + NothingToSave, + Blocked(SaveBlocker), +} + +impl SaveGate { + pub(super) fn of(pending: &PendingStructure) -> Self { + if let Err(blocker) = pending.check() { + return Self::Blocked(blocker); + } + if !pending.has_changes() { + return Self::NothingToSave; + } + Self::Enabled + } +} + +#[cfg(test)] +mod tests { + use tablepro_core::column::ColumnDefault; + use tablepro_core::ddl::{ + ColumnAttributes, ColumnDefinition, DraftColumn, DraftProblem, DraftType, TableChanges, TableDefinition, + TableDraft, diff_table, + }; + use tablepro_core::dialect::SqlDialect; + use tablepro_core::meta::TableRef; + use tablepro_core::{ColumnInfo, ServerInfo}; + + use super::*; + + fn users() -> TableDefinition { + let postgres = tablepro_driver_postgres::PgDialect::new(&ServerInfo::new("PostgreSQL", "18.0", 18, 0, 0)); + let id = ColumnInfo { + name: "id".to_owned(), + column_type: postgres.parse_type("integer").expect("a PostgreSQL type"), + nullable: false, + primary_key: true, + is_auto_increment: false, + is_generated: false, + default: ColumnDefault::None, + comment: None, + }; + TableDefinition { + table: TableRef::new(None, "users"), + columns: vec![ColumnDefinition::new(id, ColumnAttributes::default())], + primary_key: None, + indexes: Vec::new(), + foreign_keys: Vec::new(), + inbound_foreign_keys: Vec::new(), + source: None, + } + } + + fn edited(edit: impl FnOnce(&mut TableDraft)) -> PendingStructure { + let original = users(); + let mut draft = TableDraft::from_definition(&original); + edit(&mut draft); + PendingStructure { + changes: diff_table(&original, &draft), + original: Some(original), + draft, + unparsed_defaults: Vec::new(), + } + } + + fn created(columns: &[&str]) -> PendingStructure { + let mut draft = TableDraft::new(TableRef::new(None, "users")); + draft.columns = columns + .iter() + .map(|name| DraftColumn::new(*name, DraftType::Typed("integer".to_owned()))) + .collect(); + PendingStructure { + original: None, + draft, + changes: TableChanges::default(), + unparsed_defaults: Vec::new(), + } + } + + #[test] + fn nothing_to_save_when_changes_are_empty() { + let unchanged = edited(|_| {}); + + assert!(unchanged.changes.is_empty()); + assert_eq!(SaveGate::of(&unchanged), SaveGate::NothingToSave); + } + + #[test] + fn blocked_with_the_draft_problem() { + let unnamed = edited(|draft| draft.columns[0].name = String::new()); + + assert_eq!( + SaveGate::of(&unnamed), + SaveGate::Blocked(SaveBlocker::Draft(DraftProblem::EmptyColumnName)) + ); + assert_eq!( + SaveGate::of(&created(&[])), + SaveGate::Blocked(SaveBlocker::Draft(DraftProblem::NoColumns)), + "an empty new table is a problem before it is nothing to save" + ); + } + + #[test] + fn a_default_the_parser_refused_blocks_the_save() { + let pending = PendingStructure { + unparsed_defaults: vec!["id".to_owned()], + ..edited(|_| {}) + }; + + assert_eq!( + SaveGate::of(&pending), + SaveGate::Blocked(SaveBlocker::UnparsedDefault("id".to_owned())) + ); + } + + #[test] + fn enabled_for_valid_changes() { + let added = edited(|draft| { + draft + .columns + .push(DraftColumn::new("name", DraftType::Typed("text".to_owned()))); + }); + + assert_eq!(SaveGate::of(&added), SaveGate::Enabled); + assert_eq!(SaveGate::of(&created(&["id"])), SaveGate::Enabled); + } +} diff --git a/linux/crates/app/src/ui/structure_tab/save_handlers.rs b/linux/crates/app/src/ui/structure_tab/save_handlers.rs new file mode 100644 index 0000000000..8a6763aec0 --- /dev/null +++ b/linux/crates/app/src/ui/structure_tab/save_handlers.rs @@ -0,0 +1,218 @@ +use gtk4 as gtk; +use gtk4::prelude::*; +use tablepro_core::ddl::TableDraft; + +use super::save_gate::SaveGate; +use super::sql_preview::no_pending_changes; +use super::{ + ColumnsInput, SaveInput, StructureEffects, StructureMode, StructureModel, StructureTabInput, StructureTabOutput, + StructureTabSender, +}; +use crate::ui::discard_alert::confirm_discard; +use crate::ui::error_text::{build_error_text, save_blocker_text}; + +impl StructureModel { + pub(super) fn on_save(&mut self, input: SaveInput, fx: &mut StructureEffects) { + match input { + SaveInput::Save => self.save(fx), + SaveInput::SaveStarted => self.show_saving(true), + SaveInput::StopSaving => fx.output(StructureTabOutput::StopSaveRequested), + SaveInput::ConfirmDiscard => { + let pending = self.pending().change_count(); + if pending > 0 { + let sender = StructureTabSender { tab: self.this.clone() }; + confirm_discard(&self.widgets.inner_stack, pending, move || { + sender.input(SaveInput::Discard); + }); + } + } + SaveInput::Reload => { + // A table the save may have created has nothing to read + // yet, so a new one is only cleared. + match self.mode() { + StructureMode::Edit => self.read_definition(fx), + StructureMode::New => fx.input(SaveInput::Discard), + } + } + SaveInput::Discard => { + // This also lets go of a save whose outcome was unknown. + self.tracker.clear(); + if self.mode() == StructureMode::Edit && self.definition_stale { + fx.input(SaveInput::Reload); + return; + } + // New mode clears everything but the name, since a new + // table starts empty. + let restored = match self.context.borrow().original() { + Some(definition) => TableDraft::from_definition(definition), + None => TableDraft::new(self.table.clone()), + }; + self.show_draft(restored); + self.recompute_dirty_state(fx); + } + SaveInput::SaveCompleted { new_table_name } => self.save_completed(new_table_name, fx), + SaveInput::SaveFailed(message) => { + self.show_saving(false); + self.recompute_dirty_state(fx); + fx.output(StructureTabOutput::ShowAlert { + title: crate::i18n::gettext("Save Failed"), + body: message, + }); + } + SaveInput::TableChangedBeforeSave => { + self.definition_stale = true; + self.show_saving(false); + self.recompute_dirty_state(fx); + fx.output(StructureTabOutput::ShowAlert { + title: crate::i18n::gettext("Save Failed"), + body: crate::i18n::gettext( + "The table changed after this tab read it, so nothing was saved. Discard the changes to read the table again, then make them again.", + ), + }); + } + SaveInput::SaveOutcomeUnknown(message) => { + self.show_saving(false); + self.recompute_dirty_state(fx); + fx.output(StructureTabOutput::ShowAlert { + title: crate::i18n::gettext("Save Result Unknown"), + body: message, + }); + } + } + } + + fn save(&self, fx: &mut StructureEffects) { + let pending = self.pending(); + match SaveGate::of(&pending) { + SaveGate::Enabled => {} + SaveGate::Blocked(blocker) => { + fx.output(StructureTabOutput::ShowToast(save_blocker_text(&blocker))); + return; + } + SaveGate::NothingToSave => { + fx.output(StructureTabOutput::ShowToast(crate::i18n::gettext("Nothing to save."))); + return; + } + } + let context = self.context.borrow(); + let Some(ddl) = context.ddl() else { + fx.output(StructureTabOutput::ShowToast(crate::i18n::gettext( + "Connect to a database first.", + ))); + return; + }; + // Planned here only to refuse early in the user's words: the + // window plans again with the connection that runs the save. + match pending.plan(ddl) { + Ok(_) => { + self.tracker.set_pending(Some(pending)); + fx.output(StructureTabOutput::SaveRequested); + } + Err(error) => fx.output(StructureTabOutput::ShowAlert { + title: crate::i18n::gettext("Cannot Save"), + body: build_error_text(&error), + }), + } + } + + /// While a save runs, Save and Discard are off, so a double click + /// cannot send two, and Stop Saving takes their place. + fn show_saving(&mut self, saving: bool) { + self.saving = saving; + self.widgets.saving_spinner.set_visible(saving); + self.widgets.stop_button.set_visible(saving); + if saving { + self.widgets.save_button.set_sensitive(false); + self.widgets.discard_button.set_sensitive(false); + } + self.sync_editable(); + } + + /// The draft holds still from the moment a save takes it until the + /// table is read again after a commit. That read replaces the draft, + /// so an edit made before it would be lost without a prompt. + pub(super) fn edits_locked(&self) -> bool { + self.saving || self.refetching || self.tracker.is_saving() + } + + pub(super) fn sync_editable(&self) { + let editable = !self.saving && !self.refetching; + for widget in [ + self.widgets.name_row.upcast_ref::(), + self.widgets.columns_box.upcast_ref(), + self.widgets.indexes_box.upcast_ref(), + self.widgets.fks_box.upcast_ref(), + ] { + widget.set_sensitive(editable); + } + } + + /// An edit that reached the tab while its draft was locked, from a + /// dialog opened before the save or a row the save had not turned + /// off yet: the controls go back to what the draft holds. + pub(super) fn refuse_edit(&self, msg: &StructureTabInput, fx: &mut StructureEffects) { + self.show_table_name(); + if let StructureTabInput::Columns(ColumnsInput::ColumnEdited { column, .. }) = msg + && let Some(position) = self.columns.find(column) + { + self.columns.splice(position, 1, std::slice::from_ref(column)); + } + // The save landed and the read after it failed, so only Retry + // unlocks the draft. + let read_failed = self.refetching && !self.saving && !self.tracker.is_saving() && !self.load.is_running(); + let text = match read_failed { + true => { + crate::i18n::gettext("The table couldn't be read after the save. Retry, then make the change again.") + } + false => crate::i18n::gettext("Wait for the save to finish, then make the change again."), + }; + fx.output(StructureTabOutput::ShowToast(text)); + } + + fn save_completed(&mut self, new_table_name: Option, fx: &mut StructureEffects) { + self.show_saving(false); + if let Some(name) = new_table_name { + self.context.borrow_mut().mode = StructureMode::Edit; + self.table.name = name; + self.show_table_name(); + self.widgets.drop_button.set_visible(!self.read_only); + self.widgets.name_row.set_visible(false); + } + if self.mode() != StructureMode::Edit { + return; + } + // The diff against the pre-save definition would show the changes + // just committed as still pending until the read lands, and a + // close in that window would offer to save them again. So the + // dirty state goes to zero now, and so does a CREATE TABLE left + // in the preview by a draft that just became the table. + self.tracker.clear(); + self.refresh_buttons(0, true); + self.widgets.sql_buffer.set_text(&no_pending_changes()); + if self.last_dirty { + self.last_dirty = false; + fx.output(StructureTabOutput::DirtyChanged(false)); + } + self.refetching = true; + self.sync_editable(); + self.read_definition(fx); + } + + pub(super) fn refresh_buttons(&self, pending_count: usize, defaults_parsed: bool) { + let has_pending = pending_count > 0 && !self.saving; + self.widgets.save_button.set_sensitive(has_pending && defaults_parsed); + self.widgets.discard_button.set_sensitive(has_pending); + if has_pending { + let label = crate::i18n::ngettext_f( + "{n} unsaved change", + "{n} unsaved changes", + u32::try_from(pending_count).unwrap_or(u32::MAX), + &[("n", &pending_count.to_string())], + ); + crate::ui::a11y::show_status(&self.widgets.pending_label, &label); + self.widgets.pending_label.set_visible(true); + } else { + self.widgets.pending_label.set_visible(false); + } + } +} diff --git a/linux/crates/app/src/ui/structure_tab/save_input.rs b/linux/crates/app/src/ui/structure_tab/save_input.rs new file mode 100644 index 0000000000..8d211c3350 --- /dev/null +++ b/linux/crates/app/src/ui/structure_tab/save_input.rs @@ -0,0 +1,25 @@ +/// What changes where the tab's save stands: Save, Discard, Stop Saving +/// and how the window's save of the tab went. +#[derive(Debug)] +pub(crate) enum SaveInput { + Save, + /// The window started saving the tab's pending structure. + SaveStarted, + StopSaving, + /// Discard clicked: ask before the changes go. + ConfirmDiscard, + Discard, + /// Read the table again and drop the draft, for a save whose outcome + /// nobody knows. + Reload, + SaveCompleted { + new_table_name: Option, + }, + SaveFailed(String), + /// The save found the table changed since the tab read it, and left + /// it as it was. + TableChangedBeforeSave, + /// The save may have landed; the tracker refuses to save again until + /// the table is read again or the draft is discarded. + SaveOutcomeUnknown(String), +} diff --git a/linux/crates/app/src/ui/structure_tab/sql_preview.rs b/linux/crates/app/src/ui/structure_tab/sql_preview.rs new file mode 100644 index 0000000000..4840db652b --- /dev/null +++ b/linux/crates/app/src/ui/structure_tab/sql_preview.rs @@ -0,0 +1,147 @@ +use super::context::StructureContext; +use crate::i18n::gettext; +use crate::services::pending_structure::PendingStructure; +use crate::ui::error_text::build_error_text; + +/// The SQL page's text for `pending`: the statements Save would run, +/// or a comment that says why there are none. +pub(super) fn preview_text(context: &StructureContext, pending: &PendingStructure) -> String { + match (&context.dialect, context.ddl()) { + (None, _) => gettext("-- Connect to a database to see the SQL."), + (Some(_), None) => gettext("-- This database's structure can't be edited here."), + (Some(_), Some(_)) if !pending.unparsed_defaults.is_empty() => { + gettext("-- Fix the highlighted fields to see the SQL.") + } + (Some(_), Some(_)) if !pending.has_changes() => no_pending_changes(), + (Some(_), Some(ddl)) => match pending.plan(ddl) { + Ok(plan) => plan.preview(), + Err(error) => format!("-- {}", build_error_text(&error)), + }, + } +} + +pub(super) fn no_pending_changes() -> String { + gettext("-- No pending changes.") +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use gtk4::prelude::*; + use gtk4::subclass::prelude::ObjectSubclassIsExt; + use tablepro_core::ServerInfo; + use tablepro_core::ddl::{DraftColumn, DraftType, TableChanges, TableDraft}; + use tablepro_core::dialect::SqlDialect; + use tablepro_core::meta::TableRef; + use tablepro_test_support::ConnectionScript; + + use super::*; + use crate::services::structure_tracker_handle::StructureTrackerHandle; + use crate::test_support::{FakeSession, drain_main_context}; + use crate::ui::structure_tab::{ColumnsInput, StructureMode, StructureModel, StructureTab, StructureTabInit}; + + fn postgres() -> Arc { + Arc::new(tablepro_driver_postgres::PgDialect::new(&ServerInfo::new( + "PostgreSQL", + "18.0", + 18, + 0, + 0, + ))) + } + + fn new_table(dialect: Option>) -> StructureContext { + StructureContext { + mode: StructureMode::New, + dialect, + definition: None, + } + } + + fn created(columns: &[&str]) -> PendingStructure { + let mut draft = TableDraft::new(TableRef::new(None, "users")); + draft.columns = columns + .iter() + .map(|name| DraftColumn::new(*name, DraftType::Typed("integer".to_owned()))) + .collect(); + PendingStructure { + original: None, + draft, + changes: TableChanges::default(), + unparsed_defaults: Vec::new(), + } + } + + #[test] + fn a_draft_without_a_connection_asks_for_one() { + assert_eq!( + preview_text(&new_table(None), &created(&["id"])), + "-- Connect to a database to see the SQL." + ); + } + + #[test] + fn an_unparsed_default_holds_the_preview() { + let pending = PendingStructure { + unparsed_defaults: vec!["id".to_owned()], + ..created(&["id"]) + }; + + assert_eq!( + preview_text(&new_table(Some(postgres())), &pending), + "-- Fix the highlighted fields to see the SQL." + ); + } + + #[test] + fn a_plan_that_cannot_be_written_says_why() { + let preview = preview_text(&new_table(Some(postgres())), &created(&["id", "id"])); + + assert_eq!(preview, "-- Duplicate column name: id"); + } + + #[gtk4::test] + fn sql_preview_page_shows_the_plan_preview() { + libadwaita::init().expect("libadwaita initialises under the test backend"); + crate::register_test_resources(); + let runtime = crate::test_support::test_runtime(); + let script = ConnectionScript { + dialect: postgres(), + ..ConnectionScript::default() + }; + let fake = FakeSession::start(script, [], runtime.tasks()); + let tab = StructureTab::new(StructureTabInit { + tracker: StructureTrackerHandle::default(), + schema: None, + table: "users".to_owned(), + mode: StructureMode::New, + session: fake.session.clone(), + tasks: runtime.tasks(), + first_load: crate::ui::tab_load::FirstLoad::Now, + }); + tab.send(ColumnsInput::AddColumn); + drain_main_context(); + + let pending = tab + .imp() + .model + .borrow() + .as_ref() + .map(StructureModel::pending) + .expect("the tab has a model"); + let dialect = postgres(); + let plan = pending + .plan(dialect.ddl().expect("PostgreSQL edits structure")) + .expect("the draft plans"); + let buffer = tab.imp().sql_view.buffer(); + let shown = buffer.text(&buffer.start_iter(), &buffer.end_iter(), false); + + assert!(plan.preview().starts_with("CREATE TABLE"), "{}", plan.preview()); + assert_eq!(shown, plan.preview()); + + drop(tab); + drain_main_context(); + runtime.shutdown(); + } +} diff --git a/linux/crates/app/src/ui/structure_tab/structure_actions.rs b/linux/crates/app/src/ui/structure_tab/structure_actions.rs new file mode 100644 index 0000000000..f0263c0a04 --- /dev/null +++ b/linux/crates/app/src/ui/structure_tab/structure_actions.rs @@ -0,0 +1,25 @@ +use gtk4::gio; +use gtk4::prelude::*; +use gtk4::subclass::prelude::*; + +use super::{SaveInput, StructureTab}; +use crate::application::{ShortcutOwner, local_shortcut_controller}; + +impl StructureTab { + /// Ctrl+S saves as the Save button does, and only while it could. + pub(super) fn install_actions(&self) { + let imp = self.imp(); + let save = gio::SimpleAction::new("save", None); + let sender = self.sender(); + save.connect_activate(move |_, _| sender.input(SaveInput::Save)); + imp.save_button + .bind_property("sensitive", &save, "enabled") + .sync_create() + .build(); + let group = gio::SimpleActionGroup::new(); + group.add_action(&save); + self.insert_action_group("structure", Some(&group)); + self.add_controller(local_shortcut_controller(ShortcutOwner::StructureTab)); + crate::ui::a11y::announce_key_shortcuts(&imp.save_button.get(), "structure.save"); + } +} diff --git a/linux/crates/app/src/ui/structure_tab/structure_command.rs b/linux/crates/app/src/ui/structure_tab/structure_command.rs new file mode 100644 index 0000000000..7abf8cabe9 --- /dev/null +++ b/linux/crates/app/src/ui/structure_tab/structure_command.rs @@ -0,0 +1,28 @@ +use std::sync::Arc; + +use tablepro_core::ddl::TableDefinition; +use tablepro_core::dialect::SqlDialect; + +use crate::runtime::Stamped; +use crate::ui::tab_load::LoadFailure; + +/// The one read a structure tab makes. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub(crate) enum StructureRequestKind { + Definition, +} + +/// The table as one definition, read in one call, with the dialect of +/// the connection that read it, which the tab spells its DDL in. +#[derive(Debug)] +pub(crate) struct LoadedStructure { + pub definition: TableDefinition, + pub dialect: Arc, +} + +/// A reply to the tab's read, stamped with the request it answers so a +/// reply to a replaced request is dropped. +#[derive(Debug)] +pub(crate) enum StructureCommand { + Loaded(Stamped>), +} diff --git a/linux/crates/app/src/ui/structure_tab/structure_fetch.rs b/linux/crates/app/src/ui/structure_tab/structure_fetch.rs new file mode 100644 index 0000000000..67cea5dcc3 --- /dev/null +++ b/linux/crates/app/src/ui/structure_tab/structure_fetch.rs @@ -0,0 +1,105 @@ +use std::sync::Arc; + +use tablepro_core::meta::TableRef; +use tablepro_session::{OperationError, OperationKind, Session}; +use tokio_util::sync::CancellationToken; + +use super::LoadedStructure; +use crate::ui::tab_load::LoadFailure; + +/// The table's columns, indexes and foreign keys from one read, so the +/// tab never shows lists from different moments side by side, and any +/// part that fails fails the whole read rather than showing an empty +/// list as if the table had none. The read always asks the server: the +/// catalog keeps columns only, and a definition is edited against what +/// the table is now. +pub(crate) async fn fetch_structure( + session: Arc, + table: TableRef, + cancel: CancellationToken, +) -> Result { + let loaded = session + .run_cancellable(OperationKind::Metadata, &cancel, move |lease, options| { + let table = table.clone(); + async move { + let definition = lease.fetch_table_definition(&table, options).await?; + Ok(LoadedStructure { + definition, + dialect: lease.dialect(), + }) + } + }) + .await + .map_err(|error| { + if error != OperationError::Cancelled { + tracing::warn!(category = ?error.category(), "structure read failed"); + } + LoadFailure::Definition(Arc::new(error)) + })?; + match loaded.definition.columns.is_empty() { + true => Err(LoadFailure::NoColumns), + false => Ok(loaded), + } +} + +#[cfg(test)] +mod tests { + use std::time::Duration; + + use tablepro_core::DriverError; + use tablepro_core::ddl::TableDefinition; + use tablepro_test_support::{ConnectionScript, FakeReply}; + + use super::*; + use crate::test_support::{FakeSession, finish_on_main}; + + const WAIT: Duration = Duration::from_secs(10); + + fn read(script: ConnectionScript) -> Result { + let runtime = crate::test_support::test_runtime(); + let fake = FakeSession::start(script, [], runtime.tasks()); + let running = runtime.tasks().spawn_task(fetch_structure( + fake.session.clone(), + TableRef::unqualified("users"), + CancellationToken::new(), + )); + let outcome = finish_on_main(WAIT, running).expect("the read ends"); + runtime.shutdown(); + outcome.expect("the read ran to its end") + } + + #[gtk4::test] + fn fetch_structure_fails_whole_on_foreign_key_error() { + let mut script = ConnectionScript::default(); + script + .fetch_table_definition + .always(FakeReply::err(DriverError::server("foreign keys unreadable"))); + + let failure = read(script).expect_err("a failed read"); + + assert!(matches!(failure, LoadFailure::Definition(_)), "{failure:?}"); + assert!( + failure.message().contains("foreign keys unreadable"), + "{}", + failure.message() + ); + } + + #[gtk4::test] + fn a_definition_without_columns_is_a_failure() { + let mut script = ConnectionScript::default(); + script.fetch_table_definition.always(FakeReply::ok(TableDefinition { + table: TableRef::unqualified("users"), + columns: Vec::new(), + primary_key: None, + indexes: Vec::new(), + foreign_keys: Vec::new(), + inbound_foreign_keys: Vec::new(), + source: None, + })); + + let failure = read(script).expect_err("a failed read"); + + assert!(matches!(failure, LoadFailure::NoColumns), "{failure:?}"); + } +} diff --git a/linux/crates/app/src/ui/structure_tab/structure_tab_input.rs b/linux/crates/app/src/ui/structure_tab/structure_tab_input.rs new file mode 100644 index 0000000000..62ea9ed012 --- /dev/null +++ b/linux/crates/app/src/ui/structure_tab/structure_tab_input.rs @@ -0,0 +1,63 @@ +use super::columns_input::ColumnsInput; +use super::foreign_keys_input::ForeignKeysInput; +use super::indexes_input::IndexesInput; +use super::load_input::LoadInput; +use super::save_input::SaveInput; +use super::table_input::TableInput; + +/// An input to the tab, grouped by the state it changes, each group +/// handled on its own. +#[derive(Debug)] +pub(crate) enum StructureTabInput { + Load(LoadInput), + Columns(ColumnsInput), + Indexes(IndexesInput), + ForeignKeys(ForeignKeysInput), + Table(TableInput), + Save(SaveInput), +} + +impl StructureTabInput { + pub(super) fn edits_draft(&self) -> bool { + matches!( + self, + Self::Columns(_) | Self::Indexes(_) | Self::ForeignKeys(_) | Self::Table(TableInput::TableNameEdited(_)) + ) + } +} + +impl From for StructureTabInput { + fn from(input: LoadInput) -> Self { + Self::Load(input) + } +} + +impl From for StructureTabInput { + fn from(input: ColumnsInput) -> Self { + Self::Columns(input) + } +} + +impl From for StructureTabInput { + fn from(input: IndexesInput) -> Self { + Self::Indexes(input) + } +} + +impl From for StructureTabInput { + fn from(input: ForeignKeysInput) -> Self { + Self::ForeignKeys(input) + } +} + +impl From for StructureTabInput { + fn from(input: TableInput) -> Self { + Self::Table(input) + } +} + +impl From for StructureTabInput { + fn from(input: SaveInput) -> Self { + Self::Save(input) + } +} diff --git a/linux/crates/app/src/ui/structure_tab/structure_tab_widgets.rs b/linux/crates/app/src/ui/structure_tab/structure_tab_widgets.rs new file mode 100644 index 0000000000..7e668e1a71 --- /dev/null +++ b/linux/crates/app/src/ui/structure_tab/structure_tab_widgets.rs @@ -0,0 +1,36 @@ +use gtk4 as gtk; +use gtk4::glib; +use libadwaita as adw; + +use crate::ui::handler_guard::HandlerGuard; +use crate::ui::tab_load::StaleBanner; + +pub(super) struct StructureTabWidgets { + pub(super) inner_stack: gtk::Stack, + /// The status page shown when the read fails, with the driver's + /// error as its description. + pub(super) error_status: adw::StatusPage, + pub(super) stale_banner: StaleBanner, + pub(super) name_entry: adw::EntryRow, + /// The group around `name_entry`, visible only in New mode. + pub(super) name_row: adw::PreferencesGroup, + /// Blocked while the tab writes the draft's name into the entry, + /// which is no edit. + pub(super) name_changed: glib::SignalHandlerId, + pub(super) columns_box: gtk::Box, + pub(super) indexes_box: gtk::Box, + pub(super) fks_box: gtk::Box, + pub(super) add_column_row: adw::ButtonRow, + pub(super) add_index_row: adw::ButtonRow, + pub(super) add_fk_row: adw::ButtonRow, + pub(super) sql_buffer: sourceview5::Buffer, + pub(super) pending_label: gtk::Label, + pub(super) save_button: gtk::Button, + pub(super) discard_button: gtk::Button, + pub(super) drop_button: gtk::Button, + pub(super) saving_spinner: adw::Spinner, + pub(super) stop_button: gtk::Button, + /// The SQL preview follows the desktop theme through the app-wide + /// style manager, which would keep the handler past the tab. + pub(super) _theme_handler: HandlerGuard, +} diff --git a/linux/crates/app/src/ui/structure_tab/table_choice_object.rs b/linux/crates/app/src/ui/structure_tab/table_choice_object.rs new file mode 100644 index 0000000000..628460036b --- /dev/null +++ b/linux/crates/app/src/ui/structure_tab/table_choice_object.rs @@ -0,0 +1,128 @@ +//! A table the Add Foreign Key dialog offers to reference. + +use std::cell::{Cell, OnceCell}; +use std::collections::BTreeSet; + +use gtk4::glib; +use gtk4::subclass::prelude::*; +use tablepro_core::meta::{ObjectInfo, ObjectKind, TableRef}; + +mod imp { + use std::marker::PhantomData; + + use gtk4::prelude::*; + + use super::*; + + #[derive(Default, glib::Properties)] + #[properties(wrapper_type = super::TableChoiceObject)] + pub struct TableChoiceObject { + pub(super) table: OnceCell, + pub(super) qualified: Cell, + /// What the combo row shows and searches. + #[property(get = Self::label)] + label: PhantomData, + } + + #[glib::object_subclass] + impl ObjectSubclass for TableChoiceObject { + const NAME: &'static str = "TableProTableChoiceObject"; + type Type = super::TableChoiceObject; + } + + #[glib::derived_properties] + impl ObjectImpl for TableChoiceObject {} + + impl TableChoiceObject { + fn label(&self) -> String { + match (self.table.get(), self.qualified.get()) { + (Some(table), true) => match &table.schema { + Some(schema) => format!("{schema}.{}", table.name), + None => table.name.clone(), + }, + (Some(table), false) => table.name.clone(), + (None, _) => crate::i18n::gettext("None"), + } + } + } +} + +glib::wrapper! { + pub struct TableChoiceObject(ObjectSubclass); +} + +impl TableChoiceObject { + /// The choice of no table, which a combo row needs: it always has an + /// item selected once it has any. + pub(super) fn none() -> Self { + glib::Object::new() + } + + fn new(table: TableRef, qualified: bool) -> Self { + let object: Self = glib::Object::new(); + let _ = object.imp().table.set(table); + object.imp().qualified.set(qualified); + object + } + + pub(crate) fn table(&self) -> Option<&TableRef> { + self.imp().table.get() + } +} + +/// The tables a foreign key can reference, by label. A view cannot be +/// referenced. The schema is named only where tables come from more than +/// one. +pub(super) fn table_choices(objects: &[ObjectInfo]) -> Vec { + let tables: Vec<&ObjectInfo> = objects + .iter() + .filter(|object| matches!(object.kind, ObjectKind::Table | ObjectKind::PartitionedTable)) + .collect(); + let schemas: BTreeSet> = tables.iter().map(|table| table.schema.as_deref()).collect(); + let qualified = schemas.len() > 1; + let mut choices: Vec = tables + .into_iter() + .map(|table| TableChoiceObject::new(TableRef::new(table.schema.clone(), table.name.clone()), qualified)) + .collect(); + choices.sort_by_cached_key(TableChoiceObject::label); + choices +} + +#[cfg(test)] +mod tests { + use super::*; + + fn object(schema: Option<&str>, name: &str, kind: ObjectKind) -> ObjectInfo { + ObjectInfo { + schema: schema.map(str::to_owned), + name: name.to_owned(), + kind, + } + } + + fn labels(choices: &[TableChoiceObject]) -> Vec { + choices.iter().map(TableChoiceObject::label).collect() + } + + #[test] + fn only_tables_are_offered_and_named_with_their_schema_where_it_tells_them_apart() { + let one_schema = [ + object(Some("public"), "users", ObjectKind::Table), + object(Some("public"), "active_users", ObjectKind::View), + object(Some("public"), "events", ObjectKind::PartitionedTable), + ]; + let two_schemas = [ + object(Some("public"), "users", ObjectKind::Table), + object(Some("billing"), "users", ObjectKind::Table), + ]; + + assert_eq!(labels(&table_choices(&one_schema)), ["events", "users"]); + assert_eq!(labels(&table_choices(&two_schemas)), ["billing.users", "public.users"]); + assert_eq!( + table_choices(&two_schemas)[0].table(), + Some(&TableRef::new(Some("billing".to_owned()), "users")) + ); + assert_eq!(TableChoiceObject::none().table(), None); + assert_eq!(TableChoiceObject::none().label(), "None"); + } +} diff --git a/linux/crates/app/src/ui/structure_tab/table_handlers.rs b/linux/crates/app/src/ui/structure_tab/table_handlers.rs new file mode 100644 index 0000000000..3893ec2c4f --- /dev/null +++ b/linux/crates/app/src/ui/structure_tab/table_handlers.rs @@ -0,0 +1,42 @@ +use gtk4::prelude::*; + +use super::{StructureEffects, StructureModel, StructureTabOutput, TableInput}; + +impl StructureModel { + pub(super) fn on_table(&mut self, input: TableInput, fx: &mut StructureEffects) { + match input { + TableInput::TableNameEdited(text) => { + if self.table.name == text { + return; + } + self.table.name = text; + self.recompute_dirty_state(fx); + } + TableInput::DropTableRequested => { + if self.read_only { + tracing::error!("Drop reached a structure tab on a read-only connection"); + return; + } + let table = match &self.context.borrow().definition { + Some(definition) => definition.table.clone(), + None => self.table.clone(), + }; + fx.output(StructureTabOutput::DropTableRequested { + schema: table.schema, + table: table.name, + }); + } + } + } + + /// Write the draft's name into the entry without it reading as an + /// edit. + pub(super) fn show_table_name(&self) { + if self.widgets.name_entry.text() == self.table.name { + return; + } + self.widgets.name_entry.block_signal(&self.widgets.name_changed); + self.widgets.name_entry.set_text(&self.table.name); + self.widgets.name_entry.unblock_signal(&self.widgets.name_changed); + } +} diff --git a/linux/crates/app/src/ui/structure_tab/table_input.rs b/linux/crates/app/src/ui/structure_tab/table_input.rs new file mode 100644 index 0000000000..1119232fe5 --- /dev/null +++ b/linux/crates/app/src/ui/structure_tab/table_input.rs @@ -0,0 +1,6 @@ +/// What the tab does with the table as a whole. +#[derive(Debug)] +pub(crate) enum TableInput { + TableNameEdited(String), + DropTableRequested, +} diff --git a/linux/crates/app/src/ui/structure_tab/tests.rs b/linux/crates/app/src/ui/structure_tab/tests.rs new file mode 100644 index 0000000000..40ef444ee8 --- /dev/null +++ b/linux/crates/app/src/ui/structure_tab/tests.rs @@ -0,0 +1,1559 @@ +use std::cell::RefCell; +use std::rc::Rc; +use std::sync::Arc; +use std::time::Duration; + +use gtk4::subclass::prelude::ObjectSubclassIsExt; +use libadwaita::prelude::{ + ActionRowExt, AdwDialogExt, AdwWindowExt, AlertDialogExt, ComboRowExt, ExpanderRowExt, PreferencesRowExt, +}; +use tablepro_core::column::{ColumnDefault, ColumnType}; +use tablepro_core::ddl::{ + ColumnAttributes, ColumnDefinition, ColumnKeyRoles, DraftColumn, IdentityStyle, InboundForeignKey, + PrimaryKeyConstraint, TableDefinition, TableDraft, TableSource, UnmodeledAttribute, +}; +use tablepro_core::dialect::SqlDialect; +use tablepro_core::meta::{ObjectInfo, ObjectKind, TableRef}; +use tablepro_core::{ColumnInfo, DriverError, LossPhase, ServerInfo}; +use tablepro_session::fakes::FakeEstablish; +use tablepro_test_support::{CallCounters, ConnectionScript, FakeReply}; + +use super::*; +use crate::test_support::{ + FakeSession, OwnedWindow, descendants, drain_main_context, first_descendant_of_type, wait_until, +}; + +const WAIT: Duration = Duration::from_secs(10); + +struct Harness { + tab: Option, + fake: FakeSession, + runtime: Option, +} + +impl Harness { + fn open( + mode: StructureMode, + script: ConnectionScript, + reconnects: impl IntoIterator, + ) -> Self { + adw::init().expect("libadwaita initialises under the test backend"); + crate::register_test_resources(); + let runtime = crate::test_support::test_runtime(); + let fake = FakeSession::start(script, reconnects, runtime.tasks()); + let tab = StructureTab::new(StructureTabInit { + tracker: StructureTrackerHandle::default(), + schema: None, + table: "users".to_owned(), + mode, + session: fake.session.clone(), + tasks: runtime.tasks(), + first_load: crate::ui::tab_load::FirstLoad::Now, + }); + Self { + tab: Some(tab), + fake, + runtime: Some(runtime), + } + } + + fn tab(&self) -> &StructureTab { + self.tab.as_ref().expect("the tab is open") + } + + fn page(&self) -> Option { + self.tab() + .imp() + .inner_stack + .visible_child_name() + .map(|name| name.to_string()) + } + + fn draft(&self) -> TableDraft { + draft(self.tab()) + } + + fn column_names(&self) -> Vec { + self.draft().columns.into_iter().map(|column| column.name).collect() + } + + fn comments(&self) -> Vec> { + self.draft().columns.into_iter().map(|column| column.comment).collect() + } + + fn column(&self, position: u32) -> DraftColumnObject { + column_object(self.tab(), position) + } + + fn tracker(&self) -> StructureTrackerHandle { + self.tab() + .imp() + .model + .borrow() + .as_ref() + .map(|model| model.tracker.clone()) + .expect("the tab has a model") + } +} + +impl Drop for Harness { + fn drop(&mut self) { + self.tab.take(); + drain_main_context(); + if let Some(runtime) = self.runtime.take() { + runtime.shutdown(); + } + } +} + +fn draft(tab: &StructureTab) -> TableDraft { + tab.imp() + .model + .borrow() + .as_ref() + .map(StructureModel::draft) + .expect("the tab has a model") +} + +fn column_object(tab: &StructureTab, position: u32) -> DraftColumnObject { + tab.imp() + .model + .borrow() + .as_ref() + .and_then(|model| model.columns.item(position)) + .and_downcast() + .expect("a column at that position") +} + +fn postgres() -> Arc { + Arc::new(tablepro_driver_postgres::PgDialect::new(&ServerInfo::new( + "PostgreSQL", + "18.0", + 18, + 0, + 0, + ))) +} + +/// A script whose connection speaks PostgreSQL, so the tab has a DDL +/// dialect to plan with. +fn postgres_script() -> ConnectionScript { + ConnectionScript { + dialect: postgres(), + ..ConnectionScript::default() + } +} + +fn definition(column: &str) -> TableDefinition { + TableDefinition { + table: TableRef::unqualified("users"), + columns: vec![ColumnDefinition::new( + ColumnInfo { + name: column.to_owned(), + column_type: ColumnType::untyped_text("text"), + nullable: true, + primary_key: false, + is_auto_increment: false, + is_generated: false, + default: ColumnDefault::None, + comment: None, + }, + ColumnAttributes::default(), + )], + primary_key: None, + indexes: Vec::new(), + foreign_keys: Vec::new(), + inbound_foreign_keys: Vec::new(), + source: None, + } +} + +fn scheme_id(buffer: &sourceview5::Buffer) -> Option { + buffer.style_scheme().map(|scheme| scheme.id().to_string()) +} + +fn sql_buffer(tab: &StructureTab) -> sourceview5::Buffer { + tab.imp() + .sql_view + .buffer() + .downcast::() + .expect("a sourceview buffer") +} + +#[gtk4::test] +fn style_handler_disconnected_on_shutdown() { + let style = adw::StyleManager::default(); + style.set_color_scheme(adw::ColorScheme::ForceLight); + let mut harness = Harness::open(StructureMode::New, ConnectionScript::default(), []); + let buffer = sql_buffer(harness.tab()); + let light = scheme_id(&buffer); + + harness.tab.take(); + drain_main_context(); + style.set_color_scheme(adw::ColorScheme::ForceDark); + let after = scheme_id(&buffer); + style.set_color_scheme(adw::ColorScheme::Default); + + assert_eq!(after, light, "a closed structure tab still follows the theme"); +} + +#[gtk4::test] +fn structure_tab_freed_after_drop() { + let mut harness = Harness::open(StructureMode::New, ConnectionScript::default(), []); + harness.tab().send(ColumnsInput::AddColumn); + drain_main_context(); + let row = first_descendant_of_type::(harness.tab()) + .expect("the added column's row") + .downgrade(); + let widgets: Vec> = { + let imp = harness.tab().imp(); + [ + imp.inner_stack.upcast_ref::(), + imp.indexes_box.upcast_ref(), + imp.fks_box.upcast_ref(), + harness.tab().upcast_ref(), + ] + .into_iter() + .map(ObjectExt::downgrade) + .collect() + }; + + harness.tab.take(); + drain_main_context(); + + assert!(row.upgrade().is_none(), "a column row outlived its tab"); + let alive = widgets.iter().filter(|widget| widget.upgrade().is_some()).count(); + assert_eq!(alive, 0, "parts of the structure tab outlived it"); +} + +#[gtk4::test] +fn a_draft_takes_its_dialect_from_the_session() { + let harness = Harness::open(StructureMode::New, postgres_script(), []); + harness.tab().send(ColumnsInput::AddColumn); + drain_main_context(); + + let buffer = sql_buffer(harness.tab()); + let preview = buffer.text(&buffer.start_iter(), &buffer.end_iter(), false); + assert!(preview.starts_with("CREATE TABLE"), "{preview}"); +} + +#[gtk4::test] +fn failed_structure_load_refetches_after_link_replace() { + let mut first = ConnectionScript::default(); + first + .fetch_table_definition + .always(FakeReply::err(DriverError::ConnectionLost { + during: LossPhase::Statement, + })); + let mut second = ConnectionScript::default(); + second.fetch_table_definition.always(FakeReply::ok(definition("email"))); + let harness = Harness::open(StructureMode::Edit, first, [FakeEstablish::Connect(second)]); + + wait_until(WAIT, || harness.page().as_deref() == Some("editor")).expect("the structure after the reconnect"); + + assert_eq!(harness.fake.session.link_generation().get(), 1); + assert_eq!( + CallCounters::get(&harness.fake.replacement().counters().fetch_table_definition), + 1 + ); + assert_eq!(harness.column_names(), ["email"]); +} + +#[gtk4::test] +fn a_loaded_structure_is_not_read_again_on_link_replace() { + let mut first = ConnectionScript::default(); + first.fetch_table_definition.always(FakeReply::ok(definition("id"))); + let harness = Harness::open( + StructureMode::Edit, + first, + [FakeEstablish::Connect(ConnectionScript::default())], + ); + wait_until(WAIT, || harness.page().as_deref() == Some("editor")).expect("the structure"); + + harness.fake.connection.mark_lost(); + wait_until(WAIT, || harness.fake.session.link_generation().get() == 1).expect("the reconnect"); + std::thread::sleep(Duration::from_millis(200)); + drain_main_context(); + + assert_eq!( + CallCounters::get(&harness.fake.replacement().counters().fetch_table_definition), + 0 + ); + assert_eq!(harness.column_names(), ["id"]); +} + +#[gtk4::test] +fn a_schema_change_leaves_a_tab_with_edits_alone() { + let mut script = ConnectionScript::default(); + script.fetch_table_definition.always(FakeReply::ok(definition("id"))); + let harness = Harness::open(StructureMode::Edit, script, []); + wait_until(WAIT, || harness.page().as_deref() == Some("editor")).expect("the structure"); + let counters = harness.fake.connection.counters(); + + harness.tab().send(ColumnsInput::AddColumn); + harness.tab().send(LoadInput::SchemaChanged); + drain_main_context(); + std::thread::sleep(Duration::from_millis(100)); + drain_main_context(); + + assert_eq!(CallCounters::get(&counters.fetch_table_definition), 1); + assert_eq!(harness.column_names(), ["id", "column_1"]); +} + +#[gtk4::test] +fn drop_asks_the_window_with_the_table_it_shows() { + let mut script = ConnectionScript::default(); + script.fetch_table_definition.always(FakeReply::ok(definition("id"))); + let harness = Harness::open(StructureMode::Edit, script, []); + wait_until(WAIT, || harness.page().as_deref() == Some("editor")).expect("the structure"); + let asked = std::rc::Rc::new(std::cell::RefCell::new(Vec::new())); + let sink = asked.clone(); + harness + .tab() + .connect_drop_table_requested(move |_, schema, table| sink.borrow_mut().push((schema, table))); + + harness.tab().imp().drop_button.emit_clicked(); + + assert_eq!(*asked.borrow(), [(None, "users".to_owned())]); +} + +/// The driver refuses the DROP on a read-only connection, so the tab +/// does not offer it, as the sidebar's table menu does not. +#[gtk4::test] +fn a_read_only_connection_offers_no_drop() { + let mut script = ConnectionScript { + read_only: true, + ..ConnectionScript::default() + }; + script.fetch_table_definition.always(FakeReply::ok(definition("id"))); + let harness = Harness::open(StructureMode::Edit, script, []); + assert!(harness.fake.session.read_only()); + wait_until(WAIT, || harness.page().as_deref() == Some("editor")).expect("the structure"); + let asked = std::rc::Rc::new(std::cell::Cell::new(0)); + let sink = asked.clone(); + harness + .tab() + .connect_drop_table_requested(move |_, _, _| sink.set(sink.get() + 1)); + + assert!(!harness.tab().imp().drop_button.is_visible()); + harness.tab().send(TableInput::DropTableRequested); + assert_eq!(asked.get(), 0, "the tab asked to drop the table"); +} + +fn toasts(tab: &StructureTab) -> std::rc::Rc>> { + let toasts = std::rc::Rc::new(std::cell::RefCell::new(Vec::new())); + let sink = toasts.clone(); + tab.connect_toast(move |_, text| sink.borrow_mut().push(text)); + toasts +} + +#[gtk4::test] +fn copy_sql_puts_the_statement_on_the_clipboard_and_says_so() { + let harness = Harness::open(StructureMode::New, ConnectionScript::default(), []); + let toasts = toasts(harness.tab()); + sql_buffer(harness.tab()).set_text("CREATE TABLE users (id int);"); + + harness.tab().imp().copy_sql_button.emit_clicked(); + + let text = harness + .tab() + .clipboard() + .content() + .and_then(|content| content.value(String::static_type()).ok()) + .and_then(|value| value.get::().ok()); + assert_eq!(text.as_deref(), Some("CREATE TABLE users (id int);")); + assert_eq!(*toasts.borrow(), ["Copied to clipboard"]); +} + +/// The table reads once, then every read after it fails. +fn readable_once() -> ConnectionScript { + let mut script = ConnectionScript::default(); + script + .fetch_table_definition + .once(FakeReply::ok(definition("id"))) + .always(FakeReply::err(DriverError::server("denied"))); + script +} + +fn banner(harness: &Harness) -> adw::Banner { + harness.tab().imp().stale_banner.get() +} + +fn banner_tooltip(harness: &Harness) -> String { + banner(harness) + .tooltip_text() + .map(|text| text.to_string()) + .unwrap_or_default() +} + +#[gtk4::test] +fn structure_refresh_failure_keeps_editor_and_shows_banner() { + let harness = Harness::open(StructureMode::Edit, readable_once(), []); + wait_until(WAIT, || harness.page().as_deref() == Some("editor")).expect("the structure"); + let toasts = toasts(harness.tab()); + + harness.tab().send(LoadInput::SchemaChanged); + wait_until(WAIT, || banner(&harness).is_revealed()).expect("the failed read was not shown"); + + assert_eq!(harness.page().as_deref(), Some("editor")); + assert_eq!(harness.column_names(), ["id"]); + assert!( + banner_tooltip(&harness).contains("denied"), + "{}", + banner_tooltip(&harness) + ); + assert!(toasts.borrow().is_empty(), "{:?}", toasts.borrow()); + assert!(!harness.tab().is_loading()); + assert!(harness.tab().imp().columns_box.is_sensitive()); +} + +/// The definition on screen is from before the save, so the draft holds +/// still until a read lands: unlocked, its diff would offer the saved +/// changes again. +#[gtk4::test] +fn a_failed_read_after_a_save_keeps_the_saved_draft_locked() { + let harness = Harness::open(StructureMode::Edit, readable_once(), []); + wait_until(WAIT, || harness.page().as_deref() == Some("editor")).expect("the structure"); + + harness.tab().send(SaveInput::SaveCompleted { new_table_name: None }); + wait_until(WAIT, || banner(&harness).is_revealed()).expect("the failed read was not shown"); + + assert_eq!(harness.page().as_deref(), Some("editor")); + assert!(!harness.tab().imp().columns_box.is_sensitive()); + assert!( + !harness.tracker().has_pending(), + "the saved changes came back as pending" + ); +} + +/// The save landed, so waiting for it is no help: the edit waits for a +/// read of the table that only Retry starts. +#[gtk4::test] +fn an_edit_after_a_failed_read_after_a_save_says_to_retry() { + let harness = Harness::open(StructureMode::Edit, readable_once(), []); + wait_until(WAIT, || harness.page().as_deref() == Some("editor")).expect("the structure"); + harness.tab().send(SaveInput::SaveCompleted { new_table_name: None }); + wait_until(WAIT, || banner(&harness).is_revealed()).expect("the failed read was not shown"); + let toasts = toasts(harness.tab()); + + harness.tab().send(ColumnsInput::AddColumn); + + assert_eq!( + *toasts.borrow(), + ["The table couldn't be read after the save. Retry, then make the change again."] + ); + assert_eq!(harness.column_names(), ["id"]); +} + +#[gtk4::test] +fn structure_first_load_failure_shows_error_page() { + let mut script = ConnectionScript::default(); + script + .fetch_table_definition + .always(FakeReply::err(DriverError::server("no table"))); + let harness = Harness::open(StructureMode::Edit, script, []); + + wait_until(WAIT, || harness.page().as_deref() == Some("error")).expect("the error page"); + + let imp = harness.tab().imp(); + let description = imp + .error_status + .description() + .map(|text| text.to_string()) + .unwrap_or_default(); + assert!(description.contains("no <such> table"), "{description}"); + assert!(imp.retry_button.is_visible()); + assert!(!banner(&harness).is_revealed()); + assert!(!harness.tab().is_loading()); +} + +#[gtk4::test] +fn index_fetch_failure_keeps_loaded_indexes() { + let indexed = TableDefinition { + indexes: vec![tablepro_core::IndexInfo { + name: "users_id_idx".to_owned(), + keys: vec![tablepro_core::meta::IndexKey::Column { + name: "id".to_owned(), + descending: false, + }], + unique: true, + primary: false, + predicate: None, + }], + ..definition("id") + }; + let mut script = ConnectionScript::default(); + script + .fetch_table_definition + .once(FakeReply::ok(indexed)) + .always(FakeReply::err(DriverError::server("indexes unreadable"))); + let harness = Harness::open(StructureMode::Edit, script, []); + wait_until(WAIT, || harness.page().as_deref() == Some("editor")).expect("the structure"); + + harness.tab().send(LoadInput::SchemaChanged); + wait_until(WAIT, || banner(&harness).is_revealed()).expect("the failed read was not shown"); + + let indexes: Vec = harness.draft().indexes.into_iter().map(|index| index.name).collect(); + assert_eq!(indexes, ["users_id_idx"]); + assert_eq!(harness.page().as_deref(), Some("editor")); +} + +#[gtk4::test] +fn structure_retry_after_failure_hides_banner() { + let mut script = ConnectionScript::default(); + script + .fetch_table_definition + .once(FakeReply::ok(definition("id"))) + .once(FakeReply::err(DriverError::server("denied"))) + .always(FakeReply::ok(definition("email"))); + let harness = Harness::open(StructureMode::Edit, script, []); + wait_until(WAIT, || harness.page().as_deref() == Some("editor")).expect("the structure"); + harness.tab().send(LoadInput::SchemaChanged); + wait_until(WAIT, || banner(&harness).is_revealed()).expect("the stale banner"); + + banner(&harness).emit_by_name::<()>("button-clicked", &[]); + + wait_until(WAIT, || harness.column_names() == ["email"]).expect("Retry read the table again"); + assert!( + !banner(&harness).is_revealed(), + "the banner outlived the read that landed" + ); + assert_eq!( + CallCounters::get(&harness.fake.connection.counters().fetch_table_definition), + 3 + ); +} + +#[gtk4::test] +fn schema_save_shows_new_column_list() { + let mut script = ConnectionScript::default(); + script + .fetch_table_definition + .once(FakeReply::ok(TableDefinition { + columns: vec![integer_column("id", true), integer_column("name", false)], + ..definition("id") + })) + .always(FakeReply::ok(TableDefinition { + columns: vec![ + integer_column("id", true), + integer_column("name", false), + integer_column("email", false), + ], + ..definition("id") + })); + let harness = Harness::open(StructureMode::Edit, script, []); + wait_until(WAIT, || harness.page().as_deref() == Some("editor")).expect("the structure"); + let loading = Rc::new(RefCell::new(Vec::new())); + let sink = loading.clone(); + harness + .tab() + .connect_loading_changed(move |_, busy| sink.borrow_mut().push(busy)); + + harness.tab().send(SaveInput::SaveCompleted { new_table_name: None }); + + assert_eq!( + harness.page().as_deref(), + Some("editor"), + "the read after the save blanked the tab" + ); + wait_until(WAIT, || column_rows(harness.tab()).len() == 3).expect("the columns the save left"); + assert_eq!(harness.column_names(), ["id", "name", "email"]); + assert_eq!( + CallCounters::get(&harness.fake.connection.counters().fetch_table_definition), + 2 + ); + assert_eq!(*loading.borrow(), [true, false]); +} + +/// The control named `name` in the row the Columns list shows at +/// `position`. +fn column_widget(tab: &StructureTab, position: i32, name: &str) -> gtk::Widget { + let row = tab + .imp() + .columns_list + .row_at_index(position) + .unwrap_or_else(|| panic!("no column row at {position}")); + descendants(&row) + .into_iter() + .find(|widget| widget.widget_name() == name) + .unwrap_or_else(|| panic!("no widget named {name} in row {position}")) +} + +fn integer_column(name: &str, primary_key: bool) -> ColumnDefinition { + let column_type = postgres().parse_type("integer").expect("a PostgreSQL type"); + ColumnDefinition::new( + ColumnInfo { + name: name.to_owned(), + column_type, + nullable: !primary_key, + primary_key, + is_auto_increment: false, + is_generated: false, + default: ColumnDefault::None, + comment: None, + }, + ColumnAttributes::default(), + ) +} + +#[gtk4::test] +fn clickhouse_key_column_rename_is_insensitive_with_reason() { + let clickhouse: Arc = Arc::new(tablepro_driver_clickhouse::ClickHouseDialect::new( + &ServerInfo::new("ClickHouse", "25.8", 25, 8, 0), + )); + let mut key = definition("id"); + key.columns[0].info.column_type = clickhouse.parse_type("UInt64").expect("a ClickHouse type"); + key.columns[0].attributes.key_roles = ColumnKeyRoles { + sorting_key: true, + partition_key: false, + }; + let mut label = definition("label").columns.remove(0); + label.info.column_type = clickhouse.parse_type("String").expect("a ClickHouse type"); + key.columns.push(label); + let mut script = ConnectionScript { + dialect: clickhouse, + ..ConnectionScript::default() + }; + script.fetch_table_definition.always(FakeReply::ok(key)); + let harness = Harness::open(StructureMode::Edit, script, []); + wait_until(WAIT, || harness.page().as_deref() == Some("editor")).expect("the structure"); + drain_main_context(); + + let key_name = column_widget(harness.tab(), 0, "col-name"); + let plain_name = column_widget(harness.tab(), 1, "col-name"); + + assert!(!key_name.is_sensitive(), "a sorting key column could be renamed"); + assert!( + key_name + .tooltip_text() + .is_some_and(|tip| tip.contains("sorting or partition key")), + "{:?}", + key_name.tooltip_text() + ); + assert!(plain_name.is_sensitive()); + assert!(!column_widget(harness.tab(), 0, "col-remove").is_sensitive()); +} + +#[gtk4::test] +fn referenced_primary_key_switch_is_insensitive_with_foreign_key_names() { + let users = TableDefinition { + table: TableRef::unqualified("users"), + columns: vec![integer_column("id", true), integer_column("score", false)], + primary_key: Some(PrimaryKeyConstraint { + name: Some("users_pkey".to_owned()), + columns: vec!["id".to_owned()], + }), + indexes: Vec::new(), + foreign_keys: Vec::new(), + inbound_foreign_keys: vec![InboundForeignKey { + name: "orders_user_fk".to_owned(), + table: TableRef::unqualified("orders"), + columns: vec!["user_id".to_owned()], + referenced_columns: vec!["id".to_owned()], + }], + source: None, + }; + let mut script = postgres_script(); + script.fetch_table_definition.always(FakeReply::ok(users)); + let harness = Harness::open(StructureMode::Edit, script, []); + wait_until(WAIT, || harness.page().as_deref() == Some("editor")).expect("the structure"); + drain_main_context(); + + for position in [0, 1] { + let switch = column_widget(harness.tab(), position, "col-pk"); + assert!(!switch.is_sensitive(), "column {position} could move a referenced key"); + assert!( + switch.tooltip_text().is_some_and(|tip| tip.contains("orders_user_fk")), + "column {position}: {:?}", + switch.tooltip_text() + ); + } + assert!(!column_widget(harness.tab(), 0, "col-remove").is_sensitive()); + assert!(column_widget(harness.tab(), 1, "col-remove").is_sensitive()); +} + +#[gtk4::test] +fn a_computed_column_takes_no_comment_where_the_comment_restates_it() { + let mysql: Arc = Arc::new(tablepro_driver_mysql::MySqlDialect::new( + tablepro_driver_mysql::MySqlFlavour::MySql, + &ServerInfo::new("MySQL", "8.4.3", 8, 4, 3), + )); + let mut table = definition("total"); + table.columns[0].info.column_type = mysql.parse_type("int").expect("a MySQL type"); + table.columns[0].info.is_generated = true; + let mut plain = definition("base").columns.remove(0); + plain.info.column_type = mysql.parse_type("int").expect("a MySQL type"); + table.columns.push(plain); + let mut script = ConnectionScript { + dialect: mysql, + ..ConnectionScript::default() + }; + script.fetch_table_definition.always(FakeReply::ok(table)); + let harness = Harness::open(StructureMode::Edit, script, []); + wait_until(WAIT, || harness.page().as_deref() == Some("editor")).expect("the structure"); + drain_main_context(); + + let computed = column_widget(harness.tab(), 0, "col-comment"); + + assert!(!computed.is_sensitive(), "a generated column's comment could be set"); + assert!( + computed + .tooltip_text() + .is_some_and(|tip| tip.contains("computes this column")), + "{:?}", + computed.tooltip_text() + ); + assert!(column_widget(harness.tab(), 1, "col-comment").is_sensitive()); +} + +#[gtk4::test] +fn an_edit_previews_the_plan_and_saves_through_the_tracker() { + let mut script = postgres_script(); + script.fetch_table_definition.always(FakeReply::ok(TableDefinition { + columns: vec![integer_column("id", true)], + ..definition("id") + })); + let tracker = StructureTrackerHandle::default(); + adw::init().expect("libadwaita initialises under the test backend"); + crate::register_test_resources(); + let runtime = crate::test_support::test_runtime(); + let fake = FakeSession::start(script, [], runtime.tasks()); + let tab = StructureTab::new(StructureTabInit { + tracker: tracker.clone(), + schema: None, + table: "users".to_owned(), + mode: StructureMode::Edit, + session: fake.session.clone(), + tasks: runtime.tasks(), + first_load: crate::ui::tab_load::FirstLoad::Now, + }); + wait_until(WAIT, || { + tab.imp().inner_stack.visible_child_name().as_deref() == Some("editor") + }) + .expect("the structure"); + let saves = std::rc::Rc::new(std::cell::Cell::new(0)); + let counter = saves.clone(); + tab.connect_save_requested(move |_| counter.set(counter.get() + 1)); + + tab.send(ColumnsInput::ColumnEdited { + column: column_object(&tab, 0), + field: ColumnField::Default("42".to_owned()), + }); + let buffer = sql_buffer(&tab); + let preview = buffer.text(&buffer.start_iter(), &buffer.end_iter(), false).to_string(); + tab.imp().save_button.emit_clicked(); + + assert_eq!(preview, "ALTER TABLE \"users\" ALTER COLUMN \"id\" SET DEFAULT 42;"); + assert_eq!(saves.get(), 1); + let pending = tracker.begin_save().expect("a save").expect("the change is pending"); + assert_eq!(pending.changes.altered_columns.len(), 1); + tracker.settle_save(&crate::workspace::SaveOutcome::RolledBack( + crate::workspace::RollbackCause::NotApplied, + )); + + tab.send(ColumnsInput::ColumnEdited { + column: column_object(&tab, 0), + field: ColumnField::Default("1 +".to_owned()), + }); + assert!( + !tab.imp().save_button.is_sensitive(), + "a default that does not parse can be saved" + ); + // What a close prompt's Save reads. + assert_eq!( + tracker.begin_save(), + Err(crate::workspace::SaveRejection::Blocked( + crate::services::pending_structure::SaveBlocker::UnparsedDefault("id".to_owned()) + )) + ); + drop(tab); + drain_main_context(); + runtime.shutdown(); +} + +#[gtk4::test] +fn stop_saving_shows_only_while_a_save_runs() { + let mut script = postgres_script(); + script.fetch_table_definition.always(FakeReply::ok(definition("id"))); + let harness = Harness::open(StructureMode::Edit, script, []); + wait_until(WAIT, || harness.page().as_deref() == Some("editor")).expect("the structure"); + let stops = std::rc::Rc::new(std::cell::Cell::new(0)); + let stop_sink = stops.clone(); + harness + .tab() + .connect_stop_save_requested(move |_| stop_sink.set(stop_sink.get() + 1)); + harness.tab().send(ColumnsInput::ColumnEdited { + column: harness.column(0), + field: ColumnField::Comment(Some("key".to_owned())), + }); + let imp = harness.tab().imp(); + let (stop, save) = (imp.stop_button.get(), imp.save_button.get()); + assert!(!stop.is_visible()); + assert!(save.is_sensitive()); + + harness.tab().send(SaveInput::SaveStarted); + assert!(stop.is_visible() && imp.saving_spinner.is_visible()); + assert!( + !imp.columns_box.is_sensitive(), + "the columns stayed editable during the save" + ); + let toasts = toasts(harness.tab()); + harness.tab().send(ColumnsInput::ColumnEdited { + column: harness.column(0), + field: ColumnField::Comment(Some("the key".to_owned())), + }); + assert_eq!( + harness.comments(), + [Some("key".to_owned())], + "an edit got into a saving draft" + ); + assert_eq!(toasts.borrow().len(), 1, "a refused edit said nothing"); + assert!(!save.is_sensitive(), "an edit during the save turned Save back on"); + stop.emit_clicked(); + assert_eq!(stops.get(), 1); + + harness.tab().send(SaveInput::SaveFailed("rolled back".to_owned())); + assert!(!stop.is_visible() && !imp.saving_spinner.is_visible()); + assert!(save.is_sensitive() && imp.columns_box.is_sensitive()); +} + +/// The read after a commit replaces the draft, so the draft takes no +/// edit from the moment the save takes it until that read lands: one +/// made in between would be lost, and a close waiting on the save would +/// find nothing to ask about. +#[gtk4::test] +fn an_edit_waits_for_the_read_after_a_committed_save() { + let mut script = postgres_script(); + script.fetch_table_definition.always(FakeReply::ok(TableDefinition { + columns: vec![integer_column("id", true)], + ..definition("id") + })); + let harness = Harness::open(StructureMode::Edit, script, []); + wait_until(WAIT, || harness.page().as_deref() == Some("editor")).expect("the structure"); + let counters = harness.fake.connection.counters(); + let tracker = harness.tracker(); + let imp = harness.tab().imp(); + harness.tab().send(ColumnsInput::ColumnEdited { + column: harness.column(0), + field: ColumnField::Comment(Some("key".to_owned())), + }); + + let _saving = tracker.begin_save().expect("a save").expect("the change is pending"); + harness.tab().send(ColumnsInput::ColumnEdited { + column: harness.column(0), + field: ColumnField::Comment(Some("before the tab heard".to_owned())), + }); + harness.tab().send(SaveInput::SaveStarted); + tracker.settle_save(&crate::workspace::SaveOutcome::Committed { rows: None }); + harness.tab().send(ColumnsInput::AddColumn); + harness.tab().send(SaveInput::SaveCompleted { new_table_name: None }); + harness.tab().send(ColumnsInput::AddColumn); + + assert_eq!(harness.comments(), [Some("key".to_owned())]); + assert_eq!(harness.column_names(), ["id"]); + assert!(!imp.columns_box.is_sensitive()); + wait_until(WAIT, || { + CallCounters::get(&counters.fetch_table_definition) == 2 && imp.columns_box.is_sensitive() + }) + .expect("the read after the save"); + harness.tab().send(ColumnsInput::AddColumn); + assert_eq!(harness.column_names(), ["id", "column_1"]); +} + +/// `id` keyed by `users_pkey`, and a nullable `score`. +fn keyed_users() -> TableDefinition { + TableDefinition { + columns: vec![integer_column("id", true), integer_column("score", false)], + primary_key: Some(PrimaryKeyConstraint { + name: Some("users_pkey".to_owned()), + columns: vec!["id".to_owned()], + }), + ..definition("id") + } +} + +fn preview(tab: &StructureTab) -> String { + let buffer = sql_buffer(tab); + buffer.text(&buffer.start_iter(), &buffer.end_iter(), false).to_string() +} + +/// The preview is text a reader can move through, so it takes a name. +#[gtk4::test] +fn the_sql_preview_is_named() { + let mut script = postgres_script(); + script.fetch_table_definition.always(FakeReply::ok(keyed_users())); + let harness = Harness::open(StructureMode::Edit, script, []); + + assert_eq!( + crate::test_support::assert_labelled(&harness.tab().imp().sql_view.get()), + Ok(()) + ); +} + +#[gtk4::test] +fn renaming_existing_column_enables_save_and_previews_rename() { + let mut script = postgres_script(); + script.fetch_table_definition.always(FakeReply::ok(keyed_users())); + let harness = Harness::open(StructureMode::Edit, script, []); + wait_until(WAIT, || harness.page().as_deref() == Some("editor")).expect("the structure"); + drain_main_context(); + let imp = harness.tab().imp(); + assert!(!imp.save_button.is_sensitive()); + + let name = column_widget(harness.tab(), 1, "col-name") + .downcast::() + .expect("the Name entry"); + assert!(name.is_sensitive(), "an existing column's name could not be edited"); + name.set_text("points"); + drain_main_context(); + + assert_eq!(harness.column_names(), ["id", "points"]); + assert!(imp.save_button.is_sensitive(), "a rename left Save off"); + assert_eq!(imp.pending_label.label(), "1 unsaved change"); + assert_eq!( + crate::ui::a11y::announced_in(&imp.pending_label.get()), + [( + "1 unsaved change".to_owned(), + gtk4::AccessibleAnnouncementPriority::Medium + )] + ); + assert_eq!( + preview(harness.tab()), + "ALTER TABLE \"users\" RENAME COLUMN \"score\" TO \"points\";" + ); +} + +fn switch_row(tab: &StructureTab, position: i32, name: &str) -> adw::SwitchRow { + column_widget(tab, position, name) + .downcast::() + .expect("a switch row") +} + +impl Harness { + fn draft_column(&self, index: usize) -> DraftColumn { + self.draft().columns[index].clone() + } +} + +fn open_edit(dialect: Arc, table: TableDefinition) -> Harness { + let mut script = ConnectionScript { + dialect, + ..ConnectionScript::default() + }; + script.fetch_table_definition.always(FakeReply::ok(table)); + let harness = Harness::open(StructureMode::Edit, script, []); + wait_until(WAIT, || harness.page().as_deref() == Some("editor")).expect("the structure"); + drain_main_context(); + harness +} + +#[gtk4::test] +fn the_primary_key_holds_a_column_not_null_until_it_leaves() { + let harness = open_edit(postgres(), keyed_users()); + let tab = harness.tab(); + let (key, nullable) = (switch_row(tab, 1, "col-pk"), switch_row(tab, 1, "col-nullable")); + assert!( + !switch_row(tab, 0, "col-nullable").is_sensitive(), + "a key column could take NULL" + ); + assert!(nullable.is_sensitive() && nullable.is_active()); + + key.set_active(true); + drain_main_context(); + + assert!(!nullable.is_active() && !nullable.is_sensitive()); + assert!(nullable.tooltip_text().is_some_and(|tip| tip.contains("NULL"))); + assert!(harness.draft_column(1).primary_key && !harness.draft_column(1).nullable); + assert_eq!( + preview(tab), + "ALTER TABLE \"users\" DROP CONSTRAINT \"users_pkey\", ALTER COLUMN \"score\" SET NOT NULL, ADD PRIMARY KEY \ + (\"id\", \"score\");" + ); + + key.set_active(false); + drain_main_context(); + + assert!(nullable.is_sensitive() && nullable.is_active()); + assert!(nullable.tooltip_text().is_none()); + assert!(!harness.draft_column(1).primary_key && harness.draft_column(1).nullable); + assert_eq!(preview(tab), "-- No pending changes."); +} + +/// The Nullable switch shows what the draft holds, so a column whose +/// nullability cannot be restated stays nullable in the key, and the +/// preview says what holds the save. +#[gtk4::test] +fn a_held_nullable_column_joins_the_key_as_it_is() { + let mut table = keyed_users(); + table.columns[1].attributes.unmodeled = vec![UnmodeledAttribute::new("STORAGE EXTERNAL")]; + let harness = open_edit(postgres(), table); + let tab = harness.tab(); + let nullable = switch_row(tab, 1, "col-nullable"); + assert!(!nullable.is_sensitive()); + + switch_row(tab, 1, "col-pk").set_active(true); + drain_main_context(); + + assert!(nullable.is_active()); + assert!( + nullable + .tooltip_text() + .is_some_and(|tip| tip.contains("STORAGE EXTERNAL")) + ); + assert!(harness.draft_column(1).primary_key && harness.draft_column(1).nullable); + assert_eq!(preview(tab), "-- Primary key columns must be NOT NULL: score"); +} + +#[gtk4::test] +fn a_postgres_identity_keeps_numbering_outside_the_key() { + let mut table = keyed_users(); + table.columns[0].info.is_auto_increment = true; + table.columns[0].attributes.identity = IdentityStyle::GeneratedAlways; + let harness = open_edit(postgres(), table); + let tab = harness.tab(); + let auto = switch_row(tab, 0, "col-auto"); + assert!( + switch_row(tab, 1, "col-auto").is_sensitive(), + "a column outside the key could not number itself" + ); + + switch_row(tab, 0, "col-pk").set_active(false); + drain_main_context(); + + assert!(auto.is_active() && auto.is_sensitive()); + assert!(harness.draft_column(0).auto_increment && !harness.draft_column(0).primary_key); + assert_eq!(preview(tab), "ALTER TABLE \"users\" DROP CONSTRAINT \"users_pkey\";"); +} + +#[gtk4::test] +fn a_mysql_column_stops_numbering_as_it_leaves_the_key() { + let mysql: Arc = Arc::new(tablepro_driver_mysql::MySqlDialect::new( + tablepro_driver_mysql::MySqlFlavour::MySql, + &ServerInfo::new("MySQL", "8.4.3", 8, 4, 3), + )); + let mut table = keyed_users(); + for column in &mut table.columns { + column.info.column_type = mysql.parse_type("int").expect("a MySQL type"); + } + table.columns[0].info.is_auto_increment = true; + let harness = open_edit(mysql, table); + let tab = harness.tab(); + let auto = switch_row(tab, 0, "col-auto"); + assert!(auto.is_active()); + assert!(!switch_row(tab, 1, "col-auto").is_sensitive()); + + switch_row(tab, 0, "col-pk").set_active(false); + drain_main_context(); + + assert!(!auto.is_active() && !auto.is_sensitive()); + assert!(!harness.draft_column(0).auto_increment); +} + +/// Edit the table, let `change` say it changed meanwhile, then Discard, +/// and count the reads of the table. +fn reads_after_discard(change: Option) -> usize { + let mut script = postgres_script(); + script.fetch_table_definition.always(FakeReply::ok(definition("id"))); + let harness = Harness::open(StructureMode::Edit, script, []); + wait_until(WAIT, || harness.page().as_deref() == Some("editor")).expect("the structure"); + let counters = harness.fake.connection.counters(); + harness.tab().send(ColumnsInput::AddColumn); + let read_again = change.is_some(); + if let Some(change) = change { + harness.tab().send(change); + } + + harness.tab().send(SaveInput::Discard); + + if read_again { + wait_until(WAIT, || CallCounters::get(&counters.fetch_table_definition) == 2) + .expect("Discard restored the definition read before the change"); + wait_until(WAIT, || harness.page().as_deref() == Some("editor")).expect("the structure read again"); + } + drain_main_context(); + assert_eq!(harness.column_names(), ["id"]); + CallCounters::get(&counters.fetch_table_definition) +} + +#[gtk4::test] +fn a_plain_discard_restores_the_definition_it_read() { + assert_eq!(reads_after_discard(None), 1); +} + +#[gtk4::test] +fn a_discard_after_a_change_elsewhere_reads_the_table_again() { + assert_eq!(reads_after_discard(Some(LoadInput::SchemaChanged.into())), 2); +} + +#[gtk4::test] +fn a_discard_after_a_save_found_the_table_changed_reads_it_again() { + assert_eq!(reads_after_discard(Some(SaveInput::TableChangedBeforeSave.into())), 2); +} + +/// The titles of the add buttons the tab shows under `container`. +fn add_buttons(container: >k::Box) -> Vec { + descendants(container) + .into_iter() + .filter_map(|widget| widget.downcast::().ok()) + .filter(|row| row.is_visible() && row.parent().is_some_and(|list| list.is_visible())) + .map(|row| adw::prelude::PreferencesRowExt::title(&row).to_string()) + .collect() +} + +/// A SQLite `users (body)` table stored as `create_statement`. +fn sqlite_users(create_statement: &str) -> Vec { + let sqlite: Arc = Arc::new(tablepro_driver_sqlite::SqliteDialect::new( + tablepro_driver_sqlite::SqliteVersion::new(3, 45, 0), + )); + let mut table = definition("body"); + table.source = Some(TableSource { + create_statement: create_statement.to_owned(), + dependents: Vec::new(), + }); + let harness = open_edit(sqlite, table); + add_buttons(&harness.tab().imp().fks_box) +} + +#[gtk4::test] +fn an_unreadable_sqlite_table_offers_no_foreign_key() { + assert!(sqlite_users("CREATE VIRTUAL TABLE users USING fts5(body)").is_empty()); +} + +#[gtk4::test] +fn a_readable_sqlite_table_offers_a_foreign_key() { + assert_eq!(sqlite_users("CREATE TABLE users (body text)"), ["Add Foreign Key…"]); +} + +fn column_row(tab: &StructureTab, position: i32) -> adw::ExpanderRow { + tab.imp() + .columns_list + .row_at_index(position) + .and_downcast() + .unwrap_or_else(|| panic!("no column row at {position}")) +} + +fn column_rows(tab: &StructureTab) -> Vec { + let list = &tab.imp().columns_list; + (0..).map_while(|position| list.row_at_index(position)).collect() +} + +fn entry(tab: &StructureTab, position: i32, name: &str) -> adw::EntryRow { + column_widget(tab, position, name) + .downcast::() + .expect("an entry row") +} + +fn columns_store(tab: &StructureTab) -> gio::ListStore { + tab.imp() + .model + .borrow() + .as_ref() + .map(|model| model.columns.clone()) + .expect("the tab has a model") +} + +/// Rows built after an edit used to hold back every signal until the +/// next idle, so a name typed before then was lost. +#[gtk4::test] +fn table_name_edit_is_not_dropped() { + let harness = Harness::open(StructureMode::New, postgres_script(), []); + let tab = harness.tab(); + tab.send(ColumnsInput::AddColumn); + + tab.imp().name_entry.set_text("people"); + + assert_eq!(harness.draft().table.name, "people"); + drain_main_context(); + assert_eq!(harness.draft().table.name, "people"); + assert!(preview(tab).contains("\"people\""), "{}", preview(tab)); +} + +#[gtk4::test] +fn expanded_row_stays_expanded_after_editing_another_column() { + let harness = open_edit(postgres(), keyed_users()); + let tab = harness.tab(); + let first = column_row(tab, 0); + first.set_expanded(true); + + tab.send(ColumnsInput::AddColumn); + entry(tab, 2, "col-name").set_text("points"); + entry(tab, 1, "col-type").set_text("bigint"); + drain_main_context(); + + assert_eq!(column_row(tab, 0), first, "the expanded row was built again"); + assert!(first.is_expanded()); + assert_eq!(harness.column_names(), ["id", "score", "points"]); +} + +#[gtk4::test] +fn remove_by_identity_after_reorder_removes_right_column() { + let harness = open_edit(postgres(), keyed_users()); + let tab = harness.tab(); + tab.send(ColumnsInput::AddColumn); + let store = columns_store(tab); + let added = store.item(2).expect("the added column"); + store.remove(2); + store.insert(0, &added); + drain_main_context(); + assert_eq!(harness.column_names(), ["column_1", "id", "score"]); + + column_widget(tab, 2, "col-remove") + .downcast::() + .expect("the remove button") + .emit_clicked(); + + assert_eq!(harness.column_names(), ["column_1", "id"]); + assert_eq!(column_rows(tab).len(), 2); +} + +#[gtk4::test] +fn add_column_changes_one_item() { + let harness = open_edit(postgres(), keyed_users()); + let tab = harness.tab(); + let rows = column_rows(tab); + let changes = Rc::new(RefCell::new(Vec::new())); + let sink = changes.clone(); + columns_store(tab).connect_items_changed(move |_, position, removed, added| { + sink.borrow_mut().push((position, removed, added)); + }); + + tab.send(ColumnsInput::AddColumn); + + assert_eq!(*changes.borrow(), [(2, 0, 1)]); + let after = column_rows(tab); + assert_eq!(after.len(), 3); + assert_eq!(after[..2], rows[..], "the rows already there were built again"); + assert_eq!(column_row(tab, 2).title(), "column_1"); +} + +/// Building the restored rows sets every control, which is no edit, and +/// the rows take the user's next edit at once. +#[gtk4::test] +fn discard_restores_without_phantom_ops() { + let harness = open_edit(postgres(), keyed_users()); + let window = in_window(&harness); + let tab = harness.tab(); + let imp = tab.imp(); + let dirty = Rc::new(RefCell::new(Vec::new())); + let sink = dirty.clone(); + tab.connect_dirty_changed(move |_, value| sink.borrow_mut().push(value)); + entry(tab, 1, "col-name").set_text("points"); + entry(tab, 0, "col-comment").set_text("key"); + tab.send(ColumnsInput::AddColumn); + assert_eq!(imp.pending_label.label(), "3 unsaved changes"); + + imp.discard_button.emit_clicked(); + answer_discard(&window, "discard"); + + assert_eq!(harness.column_names(), ["id", "score"]); + assert_eq!(harness.comments(), [None, None]); + assert!(!imp.save_button.is_sensitive() && !imp.pending_label.is_visible()); + assert_eq!(preview(tab), "-- No pending changes."); + assert_eq!(*dirty.borrow(), [true, false]); + assert_eq!(harness.tracker().begin_save(), Ok(None)); + + entry(tab, 1, "col-name").set_text("points"); + + assert_eq!(harness.column_names(), ["id", "points"]); + assert_eq!(imp.pending_label.label(), "1 unsaved change"); +} + +/// Discard drops the undo history with the changes, so it asks first, +/// as F5 does on a browse tab. +#[gtk4::test] +fn a_discard_not_confirmed_keeps_every_change() { + let harness = open_edit(postgres(), keyed_users()); + let window = in_window(&harness); + let imp = harness.tab().imp(); + entry(harness.tab(), 1, "col-name").set_text("points"); + + imp.discard_button.emit_clicked(); + answer_discard(&window, "cancel"); + + assert_eq!(harness.column_names(), ["id", "points"]); + assert_eq!(imp.pending_label.label(), "1 unsaved change"); +} + +/// Answer the alert Discard opens. +fn answer_discard(window: &adw::Window, response: &str) { + let alert = shown_form(window) + .downcast::() + .expect("Discard asks before it drops the changes"); + assert_eq!(alert.close_response(), "cancel"); + alert.emit_by_name::<()>("response", &[&response]); + alert.force_close(); + drain_main_context(); +} + +#[gtk4::test] +fn subtitle_updates_on_type_edit() { + let harness = open_edit(postgres(), keyed_users()); + let tab = harness.tab(); + let row = column_row(tab, 1); + let before = row.subtitle(); + + entry(tab, 1, "col-type").set_text("bigint"); + + assert_eq!(row.subtitle(), "bigint · nullable", "was {before}"); + assert!(harness.column(1).summary().starts_with("bigint")); +} + +/// The header shows a name as typed, never as Pango markup. +#[gtk4::test] +fn a_column_name_with_markup_characters_shows_as_typed() { + let harness = open_edit(postgres(), keyed_users()); + let tab = harness.tab(); + + entry(tab, 1, "col-name").set_text("a & b"); + + let shown: Vec = descendants(&column_row(tab, 1)) + .into_iter() + .filter_map(|widget| widget.downcast::().ok()) + .filter(|label| label.is_visible() && label.text() == "a & b") + .map(|label| label.text().to_string()) + .collect(); + assert_eq!(shown.len(), 1, "the name was not shown as typed"); +} + +#[gtk4::test] +fn a_default_the_parser_refuses_is_marked_until_it_parses() { + let harness = open_edit(postgres(), keyed_users()); + let tab = harness.tab(); + let default = entry(tab, 1, "col-default"); + + default.set_text("1 +"); + + assert!(default.has_css_class("error")); + assert!(default.tooltip_text().is_some()); + assert!(!tab.imp().save_button.is_sensitive()); + assert_eq!( + harness + .column(1) + .unparsed_default() + .map(|unparsed| unparsed.text) + .as_deref(), + Some("1 +") + ); + + default.set_text("42"); + + assert!(!default.has_css_class("error")); + assert!(default.tooltip_text().is_none()); + assert!(tab.imp().save_button.is_sensitive()); +} + +/// The tab in a window of its own, which the add rows open their forms +/// on. +fn in_window(harness: &Harness) -> OwnedWindow { + let window = super::form_dialog::tests::window(); + window.set_content(Some(harness.tab())); + window +} + +fn shown_form(window: &adw::Window) -> adw::Dialog { + let mut shown = None; + wait_until(WAIT, || { + shown = window.visible_dialog(); + shown.is_some() + }) + .expect("the form"); + shown.expect("the form") +} + +fn rows_titled + IsA>(form: &adw::Dialog, title: &str) -> Option { + descendants(form) + .into_iter() + .filter_map(|widget| widget.downcast::().ok()) + .find(|row| row.title() == title) +} + +/// The tab keeps nothing of a form it opens, so a form that does not +/// keep its own state stops answering here. +#[gtk4::test] +fn add_index_from_the_tab_adds_the_index() { + let harness = open_edit(postgres(), keyed_users()); + let window = in_window(&harness); + + harness.tab().imp().add_index_row.emit_by_name::<()>("activated", &[]); + let form = shown_form(&window); + let name: adw::EntryRow = rows_titled(&form, "Name").expect("the Name row"); + wait_until(WAIT, || name.is_mapped()).expect("the form on screen"); + name.set_text("by_score"); + assert!( + form.activate_action("index.add-key", Some(&"score".to_variant())) + .is_ok() + ); + super::form_dialog::tests::press_enter(&name); + + wait_until(WAIT, || harness.draft().indexes.len() == 1).expect("the index to be added"); + let index = harness.draft().indexes[0].clone(); + assert_eq!(index.name, "by_score"); + assert_eq!( + index + .keys + .iter() + .map(|key| key.column_name().unwrap_or_default().to_owned()) + .collect::>(), + ["score"] + ); + assert!(harness.tab().imp().save_button.is_sensitive()); + window.destroy(); +} + +#[gtk4::test] +fn add_foreign_key_from_the_tab_adds_the_key() { + let mut script = postgres_script(); + script.fetch_table_definition.always(FakeReply::ok(keyed_users())); + script.list_objects.always(FakeReply::ok(vec![ObjectInfo { + schema: Some("public".to_owned()), + name: "accounts".to_owned(), + kind: ObjectKind::Table, + }])); + script + .fetch_columns + .always(FakeReply::ok(vec![integer_column("id", true).info])); + let harness = Harness::open(StructureMode::Edit, script, []); + wait_until(WAIT, || harness.page().as_deref() == Some("editor")).expect("the structure"); + drain_main_context(); + let window = in_window(&harness); + + harness.tab().imp().add_fk_row.emit_by_name::<()>("activated", &[]); + let form = shown_form(&window); + let name: adw::EntryRow = rows_titled(&form, "Name").expect("the Name row"); + wait_until(WAIT, || name.is_mapped()).expect("the form on screen"); + name.set_text("fk_account"); + let table: adw::ComboRow = rows_titled(&form, "Referenced table").expect("the table row"); + wait_until(WAIT, || table.is_sensitive()).expect("the tables to be read"); + table.set_selected(1); + let id: adw::ActionRow = rows_titled(&form, "id").expect("the id check row"); + id.activatable_widget() + .and_downcast::() + .expect("the id check") + .set_active(true); + wait_until(WAIT, || { + rows_titled::(&form, "id references").is_some_and(|pair| pair.is_sensitive()) + }) + .expect("the columns of accounts to be read"); + super::form_dialog::tests::press_enter(&name); + + wait_until(WAIT, || harness.draft().foreign_keys.len() == 1).expect("the key to be added"); + let key = harness.draft().foreign_keys[0].clone(); + assert_eq!((key.name.as_str(), key.ref_table.as_str()), ("fk_account", "accounts")); + assert_eq!( + (key.columns, key.ref_columns), + (vec!["id".to_owned()], vec!["id".to_owned()]) + ); + window.destroy(); +} + +/// Ctrl+S saves as the Save button does, and only while the button could. +#[gtk4::test] +fn ctrl_s_saves_while_save_is_on() { + use crate::application::ShortcutOwner; + use crate::test_support::shortcut_keys::{keystroke, owner_controller, shortcuts, trigger_keys}; + let mut script = postgres_script(); + script.fetch_table_definition.always(FakeReply::ok(TableDefinition { + columns: vec![integer_column("id", true)], + ..definition("id") + })); + adw::init().expect("libadwaita initialises under the test backend"); + crate::register_test_resources(); + let runtime = crate::test_support::test_runtime(); + let fake = FakeSession::start(script, [], runtime.tasks()); + let tab = StructureTab::new(StructureTabInit { + tracker: StructureTrackerHandle::default(), + schema: None, + table: "users".to_owned(), + mode: StructureMode::Edit, + session: fake.session.clone(), + tasks: runtime.tasks(), + first_load: crate::ui::tab_load::FirstLoad::Now, + }); + wait_until(WAIT, || { + tab.imp().inner_stack.visible_child_name().as_deref() == Some("editor") + }) + .expect("the structure"); + let saves = std::rc::Rc::new(std::cell::Cell::new(0)); + let counter = saves.clone(); + tab.connect_save_requested(move |_| counter.set(counter.get() + 1)); + let ctrl_s = shortcuts(&owner_controller(&tab, ShortcutOwner::StructureTab).expect("the tab's keys")) + .into_iter() + .find(|shortcut| { + shortcut + .trigger() + .is_some_and(|trigger| trigger_keys(&trigger).contains(&keystroke("s"))) + }) + .and_then(|shortcut| shortcut.action()) + .expect("Ctrl+S is bound"); + let press = || ctrl_s.activate(gtk::ShortcutActionFlags::empty(), &tab, None); + + assert!(!press(), "Ctrl+S was taken with nothing to save"); + tab.send(ColumnsInput::ColumnEdited { + column: column_object(&tab, 0), + field: ColumnField::Default("42".to_owned()), + }); + assert!(press()); + + assert_eq!(saves.get(), 1); + drop(tab); + drain_main_context(); + runtime.shutdown(); +} + +#[gtk4::test] +fn icon_buttons_are_labelled_without_accelerator_hints() { + let table = TableDefinition { + table: TableRef::unqualified("users"), + columns: vec![integer_column("id", true), integer_column("team_id", false)], + primary_key: Some(PrimaryKeyConstraint { + name: Some("users_pkey".to_owned()), + columns: vec!["id".to_owned()], + }), + indexes: vec![tablepro_core::IndexInfo { + name: "users_team_idx".to_owned(), + keys: vec![tablepro_core::meta::IndexKey::Column { + name: "team_id".to_owned(), + descending: false, + }], + unique: false, + primary: false, + predicate: None, + }], + foreign_keys: vec![tablepro_core::ForeignKeyInfo { + name: "users_team_fk".to_owned(), + columns: vec!["team_id".to_owned()], + ref_schema: None, + ref_table: "teams".to_owned(), + ref_columns: vec!["id".to_owned()], + on_delete: None, + on_update: None, + }], + inbound_foreign_keys: Vec::new(), + source: None, + }; + let mut script = postgres_script(); + script.fetch_table_definition.always(FakeReply::ok(table)); + let harness = Harness::open(StructureMode::Edit, script, []); + wait_until(WAIT, || harness.page().as_deref() == Some("editor")).expect("the structure"); + drain_main_context(); + let tab = harness.tab(); + for row in descendants(tab) + .into_iter() + .filter_map(|widget| widget.downcast::().ok()) + { + row.set_expanded(true); + } + let removes = descendants(tab) + .into_iter() + .filter(|widget| ["col-remove", "index-remove", "fk-remove"].contains(&widget.widget_name().as_str())) + .count(); + let suggestions = descendants(tab) + .into_iter() + .filter(|widget| widget.is::()) + .count(); + + assert_eq!(removes, 4, "a column, index or foreign key row is missing"); + assert!(suggestions > 0, "no column offers its suggested types"); + assert_eq!(crate::test_support::unlabelled_icon_buttons(tab), Vec::::new()); + assert_eq!(crate::test_support::tooltips_with_keys(tab), Vec::::new()); +} diff --git a/linux/crates/app/src/ui/style_scheme_grid.rs b/linux/crates/app/src/ui/style_scheme_grid.rs new file mode 100644 index 0000000000..e40fada31f --- /dev/null +++ b/linux/crates/app/src/ui/style_scheme_grid.rs @@ -0,0 +1,48 @@ +use std::rc::Rc; + +use gtk4::prelude::*; +use tablepro_storage::AppSettings; +use tablepro_storage::settings::keys; + +/// One preview per installed scheme. `StyleSchemePreview` is a toggle, +/// so the group behaves like a radio set: picking one writes the id and +/// clears the rest. +pub(crate) fn populate(container: >k4::FlowBox, settings: &Rc) { + let manager = sourceview5::StyleSchemeManager::default(); + let selected = settings.style_scheme(); + let previews: Rc> = Rc::new( + manager + .scheme_ids() + .iter() + .filter_map(|id| manager.scheme(id)) + .map(|scheme| sourceview5::StyleSchemePreview::new(&scheme)) + .collect(), + ); + + for preview in previews.iter() { + preview.set_selected(preview.scheme().id() == selected); + let settings = settings.clone(); + let previews = previews.clone(); + preview.connect_activate(move |activated| { + let id = activated.scheme().id(); + if let Err(error) = settings.set_style_scheme(&id) { + tracing::warn!(%error, "could not save the editor style scheme"); + return; + } + for preview in previews.iter() { + preview.set_selected(preview.scheme().id() == id); + } + }); + container.append(preview); + } + + let previews_for_settings = previews.clone(); + settings + .gio() + .connect_changed(Some(keys::STYLE_SCHEME), move |settings, key| { + let id = settings.string(key); + for preview in previews_for_settings.iter() { + preview.set_selected(preview.scheme().id() == id); + } + }); +} diff --git a/linux/crates/app/src/ui/style_scheme_resolver.rs b/linux/crates/app/src/ui/style_scheme_resolver.rs new file mode 100644 index 0000000000..ba6629b067 --- /dev/null +++ b/linux/crates/app/src/ui/style_scheme_resolver.rs @@ -0,0 +1,57 @@ +use gtk4::prelude::*; +use sourceview5::prelude::*; + +const LIGHT_FALLBACK: &str = "Adwaita"; +const DARK_FALLBACK: &str = "Adwaita-dark"; + +/// GtkSourceView ships light and dark schemes as separate ids linked by +/// `dark-variant` and `light-variant` metadata. The user picks one id; +/// this follows the link so the editor tracks the desktop theme instead +/// of staying light on a dark desktop. +pub(crate) fn resolve_id(manager: &sourceview5::StyleSchemeManager, preferred: &str, dark: bool) -> String { + let fallback = if dark { DARK_FALLBACK } else { LIGHT_FALLBACK }; + let Some(scheme) = manager.scheme(preferred) else { + return fallback.to_owned(); + }; + let key = if dark { "dark-variant" } else { "light-variant" }; + match scheme.metadata(key) { + Some(variant) if manager.scheme(&variant).is_some() => variant.into(), + _ => preferred.to_owned(), + } +} + +pub(crate) fn apply(view: &sourceview5::View, preferred: &str) { + let manager = sourceview5::StyleSchemeManager::default(); + let dark = libadwaita::StyleManager::default().is_dark(); + let id = resolve_id(&manager, preferred, dark); + let Some(scheme) = manager.scheme(&id) else { + return; + }; + if let Ok(buffer) = view.buffer().downcast::() { + buffer.set_style_scheme(Some(&scheme)); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[gtk4::test] + fn resolve_dark_variant() { + let manager = sourceview5::StyleSchemeManager::default(); + + let dark = resolve_id(&manager, LIGHT_FALLBACK, true); + let light = resolve_id(&manager, LIGHT_FALLBACK, false); + + assert_eq!(dark, DARK_FALLBACK); + assert_eq!(light, LIGHT_FALLBACK); + } + + #[gtk4::test] + fn unknown_scheme_falls_back_to_the_adwaita_pair() { + let manager = sourceview5::StyleSchemeManager::default(); + + assert_eq!(resolve_id(&manager, "not-a-scheme", true), DARK_FALLBACK); + assert_eq!(resolve_id(&manager, "not-a-scheme", false), LIGHT_FALLBACK); + } +} diff --git a/linux/crates/app/src/ui/tab_load/content_load.rs b/linux/crates/app/src/ui/tab_load/content_load.rs new file mode 100644 index 0000000000..29713b07c6 --- /dev/null +++ b/linux/crates/app/src/ui/tab_load/content_load.rs @@ -0,0 +1,227 @@ +use super::{ContentPage, LoadFailure, LoadPresentation}; + +/// Where a tab's read of its content stands. +/// +/// Content on screen stays there while it is read again, and stays when +/// that read fails, under a banner until a read lands. Only a tab with +/// nothing to show shows the spinner or the error page. +#[derive(Debug, Clone, Default)] +pub(crate) enum ContentLoad { + #[default] + Initial, + Loading, + Loaded, + /// Reading the content on screen again. `stale` is the failure the + /// banner keeps showing until this read lands. + Refreshing { + stale: Option, + }, + /// Reading other content than the one on screen, which stays until + /// the new content lands but is not kept if the read fails: it is no + /// longer what the tab was asked for. + Replacing, + Stale(LoadFailure), + Failed(LoadFailure), +} + +impl ContentLoad { + /// A read of the content on screen, or of the first content. + pub(crate) fn begin(&mut self) -> LoadPresentation { + *self = match std::mem::take(self) { + Self::Initial | Self::Loading | Self::Failed(_) => Self::Loading, + Self::Loaded => Self::Refreshing { stale: None }, + Self::Stale(failure) => Self::Refreshing { stale: Some(failure) }, + running @ (Self::Refreshing { .. } | Self::Replacing) => running, + }; + self.presentation() + } + + /// A read of other content than the one on screen: another page, or + /// the same table under another filter. + pub(crate) fn begin_replacing(&mut self) -> LoadPresentation { + *self = match std::mem::take(self) { + Self::Initial | Self::Loading | Self::Failed(_) => Self::Loading, + Self::Loaded | Self::Stale(_) | Self::Refreshing { .. } | Self::Replacing => Self::Replacing, + }; + self.presentation() + } + + pub(crate) fn succeed(&mut self) -> LoadPresentation { + *self = Self::Loaded; + self.presentation() + } + + pub(crate) fn fail(&mut self, failure: LoadFailure) -> LoadPresentation { + *self = match std::mem::take(self) { + Self::Loaded | Self::Stale(_) | Self::Refreshing { .. } => Self::Stale(failure), + Self::Initial | Self::Loading | Self::Replacing | Self::Failed(_) => Self::Failed(failure), + }; + self.presentation() + } + + /// The read stopped before it asked for anything, so what was on + /// screen before it stands as it was. + pub(crate) fn abandon(&mut self) -> LoadPresentation { + *self = match std::mem::take(self) { + Self::Loading => Self::Initial, + Self::Refreshing { stale: None } | Self::Replacing => Self::Loaded, + Self::Refreshing { stale: Some(failure) } => Self::Stale(failure), + settled => settled, + }; + self.presentation() + } + + /// The content on screen no longer fits what the tab reads, so the + /// read running goes on with nothing shown. + pub(crate) fn drop_content(&mut self) -> LoadPresentation { + *self = match std::mem::take(self) { + Self::Loading | Self::Refreshing { .. } | Self::Replacing => Self::Loading, + Self::Loaded | Self::Stale(_) => Self::Initial, + settled => settled, + }; + self.presentation() + } + + pub(crate) fn has_content(&self) -> bool { + matches!( + self, + Self::Loaded | Self::Refreshing { .. } | Self::Replacing | Self::Stale(_) + ) + } + + pub(crate) fn is_running(&self) -> bool { + matches!(self, Self::Loading | Self::Refreshing { .. } | Self::Replacing) + } + + pub(crate) fn presentation(&self) -> LoadPresentation { + let (page, stale) = match self { + Self::Initial | Self::Loading => (ContentPage::Spinner, None), + Self::Loaded | Self::Replacing => (ContentPage::Content, None), + Self::Refreshing { stale } => (ContentPage::Content, stale.clone()), + Self::Stale(failure) => (ContentPage::Content, Some(failure.clone())), + Self::Failed(failure) => (ContentPage::Error(failure.clone()), None), + }; + LoadPresentation { + page, + tab_loading: self.is_running(), + stale, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[derive(Debug, PartialEq)] + enum Shown { + Spinner, + Content, + Error, + } + + /// The page, whether the tab spins, and whether the banner shows. + fn shown(presentation: &LoadPresentation) -> (Shown, bool, bool) { + let page = match presentation.page { + ContentPage::Spinner => Shown::Spinner, + ContentPage::Content => Shown::Content, + ContentPage::Error(_) => Shown::Error, + }; + (page, presentation.tab_loading, presentation.stale.is_some()) + } + + type Step = fn(&mut ContentLoad) -> LoadPresentation; + /// The steps from a new tab, and the page, spinner and banner after. + type Case = (&'static [Step], (Shown, bool, bool)); + + fn after(steps: &[Step]) -> (ContentLoad, LoadPresentation) { + let mut load = ContentLoad::default(); + let mut last = load.presentation(); + for step in steps { + last = step(&mut load); + } + (load, last) + } + + fn begin(load: &mut ContentLoad) -> LoadPresentation { + load.begin() + } + + fn replace(load: &mut ContentLoad) -> LoadPresentation { + load.begin_replacing() + } + + fn succeed(load: &mut ContentLoad) -> LoadPresentation { + load.succeed() + } + + fn failure() -> LoadFailure { + LoadFailure::Page(std::sync::Arc::new(tablepro_session::OperationError::Closed)) + } + + fn fail(load: &mut ContentLoad) -> LoadPresentation { + load.fail(failure()) + } + + fn abandon(load: &mut ContentLoad) -> LoadPresentation { + load.abandon() + } + + fn drop_content(load: &mut ContentLoad) -> LoadPresentation { + load.drop_content() + } + + #[test] + fn content_load_transition_table() { + let table: &[Case] = &[ + (&[], (Shown::Spinner, false, false)), + (&[begin], (Shown::Spinner, true, false)), + (&[begin, fail, begin], (Shown::Spinner, true, false)), + (&[begin, succeed, begin], (Shown::Content, true, false)), + (&[begin, succeed, begin, fail, begin], (Shown::Content, true, true)), + (&[begin, fail], (Shown::Error, false, false)), + (&[begin, succeed, begin, fail], (Shown::Content, false, true)), + (&[begin, succeed], (Shown::Content, false, false)), + ( + &[begin, succeed, begin, fail, begin, succeed], + (Shown::Content, false, false), + ), + (&[begin, fail, begin, succeed], (Shown::Content, false, false)), + (&[begin, succeed, replace], (Shown::Content, true, false)), + (&[begin, succeed, replace, fail], (Shown::Error, false, false)), + (&[begin, succeed, begin, fail, replace], (Shown::Content, true, false)), + (&[begin, succeed, begin, abandon], (Shown::Content, false, false)), + ( + &[begin, succeed, begin, fail, begin, abandon], + (Shown::Content, false, true), + ), + (&[begin, abandon], (Shown::Spinner, false, false)), + (&[begin, succeed, begin, drop_content], (Shown::Spinner, true, false)), + ( + &[begin, succeed, begin, drop_content, fail], + (Shown::Error, false, false), + ), + ]; + for (steps, expected) in table { + let (_, presentation) = after(steps); + assert_eq!(&shown(&presentation), expected, "after {} steps", steps.len()); + } + } + + #[test] + fn a_refresh_that_fails_again_shows_the_newest_failure() { + let mut load = ContentLoad::default(); + load.begin(); + load.succeed(); + load.begin(); + load.fail(failure()); + load.begin(); + + let presentation = load.fail(LoadFailure::Columns(std::sync::Arc::new( + tablepro_session::OperationError::Cancelled, + ))); + + assert!(matches!(presentation.stale, Some(LoadFailure::Columns(_)))); + assert!(load.has_content()); + } +} diff --git a/linux/crates/app/src/ui/tab_load/first_load.rs b/linux/crates/app/src/ui/tab_load/first_load.rs new file mode 100644 index 0000000000..53c2dbd0f8 --- /dev/null +++ b/linux/crates/app/src/ui/tab_load/first_load.rs @@ -0,0 +1,127 @@ +use std::cell::RefCell; +use std::rc::Rc; + +use gtk4 as gtk; +use gtk4::glib; +use gtk4::prelude::*; + +/// When a tab makes its first read. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum FirstLoad { + #[default] + Now, + /// The first time the tab is on screen. A restored session reopens + /// every tab together, and reading them all at once would queue more + /// reads than the pool has connections. + WhenShown, +} + +/// Run `start` now, or for `WhenShown` once `widget` stays mapped past +/// the main loop's turn that mapped it. A restore selects the first tab +/// it adds before the one that was active, and that first tab is on +/// screen for no time at all. +pub(crate) fn start_first_load(widget: &impl IsA, load: FirstLoad, start: impl FnOnce() + 'static) { + if load == FirstLoad::Now { + start(); + return; + } + let pending = PendingStart { + start: Rc::new(RefCell::new(Some(Box::new(start)))), + handler: Rc::default(), + }; + let widget = widget.as_ref(); + let id = widget.connect_map({ + let pending = pending.clone(); + move |widget| pending.start_if_it_stays(widget) + }); + pending.handler.replace(Some(id)); + if widget.is_mapped() { + pending.start_if_it_stays(widget); + } +} + +/// The first read, taken by whichever showing starts it. +type StartOnce = Rc>>>; + +/// A first read waiting for its tab to stay on screen. +#[derive(Clone)] +struct PendingStart { + start: StartOnce, + handler: Rc>>, +} + +impl PendingStart { + fn start_if_it_stays(&self, widget: >k::Widget) { + let widget = widget.downgrade(); + let pending = self.clone(); + glib::idle_add_local_once(move || { + let Some(widget) = widget.upgrade().filter(gtk::Widget::is_mapped) else { + return; + }; + if let Some(id) = pending.handler.borrow_mut().take() { + widget.disconnect(id); + } + let start = pending.start.borrow_mut().take(); + if let Some(start) = start { + start(); + } + }); + } +} + +#[cfg(test)] +mod tests { + use std::cell::Cell; + use std::time::Duration; + + use super::*; + use crate::test_support::{OwnedWindow, drain_main_context, wait_until}; + + fn counting(label: >k::Label, load: FirstLoad) -> Rc> { + let starts = Rc::new(Cell::new(0)); + start_first_load(label, load, { + let starts = starts.clone(); + move || starts.set(starts.get() + 1) + }); + starts + } + + #[gtk4::test] + fn a_tab_loaded_when_shown_starts_once_it_stays_on_screen() { + let label = gtk::Label::new(None); + let starts = counting(&label, FirstLoad::WhenShown); + assert_eq!(starts.get(), 0, "a tab not on screen read its table"); + + let window = OwnedWindow::presented(gtk::Window::new()); + window.set_child(Some(&label)); + wait_until(Duration::from_secs(5), || starts.get() == 1).expect("the read once the tab is shown"); + label.set_visible(false); + label.set_visible(true); + drain_main_context(); + assert_eq!(starts.get(), 1, "a second map read again"); + window.set_child(None::<>k::Widget>); + } + + #[gtk4::test] + fn a_tab_shown_for_no_time_waits_for_its_next_showing() { + let label = gtk::Label::new(None); + let starts = counting(&label, FirstLoad::WhenShown); + let window = OwnedWindow::presented(gtk::Window::new()); + wait_until(Duration::from_secs(5), || window.is_mapped()).expect("the window on screen"); + + window.set_child(Some(&label)); + label.set_visible(false); + drain_main_context(); + assert_eq!(starts.get(), 0, "a tab on screen for no time read its table"); + + label.set_visible(true); + wait_until(Duration::from_secs(5), || starts.get() == 1).expect("the read once the tab stays"); + window.set_child(None::<>k::Widget>); + } + + #[gtk4::test] + fn a_tab_loaded_now_starts_at_once() { + let starts = counting(>k::Label::new(None), FirstLoad::Now); + assert_eq!(starts.get(), 1); + } +} diff --git a/linux/crates/app/src/ui/tab_load/load_failure.rs b/linux/crates/app/src/ui/tab_load/load_failure.rs new file mode 100644 index 0000000000..17f743ccad --- /dev/null +++ b/linux/crates/app/src/ui/tab_load/load_failure.rs @@ -0,0 +1,88 @@ +use std::sync::Arc; + +use tablepro_core::ErrorCategory; +use tablepro_session::OperationError; + +/// Why a tab's read showed nothing new. +#[derive(Debug, Clone)] +pub(crate) enum LoadFailure { + /// The table's columns, or what tells its rows apart. + Columns(Arc), + /// The catalog lists no column the account can read. + NoColumns, + /// The page came back with other columns than the ones read for it, + /// and again after they were read again. + ColumnsChanged, + Page(Arc), + /// The table's definition, for the structure tab. + Definition(Arc), +} + +impl LoadFailure { + /// Plain text: a server's message can hold `<` and `&`, so whatever + /// shows it must not read it as markup. + pub(crate) fn message(&self) -> String { + match self { + Self::Columns(error) | Self::Page(error) | Self::Definition(error) => { + crate::ui::error_text::operation_error_message(error) + } + Self::NoColumns => crate::i18n::gettext( + "This table has no columns you can read. It may have been dropped, or your account can't read it.", + ), + Self::ColumnsChanged => crate::i18n::gettext( + "The table's columns changed while its rows were read. Try again to read it as it is now.", + ), + } + } + + /// What kind of problem it was, for the log, which never takes the + /// server's text. + pub(crate) fn category(&self) -> Option { + match self { + Self::Columns(error) | Self::Page(error) | Self::Definition(error) => error.category(), + Self::NoColumns | Self::ColumnsChanged => None, + } + } +} + +#[cfg(test)] +mod tests { + use tablepro_core::{DriverError, LossPhase, TimeoutPhase}; + + use super::*; + + #[test] + fn load_failure_message_uses_error_category() { + let lost = LoadFailure::Page(Arc::new(OperationError::Driver(DriverError::ConnectionLost { + during: LossPhase::Statement, + }))); + let timed_out = LoadFailure::Columns(Arc::new(OperationError::Driver(DriverError::Timeout { + phase: TimeoutPhase::Statement, + server_cancelled: true, + }))); + + assert_eq!(lost.message(), "The connection was closed. Try reconnecting."); + assert_eq!( + timed_out.message(), + "The statement ran past its time limit, so the server stopped it." + ); + } + + #[test] + fn no_columns_failure_message() { + assert_eq!( + LoadFailure::NoColumns.message(), + "This table has no columns you can read. It may have been dropped, or your account can't read it." + ); + assert_eq!(LoadFailure::NoColumns.category(), None); + } + + #[test] + fn a_server_message_stays_plain_text() { + let failure = LoadFailure::Page(Arc::new(OperationError::Driver(DriverError::server( + "relation users & co", + )))); + + assert!(failure.message().contains("users & co"), "{}", failure.message()); + } +} diff --git a/linux/crates/app/src/ui/tab_load/load_presentation.rs b/linux/crates/app/src/ui/tab_load/load_presentation.rs new file mode 100644 index 0000000000..bbd14835f5 --- /dev/null +++ b/linux/crates/app/src/ui/tab_load/load_presentation.rs @@ -0,0 +1,18 @@ +use super::LoadFailure; + +/// What a tab shows for where its read stands. +#[derive(Debug, Clone)] +pub(crate) struct LoadPresentation { + pub page: ContentPage, + /// The tab's page in the tab bar spins while a read runs. + pub tab_loading: bool, + /// The read that failed while the content stayed on screen. + pub stale: Option, +} + +#[derive(Debug, Clone)] +pub(crate) enum ContentPage { + Spinner, + Content, + Error(LoadFailure), +} diff --git a/linux/crates/app/src/ui/tab_load/mod.rs b/linux/crates/app/src/ui/tab_load/mod.rs new file mode 100644 index 0000000000..e2796795e8 --- /dev/null +++ b/linux/crates/app/src/ui/tab_load/mod.rs @@ -0,0 +1,16 @@ +//! How a tab shows what it reads: a spinner until the first read lands, +//! the content from then on, and an error page only while there is +//! nothing to show. A read that fails with content on screen leaves the +//! content there under a banner, so a blip never blanks a tab. + +mod content_load; +mod first_load; +mod load_failure; +mod load_presentation; +mod stale_banner; + +pub(crate) use self::content_load::ContentLoad; +pub(crate) use self::first_load::{FirstLoad, start_first_load}; +pub(crate) use self::load_failure::LoadFailure; +pub(crate) use self::load_presentation::{ContentPage, LoadPresentation}; +pub(crate) use self::stale_banner::StaleBanner; diff --git a/linux/crates/app/src/ui/tab_load/stale_banner.rs b/linux/crates/app/src/ui/tab_load/stale_banner.rs new file mode 100644 index 0000000000..d290c73a8b --- /dev/null +++ b/linux/crates/app/src/ui/tab_load/stale_banner.rs @@ -0,0 +1,41 @@ +use gtk4 as gtk; +use gtk4::prelude::*; +use libadwaita as adw; + +use super::LoadFailure; + +/// The banner over content a tab could not read again. Its title is the +/// tab's own, from its template; the failure goes in the tooltip, so the +/// banner stays one line. +pub(crate) struct StaleBanner { + banner: adw::Banner, +} + +impl StaleBanner { + pub(crate) fn new(banner: adw::Banner, retry: impl Fn() + 'static) -> Self { + banner.set_use_markup(false); + banner.set_button_label(Some(&crate::i18n::gettext("Retry"))); + banner.connect_button_clicked(move |_| retry()); + Self { banner } + } + + /// A new failure is said, with its reason: the focus stays in the + /// content, and the banner's reason shows only on hover. + pub(crate) fn show(&self, stale: Option<&LoadFailure>) { + let message = stale.map(LoadFailure::message); + let news = message.is_some() + && (!self.banner.is_revealed() || self.banner.tooltip_text().as_deref() != message.as_deref()); + self.banner.set_tooltip_text(message.as_deref()); + self.banner + .upcast_ref::() + .update_property(&[gtk::accessible::Property::Description(message.as_deref().unwrap_or(""))]); + self.banner.set_revealed(stale.is_some()); + if let Some(message) = message.filter(|_| news) { + crate::ui::a11y::announce( + &self.banner, + &format!("{} {message}", self.banner.title()), + gtk::AccessibleAnnouncementPriority::High, + ); + } + } +} diff --git a/linux/crates/app/src/ui/welcome_view.rs b/linux/crates/app/src/ui/welcome_view.rs new file mode 100644 index 0000000000..26b04bee0a --- /dev/null +++ b/linux/crates/app/src/ui/welcome_view.rs @@ -0,0 +1,278 @@ +//! The picker's saved connections: a preferences page bound to the list +//! every window shares, with a group for the connections in no group +//! and one for each group the user made. + +use std::cell::{Cell, OnceCell, RefCell}; +use std::rc::Rc; + +use gtk4 as gtk; +use gtk4::glib; +use gtk4::prelude::*; +use gtk4::subclass::prelude::*; +use libadwaita as adw; +use libadwaita::prelude::*; +use tablepro_session::runtime::Tasks; +use tablepro_storage::{ConnectionGroupName, ConnectionListState}; +use uuid::Uuid; + +use crate::services::saved_connection_list::{SavedConnectionList, StateObserverId}; +use crate::services::saved_connection_object::SavedConnectionObject; +use crate::ui::picker::connection_row; + +mod imp { + use super::*; + use libadwaita::subclass::prelude::*; + + #[derive(Default, gtk::CompositeTemplate)] + #[template(resource = "/app/tablepro/TablePro/ui/welcome-view.ui")] + pub struct WelcomeView { + #[template_child] + pub stack: TemplateChild, + #[template_child] + pub page: TemplateChild, + #[template_child] + pub ungrouped: TemplateChild, + pub(super) parts: OnceCell, + } + + #[glib::object_subclass] + impl ObjectSubclass for WelcomeView { + const NAME: &'static str = "TableProWelcomeView"; + type Type = super::WelcomeView; + type ParentType = adw::Bin; + + fn class_init(klass: &mut Self::Class) { + klass.bind_template(); + } + + fn instance_init(object: &glib::subclass::InitializingObject) { + object.init_template(); + } + } + + impl ObjectImpl for WelcomeView { + fn dispose(&self) { + self.obj().shutdown(); + } + } + + impl WidgetImpl for WelcomeView {} + impl BinImpl for WelcomeView {} +} + +/// The row under `root` whose activation connects to `target`. +fn row_for(root: >k::Widget, target: &str) -> Option { + let mut child = root.first_child(); + while let Some(widget) = child { + if let Some(row) = widget.downcast_ref::() + && row + .action_target_value() + .and_then(|value| value.get::()) + .is_some_and(|value| value == target) + { + return Some(row.clone()); + } + if let Some(row) = row_for(&widget, target) { + return Some(row); + } + child = widget.next_sibling(); + } + None +} + +glib::wrapper! { + pub struct WelcomeView(ObjectSubclass) + @extends adw::Bin, gtk::Widget, + @implements gtk::Accessible, gtk::Buildable, gtk::ConstraintTarget; +} + +pub(super) struct Parts { + list: Rc, + tasks: Option, + /// The groups after the ungrouped one, by name, in list order. + groups: RefCell>, + /// A connection whose row takes the focus once the list shows it. + focus: Cell>, + rows_changed: RefCell>, + state_changed: RefCell>, +} + +impl WelcomeView { + /// A view of `list`. `tasks` reads the file a portal path stands for. + pub(crate) fn new(list: Rc, tasks: Option) -> Self { + let view: Self = glib::Object::new(); + let imp = view.imp(); + let _ = imp.parts.set(Parts { + list: list.clone(), + tasks, + groups: RefCell::new(Vec::new()), + focus: Cell::new(None), + rows_changed: RefCell::new(None), + state_changed: RefCell::new(None), + }); + let ungrouped = view.filtered(None); + let group = imp.ungrouped.get(); + group.set_visible(ungrouped.n_items() > 0); + let shown = group.downgrade(); + ungrouped.connect_items_changed(move |model, _, _, _| { + if let Some(group) = shown.upgrade() { + group.set_visible(model.n_items() > 0); + } + }); + view.bind(&group, &ungrouped); + let weak = view.downgrade(); + let rows_changed = list.model().connect_items_changed(move |_, _, _, _| { + if let Some(view) = weak.upgrade() { + view.refresh(); + view.take_focus(); + } + }); + let weak = view.downgrade(); + let state_changed = list.connect_state_changed(move |_| { + if let Some(view) = weak.upgrade() { + view.refresh_page(); + } + }); + if let Some(parts) = imp.parts.get() { + parts.rows_changed.replace(Some(rows_changed)); + parts.state_changed.replace(Some(state_changed)); + } + view.refresh(); + view + } + + /// Stop following the list, which outlives every window. + pub(crate) fn shutdown(&self) { + let Some(parts) = self.imp().parts.get() else { + return; + }; + if let Some(handler) = parts.rows_changed.take() { + parts.list.model().disconnect(handler); + } + if let Some(observer) = parts.state_changed.take() { + parts.list.disconnect_state_changed(observer); + } + } + + /// The rows of the connections in `group`, or in none. + fn filtered(&self, group: Option<&str>) -> gtk::FilterListModel { + let list = self.imp().parts.get().map(|parts| parts.list.model()); + let group = group.map(str::to_owned); + let filter = gtk::CustomFilter::new(move |item| { + item.downcast_ref::() + .is_some_and(|object| object.group() == group) + }); + gtk::FilterListModel::new(list, Some(filter)) + } + + fn bind(&self, group: &adw::PreferencesGroup, rows: >k::FilterListModel) { + let Some(parts) = self.imp().parts.get() else { + return; + }; + let (list, tasks) = (parts.list.clone(), parts.tasks.clone()); + group.bind_model(Some(rows), move |object| { + connection_row(object, &list.groups(), tasks.as_ref()) + }); + } + + /// Put the focus on the row of the connection `id`, now or once the + /// list shows it: a copy reaches the list after the write that + /// made it. + pub(crate) fn focus_connection(&self, id: Uuid) { + let Some(parts) = self.imp().parts.get() else { + return; + }; + parts.focus.set(Some(id)); + self.take_focus(); + } + + fn take_focus(&self) { + let Some(parts) = self.imp().parts.get() else { + return; + }; + let Some(id) = parts.focus.get() else { + return; + }; + if let Some(row) = row_for(self.imp().page.upcast_ref(), &id.to_string()) { + parts.focus.set(None); + row.grab_focus(); + } + } + + fn refresh(&self) { + self.refresh_groups(); + self.refresh_page(); + } + + /// A group of its own for each group the list has, after the + /// connections in none, rebuilt only when the set of groups changes. + fn refresh_groups(&self) { + let imp = self.imp(); + let Some(parts) = imp.parts.get() else { + return; + }; + let names = parts.list.groups(); + // Next to named groups, "Saved Connections" would read as all of them. + imp.ungrouped.set_title(&if names.is_empty() { + crate::i18n::gettext("Saved Connections") + } else { + crate::i18n::gettext("Connections") + }); + let unchanged = parts.groups.borrow().iter().map(|(name, _)| name).eq(names.iter()); + if unchanged { + return; + } + for (_, group) in parts.groups.take() { + imp.page.remove(&group); + } + let mut built = Vec::with_capacity(names.len()); + for name in names { + // A group's title is markup, and the name is the user's text. + let group = adw::PreferencesGroup::builder() + .title(glib::markup_escape_text(name.as_str()).as_str()) + .build(); + self.bind(&group, &self.filtered(Some(name.as_str()))); + imp.page.add(&group); + built.push((name, group)); + } + parts.groups.replace(built); + } + + /// The empty page only once the list is read and holds nothing. A + /// list still loading, or one that cannot be read, shows no promise + /// to add to it; the banner says why. + fn refresh_page(&self) { + let imp = self.imp(); + let Some(parts) = imp.parts.get() else { + return; + }; + let empty = parts.list.model().n_items() == 0 && matches!(parts.list.state(), ConnectionListState::Ready(_)); + imp.stack.set_visible_child_name(if empty { "empty" } else { "list" }); + } + + #[cfg(test)] + pub(crate) fn sections(&self) -> Vec<(String, Vec)> { + let imp = self.imp(); + let mut groups = vec![imp.ungrouped.get()]; + if let Some(parts) = imp.parts.get() { + groups.extend(parts.groups.borrow().iter().map(|(_, group)| group.clone())); + } + groups + .into_iter() + .filter(|group| group.is_visible()) + .map(|group| { + let rows = crate::test_support::descendants(&group) + .into_iter() + .filter_map(|widget| widget.downcast::().ok()) + .map(|row| row.title().to_string()) + .collect(); + (group.title().to_string(), rows) + }) + .collect() + } + + #[cfg(test)] + pub(crate) fn visible_page(&self) -> Option { + self.imp().stack.visible_child_name().map(|name| name.to_string()) + } +} diff --git a/linux/crates/app/src/ui/window_geometry.rs b/linux/crates/app/src/ui/window_geometry.rs new file mode 100644 index 0000000000..139553261e --- /dev/null +++ b/linux/crates/app/src/ui/window_geometry.rs @@ -0,0 +1,76 @@ +use gtk4::prelude::*; +use tablepro_storage::{AppSettings, WindowGeometry}; + +pub(crate) fn restore(window: &libadwaita::ApplicationWindow, settings: &AppSettings) { + let geometry = settings.window_geometry(); + window.set_default_size(geometry.width, geometry.height); + if geometry.maximized { + window.maximize(); + } +} + +/// A maximized window reports the screen size, which would come back as +/// the restored size the next time it opens unmaximized. GTK keeps the +/// pre-maximize size in the default-width and default-height properties, +/// so read those instead. A window that was never allocated reports a +/// zero size, which the schema range refuses, so the default size stands +/// in for it. +pub(crate) fn persist(window: &libadwaita::ApplicationWindow, settings: &AppSettings) { + let maximized = window.is_maximized(); + let allocated = (window.width(), window.height()); + let (width, height) = if maximized || allocated.0 <= 0 || allocated.1 <= 0 { + (window.default_width(), window.default_height()) + } else { + allocated + }; + if let Err(error) = settings.set_window_geometry(WindowGeometry { + width, + height, + maximized, + }) { + tracing::warn!(%error, "could not save the window geometry"); + } +} + +#[cfg(test)] +mod tests { + use tablepro_storage::WindowGeometry; + + use crate::test_support::{MemorySettings, OwnedWindow}; + + use super::*; + + #[gtk4::test] + fn restore_applies_the_stored_size() { + let settings = MemorySettings::new(); + settings + .get() + .set_window_geometry(WindowGeometry { + width: 1024, + height: 640, + maximized: false, + }) + .expect("store the geometry"); + let window = OwnedWindow::new(libadwaita::ApplicationWindow::builder().build()); + + restore(&window, settings.get()); + + assert_eq!(window.default_width(), 1024); + assert_eq!(window.default_height(), 640); + assert!(!window.is_maximized()); + } + + #[gtk4::test] + fn persist_round_trips_through_settings() { + let settings = MemorySettings::new(); + let window = OwnedWindow::new(libadwaita::ApplicationWindow::builder().build()); + window.set_default_size(1440, 900); + + persist(&window, settings.get()); + + let stored = settings.get().window_geometry(); + assert_eq!(stored.width, 1440); + assert_eq!(stored.height, 900); + assert!(!stored.maximized); + } +} diff --git a/linux/crates/app/src/ui/workspace/close_flow.rs b/linux/crates/app/src/ui/workspace/close_flow.rs new file mode 100644 index 0000000000..7b7c7f4576 --- /dev/null +++ b/linux/crates/app/src/ui/workspace/close_flow.rs @@ -0,0 +1,272 @@ +use std::collections::HashSet; +use std::sync::Arc; + +use gtk4 as gtk; +use gtk4::glib; +use libadwaita as adw; +use libadwaita::prelude::*; +use tablepro_core::EditorSession; +use uuid::Uuid; + +use super::unsaved_icon::apply_unsaved_icon; +use super::workspace_persistence::WorkspaceTabs; +use super::{WorkspaceInput, WorkspaceModel, WorkspaceTab}; +use crate::runtime::DeliverInput; +use crate::ui::browse_tab::BrowseTab; +use crate::workspace::CloseDecision; + +/// Every editor tab's connection, taken from its tab. Each tab's run is +/// stopped first, so nothing is still writing to the session it gives +/// up. +fn take_editor_sessions(tabs: &WorkspaceTabs) -> Vec> { + tabs.borrow() + .values() + .filter_map(|tab| match tab { + WorkspaceTab::Editor(slot) => slot.editor.take_editor_session(), + WorkspaceTab::Structure(_) | WorkspaceTab::Table(_) => None, + }) + .collect() +} + +impl WorkspaceModel { + /// Write what the workspace shows as it is now: the last column drag + /// and filter change, the editor text and the tab list. The process + /// is held until every write lands, so quitting right after a + /// disconnect does not lose them. + pub(super) fn flush(&self) { + let hold = self.context.hold.acquire(); + let column_widths = self.storage.column_widths().flush(); + let filter_settings = self.storage.filter_settings().flush(); + // The key is free once the window lets go of it, which is before + // these land; a delete of the connection waits for them. + let drafts_landing = self.storage.workspaces().writes_in_flight(&self.key); + let editor_drafts = self.drafts.flush_all(); + let writer = self.drafts.clone(); + let tabs = self.workspace.detach(); + glib::spawn_future_local(async move { + column_widths.await; + filter_settings.await; + for failure in editor_drafts.await { + tracing::warn!(failure, "an editor draft was not saved"); + } + drop(drafts_landing); + writer.release_closed(); + if let Some(tabs) = tabs + && let Err(error) = tabs.await + { + tracing::debug!(%error, "the open tabs were not saved"); + } + drop(hold); + }); + } + + pub(super) fn take_editor_sessions(&self) -> Vec> { + take_editor_sessions(&self.workspace_tabs) + } + + /// The table tabs, or only `id` when it is one. + pub(super) fn browse_tabs(&self, id: Option) -> Vec { + self.workspace_tabs + .borrow() + .iter() + .filter(|(tab, _)| id.is_none_or(|id| id == **tab)) + .filter_map(|(_, tab)| match tab { + WorkspaceTab::Table(slot) => Some(slot.browse.clone()), + WorkspaceTab::Editor(_) | WorkspaceTab::Structure(_) => None, + }) + .collect() + } + + /// A tab asked to close. One with nothing to lose closes now; any + /// other waits for the prompts. A value typed into a cell is an + /// unsaved change by then, as it is once the focus leaves the cell. + pub(super) fn close_workspace_tab_by_id(&mut self, id: Uuid) { + for browse in self.browse_tabs(Some(id)) { + browse.commit_cell_edit(); + } + let tab_view = self.tab_view.clone(); + if self.close_guards.may_close_tab(&self.workspace_handle, id) { + self.finish_close_workspace_tab(id, &tab_view); + return; + } + let Some(parent) = self.dialog_parent() else { + return; + }; + let input = self.input.clone(); + self.close_guards + .ask_close_tab(&parent, &self.workspace_handle, id, move |decision| { + input.deliver(WorkspaceInput::TabCloseDecided { + id, + approved: decision == CloseDecision::Approved, + }); + }); + } + + /// Take the keyboard focus out of `page` before it closes. The page + /// the view selects next is selected first, so the view hands it the + /// focus while `page` is still whole. GTK 4.22 leaks a focus that is + /// hidden and then unparented in one go, which the close does when + /// that page takes no focus. + pub(super) fn move_focus_off(&self, page: &adw::TabPage) { + let Some(window) = self.window() else { + return; + }; + let child = page.child(); + let holds_focus = || { + gtk::prelude::GtkWindowExt::focus(&window).is_some_and(|focus| focus == child || focus.is_ancestor(&child)) + }; + if !holds_focus() { + return; + } + // The documented choice for a page with no parent, and this + // workspace never gives one. The last page leaves the focus to + // the empty page the stack shows next. + if page.is_selected() && !self.tab_view.select_next_page() && !self.tab_view.select_previous_page() { + return; + } + // Where GTK moves a focus left in a removed widget, from the + // window's first control. + if holds_focus() { + gtk::prelude::GtkWindowExt::set_focus(&window, None::<>k::Widget>); + window.child_focus(gtk::DirectionType::TabForward); + } + } + + pub(super) fn on_tab_close_decided(&mut self, id: Uuid, approved: bool) { + let tab_view = self.tab_view.clone(); + if approved { + self.finish_close_workspace_tab(id, &tab_view); + return; + } + let page = self.workspace_tabs.borrow().get(&id).map(|tab| tab.page().clone()); + if let Some(page) = page { + tab_view.close_page_finish(&page, false); + } + } + + /// Mark each tab that closing would lose something in, and tell the + /// window whether a logout would, so it holds the session back. + pub(super) fn refresh_unsaved(&self) { + let dirty_tabs = self.workspace_handle.dirty_tabs(); + let unsaved: HashSet = dirty_tabs.iter().map(|tab| tab.id).collect(); + let marked: Vec = self + .workspace_tabs + .borrow() + .iter() + .filter(|(id, tab)| tab.page().icon().is_some() != unsaved.contains(id)) + .map(|(id, _)| *id) + .collect(); + for (id, tab) in self.workspace_tabs.borrow().iter() { + apply_unsaved_icon(tab.page(), unsaved.contains(id)); + } + // The tooltip carries the mark to a screen reader. + for id in marked { + self.retitle_tab(id); + } + let dirty = dirty_tabs.iter().any(|tab| tab.kind.lost_with_the_window()); + if self.reported_dirty.replace(Some(dirty)) == Some(dirty) { + return; + } + if let Some(workspace) = self.this.upgrade() { + workspace.emit_by_name::<()>("dirty-changed", &[&dirty]); + } + } +} + +#[cfg(test)] +mod tests { + use std::cell::RefCell; + use std::collections::HashMap; + use std::rc::Rc; + use std::time::Duration; + + use libadwaita as adw; + use tablepro_core::TransactionState; + use tablepro_session::runtime::CountingHold; + use tablepro_storage::DraftId; + use tablepro_test_support::{ConnectionScript, FakeEditorSession, FakeReply, FakeRun, Replies}; + + use super::*; + use crate::services::active_session::ActiveSession; + use crate::test_support::{FakeSession, MemorySettings, wait_until}; + use crate::ui::editor::{SqlEditor, SqlEditorInit}; + use crate::ui::workspace::EditorTabSlot; + use crate::ui::workspace::tab_index::TabIndex; + use crate::ui::workspace::window_tabs::WindowTabs; + use crate::workspace::{TabDirectory, TabEntry}; + + const WAIT: Duration = Duration::from_secs(10); + + #[gtk4::test] + fn an_open_transaction_is_named_and_its_session_handed_over_once() { + adw::init().expect("libadwaita initialises under the test backend"); + crate::register_test_resources(); + let runtime = crate::test_support::test_runtime(); + let settings = MemorySettings::new(); + let mut runs = Replies::default(); + runs.always(FakeRun::completed(Vec::new()).leaving(TransactionState::InTransaction)); + let editor_session = Arc::new(FakeEditorSession::new(runs)); + let mut script = ConnectionScript::default(); + let opened: Arc = editor_session.clone(); + script.open_editor_session.always(FakeReply::ok(opened)); + script.probe.always(FakeReply::ok(())); + let fake = FakeSession::start(script, [], runtime.tasks()); + let active = ActiveSession::new(); + active.replace(fake.session.clone()); + let hold = Rc::new(CountingHold::new()); + let editor = SqlEditor::new(SqlEditorInit { + initial_query: Some("BEGIN; UPDATE t SET a = 1".to_owned()), + settings: settings.get().clone(), + history: None, + tasks: runtime.tasks(), + session: active.clone(), + hold: hold.clone(), + close_timeout: Duration::from_secs(5), + }); + editor.run(); + wait_until(WAIT, || !editor.is_running()).expect("the run to end"); + let view = adw::TabView::new(); + let page = view.append(&editor); + page.set_title("Query 1"); + let id = Uuid::new_v4(); + let index = Rc::new(TabIndex::default()); + index.insert(&page, id); + let tabs: WorkspaceTabs = Rc::new(RefCell::new(HashMap::from([( + id, + WorkspaceTab::Editor(EditorTabSlot { + editor: editor.clone(), + page, + draft: DraftId::new(), + ordinal: 1, + label: crate::ui::workspace::editor_label::EditorLabel::Untitled(1), + }), + )]))); + let directory = WindowTabs::new(tabs.clone(), index, &view); + + assert_eq!( + directory.tabs(), + [TabEntry { + id, + title: "Query 1".to_owned(), + open_transaction: true, + unsaved_file: false, + }] + ); + + let taken = take_editor_sessions(&tabs); + editor.shutdown(); + crate::test_support::drain_main_context(); + + assert_eq!(taken.len(), 1, "the tab's session was not handed over"); + assert!(take_editor_sessions(&tabs).is_empty(), "handed over twice"); + assert_eq!( + editor_session.close_count(), + 0, + "the tab closed the session it handed over" + ); + assert_eq!(hold.live(), 0); + drop((taken, tabs)); + crate::test_support::drain_main_context(); + runtime.shutdown(); + } +} diff --git a/linux/crates/app/src/ui/workspace/editor_label.rs b/linux/crates/app/src/ui/workspace/editor_label.rs new file mode 100644 index 0000000000..9b18d05d49 --- /dev/null +++ b/linux/crates/app/src/ui/workspace/editor_label.rs @@ -0,0 +1,35 @@ +use gtk4::gio; +use gtk4::prelude::*; + +/// What an editor tab is named after. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum EditorLabel { + /// No text to name it by: the tab's number among the editor tabs. + Untitled(usize), + /// The start of its script, at most the 200 characters the tab + /// reads, and whether the script goes on past it. + Query { prefix: String, truncated: bool }, + /// The `.sql` file it is saved to: its name, and where it is. + File { name: String, location: String }, +} + +impl EditorLabel { + pub(crate) fn file(file: &gio::File) -> Self { + Self::File { + name: crate::ui::editor::file_name(file), + location: file.parse_name().to_string(), + } + } + + /// A blank script is named by `ordinal`, so a label is never empty. + pub(crate) fn of(ordinal: usize, prefix: &str, truncated: bool) -> Self { + if !truncated && prefix.trim().is_empty() { + Self::Untitled(ordinal) + } else { + Self::Query { + prefix: prefix.to_owned(), + truncated, + } + } + } +} diff --git a/linux/crates/app/src/ui/workspace/finished_work.rs b/linux/crates/app/src/ui/workspace/finished_work.rs new file mode 100644 index 0000000000..3be85c50d7 --- /dev/null +++ b/linux/crates/app/src/ui/workspace/finished_work.rs @@ -0,0 +1,101 @@ +//! What the window hears about the tabs for its notifications: work +//! that ended in one, and a change to which tabs are open or in front. + +use std::collections::HashSet; + +use gtk4::glib; +use gtk4::prelude::*; +use gtk4::subclass::prelude::*; +use uuid::Uuid; + +use super::{Workspace, WorkspaceModel, WorkspaceTab, tab_title}; +use crate::workspace::{CompletedWork, WorkKind, WorkOutcome}; + +impl Workspace { + /// A run, save or export ended in a tab that is still open. + pub(crate) fn connect_work_completed(&self, f: F) -> glib::SignalHandlerId { + self.connect_closure( + "work-completed", + false, + glib::closure_local!(move |workspace: &Self, work: glib::BoxedAnyObject| { + f(workspace, *work.borrow::()); + }), + ) + } + + /// Another tab came to the front, or one opened or closed. + pub(crate) fn connect_tabs_changed(&self, f: F) -> glib::SignalHandlerId { + self.connect_closure( + "tabs-changed", + false, + glib::closure_local!(move |workspace: &Self| f(workspace)), + ) + } + + /// The tab in front. `None` with no tab, or while the workspace is + /// handling input. + pub(crate) fn selected_tab(&self) -> Option { + self.read(WorkspaceModel::selected_workspace_tab_id).flatten() + } + + /// Every open tab. `None` while the workspace is handling input. + pub(crate) fn open_tabs(&self) -> Option> { + self.read(|model| model.workspace_tabs.borrow().keys().copied().collect()) + } + + /// What a notification calls tab `id`, while it is open. + pub(crate) fn notice_label(&self, id: Uuid) -> Option { + self.read(|model| model.notice_label(id)).flatten() + } + + /// The connection's name, as the window's title gives it. + pub(crate) fn connection_name(&self) -> Option { + self.read(|model| model.context.active.metadata().map(|metadata| metadata.name)) + .flatten() + } +} + +impl WorkspaceModel { + /// Tell the window that work ended in tab `tab`. A tab closed since + /// has nothing left to show or bring forward, so it is not told. + pub(super) fn report_work(&self, tab: Uuid, kind: WorkKind, outcome: WorkOutcome) { + if !self.workspace_tabs.borrow().contains_key(&tab) { + return; + } + if let Some(workspace) = self.this.upgrade() { + let work = CompletedWork { tab, kind, outcome }; + workspace.emit_by_name::<()>("work-completed", &[&glib::BoxedAnyObject::new(work)]); + } + } + + pub(super) fn report_tabs_changed(&self) { + if let Some(workspace) = self.this.upgrade() { + workspace.emit_by_name::<()>("tabs-changed", &[]); + } + } + + /// Bring tab `id` to the front, out of the tab overview, as clicking + /// a notification about it asks. + pub(super) fn focus_tab(&self, id: Uuid) { + let page = self.workspace_tabs.borrow().get(&id).map(|tab| tab.page().clone()); + let Some(page) = page else { + tracing::debug!(%id, "a notification named a tab that has closed"); + return; + }; + self.tab_view.set_selected_page(&page); + if let Some(workspace) = self.this.upgrade() { + workspace.imp().tab_overview.set_open(false); + } + } + + /// The tab's name for a notification, which can show on the lock + /// screen. An editor tab's title is the start of its script, so it + /// goes by its number instead. + fn notice_label(&self, id: Uuid) -> Option { + let tabs = self.workspace_tabs.borrow(); + Some(match tabs.get(&id)? { + WorkspaceTab::Editor(slot) => tab_title::numbered_query(slot.ordinal), + tab => tab_title::resolve_tab_title(&tab.identity(), self.naming.get()).label, + }) + } +} diff --git a/linux/crates/app/src/ui/workspace/health_banner.rs b/linux/crates/app/src/ui/workspace/health_banner.rs new file mode 100644 index 0000000000..713c5593af --- /dev/null +++ b/linux/crates/app/src/ui/workspace/health_banner.rs @@ -0,0 +1,782 @@ +//! The banner over the tabs while the session needs attention. + +use gtk4::prelude::*; +use tablepro_core::{DriverError, SshFailure, TransportError}; +use tablepro_session::{FailureCause, MonitorSignal, ReviewedHostKey, SessionHealth}; +use tablepro_ssh::ForgetHostKeyError; + +use super::{WorkspaceInput, WorkspaceModel}; +use crate::runtime::{DeliverInput, spawn_and_deliver}; +use crate::ui::host_key_failure::{HostKeyFailure, HostKeyFailureKind}; +use crate::ui::host_key_review::{HostKeyReviewOutcome, review_host_key}; + +pub(super) const REVIEW_HOST_KEY: &str = "review-host-key"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum BannerButton { + Retry, + ReviewKey, +} + +impl WorkspaceModel { + /// GNOME apps keep banners for states the user can act on, so the + /// banner shows only while the session is not online. A host key ssh + /// refused offers a review instead of a retry, which would only be + /// refused again. + pub(super) fn refresh_health_banner(&self, health: &SessionHealth) { + let review = self.host_key_to_review(health); + if let Some(window) = self.window() { + super::workspace_actions::set_enabled(&window, REVIEW_HOST_KEY, review.is_some()); + } + let title = self + .rejection_text(health, review.as_ref()) + .or_else(|| health_banner_text(health, review.as_ref())); + let Some(title) = title else { + self.reconnect_banner.set_revealed(false); + return; + }; + let (label, action) = match banner_button(review.as_ref()) { + BannerButton::Retry => (crate::i18n::gettext("Retry"), "win.reconnect".to_owned()), + BannerButton::ReviewKey => (crate::i18n::gettext("Review Key…"), format!("win.{REVIEW_HOST_KEY}")), + }; + let news = !self.reconnect_banner.is_revealed() || self.reconnect_banner.title() != title; + self.reconnect_banner.set_title(&title); + self.reconnect_banner.set_button_label(Some(&label)); + self.reconnect_banner.set_action_name(Some(&action)); + self.reconnect_banner.set_revealed(true); + // The focus stays in the grid or the editor, under a banner no + // screen reader reads by itself. + if news { + crate::ui::a11y::announce( + &self.reconnect_banner, + &title, + gtk4::AccessibleAnnouncementPriority::High, + ); + } + } + + /// What the server or the settings said when the session parked, + /// which says more than the cause's own sentence. A host key keeps + /// its own banner and review. + fn rejection_text(&self, health: &SessionHealth, review: Option<&HostKeyFailure>) -> Option { + let SessionHealth::Failed { cause } = health else { + return None; + }; + if review.is_some() || is_host_key(*cause) { + return None; + } + let rejection = self.context.active.get()?.last_rejection()?; + Some(crate::ui::error_text::connect_error_message(&rejection)) + } + + /// The host key the session is parked on, read from the error that + /// parked it. + fn host_key_to_review(&self, health: &SessionHealth) -> Option { + let SessionHealth::Failed { cause } = health else { + return None; + }; + if !is_host_key(*cause) { + return None; + } + let rejection = self.context.active.get()?.last_rejection()?; + HostKeyFailure::from_connect_error(&rejection) + } + + pub(super) fn on_review_host_key(&mut self) { + if self.reviewing_host_key { + return; + } + let Some(session) = self.context.active.get() else { + return; + }; + let health = session.health().borrow().clone(); + let Some(failure) = self.host_key_to_review(&health) else { + return; + }; + let Some(parent) = self.dialog_parent() else { + return; + }; + self.reviewing_host_key = true; + let input = self.input.clone(); + gtk4::glib::spawn_future_local(async move { + let outcome = review_host_key(&parent, &failure).await; + input.deliver(WorkspaceInput::HostKeyReviewed { failure, outcome }); + }); + } + + pub(super) fn on_host_key_reviewed(&mut self, failure: HostKeyFailure, outcome: HostKeyReviewOutcome) { + self.reviewing_host_key = false; + match outcome { + HostKeyReviewOutcome::Dismissed => {} + HostKeyReviewOutcome::Retry => self.retry(), + HostKeyReviewOutcome::Trust => self.retry_trusting(&failure), + HostKeyReviewOutcome::Replace => self.reviewing_host_key = self.forget_old_host_key(failure), + } + } + + pub(super) fn on_old_host_key_forgotten(&mut self, failure: HostKeyFailure, forgotten: Result<(), String>) { + self.reviewing_host_key = false; + match forgotten { + Ok(()) => self.retry_trusting(&failure), + Err(detail) => { + tracing::warn!(host = %failure.host, %detail, "the old SSH host key was not removed"); + let file = match &failure.kind { + HostKeyFailureKind::Changed { known_hosts, .. } => known_hosts.display().to_string(), + _ => String::new(), + }; + self.show_toast(&crate::i18n::gettext_f( + "The old key for {host} could not be removed from {file}: {detail}", + &[("host", &failure.host), ("file", &file), ("detail", &detail)], + )); + } + } + } + + /// ssh is asked again with the user's answer given: the reviewed key + /// is accepted, any other is shown to them as on a first connect. + /// Without a fingerprint there is nothing to answer for them, so ssh + /// asks. + fn retry_trusting(&self, failure: &HostKeyFailure) { + let Some(session) = self.context.active.get() else { + return; + }; + match failure.fingerprint.is_empty() { + true => session.signal(MonitorSignal::RetryNow), + false => session.retry_trusting_host_key(ReviewedHostKey { + host: failure.host.clone(), + fingerprint: failure.fingerprint.clone(), + }), + } + } + + fn retry(&self) { + if let Some(session) = self.context.active.get() { + session.signal(MonitorSignal::RetryNow); + } + } + + /// ssh never accepts a changed key, so the old one leaves known_hosts + /// first, with the ssh-keygen command ssh itself suggests, once the + /// session's own tunnel confirms the host and the file. Returns + /// whether that was started. + fn forget_old_host_key(&self, failure: HostKeyFailure) -> bool { + let HostKeyFailureKind::Changed { known_hosts, .. } = &failure.kind else { + return false; + }; + let Some(ssh) = self.ssh.clone() else { + tracing::warn!("no SSH services to remove the old host key with"); + return false; + }; + let Some(config) = self + .context + .active + .get() + .and_then(|session| session.ssh_config().cloned()) + else { + tracing::warn!("the session has no SSH tunnel to check the changed host key against"); + return false; + }; + let (host, known_hosts) = (failure.host.clone(), known_hosts.clone()); + let input = self.input.clone(); + drop(spawn_and_deliver( + &self.tasks, + async move { tablepro_ssh::forget_changed_host_key(&ssh, &config, &host, &known_hosts).await }, + move |forgotten| { + let forgotten = match forgotten { + Ok(Ok(())) => Ok(()), + Ok(Err(error)) => Err(forget_failure_detail(&error)), + Err(error) => Err(error.to_string()), + }; + input.deliver(WorkspaceInput::OldHostKeyForgotten { failure, forgotten }); + }, + )); + true + } +} + +fn forget_failure_detail(error: &ForgetHostKeyError) -> String { + match error { + ForgetHostKeyError::NotThisConnection { .. } => crate::i18n::gettext("it is not a key this connection uses"), + ForgetHostKeyError::Ssh(SshFailure::ConfigRejected { detail }) => detail.clone(), + ForgetHostKeyError::Ssh(other) => { + crate::ui::error_text::driver_message(&DriverError::Transport(TransportError::Ssh(other.clone()))) + } + } +} + +fn is_host_key(cause: FailureCause) -> bool { + matches!( + cause, + FailureCause::HostKeyUnknown | FailureCause::HostKeyChanged | FailureCause::HostKeyRevoked + ) +} + +fn banner_button(review: Option<&HostKeyFailure>) -> BannerButton { + match review { + Some(_) => BannerButton::ReviewKey, + None => BannerButton::Retry, + } +} + +/// The banner's sentence for a session that needs attention, `None` +/// while it is online. +fn health_banner_text(health: &SessionHealth, review: Option<&HostKeyFailure>) -> Option { + match health { + SessionHealth::Online => None, + SessionHealth::Reconnecting { attempt } => Some(crate::i18n::gettext_f( + "Connection lost. Reconnecting (attempt {n}).", + &[("n", &attempt.to_string())], + )), + SessionHealth::Offline => Some(crate::i18n::gettext("Offline. Waiting for the network.")), + SessionHealth::Failed { cause } => Some(match review { + Some(failure) => host_key_banner_text(failure), + None => failed_banner_text(*cause), + }), + } +} + +fn host_key_banner_text(failure: &HostKeyFailure) -> String { + let host = [("host", failure.host.as_str())]; + match failure.kind { + HostKeyFailureKind::Unknown { .. } => crate::i18n::gettext_f("The SSH host {host} is not trusted yet.", &host), + HostKeyFailureKind::Changed { .. } => { + crate::i18n::gettext_f("The SSH host key for {host} does not match.", &host) + } + HostKeyFailureKind::Revoked { .. } => crate::i18n::gettext_f("The SSH host key for {host} is revoked.", &host), + } +} + +fn failed_banner_text(cause: FailureCause) -> String { + match cause { + FailureCause::Authentication => { + crate::i18n::gettext("The server refused the login. Retry to enter the password again.") + } + FailureCause::Tls => crate::i18n::gettext("The secure connection failed. Check the connection's TLS settings."), + FailureCause::Configuration => { + crate::i18n::gettext("The connection settings no longer work. Check them and retry.") + } + FailureCause::HostKeyUnknown => crate::i18n::gettext("The SSH server's host key is not trusted yet."), + FailureCause::HostKeyChanged => crate::i18n::gettext( + "The SSH server's host key changed. Check with its administrator before connecting again.", + ), + FailureCause::HostKeyRevoked => crate::i18n::gettext("The SSH server's host key was revoked."), + } +} + +#[cfg(test)] +mod tests { + use std::path::{Path, PathBuf}; + use std::sync::Arc; + use std::time::Duration; + + use gtk4 as gtk; + use gtk4::subclass::prelude::ObjectSubclassIsExt; + use libadwaita as adw; + use libadwaita::prelude::*; + use tablepro_session::ConnectError; + use tablepro_session::fakes::FakeEstablish; + use tablepro_ssh::{SshRuntimeCell, SshServices}; + use tablepro_storage::{SavedSshAuth, SavedSshConfig}; + use tablepro_test_support::{ConnectionScript, FakeReply}; + + use super::*; + use crate::test_support::{descendants, drain_main_context, wait_until}; + use crate::ui::SessionWindowInput; + use crate::ui::session_window::tests::{Script, TestWindow, WAIT}; + + const FINGERPRINT: &str = "SHA256:reviewed"; + const KEY: &str = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8g"; + + #[test] + fn the_banner_shows_only_when_the_session_needs_attention() { + assert_eq!(health_banner_text(&SessionHealth::Online, None), None); + let reconnecting = health_banner_text(&SessionHealth::Reconnecting { attempt: 3 }, None).expect("a banner"); + assert!(reconnecting.contains('3'), "{reconnecting}"); + assert_eq!( + health_banner_text(&SessionHealth::Offline, None).as_deref(), + Some("Offline. Waiting for the network.") + ); + } + + #[test] + fn each_parked_cause_has_its_own_banner() { + let causes = [ + FailureCause::Authentication, + FailureCause::Tls, + FailureCause::Configuration, + FailureCause::HostKeyUnknown, + FailureCause::HostKeyChanged, + FailureCause::HostKeyRevoked, + ]; + + let texts: std::collections::HashSet = causes + .into_iter() + .map(|cause| health_banner_text(&SessionHealth::Failed { cause }, None).expect("a banner")) + .collect(); + + assert_eq!(texts.len(), causes.len()); + } + + #[test] + fn a_host_key_banner_names_the_host() { + let kinds = [ + HostKeyFailureKind::Unknown { trust_allowed: true }, + HostKeyFailureKind::Changed { + known_hosts: PathBuf::from("/home/u/.ssh/known_hosts"), + line: 1, + trust_allowed: true, + }, + HostKeyFailureKind::Revoked { + detail: "revoked".to_owned(), + }, + ]; + for kind in kinds { + let failure = HostKeyFailure { + host: "bastion".to_owned(), + algorithm: "ED25519".to_owned(), + fingerprint: FINGERPRINT.to_owned(), + kind, + }; + let parked = SessionHealth::Failed { + cause: FailureCause::HostKeyUnknown, + }; + + let text = health_banner_text(&parked, Some(&failure)).expect("a banner"); + + assert!(text.contains("bastion"), "{text}"); + assert_eq!(banner_button(Some(&failure)), BannerButton::ReviewKey); + } + assert_eq!(banner_button(None), BannerButton::Retry); + } + + fn healthy() -> ConnectionScript { + let mut script = ConnectionScript::default(); + script.probe.always(FakeReply::ok(())); + script.list_schemas.always(FakeReply::ok(Vec::new())); + script.list_objects.always(FakeReply::ok(Vec::new())); + script + } + + /// ssh asking about `fingerprint`: an unattended reconnect declines + /// it, an attended one connects on a yes. + fn asks(host: &str, fingerprint: &str) -> FakeEstablish { + FakeEstablish::AskHostKey { + host: host.to_owned(), + fingerprint: fingerprint.to_owned(), + script: healthy(), + } + } + + /// A window whose connection dropped and whose first reconnect + /// parked it; `reconnects` answer that one and the ones after. + fn parked(name: &str, reconnects: Vec, ssh: Option) -> TestWindow { + parked_with(name, reconnects, ssh, None) + } + + fn parked_with( + name: &str, + reconnects: Vec, + ssh: Option, + tunnel: Option, + ) -> TestWindow { + let mut establish = vec![FakeEstablish::Connect(healthy())]; + establish.extend(reconnects); + let test = TestWindow::with_script( + name, + Script { + establish, + ssh, + tunnel, + ..Script::default() + }, + ); + test.window + .send(SessionWindowInput::OpenSaved(Box::new(test.saved.clone()))); + test.wait_for_page("workspace"); + test.connection().mark_lost(); + wait_until(WAIT, || matches!(health(&test), SessionHealth::Failed { .. })).expect("the session to park"); + test + } + + fn health(test: &TestWindow) -> SessionHealth { + test.services + .sessions + .session(&test.key()) + .map_or(SessionHealth::Online, |session| session.health().borrow().clone()) + } + + fn banner(test: &TestWindow) -> adw::Banner { + test.workspace().imp().reconnect_banner.get() + } + + fn wait_for_button(test: &TestWindow, label: &str) -> adw::Banner { + let banner = banner(test); + wait_until(WAIT, || { + banner.is_revealed() && banner.button_label().as_deref() == Some(label) + }) + .unwrap_or_else(|_| panic!("the banner never offered {label}")); + banner + } + + fn review_enabled(test: &TestWindow) -> bool { + test.window + .lookup_action(REVIEW_HOST_KEY) + .is_some_and(|action| action.is_enabled()) + } + + fn alert(test: &TestWindow) -> Option { + test.window.visible_dialog().and_downcast::() + } + + /// Open the review from the banner and click `response`, the way a + /// user does. + fn review_and_answer(test: &TestWindow, response: &str) { + answer(test, &open_review(test), response); + } + + fn open_review(test: &TestWindow) -> adw::AlertDialog { + gtk::prelude::WidgetExt::activate_action(&test.window, &format!("win.{REVIEW_HOST_KEY}"), None) + .expect("the review action"); + wait_until(WAIT, || alert(test).is_some()).expect("the review dialog"); + alert(test).expect("the review dialog") + } + + fn answer(test: &TestWindow, dialog: &adw::AlertDialog, response: &str) { + let label = dialog.response_label(response); + descendants(dialog) + .into_iter() + .filter_map(|widget| widget.downcast::().ok()) + .find(|button| button.label().as_deref() == Some(label.as_str())) + .unwrap_or_else(|| panic!("no {response} button")) + .emit_clicked(); + wait_until(WAIT, || alert(test).is_none()).expect("the review dialog to close"); + } + + #[gtk4::test] + fn banner_offers_review_for_host_key_failure() { + let test = parked("ReviewOffered", vec![asks("bastion", FINGERPRINT)], None); + + let banner = wait_for_button(&test, "Review Key…"); + + assert!(banner.title().contains("bastion"), "{}", banner.title()); + assert_eq!( + banner.action_name().as_deref(), + Some(format!("win.{REVIEW_HOST_KEY}").as_str()) + ); + assert!(review_enabled(&test)); + } + + #[gtk4::test] + fn the_banner_is_said_when_it_shows() { + let test = parked("BannerSaid", vec![asks("bastion", FINGERPRINT)], None); + + let banner = wait_for_button(&test, "Review Key…"); + + assert_eq!( + crate::ui::a11y::announced_in(&banner).last(), + Some(&(banner.title().to_string(), gtk::AccessibleAnnouncementPriority::High)) + ); + } + + #[gtk4::test] + fn review_trust_retries_trusting_that_key() { + let test = parked( + "ReviewTrust", + vec![asks("bastion", FINGERPRINT), asks("bastion", FINGERPRINT)], + None, + ); + wait_for_button(&test, "Review Key…"); + + review_and_answer(&test, "trust"); + + wait_until(WAIT, || health(&test) == SessionHealth::Online).expect("the session back online"); + wait_until(WAIT, || !banner(&test).is_revealed()).expect("the banner to go"); + let calls = test.connector.calls(); + assert_eq!(calls.len(), 3); + assert!(calls[2].attended, "the retry could not ask anything"); + drain_main_context(); + assert!( + alert(&test).is_none(), + "ssh's question about the trusted key reached the user" + ); + assert!(!review_enabled(&test)); + } + + #[gtk4::test] + fn dismiss_keeps_session_parked() { + let test = parked("ReviewDismiss", vec![asks("bastion", FINGERPRINT)], None); + wait_for_button(&test, "Review Key…"); + + review_and_answer(&test, "cancel"); + std::thread::sleep(Duration::from_millis(200)); + drain_main_context(); + + assert_eq!( + health(&test), + SessionHealth::Failed { + cause: FailureCause::HostKeyUnknown + } + ); + assert_eq!(test.connector.calls().len(), 2, "a dismissed review retried"); + assert!(banner(&test).is_revealed()); + assert!(review_enabled(&test)); + } + + #[gtk4::test] + fn review_retry_reconnects_a_key_fixed_outside_tablepro() { + let strict = FakeEstablish::Fail(ConnectError::Driver(DriverError::Transport(TransportError::Ssh( + SshFailure::HostKeyUnknown { + host: "bastion".to_owned(), + algorithm: String::new(), + sha256_fingerprint: String::new(), + trust_allowed: false, + declined: false, + }, + )))); + let test = parked("ReviewRetry", vec![strict, FakeEstablish::Connect(healthy())], None); + wait_for_button(&test, "Review Key…"); + + review_and_answer(&test, "retry"); + + wait_until(WAIT, || health(&test) == SessionHealth::Online).expect("the session back online"); + assert_eq!(test.connector.calls().len(), 3); + } + + #[gtk4::test] + fn a_parked_session_says_what_the_server_said() { + let refused = FakeEstablish::Fail(ConnectError::Driver(DriverError::LoginRefused(Box::new( + tablepro_core::ServerDiagnostics::new(None, "no pg_hba.conf entry for host"), + )))); + let test = parked("RejectionText", vec![refused], None); + + // The banner follows the session's health after it changes, and offers + // Retry while reconnecting too, so the button alone can come too early. + let banner = banner(&test); + wait_until(WAIT, || banner.title().contains("no pg_hba.conf entry")) + .unwrap_or_else(|_| panic!("the banner said {}", banner.title())); + assert_eq!(banner.button_label().as_deref(), Some("Retry")); + } + + #[gtk4::test] + fn review_action_disabled_for_other_failures() { + let refused = FakeEstablish::Fail(ConnectError::Driver(DriverError::auth(None))); + let test = parked("ReviewAuth", vec![refused], None); + + let banner = wait_for_button(&test, "Retry"); + + assert_eq!(banner.action_name().as_deref(), Some("win.reconnect")); + assert!(!review_enabled(&test)); + } + + fn ssh_services(dir: &Path) -> SshServices { + SshServices { + runtime: Arc::new(SshRuntimeCell::new(dir.join("runtime"))), + ssh_program: "ssh".into(), + ssh_keygen_program: "ssh-keygen".into(), + askpass_program: dir.join("tablepro-askpass"), + config_file: None, + } + } + + fn changed_key(known_hosts: &Path) -> FakeEstablish { + changed_key_of("[bastion]:2222", known_hosts, true) + } + + fn changed_key_of(host: &str, known_hosts: &Path, trust_allowed: bool) -> FakeEstablish { + FakeEstablish::Fail(ConnectError::Driver(DriverError::Transport(TransportError::Ssh( + SshFailure::HostKeyChanged { + host: host.to_owned(), + algorithm: "ED25519".to_owned(), + sha256_fingerprint: FINGERPRINT.to_owned(), + known_hosts: known_hosts.to_owned(), + line: 1, + trust_allowed, + }, + )))) + } + + /// The tunnel the parked sessions go through. The server is on this + /// machine, so no reachability check runs. + fn tunnel() -> SavedSshConfig { + SavedSshConfig { + host: "127.0.0.1".to_owned(), + port: Some(2222), + username: None, + jump_hosts: Vec::new(), + auth: SavedSshAuth::Agent, + } + } + + /// SSH services whose `ssh -G` resolves the tunnel's host to + /// `[bastion]:2222`, looked up in `known_hosts`, the way an + /// ssh_config with `HostName bastion` would. + fn resolving_ssh(dir: &Path, known_hosts: &Path) -> SshServices { + let program = dir.join("ssh"); + let resolved = format!( + "hostname bastion\\nport 2222\\nhostkeyalias none\\nuserknownhostsfile {} {}\\n", + known_hosts.display(), + dir.join("known_hosts2").display() + ); + tablepro_test_support::write_executable( + &program, + format!("#!/bin/sh\nprintf '{resolved}'\n").as_bytes(), + 0o755, + ) + .unwrap(); + SshServices { + ssh_program: program, + ..ssh_services(dir) + } + } + + fn has_toast(test: &TestWindow, text: &str) -> bool { + descendants(&test.workspace()) + .into_iter() + .filter_map(|widget| widget.downcast::().ok()) + .any(|label| label.label().contains(text)) + } + + #[gtk4::test] + fn replace_removes_the_old_key_and_retries_trusting_the_new_one() { + let dir = tempfile::tempdir().unwrap(); + let known_hosts = dir.path().join("known_hosts"); + std::fs::write(&known_hosts, format!("[bastion]:2222 {KEY}\nother {KEY}\n")).unwrap(); + let test = parked_with( + "ReviewReplace", + vec![changed_key(&known_hosts), asks("[bastion]:2222", FINGERPRINT)], + Some(resolving_ssh(dir.path(), &known_hosts)), + Some(tunnel()), + ); + let banner = wait_for_button(&test, "Review Key…"); + assert!(banner.title().contains("does not match"), "{}", banner.title()); + + review_and_answer(&test, "replace"); + + wait_until(WAIT, || health(&test) == SessionHealth::Online).expect("the session back online"); + let left = std::fs::read_to_string(&known_hosts).unwrap(); + assert!(!left.contains("[bastion]:2222"), "{left}"); + assert!(left.contains("other "), "{left}"); + assert!( + alert(&test).is_none(), + "ssh's question about the reviewed key reached the user" + ); + } + + #[gtk4::test] + fn no_second_review_while_the_old_key_is_removed() { + let dir = tempfile::tempdir().unwrap(); + let known_hosts = dir.path().join("known_hosts"); + std::fs::write(&known_hosts, format!("[bastion]:2222 {KEY}\n")).unwrap(); + let slow_keygen = dir.path().join("slow-ssh-keygen"); + tablepro_test_support::write_executable(&slow_keygen, b"#!/bin/sh\nsleep 1\nexec ssh-keygen \"$@\"\n", 0o755) + .unwrap(); + let ssh = SshServices { + ssh_keygen_program: slow_keygen, + ..resolving_ssh(dir.path(), &known_hosts) + }; + let test = parked_with( + "ReviewReplaceBusy", + vec![changed_key(&known_hosts), asks("[bastion]:2222", FINGERPRINT)], + Some(ssh), + Some(tunnel()), + ); + wait_for_button(&test, "Review Key…"); + review_and_answer(&test, "replace"); + drain_main_context(); + + gtk::prelude::WidgetExt::activate_action(&test.window, &format!("win.{REVIEW_HOST_KEY}"), None) + .expect("the review action"); + std::thread::sleep(Duration::from_millis(200)); + drain_main_context(); + + assert!( + alert(&test).is_none(), + "a second review opened while ssh-keygen was still running" + ); + wait_until(WAIT, || health(&test) == SessionHealth::Online).expect("the session back online"); + } + + /// ssh's stderr carries the server's login banner too, so a changed + /// key it reports for a host or a file this connection does not use + /// stays where it is. + #[gtk4::test] + fn replace_leaves_a_key_this_connection_does_not_use() { + for (name, host, file) in [ + ("ReviewForgedHost", "other", "known_hosts"), + ("ReviewForgedFile", "[bastion]:2222", "elsewhere"), + ] { + let dir = tempfile::tempdir().unwrap(); + let known_hosts = dir.path().join("known_hosts"); + let reported = dir.path().join(file); + let keys = format!("[bastion]:2222 {KEY}\nother {KEY}\n"); + for path in [&known_hosts, &dir.path().join("elsewhere")] { + std::fs::write(path, &keys).unwrap(); + } + let test = parked_with( + name, + vec![changed_key_of(host, &reported, true)], + Some(resolving_ssh(dir.path(), &known_hosts)), + Some(tunnel()), + ); + wait_for_button(&test, "Review Key…"); + + review_and_answer(&test, "replace"); + + wait_until(WAIT, || has_toast(&test, "it is not a key this connection uses")) + .unwrap_or_else(|_| panic!("{name}: the forged report was not refused")); + assert_eq!(std::fs::read_to_string(&reported).unwrap(), keys, "{name}"); + assert_eq!(test.connector.calls().len(), 2, "{name}: a refused replace retried"); + } + } + + /// Under StrictHostKeyChecking yes ssh would refuse the new key once + /// the old one is gone, so the review offers no replace and the old + /// key stays. + #[gtk4::test] + fn review_offers_no_replace_when_the_configuration_takes_only_known_keys() { + let dir = tempfile::tempdir().unwrap(); + let known_hosts = dir.path().join("known_hosts"); + let keys = format!("[bastion]:2222 {KEY}\n"); + std::fs::write(&known_hosts, &keys).unwrap(); + let test = parked_with( + "ReviewStrictChanged", + vec![ + changed_key_of("[bastion]:2222", &known_hosts, false), + FakeEstablish::Connect(healthy()), + ], + Some(resolving_ssh(dir.path(), &known_hosts)), + Some(tunnel()), + ); + wait_for_button(&test, "Review Key…"); + + let dialog = open_review(&test); + + assert!(!dialog.has_response("replace"), "a replace was offered"); + assert!(dialog.has_response("retry")); + assert!( + dialog.body().contains(&known_hosts.display().to_string()), + "{}", + dialog.body() + ); + answer(&test, &dialog, "retry"); + wait_until(WAIT, || health(&test) == SessionHealth::Online).expect("the session back online"); + assert_eq!(std::fs::read_to_string(&known_hosts).unwrap(), keys); + } + + #[gtk4::test] + fn trust_answers_only_for_the_host_it_was_reviewed_for() { + let test = parked( + "ReviewTrustHost", + vec![asks("bastion", FINGERPRINT), asks("jump", FINGERPRINT)], + None, + ); + wait_for_button(&test, "Review Key…"); + + review_and_answer(&test, "trust"); + + wait_until(WAIT, || alert(&test).is_some()).expect("ssh's question about another host to reach the user"); + if let Some(question) = alert(&test) { + question.force_close(); + } + } +} diff --git a/linux/crates/app/src/ui/workspace/imp.rs b/linux/crates/app/src/ui/workspace/imp.rs new file mode 100644 index 0000000000..e0b2d9942b --- /dev/null +++ b/linux/crates/app/src/ui/workspace/imp.rs @@ -0,0 +1,130 @@ +use std::cell::RefCell; +use std::sync::OnceLock; + +use glib::subclass::Signal; +use gtk4 as gtk; +use gtk4::glib; +use gtk4::prelude::*; +use gtk4::subclass::prelude::*; +use libadwaita as adw; +use libadwaita::subclass::prelude::*; + +use super::WorkspaceModel; +use crate::ui::export::ExportOperationsButton; + +#[derive(Default, gtk::CompositeTemplate)] +#[template(resource = "/app/tablepro/TablePro/ui/workspace.ui")] +pub struct Workspace { + #[template_child] + pub tab_overview: TemplateChild, + #[template_child] + pub breakpoint_bin: TemplateChild, + #[template_child] + pub narrow_breakpoint: TemplateChild, + #[template_child] + pub toast_overlay: TemplateChild, + #[template_child] + pub split_view: TemplateChild, + #[template_child] + pub sidebar_root: TemplateChild, + #[template_child] + pub sidebar_header: TemplateChild, + #[template_child] + pub sidebar_title: TemplateChild, + #[template_child] + pub table_search_toggle: TemplateChild, + #[template_child] + pub refresh_tables_button: TemplateChild, + #[template_child] + pub table_search_bar: TemplateChild, + #[template_child] + pub table_search: TemplateChild, + #[template_child] + pub catalog_banner: TemplateChild, + #[template_child] + pub sidebar_slot: TemplateChild, + #[template_child] + pub content_root: TemplateChild, + #[template_child] + pub content_header: TemplateChild, + #[template_child] + pub sidebar_toggle: TemplateChild, + #[template_child] + pub new_tab_button: TemplateChild, + #[template_child] + pub window_title: TemplateChild, + #[template_child] + pub tab_button: TemplateChild, + #[template_child] + pub export_operations_button: TemplateChild, + #[template_child] + pub row_op_spinner: TemplateChild, + #[template_child] + pub tab_bar: TemplateChild, + #[template_child] + pub reconnect_banner: TemplateChild, + #[template_child] + pub layout_banner: TemplateChild, + #[template_child] + pub tab_stack: TemplateChild, + #[template_child] + pub tab_view: TemplateChild, + + pub(super) model: RefCell>, + /// Handles the queued input, and with it every reply the workspace + /// is still waiting on. Aborted at shutdown, which drops them. + pub(super) pump: RefCell>>, +} + +#[glib::object_subclass] +impl ObjectSubclass for Workspace { + const NAME: &'static str = "TableProWorkspace"; + type Type = super::Workspace; + type ParentType = adw::Bin; + + fn class_init(klass: &mut Self::Class) { + ExportOperationsButton::ensure_type(); + klass.bind_template(); + } + + fn instance_init(object: &glib::subclass::InitializingObject) { + object.init_template(); + } +} + +impl ObjectImpl for Workspace { + fn constructed(&self) { + self.parent_constructed(); + super::shell::describe_buttons(&self.obj()); + } + + fn signals() -> &'static [Signal] { + static SIGNALS: OnceLock> = OnceLock::new(); + SIGNALS.get_or_init(|| { + vec![ + Signal::builder("dirty-changed") + .param_types([bool::static_type()]) + .build(), + Signal::builder("saves-in-flight-changed") + .param_types([u32::static_type()]) + .build(), + Signal::builder("title-changed") + .param_types([String::static_type()]) + .build(), + Signal::builder("work-completed") + .param_types([glib::BoxedAnyObject::static_type()]) + .build(), + Signal::builder("tabs-changed").build(), + ] + }) + } + + /// A workspace dropped without a teardown still stops its reads and + /// lets go of its tabs. + fn dispose(&self) { + self.obj().shutdown(); + } +} + +impl WidgetImpl for Workspace {} +impl BinImpl for Workspace {} diff --git a/linux/crates/app/src/ui/workspace/import.rs b/linux/crates/app/src/ui/workspace/import.rs new file mode 100644 index 0000000000..fae483fca4 --- /dev/null +++ b/linux/crates/app/src/ui/workspace/import.rs @@ -0,0 +1,723 @@ +use std::sync::Arc; + +use gtk4 as gtk; +use gtk4::{gio, glib}; +use libadwaita::prelude::*; +use relm4::{Component, ComponentController}; + +use std::ops::Range; +use std::time::Duration; + +use tablepro_core::dialect::SqlDialect; +use tablepro_core::edit::CellInput; +use tablepro_core::import::{ImportColumn, ImportPlan, plan_import}; +use tablepro_core::meta::TableRef; +use tablepro_core::{CallOptions, DriverError, WriteKind}; +use tablepro_session::runtime::Tasks; +use tablepro_session::{OperationError, OperationKind, Session}; +use tokio_util::sync::CancellationToken; + +use crate::services::history_service::HistoryService; +use crate::ui::error_text::{driver_message, operation_error_message}; +use crate::ui::file_read::{FileReadError, read_capped_file}; +use crate::ui::import_dialog::{CsvImport, MAX_CSV_FILE_BYTES, import_error_text}; + +use super::{WorkspaceInput, WorkspaceModel}; +use crate::runtime::DeliverInput; +use crate::workspace::RanStatements; + +/// How many of an import's statements go into the log line that +/// announces it, so a failed import has something to read back. +const IMPORT_LOG_SAMPLE: usize = 3; + +/// What each statement of an import adds to the save deadline. A +/// statement carries up to a thousand rows, so this bounds a server that +/// stopped answering rather than one that is merely slow. +const IMPORT_TIME_PER_STATEMENT: Duration = Duration::from_secs(5); + +impl WorkspaceModel { + /// Ask for a CSV, then read it and the table's columns together so + /// the mapping dialog opens with both. + pub(super) fn on_import_csv_requested(&self, schema: Option, table: String) { + let Some(session) = self.context.active.get() else { + self.show_toast(&crate::i18n::gettext("Connect to a database first.")); + return; + }; + if session.read_only() { + self.show_toast(&crate::i18n::gettext("This connection is read-only.")); + return; + } + + let filter = gtk::FileFilter::new(); + filter.set_name(Some(&crate::i18n::gettext("CSV files"))); + filter.add_suffix("csv"); + filter.add_suffix("tsv"); + filter.add_mime_type("text/csv"); + let filters = gio::ListStore::new::(); + filters.append(&filter); + let dialog = gtk::FileDialog::builder() + .title(crate::i18n::gettext("Import CSV")) + .modal(true) + .default_filter(&filter) + .filters(&filters) + .build(); + + let input = self.input.clone(); + let tasks = self.context.tasks.clone(); + dialog.open(self.window().as_ref(), gio::Cancellable::NONE, move |outcome| { + let Ok(file) = outcome else { return }; + let input = input.clone(); + let schema = schema.clone(); + let table = table.clone(); + let session = session.clone(); + let tasks = tasks.clone(); + glib::spawn_future_local(async move { + let bytes = match read_csv_file(&file).await { + Ok(bytes) => bytes, + Err(message) => { + input.deliver(WorkspaceInput::ShowToast(message)); + return; + } + }; + // The mapping is against the table's own columns, so the + // dialog cannot open until they are read. The read runs + // on the runtime: a driver future polled here has no + // tokio context to run in. + let table_ref = TableRef::new(schema.clone(), table.clone()); + let columns = match tasks.spawn_task(read_columns(session, table_ref)).await { + Ok(Ok(columns)) => columns, + Ok(Err(error)) => { + tracing::warn!(%error, "could not read the table to import into"); + input.deliver(WorkspaceInput::ShowToast(crate::i18n::gettext( + "The table's columns could not be read.", + ))); + return; + } + Err(failure) => { + tracing::warn!(%failure, "the column read did not finish"); + return; + } + }; + input.deliver(WorkspaceInput::ImportCsvReady { + schema, + table, + columns, + bytes, + }); + }); + }); + } + + pub(super) fn on_import_csv_ready( + &mut self, + schema: Option, + table: String, + columns: Vec, + bytes: Vec, + ) { + use crate::ui::import_dialog::{ImportCsvDialog, ImportCsvDialogInit, ImportCsvDialogOutput}; + + let dialog = ImportCsvDialog::builder() + .launch(ImportCsvDialogInit { + schema, + table, + columns, + bytes, + }) + .connect_receiver({ + let input = self.input.clone(); + move |_, out| { + input.deliver(match out { + ImportCsvDialogOutput::Import(import) => WorkspaceInput::ImportCsvRun(import), + }); + } + }); + dialog.model().dialog().present(self.dialog_parent().as_ref()); + self.import_dialog = Some(dialog); + } + + /// Read the whole file, turn its rows into INSERTs and write them as + /// one batch, so a file either lands whole or not at all. The file + /// is read and planned on the blocking pool, never on this thread. + pub(super) fn on_import_csv_run(&self, import: CsvImport) { + let Some((session, dialect)) = self + .context + .active + .get() + .and_then(|session| session.current_dialect().map(|dialect| (session, dialect))) + else { + self.show_toast(&crate::i18n::gettext("Connect to a database first.")); + return; + }; + let Some(ticket) = self.imports.begin() else { + return; + }; + let history = Some(self.history.clone()); + let connection = self.context.active.metadata(); + let tasks = self.context.tasks.clone(); + let work = run_import(ImportRun { + session, + dialect, + import, + cancel: ticket.cancel.clone(), + tasks: tasks.clone(), + history, + connection, + }); + let imports = self.imports.clone(); + let input = self.input.clone(); + let _delivery = crate::runtime::spawn_and_deliver(&tasks, work, move |finished| { + imports.settle(ticket.id); + match finished { + Ok(Ok(rows)) => { + input.deliver(WorkspaceInput::RefreshPage); + input.deliver(WorkspaceInput::ShowToast(crate::i18n::ngettext_f( + "Imported {n} row.", + "Imported {n} rows.", + u32::try_from(rows).unwrap_or(u32::MAX), + &[("n", &rows.to_string())], + ))); + } + Ok(Err(ImportEnd::Empty)) => input.deliver(WorkspaceInput::ShowToast(crate::i18n::gettext( + "That file has no rows to import.", + ))), + Ok(Err(ImportEnd::Stopped)) => { + input.deliver(WorkspaceInput::RefreshPage); + input.deliver(WorkspaceInput::ShowToast(crate::i18n::gettext("Import stopped."))); + } + Ok(Err(ImportEnd::Failed(message))) => input.deliver(WorkspaceInput::ShowAlert { + title: crate::i18n::gettext("Couldn't Import"), + body: message, + }), + Err(failure) => input.deliver(WorkspaceInput::ShowAlert { + title: crate::i18n::gettext("Couldn't Import"), + body: crate::ui::error_text::task_failure_message(&failure), + }), + } + }); + } +} + +/// Everything an import needs once it leaves the GTK thread. +struct ImportRun { + session: Arc, + dialect: Arc, + import: CsvImport, + cancel: CancellationToken, + tasks: Tasks, + history: Option, + connection: Option, +} + +/// Why an import wrote nothing, or not everything. +#[derive(Debug)] +enum ImportEnd { + Empty, + Stopped, + Failed(String), +} + +/// Read and plan the file on the blocking pool, then write the plan. +/// Gives back how many rows landed. +async fn run_import(run: ImportRun) -> Result { + let ImportRun { + session, + dialect, + import, + cancel, + tasks, + history, + connection, + } = run; + let first_line = first_data_line(&import); + let planning_dialect = dialect.clone(); + let planned = tasks + .spawn_blocking_task(move || plan_file(&import, &*planning_dialect)) + .await + .map_err(|failure| ImportEnd::Failed(crate::ui::error_text::task_failure_message(&failure)))??; + let Some((plan, rows)) = planned else { + return Err(ImportEnd::Empty); + }; + if cancel.is_cancelled() { + return Err(ImportEnd::Stopped); + } + let sql: Vec = plan + .batch + .steps + .iter() + .map(|step| step.statement.sql().to_owned()) + .collect(); + tracing::info!( + rows, + statements = sql.len(), + sample = ?sql.iter().take(IMPORT_LOG_SAMPLE).collect::>(), + "importing a CSV" + ); + let batch = Arc::new(plan.batch); + let written = session + .run_cancellable( + OperationKind::Save(WriteKind::RowChanges), + &cancel, + move |lease, options| { + let batch = batch.clone(); + let options = import_call(options, batch.len()); + async move { Ok(lease.write_batch(&batch, options).await?) } + }, + ) + .await; + match written { + Ok(report) => { + // An import changes the database, so it belongs in the same + // history as a statement run from the editor. + let touched = report.total_rows().and_then(|rows| i64::try_from(rows).ok()); + RanStatements::starting(history, connection, Some(dialect), sql) + .finished(touched, tablepro_storage::query_history::Outcome::Success) + .await; + Ok(rows) + } + Err(OperationError::Cancelled) => Err(ImportEnd::Stopped), + Err(error) => Err(ImportEnd::Failed(import_failure_text( + &error, + &plan.step_rows, + first_line, + ))), + } +} + +/// The call the write runs under: the save deadline, and as long again +/// as every statement may take on top of it, so a large file is not cut +/// off by the deadline a single edit gets. +fn import_call(options: CallOptions, statements: usize) -> CallOptions { + let extra = IMPORT_TIME_PER_STATEMENT.saturating_mul(u32::try_from(statements).unwrap_or(u32::MAX)); + CallOptions::new( + options.deadline().map(|deadline| deadline + extra), + options.cancel().clone(), + ) +} + +/// The header takes the first line when there is one, so the first data +/// row is line 2 in the file the user is looking at. +fn first_data_line(import: &CsvImport) -> usize { + match import.options.has_header { + true => 2, + false => 1, + } +} + +/// The plan for the whole file and how many rows it writes, `None` for +/// a file with no rows, or what to tell the user about the file instead. +fn plan_file(import: &CsvImport, dialect: &dyn SqlDialect) -> Result, ImportEnd> { + let rows = import_rows(import).map_err(ImportEnd::Failed)?; + if rows.is_empty() { + return Ok(None); + } + let count = rows.len(); + let table = TableRef::new(import.schema.clone(), import.table.clone()); + let plan = plan_import(dialect, &table, &import.columns, rows).map_err(|error| { + ImportEnd::Failed(crate::ui::error_text::operation_error_message(&OperationError::Build( + error, + ))) + })?; + Ok(Some((plan, count))) +} + +/// Why the write failed, told as the lines of the file the failing +/// statement held rather than as a statement number the user never saw. +fn import_failure_text(error: &OperationError, step_rows: &[Range], first_line: usize) -> String { + let lines = |index: usize| { + step_rows + .get(index) + .map(|rows| (first_line + rows.start, first_line + rows.end.saturating_sub(1))) + }; + match error { + OperationError::Driver(DriverError::RolledBack { + statement_index, + source, + }) => match lines(*statement_index) { + Some((first, last)) if first == last => crate::i18n::gettext_f( + "Line {line} could not be written: {error}. The import was rolled back, so no rows were added.", + &[("line", &first.to_string()), ("error", &driver_message(source))], + ), + Some((first, last)) => crate::i18n::gettext_f( + "Lines {first} to {last} could not be written: {error}. The import was rolled back, so no rows were added.", + &[ + ("first", &first.to_string()), + ("last", &last.to_string()), + ("error", &driver_message(source)), + ], + ), + None => operation_error_message(error), + }, + OperationError::Driver(DriverError::PartiallyApplied { + statement_index, + source, + .. + }) => { + let written: usize = step_rows + .iter() + .take(*statement_index) + .map(ExactSizeIterator::len) + .sum(); + let count = u32::try_from(written).unwrap_or(u32::MAX); + match lines(*statement_index) { + Some((first, last)) if first == last => crate::i18n::ngettext_f( + "Line {line} could not be written: {error}. The {written} row before it stays in the table, which has no transaction to undo it.", + "Line {line} could not be written: {error}. The {written} rows before it stay in the table, which has no transaction to undo them.", + count, + &[ + ("line", &first.to_string()), + ("error", &driver_message(source)), + ("written", &written.to_string()), + ], + ), + // A table without transactions can keep the rows of a + // multi-row statement that came before the one it + // refused. + Some((first, last)) => crate::i18n::ngettext_f( + "Lines {first} to {last} could not be written: {error}. The table has no transaction to undo the import, so the {written} row before them stays, and some of these lines may have been written too.", + "Lines {first} to {last} could not be written: {error}. The table has no transaction to undo the import, so the {written} rows before them stay, and some of these lines may have been written too.", + count, + &[ + ("first", &first.to_string()), + ("last", &last.to_string()), + ("error", &driver_message(source)), + ("written", &written.to_string()), + ], + ), + None => operation_error_message(error), + } + } + _ => operation_error_message(error), + } +} + +/// The chosen CSV, whole, or what to tell the user instead. +async fn read_csv_file(file: &gio::File) -> Result, String> { + read_capped_file(file, MAX_CSV_FILE_BYTES) + .await + .map_err(|error| match error { + FileReadError::NotRegular => crate::i18n::gettext("Only regular files can be imported."), + FileReadError::TooLarge => crate::i18n::gettext("That file is too large to import in one go."), + FileReadError::Io(error) => { + tracing::warn!(%error, "could not read the CSV to import"); + crate::i18n::gettext("That file could not be read.") + } + }) +} + +/// The table's columns with what the engine lets an insert write, so +/// the mapping never offers a column the server fills. +async fn read_columns(session: Arc, table: TableRef) -> Result, OperationError> { + session + .run(OperationKind::Metadata, move |lease, options| { + let table = table.clone(); + async move { + let definition = lease.fetch_table_definition(&table, options).await?; + Ok(definition.columns.iter().map(ImportColumn::from_definition).collect()) + } + }) + .await +} + +/// Every row of the file as the cells to insert, or the first row that +/// could not be read. +/// +/// The whole file is read here rather than in the dialog, which only +/// ever saw the preview. +fn import_rows(import: &CsvImport) -> Result>, String> { + use tablepro_core::import::{read_csv, row_to_cells}; + + let sheet = read_csv(&import.bytes, &import.options, None).map_err(|error| import_error_text(&error))?; + let first_line = first_data_line(import); + sheet + .rows + .iter() + .enumerate() + .map(|(index, row)| { + row_to_cells( + row, + &import.mapping, + &import.columns, + &import.options, + first_line + index, + ) + .map_err(|error| { + crate::i18n::gettext_f( + "Line {line}, column {column}: {reason} (the value was “{text}”)", + &[ + ("line", &error.line.to_string()), + ("column", &error.column), + ("reason", &error.reason), + ("text", &error.text), + ], + ) + }) + }) + .collect() +} + +#[cfg(test)] +mod tests { + use tablepro_core::column::{ColumnDefault, ColumnKind, ColumnType, TextLength}; + use tablepro_core::ddl::{ColumnAttributes, ColumnDefinition, IdentityStyle, TableDefinition}; + use tablepro_core::{ColumnInfo, LivenessPolicy, Value, WriteReport}; + use tablepro_session::fakes::{FakeConnector, FakeCredentialSource}; + use tablepro_session::operation::DefaultDeadlines; + use tablepro_session::{SessionDeps, SessionHealth}; + use tablepro_test_support::{CallCounters, ConnectionScript, FakeConnection, FakeReply}; + + use super::*; + + fn info(name: &str) -> ColumnInfo { + let mut column_type = ColumnType::untyped_text("text"); + column_type.kind = ColumnKind::Text { + length: TextLength::Unbounded, + }; + ColumnInfo { + name: name.to_owned(), + column_type, + nullable: true, + primary_key: false, + is_auto_increment: false, + is_generated: false, + default: ColumnDefault::None, + comment: None, + } + } + + fn column(name: &str) -> ImportColumn { + ImportColumn { + info: info(name), + server_filled: false, + } + } + + fn definition(name: &str, identity: IdentityStyle) -> ColumnDefinition { + ColumnDefinition::new( + info(name), + ColumnAttributes { + identity, + ..ColumnAttributes::default() + }, + ) + } + + fn session(script: ConnectionScript) -> (Arc, Arc) { + let (pending, connection) = FakeConnector::pending_session(FakeConnector::request().unwrap(), script); + let session = Session::start( + pending, + SessionDeps { + connector: Arc::new(FakeConnector::new([])), + credentials: Arc::new(FakeCredentialSource::new()), + deadlines: Arc::new(DefaultDeadlines::new(LivenessPolicy::DESKTOP)), + tasks: crate::test_support::paused_tasks(), + prompter: None, + }, + ); + (session, connection) + } + + fn import(bytes: &str) -> CsvImport { + CsvImport { + schema: None, + table: "people".to_owned(), + columns: vec![column("name"), column("city")], + mapping: vec![Some(0), Some(1)], + options: tablepro_core::import::CsvImportOptions::default(), + bytes: bytes.as_bytes().to_vec(), + } + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] + async fn csv_import_reads_columns_through_session() { + let mut script = ConnectionScript::default(); + script.fetch_table_definition.always(FakeReply::ok(TableDefinition { + table: TableRef::unqualified("people"), + columns: vec![ + definition("id", IdentityStyle::MssqlIdentity), + definition("name", IdentityStyle::None), + ], + primary_key: None, + indexes: Vec::new(), + foreign_keys: Vec::new(), + inbound_foreign_keys: Vec::new(), + source: None, + })); + let (session, connection) = session(script); + + let columns = crate::test_support::paused_tasks() + .spawn_task(read_columns(session.clone(), TableRef::unqualified("people"))) + .await + .unwrap() + .unwrap(); + + assert_eq!( + columns.iter().map(|column| column.server_filled).collect::>(), + vec![true, false], + "an identity that refuses a value was offered to the file" + ); + assert_eq!(CallCounters::get(&connection.counters().fetch_table_definition), 1); + assert_eq!(*session.health().borrow(), SessionHealth::Online); + } + + fn run(session: Arc, import: CsvImport, cancel: CancellationToken) -> ImportRun { + ImportRun { + session, + dialect: Arc::new(tablepro_test_support::FakeDialect), + import, + cancel, + tasks: crate::test_support::paused_tasks(), + history: None, + connection: None, + } + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] + async fn an_import_writes_its_rows_as_one_multi_row_statement() { + let mut script = ConnectionScript::default(); + script + .write_batch + .always(FakeReply::ok(WriteReport::new(vec![Some(2)]))); + let (session, connection) = session(script); + let started = tokio::time::Instant::now(); + + let rows = run_import(run( + session, + import("name,city\nAda,London\nGrace,\n"), + CancellationToken::new(), + )) + .await + .unwrap(); + + assert_eq!(rows, 2); + assert_eq!(CallCounters::get(&connection.counters().write_batch), 1); + let sql = connection.observed_sql("write_batch"); + assert!( + sql.iter() + .flatten() + .any(|sql| sql.contains("VALUES (") && sql.contains("), (")), + "the rows went one statement each: {sql:?}" + ); + let deadline = connection.observed_deadlines("write_batch")[0].expect("a deadline"); + assert!( + deadline >= started + DefaultDeadlines::ROW_CHANGES + IMPORT_TIME_PER_STATEMENT, + "the deadline did not grow with the import" + ); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] + async fn a_stopped_import_says_it_stopped() { + let mut script = ConnectionScript::default(); + script.write_batch.always(FakeReply::UntilStopped); + let (session, connection) = session(script); + let cancel = CancellationToken::new(); + let stop = cancel.clone(); + + let running = crate::test_support::paused_tasks().spawn_task(run_import(run( + session, + import("name,city\nAda,London\n"), + cancel, + ))); + while CallCounters::get(&connection.counters().write_batch) == 0 { + tokio::task::yield_now().await; + } + stop.cancel(); + + assert!(matches!(running.await.unwrap(), Err(ImportEnd::Stopped))); + } + + #[test] + fn a_failed_statement_is_told_as_the_lines_it_held() { + let steps = vec![0..1_000, 1_000..1_500]; + let rolled_back = OperationError::Driver(DriverError::RolledBack { + statement_index: 1, + source: Box::new(DriverError::server("null value in column \"city\"")), + }); + let partial = OperationError::Driver(DriverError::PartiallyApplied { + applied: 1, + statement_index: 1, + failed_outcome: tablepro_core::StatementOutcome::Failed, + source: Box::new(DriverError::server("null value in column \"city\"")), + }); + + let rolled_back = import_failure_text(&rolled_back, &steps, 2); + let partial = import_failure_text(&partial, &steps, 2); + + assert!( + rolled_back.starts_with("Lines 1002 to 1501 could not be written: "), + "{rolled_back}" + ); + assert!( + rolled_back.ends_with("The import was rolled back, so no rows were added."), + "{rolled_back}" + ); + assert!( + partial.contains("so the 1000 rows before them stay, and some of these lines may"), + "{partial}" + ); + } + + #[test] + fn a_bad_row_names_its_line_in_the_file() { + let mut bad = import("name,city\nAda,London\n"); + bad.columns[1].info.column_type.kind = ColumnKind::Integer(tablepro_core::column::IntegerKind { + width: tablepro_core::column::IntegerWidth::W32, + signed: true, + }); + + let message = import_rows(&bad).unwrap_err(); + + assert!(message.contains('2'), "{message}"); + assert!(message.contains("London"), "{message}"); + } + + fn read(path: &std::path::Path) -> Result, String> { + let file = gio::File::for_path(path); + crate::test_support::finish_on_main( + std::time::Duration::from_secs(10), + async move { read_csv_file(&file).await }, + ) + .expect("the read to end") + } + + #[gtk4::test] + fn a_pipe_is_refused_without_waiting_for_a_writer() { + let root = tempfile::tempdir().unwrap(); + let path = root.path().join("people.csv"); + assert!( + std::process::Command::new("mkfifo") + .arg(&path) + .status() + .unwrap() + .success() + ); + + let refused = read(&path).unwrap_err(); + + assert!(refused.contains("regular files"), "{refused}"); + } + + #[gtk4::test] + fn a_device_is_refused_before_it_is_read() { + let refused = read(std::path::Path::new("/dev/zero")).unwrap_err(); + + assert!(refused.contains("regular files"), "{refused}"); + } + + #[gtk4::test] + fn a_csv_is_read_whole() { + let root = tempfile::tempdir().unwrap(); + let path = root.path().join("people.csv"); + std::fs::write(&path, "name,city\nAda,London\n").unwrap(); + + assert_eq!(read(&path).unwrap(), b"name,city\nAda,London\n"); + } + + #[test] + fn an_empty_field_is_kept_as_what_the_file_said() { + let rows = import_rows(&import("name,city\nGrace,\n")).unwrap(); + + assert_eq!(rows.len(), 1); + assert_eq!(rows[0][0], CellInput::Value(Value::Text("Grace".to_owned()))); + } +} diff --git a/linux/crates/app/src/ui/workspace/import_ledger.rs b/linux/crates/app/src/ui/workspace/import_ledger.rs new file mode 100644 index 0000000000..0c5fb8d009 --- /dev/null +++ b/linux/crates/app/src/ui/workspace/import_ledger.rs @@ -0,0 +1,263 @@ +use std::cell::RefCell; +use std::collections::BTreeMap; +use std::future::Future; +use std::rc::Rc; + +use futures::channel::oneshot; +use futures::future::LocalBoxFuture; +use tablepro_session::runtime::{HoldToken, ProcessHold}; +use tokio_util::sync::CancellationToken; + +use crate::i18n::{gettext, ngettext_f}; +use crate::ui::format::plural_count; +use crate::workspace::{CloseBlockPrompt, CloseBlocker, InhibitReason, WindowInhibitor}; + +/// The CSV imports a workspace has running. +/// +/// Each one holds the process, so quitting while one writes lets it +/// commit or roll back and report, and closing the window asks before +/// it stops one. While any runs, the session is asked not to log out or +/// suspend under it. +pub(crate) struct ImportLedger { + hold: Rc, + inhibitor: RefCell, + state: RefCell, +} + +#[derive(Default)] +struct LedgerState { + next: u64, + running: BTreeMap, + waiters: Vec>, + /// The workspace shut down: its close already asked about the + /// imports, and a new one would have no window to report to. + shut_down: bool, +} + +struct Running { + cancel: CancellationToken, + _hold: HoldToken, +} + +/// A started import: its id to settle it by, and the token that stops +/// it. +#[derive(Debug, Clone)] +pub(crate) struct ImportTicket { + pub id: u64, + pub cancel: CancellationToken, +} + +impl ImportLedger { + pub(crate) fn new(hold: Rc, inhibitor: WindowInhibitor) -> Rc { + Rc::new(Self { + hold, + inhibitor: RefCell::new(inhibitor), + state: RefCell::default(), + }) + } + + /// Record an import, unless the workspace has shut down. + pub(crate) fn begin(&self) -> Option { + let ticket = { + let mut state = self.state.borrow_mut(); + if state.shut_down { + return None; + } + state.next += 1; + let ticket = ImportTicket { + id: state.next, + cancel: CancellationToken::new(), + }; + state.running.insert( + ticket.id, + Running { + cancel: ticket.cancel.clone(), + _hold: self.hold.acquire(), + }, + ); + ticket + }; + self.refresh_inhibitor(); + Some(ticket) + } + + /// The import ended. A second call for the same one does nothing. + pub(crate) fn settle(&self, id: u64) { + let (finished, woken) = { + let mut state = self.state.borrow_mut(); + let finished = state.running.remove(&id); + let woken = match state.running.is_empty() { + true => std::mem::take(&mut state.waiters), + false => Vec::new(), + }; + (finished, woken) + }; + self.refresh_inhibitor(); + // The hold goes only after the state is consistent, in case + // dropping it ends the main loop. + drop(finished); + for waiter in woken { + let _ = waiter.send(()); + } + } + + pub(crate) fn in_flight(&self) -> usize { + self.state.borrow().running.len() + } + + /// Resolves once no import is running. + pub(crate) fn when_idle(&self) -> impl Future + use<> { + let receiver = match self.in_flight() { + 0 => None, + _ => { + let (sender, receiver) = oneshot::channel(); + self.state.borrow_mut().waiters.push(sender); + Some(receiver) + } + }; + async move { + if let Some(receiver) = receiver { + // A ledger dropped with imports running wakes nobody, + // and a waiter that outlived it has nothing to wait on. + let _ = receiver.await; + } + } + } + + /// Stop every running import. Each settles once its driver has + /// answered the stop. + pub(crate) fn cancel_all(&self) { + for running in self.state.borrow().running.values() { + running.cancel.cancel(); + } + } + + /// Stop every running import and refuse new ones, for a workspace + /// that is going away. + pub(crate) fn shut_down(&self) { + self.state.borrow_mut().shut_down = true; + self.cancel_all(); + } + + fn refresh_inhibitor(&self) { + let reason = (self.in_flight() > 0).then_some(InhibitReason::SavingChanges); + self.inhibitor.borrow_mut().update(reason); + } +} + +impl CloseBlocker for ImportLedger { + fn blocking(&self) -> Option { + let running = self.in_flight(); + (running > 0).then(|| CloseBlockPrompt { + heading: gettext("Stop Imports?"), + body: ngettext_f( + "{n} CSV import is still running. Stopping it rolls back the rows it wrote, unless the table has no transactions.", + "{n} CSV imports are still running. Stopping them rolls back the rows they wrote, unless the table has no transactions.", + plural_count(running as u64), + &[("n", &running.to_string())], + ), + keep_label: gettext("_Keep Importing"), + stop_label: gettext("_Stop Imports"), + }) + } + + fn stop(&self) -> LocalBoxFuture<'static, ()> { + self.cancel_all(); + Box::pin(self.when_idle()) + } +} + +impl std::fmt::Debug for ImportLedger { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ImportLedger") + .field("in_flight", &self.in_flight()) + .finish_non_exhaustive() + } +} + +#[cfg(test)] +mod tests { + use std::cell::Cell; + + use gtk4 as gtk; + use tablepro_session::runtime::CountingHold; + + use super::*; + use crate::workspace::InhibitTarget; + + /// Counts the inhibitions held. + #[derive(Clone, Default)] + struct Session { + held: Rc>, + } + + impl InhibitTarget for Session { + fn inhibit(&self, _flags: gtk::ApplicationInhibitFlags, _reason: &str) -> u32 { + self.held.set(self.held.get() + 1); + self.held.get() + } + + fn uninhibit(&self, _cookie: u32) { + self.held.set(self.held.get() - 1); + } + } + + fn ledger() -> (Rc, Rc, Session) { + let hold = Rc::new(CountingHold::new()); + let session = Session::default(); + let ledger = ImportLedger::new(hold.clone(), WindowInhibitor::new(session.clone())); + (ledger, hold, session) + } + + #[test] + fn a_running_import_holds_the_process_and_asks_before_a_close() { + let (ledger, hold, session) = ledger(); + assert_eq!(ledger.blocking(), None); + + let ticket = ledger.begin().expect("an import starts"); + let prompt = ledger.blocking().expect("a running import blocks the close"); + + assert_eq!(prompt.heading, "Stop Imports?"); + assert!( + prompt.body.starts_with("1 CSV import is still running."), + "{}", + prompt.body + ); + assert_eq!(hold.live(), 1); + assert_eq!(session.held.get(), 1); + + ledger.settle(ticket.id); + + assert_eq!(ledger.blocking(), None); + assert_eq!(hold.live(), 0); + assert_eq!(session.held.get(), 0); + } + + #[tokio::test] + async fn stop_cancels_the_import_and_waits_for_it_to_settle() { + let (ledger, _, _) = ledger(); + let ticket = ledger.begin().expect("an import starts"); + + let mut stopped = ledger.stop(); + assert!(ticket.cancel.is_cancelled(), "the stop did not reach the write"); + assert!( + futures::poll!(&mut stopped).is_pending(), + "the stop ended before the import settled" + ); + ledger.settle(ticket.id); + + stopped.await; + assert_eq!(ledger.in_flight(), 0); + } + + #[test] + fn a_workspace_that_shut_down_starts_no_import() { + let (ledger, _, _) = ledger(); + let running = ledger.begin().expect("an import starts"); + + ledger.shut_down(); + + assert!(running.cancel.is_cancelled()); + assert!(ledger.begin().is_none()); + } +} diff --git a/linux/crates/app/src/ui/workspace/mod.rs b/linux/crates/app/src/ui/workspace/mod.rs new file mode 100644 index 0000000000..9d7e546c5b --- /dev/null +++ b/linux/crates/app/src/ui/workspace/mod.rs @@ -0,0 +1,1058 @@ +//! One connection session's workspace: the sidebar of its tables and +//! the tabs open on them. A workspace never changes session; a +//! disconnect drops it and the window shows the connection picker. + +mod close_flow; +mod editor_label; +mod finished_work; +mod health_banner; +mod imp; +mod import; +mod import_ledger; +mod row_ops; +mod saves; +mod session_watches; +mod shell; +mod sidebar; +mod status_pages; +mod structure; +mod structure_target; +mod tab_factory; +mod tab_identity; +mod tab_index; +mod tab_spec; +mod tab_title; +mod table_naming; +#[cfg(test)] +mod tests; +mod unsaved_icon; +mod window_tabs; +mod window_title; +mod workspace_actions; +mod workspace_persistence; +mod workspace_tab_view; +mod workspace_tabs; + +use std::cell::{Cell, RefCell}; +use std::collections::{HashMap, VecDeque}; +use std::rc::Rc; +use std::sync::Arc; + +use gtk4 as gtk; +use gtk4::glib; +use gtk4::prelude::*; +use gtk4::subclass::prelude::*; +use libadwaita as adw; +use libadwaita::prelude::*; +use relm4::{ComponentController, Controller}; + +use tablepro_core::EditorSession; +use tablepro_core::meta::{ObjectInfo, SchemaInfo}; +use tablepro_session::{ObjectListSnapshot, Session, SessionHealth, SessionKey}; +use uuid::Uuid; + +use self::session_watches::SessionWatches; +use super::browse_tab::BrowseTab; +use super::editor::SqlEditor; +use super::export::{ExportLedger, ExportLedgerInit, ExportOffer, ExportReporter}; +use super::history_dialog::HistoryDialog; +use super::host_key_failure::HostKeyFailure; +use super::host_key_review::HostKeyReviewOutcome; +use crate::application::AppServices; +use crate::runtime::{DeliverInput, InputSender}; +use crate::services::change_tracker_registry::ChangeTrackerRegistry; +use crate::services::session_context::SessionContext; +use crate::services::structure_tracker_registry::StructureTrackerRegistry; +use crate::workspace::{ApplicationInhibit, CloseBlocker, CloseGuards, WindowInhibitor, WorkspaceHandle}; + +glib::wrapper! { + pub struct Workspace(ObjectSubclass) + @extends adw::Bin, gtk::Widget, + @implements gtk::Accessible, gtk::Buildable, gtk::ConstraintTarget; +} + +/// What a window hands the workspace it builds for an adopted session. +pub(crate) struct WorkspaceInit { + pub key: SessionKey, + pub session: Arc, + pub services: Rc, + /// The window's own, whose session slot the workspace does not use. + pub context: Rc, + pub window: adw::ApplicationWindow, + /// The window's prompts, so a tab's close and the window's never ask + /// at once. + pub close_guards: Rc, +} + +pub(crate) type WorkspaceSender = InputSender; + +pub(crate) struct WorkspaceModel { + context: Rc, + /// What the session's tabs are saved under. + key: SessionKey, + input: WorkspaceSender, + this: glib::WeakRef, + window: glib::WeakRef, + settings: Rc, + storage: crate::storage::SharedStorage, + tasks: tablepro_session::runtime::Tasks, + history: crate::services::history_service::HistoryService, + drafts: Rc, + window_title: adw::WindowTitle, + sidebar_title: adw::WindowTitle, + /// The header bars' buttons and bindings the workspace set up, let go + /// of at shutdown. + shell: shell::ShellHandles, + /// Watches `HistoryService` and flips `win.show-history`. Aborted + /// with the workspace, whose actions it acts on. + history_gate: glib::JoinHandle<()>, + /// The window's actions this workspace added, removed with it. + actions: Vec<&'static str>, + sidebar: sidebar::SidebarView, + /// The schemas the sidebar's sections are made from, which also + /// decide whether tab titles name their schema. + catalog_schemas: Arc<[SchemaInfo]>, + /// The last schema read the sidebar asked for; an older one landing + /// late is not shown. + sidebar_read: u64, + /// Whether every schema's list has landed once. Until then the + /// sidebar shows the default schema's. + all_schemas_listed: bool, + toast_overlay: adw::ToastOverlay, + reconnect_banner: adw::Banner, + /// Says the sidebar's list could not be read again, until a read + /// lands. + catalog_banner: adw::Banner, + /// Says the saved tabs could not be read, until Reset moves them + /// aside. + layout_banner: adw::Banner, + row_op_spinner: adw::Spinner, + table_search: gtk::SearchEntry, + /// Removes a changed host key from known_hosts. `None` without ssh. + ssh: Option, + file_revealer: Rc, + /// Held until a Replace's ssh-keygen finishes too, so a second + /// Replace cannot remove the key the first one's retry wrote. + reviewing_host_key: bool, + tab_view: adw::TabView, + /// Which tab each page shows. + index: Rc, + /// Builds every tab the workspace opens. + factory: Rc, + /// Whether tab titles name the schema, from the session's schemas. + naming: Rc>, + /// Per-tab state, by tab id. Display order is read from the tab + /// view's pages. + workspace_tabs: Rc>>, + /// Pending row edits, one tracker per Table tab. + change_trackers: Rc, + /// Pending DDL, one tracker per Structure tab. + structure_trackers: Rc, + /// Starts, stops and settles the saves, and says what closing any + /// part of the workspace would lose. + workspace_handle: WorkspaceHandle, + close_guards: Rc, + /// The exports running from this workspace's grids. + exports: Rc, + /// The CSV imports running into this workspace's tables. + imports: Rc, + /// Whether the window was last told there is something to lose. + reported_dirty: Cell>, + history_dialog: Option, + saved_queries_dialog: Option>, + import_dialog: Option>, + driver_id: String, + /// The driver as the user knows it, for the header's subtitle. + driver_name: String, + read_only: bool, + /// Writes the tab list a moment after the last change. + workspace: Rc, + /// Recently closed tabs for Ctrl+Shift+T. Pending edits are not + /// kept: the close has already cleared them. + closed_tabs_stack: Rc>>, + _watches: SessionWatches, + /// Tells the user once when editor drafts start failing to land. + _draft_failures: tablepro_session::runtime::WatchForwarder, +} + +/// The workspace's own slot: a tab reads it for the session, and it +/// empties when the workspace goes. +fn session_slot(window_context: &SessionContext, session: &Arc) -> Rc { + let context = Rc::new(SessionContext { + active: crate::services::active_session::ActiveSession::new(), + ..window_context.clone() + }); + context.active.replace(session.clone()); + context +} + +fn window_inhibitor(window: &adw::ApplicationWindow) -> WindowInhibitor { + WindowInhibitor::new(ApplicationInhibit::new(window)) +} + +/// The driver's name, or its id when this build has no such driver. +fn driver_display_name(context: &SessionContext, driver_id: &str) -> String { + context + .drivers + .get(driver_id) + .map_or_else(|| driver_id.to_owned(), |driver| driver.display_name().to_owned()) +} + +/// Tells the user once each time editor drafts start failing to land. +fn draft_failure_watch( + drafts: &crate::workspace::DraftWriter, + input: &WorkspaceSender, +) -> tablepro_session::runtime::WatchForwarder { + let failing = input.clone(); + tablepro_session::runtime::WatchForwarder::new( + drafts.failing(), + move |failed: bool| { + if failed { + failing.deliver(WorkspaceInput::DraftsNotSaved); + } + }, + sidebar::spawn_local, + ) +} + +struct WorkspaceTabParts { + tab_view: adw::TabView, + index: Rc, + workspace_tabs: workspace_persistence::WorkspaceTabs, + change_trackers: Rc, + structure_trackers: Rc, + naming: Rc>, + factory: Rc, + workspace_handle: WorkspaceHandle, +} + +/// Snapshot of a tab the user just closed, retained for Ctrl+Shift+T +/// reopen, so a reopen builds its tab the way a restore does, through +/// a `TabSpec`. +#[derive(Debug, Clone)] +pub enum ClosedTabDescriptor { + Editor { + query: String, + file: Option, + }, + Table { + schema: Option, + table: String, + offset: u64, + page_size: u64, + sort: Option<(usize, bool)>, + }, + Structure { + schema: Option, + table: String, + }, +} + +pub(super) const CLOSED_TABS_CAPACITY: usize = 10; + +pub struct EditorTabSlot { + pub editor: SqlEditor, + pub page: adw::TabPage, + /// The file this tab's text is written to. The text itself lives in + /// the buffer, not here. + pub draft: tablepro_storage::DraftId, + /// Its number among the editor tabs when it opened, which names it + /// while it has no text. + pub ordinal: usize, + /// What the tab is named after, kept so titling the tabs again does + /// not read the buffer. + pub label: editor_label::EditorLabel, +} + +pub struct StructureTabSlot { + pub id: Uuid, + pub structure: crate::ui::structure_tab::StructureTab, + pub page: adw::TabPage, + pub schema: Option, + /// Empty in `New` mode until SaveCompleted carries the canonical + /// table name back from the driver. Edit mode populates from the + /// sidebar click or restore record. + pub table: String, + pub mode: crate::ui::structure_tab::StructureMode, +} + +/// A tab showing one table's rows. The table's structure opens in a +/// tab of its own: one TabPage per surface, as GNOME Files and Builder +/// do. +pub struct TableTabSlot { + pub id: Uuid, + pub page: adw::TabPage, + pub schema: Option, + pub table: String, + pub browse: BrowseTab, +} + +/// A tab in the unified workspace. +/// +/// - **Table**: one (schema, table) entity in Data (Browse grid) +/// view. Default for every sidebar single-click. +/// - **Editor**: a free-form SQL workspace, orthogonal to any one +/// table. +/// - **Structure**: the DDL editor, for a new table's draft or an +/// existing table. +pub enum WorkspaceTab { + Editor(EditorTabSlot), + Structure(StructureTabSlot), + Table(TableTabSlot), +} + +impl WorkspaceTab { + /// The Browse tab. Only `Table` slots carry one. + pub fn browse_tab(&self) -> Option<&BrowseTab> { + match self { + WorkspaceTab::Table(s) => Some(&s.browse), + _ => None, + } + } + + /// The Structure-side controller. Only `Structure` slots carry + /// one (Table slots no longer fuse the DDL editor in). + pub fn structure_tab(&self) -> Option<&crate::ui::structure_tab::StructureTab> { + match self { + WorkspaceTab::Structure(s) => Some(&s.structure), + _ => None, + } + } + + /// `(schema, table)` when the slot is pinned to one. Editor + /// returns `None`. + pub fn schema_table(&self) -> Option<(Option<&str>, &str)> { + match self { + WorkspaceTab::Structure(s) => Some((s.schema.as_deref(), &s.table)), + WorkspaceTab::Table(s) => Some((s.schema.as_deref(), &s.table)), + WorkspaceTab::Editor(_) => None, + } + } + + pub fn page(&self) -> &adw::TabPage { + match self { + WorkspaceTab::Editor(slot) => &slot.page, + WorkspaceTab::Structure(slot) => &slot.page, + WorkspaceTab::Table(slot) => &slot.page, + } + } + + /// What the tab's title is made from. + pub(crate) fn identity(&self) -> tab_identity::TabIdentity { + use tab_identity::TabIdentity; + match self { + WorkspaceTab::Editor(slot) => TabIdentity::Editor(slot.label.clone()), + WorkspaceTab::Structure(slot) => TabIdentity::Structure(structure_target::StructureTarget::of( + slot.schema.as_deref(), + &slot.table, + )), + WorkspaceTab::Table(slot) => TabIdentity::Table(tablepro_core::meta::TableRef::new( + slot.schema.clone(), + slot.table.clone(), + )), + } + } +} + +/// Give `page` its title and tooltip. libadwaita treats an empty tooltip +/// as none, which clears the last one. The tooltip says what the unsaved +/// icon shows, since a screen reader reads a tab's tooltip and not its +/// icon. +pub(crate) fn apply_tab_title(page: &adw::TabPage, title: &tab_title::TabTitle) { + page.set_title(&title.label); + let unsaved = page.icon().is_some().then(|| crate::i18n::gettext("Unsaved changes")); + let lines: Vec<&str> = [title.tooltip.as_deref(), unsaved.as_deref()] + .into_iter() + .flatten() + .collect(); + page.set_tooltip(&lines.join("\n")); +} + +#[derive(Debug, Clone, Copy)] +pub enum OpenMode { + /// Plain sidebar click: activate the table's tab, or append one. + SwitchOrAppend, + /// Ctrl+click / right-click "Open in new tab": always append a + /// new tab even when the same table is already open. + NewTab, +} + +/// A dialog the user already closed has left its window, and closing it +/// again logs a critical. +fn close_if_presented(dialog: &adw::Dialog) { + if dialog.parent().is_some() { + dialog.close(); + } +} + +#[derive(Debug)] +pub(crate) enum WorkspaceInput { + HealthChanged(SessionHealth), + /// The banner's Review Key, for a session parked on a host key. + ReviewHostKey, + HostKeyReviewed { + failure: HostKeyFailure, + outcome: HostKeyReviewOutcome, + }, + /// ssh-keygen removed the changed key, or says why it could not. + OldHostKeyForgotten { + failure: HostKeyFailure, + forgotten: Result<(), String>, + }, + /// Every schema's object list landed: a reconnect read it again, or + /// a schema change refreshed it. + CatalogChanged(ObjectListSnapshot), + /// A refresh of that list failed, and the list stays as it was. + CatalogReadFailed(String), + /// Read the session's tables again: the sidebar's Refresh, or a run + /// in the editor that changed them. + RefreshCatalog, + /// The default schema's list landed, as a reconnect commits it. + DefaultListCommitted(ObjectListSnapshot), + /// The schemas the sidebar's sections are made from, read for + /// `objects`. `None` when they could not be read. + SidebarRead { + read: u64, + objects: Arc<[ObjectInfo]>, + schemas: Option>, + }, + /// A reconnect replaced the connection. + LinkChanged, + SelectTable { + schema: Option, + name: String, + open_mode: OpenMode, + }, + NewEditorTab, + CloseActiveWorkspaceTab, + EditorTabRunStateChanged(Uuid, bool), + EditorTabRunFinished(Uuid, crate::workspace::WorkOutcome), + EditorTabContentChanged(Uuid), + /// An editor tab was saved, opened a file, or its text came to + /// differ from its file's or match it again. + EditorTabFileChanged(Uuid), + /// The session's saved tabs and their editor text, read off the GTK + /// thread. + WorkspaceLoaded(workspace_tabs::LoadedWorkspace), + /// The banner's Reset: move the unreadable saved tabs aside. + ResetWorkspaceLayout, + WorkspaceLayoutReset(Result, crate::workspace::WorkspaceSaveError>), + /// An editor draft failed to land after the last one did. + DraftsNotSaved, + ShowHistory, + ShowSavedQueries, + SaveActiveQuery, + SaveQueryNamed { + name: String, + query: String, + }, + ImportCsvRequested { + schema: Option, + table: String, + }, + /// The file came back with the table's columns beside it, so the + /// mapping dialog has everything it needs. + ImportCsvReady { + schema: Option, + table: String, + columns: Vec, + bytes: Vec, + }, + ImportCsvRun(Box), + OpenHistoryQuery(String), + ReplaceActiveTabQuery(String), + TabCloseDecided { + id: Uuid, + approved: bool, + }, + RefreshPage, + ExportResults { + tab: Uuid, + offer: Box, + }, + /// An export a tab offered ended. + ExportEnded { + tab: Uuid, + outcome: crate::workspace::WorkOutcome, + }, + WorkspaceTabClosed(Uuid), + /// Each tab still asks about its own unsaved changes. + CloseOtherWorkspaceTabs(Uuid), + CloseWorkspaceTabsToRight(Uuid), + /// The tab order, the selection or a tab's state changed, so the + /// workspace is saved. + WorkspaceTabsChanged, + SaveTab(Uuid), + StopSaving(Uuid), + /// A save started or settled, or tabs' changes were dropped. + Workspace(crate::workspace::WorkspaceEvent), + SavesInFlightChanged(usize), + /// An editor tab's transaction opened or ended. + EditorTransactionChanged, + ShowAlert { + title: String, + body: String, + }, + ShowToast(String), + /// A table or structure tab gained or lost unsaved changes. + TabDirtyChanged, + NewTableTab { + schema: Option, + }, + EditStructureTab { + schema: Option, + table: String, + }, + ShowCreateTableForExisting { + schema: Option, + table: String, + }, + ShowCreateTableLoaded(String), + DropTablePrompt { + schema: Option, + table: String, + }, + /// The foreign keys on other tables that point at the table, read + /// before the prompt asks to drop it. + DropTableChecked { + table: tablepro_core::TableRef, + referencing: Vec, + }, + DropTableConfirmed { + schema: Option, + table: String, + }, + /// The table is gone, so its tabs close without asking. + DropTableSucceeded(tablepro_core::TableRef), + SchemaChanged { + schema: Option, + table: Option, + }, + ReopenClosedTab, + /// A browse tab's F5. + RefreshTab(Uuid), + /// Bring the tab to the front, for a notification about it. + FocusTab(Uuid), +} + +impl Workspace { + pub(crate) fn new(init: WorkspaceInit) -> Self { + let WorkspaceInit { + key, + session, + services, + context, + window, + close_guards, + } = init; + let workspace: Self = glib::Object::new(); + let imp = workspace.imp(); + let input = workspace.pump_inputs(); + let context = session_slot(&context, &session); + let settings = services.settings.clone(); + let storage = services.storage.clone(); + let tasks = services.tasks.clone(); + let history = services.history(); + let drafts = crate::workspace::DraftWriter::new( + tablepro_storage::DraftStore::new(storage.paths().drafts_dir()), + tasks.clone(), + ); + let persistence = workspace_persistence::WorkspacePersistence::new(storage.workspaces().clone()); + let exports = workspace.export_ledger(&window, &services, &context, &input); + let imports = import_ledger::ImportLedger::new(context.hold.clone(), window_inhibitor(&window)); + let sidebar = sidebar::build(&workspace, &input, sidebar::SidebarAffordances::for_session(&session)); + let WorkspaceTabParts { + tab_view, + index, + workspace_tabs, + change_trackers, + structure_trackers, + naming, + factory, + workspace_handle, + } = workspace.build_tabs(&key, &session, &services, &context, &drafts, &input); + let mut actions = workspace_actions::install(&window, &input); + let shell = shell::install(&workspace, &window, &factory); + actions.push(shell::SHOW_SIDEBAR); + let history_gate = workspace_actions::spawn_history_gate(&window, history.availability()); + let watches = SessionWatches::new(&session, &input); + let draft_failures = draft_failure_watch(&drafts, &input); + + let driver_id = session.metadata().driver_id.clone(); + let driver_name = driver_display_name(&context, &driver_id); + let model = WorkspaceModel { + context, + key, + input, + this: workspace.downgrade(), + window: window.downgrade(), + settings, + storage, + tasks, + history, + drafts, + window_title: imp.window_title.get(), + sidebar_title: imp.sidebar_title.get(), + shell, + history_gate, + actions, + sidebar, + catalog_schemas: Arc::from([]), + sidebar_read: 0, + all_schemas_listed: false, + toast_overlay: imp.toast_overlay.get(), + reconnect_banner: imp.reconnect_banner.get(), + catalog_banner: imp.catalog_banner.get(), + layout_banner: imp.layout_banner.get(), + row_op_spinner: imp.row_op_spinner.get(), + table_search: imp.table_search.get(), + ssh: services.ssh(), + file_revealer: services.file_revealer.clone(), + reviewing_host_key: false, + tab_view, + index, + factory, + naming, + workspace_tabs, + change_trackers, + structure_trackers, + workspace_handle, + close_guards, + exports: exports.clone(), + imports: imports.clone(), + reported_dirty: Cell::new(None), + history_dialog: None, + saved_queries_dialog: None, + import_dialog: None, + driver_id, + driver_name, + read_only: session.read_only(), + workspace: persistence, + closed_tabs_stack: Rc::new(RefCell::new(VecDeque::with_capacity(CLOSED_TABS_CAPACITY))), + _watches: watches, + _draft_failures: draft_failures, + }; + imp.model.replace(Some(model)); + workspace.register_close_blocker(exports); + workspace.register_close_blocker(imports); + workspace + } + + fn pump_inputs(&self) -> WorkspaceSender { + let (input, receiver) = crate::runtime::input_queue::(); + self.imp().pump.replace(Some(crate::runtime::pump_inputs( + self, + receiver, + |workspace, message| workspace.handle_input(message), + ))); + input + } + + /// The exports this workspace's grids start, shown in the header's + /// operations button and reported in its toasts. + fn export_ledger( + &self, + window: &adw::ApplicationWindow, + services: &AppServices, + context: &SessionContext, + input: &WorkspaceSender, + ) -> Rc { + let imp = self.imp(); + let exports = ExportLedger::new(ExportLedgerInit { + tasks: services.tasks.clone(), + hold: context.hold.clone(), + reporter: ExportReporter::new(window, &imp.toast_overlay.get(), services.file_revealer.clone()), + inhibitor: window_inhibitor(window), + }); + imp.export_operations_button.bind(&exports.jobs()); + let ended = input.clone(); + exports.on_ended(move |tab, outcome| ended.deliver(WorkspaceInput::ExportEnded { tab, outcome })); + exports + } + + /// The tab view's registries, the factory that builds every tab into + /// them, and the handle that saves and settles their changes. + fn build_tabs( + &self, + key: &SessionKey, + session: &Arc, + services: &AppServices, + context: &Rc, + drafts: &Rc, + input: &WorkspaceSender, + ) -> WorkspaceTabParts { + let change_trackers = Rc::new(ChangeTrackerRegistry::default()); + let structure_trackers = Rc::new(StructureTrackerRegistry::default()); + let workspace_tabs: workspace_persistence::WorkspaceTabs = Rc::new(RefCell::new(HashMap::new())); + let index = Rc::new(tab_index::TabIndex::default()); + let tab_view = self.imp().tab_view.get(); + let window_tabs = Rc::new(window_tabs::WindowTabs::new( + workspace_tabs.clone(), + index.clone(), + &tab_view, + )); + let events = input.clone(); + let workspace_handle = WorkspaceHandle::new(crate::workspace::WorkspaceDeps { + session: context.active.clone(), + tasks: context.tasks.clone(), + hold: context.hold.clone(), + history: Some(services.history()), + rows: change_trackers.clone(), + structures: structure_trackers.clone(), + directory: window_tabs, + events: Rc::new(move |event| events.deliver(WorkspaceInput::Workspace(event))), + engine: crate::workspace::EngineProfile::of(&context.drivers, &session.metadata().driver_id), + }); + let saves = input.clone(); + workspace_handle + .ledger() + .on_change(move |count| saves.deliver(WorkspaceInput::SavesInFlightChanged(count))); + + let naming = Rc::new(Cell::new(table_naming::TableNaming::default())); + let factory = tab_factory::TabFactory::new(tab_factory::TabFactoryParts { + view: tab_view.clone(), + tabs: workspace_tabs.clone(), + index: index.clone(), + input: input.clone(), + toasts: self.imp().toast_overlay.downgrade(), + context: context.clone(), + settings: services.settings.clone(), + storage: services.storage.clone(), + history: Some(services.history()), + drafts: drafts.clone(), + draft_scope: crate::workspace::WorkspaceFileName::for_key(key).draft_scope(), + change_trackers: change_trackers.clone(), + structure_trackers: structure_trackers.clone(), + read_only: session.read_only(), + naming: naming.clone(), + }); + workspace_tabs::connect_tab_view(&tab_view, &index, input); + WorkspaceTabParts { + tab_view, + index, + workspace_tabs, + change_trackers, + structure_trackers, + naming, + factory, + workspace_handle, + } + } + + /// Show the session: its tables, the empty tab area, and the tabs + /// saved for it. Called once the window listens for the + /// workspace's signals, so it hears the first title. + pub(crate) fn start(&self) { + self.with_model(WorkspaceModel::open); + } + + /// Where the window shows its toasts while it shows this workspace: + /// under the header bars, inside the tab overview. + pub(crate) fn toast_overlay(&self) -> adw::ToastOverlay { + self.imp().toast_overlay.get() + } + + /// What the workspace's actions, tabs and background work send it. + pub(crate) fn input(&self) -> Option { + self.read(|model| model.input.clone()) + } + + /// The saves and the close prompts' view of the tabs. + pub(crate) fn handle(&self) -> Option { + self.read(|model| model.workspace_handle.clone()) + } + + /// Make a value typed into a cell an unsaved change, as the focus + /// leaving the cell does, so the close prompts ask about it. + pub(crate) fn commit_cell_edits(&self) { + for browse in self.read(|model| model.browse_tabs(None)).unwrap_or_default() { + browse.commit_cell_edit(); + } + } + + /// The exports running from this workspace. + pub(crate) fn exports(&self) -> Option> { + self.read(|model| model.exports.clone()) + } + + /// The pending row changes of table tab `tab`. + #[cfg(test)] + pub(crate) fn row_tracker(&self, tab: Uuid) -> Option { + self.read(|model| model.change_trackers.get(tab)).flatten() + } + + /// Have every close of the window ask about `blocker`'s work first. + pub(crate) fn register_close_blocker(&self, blocker: Rc) { + self.with_model(|model| model.workspace_handle.register_close_blocker(blocker)); + } + + /// Write what the workspace shows: the tab list, the editor text, + /// and the last column drag and filter change. The process is held + /// until every write lands. + pub(crate) fn flush(&self) { + self.with_model(|model| model.flush()); + } + + /// Stop every editor run and take each editor tab's session, so the + /// teardown closes them all under one hold. + pub(crate) fn take_editor_sessions(&self) -> Vec> { + self.with_model(|model| model.take_editor_sessions()) + .unwrap_or_default() + } + + /// Stop the tabs, their reads and the watchers, take the + /// workspace's actions off the window and drop what it holds. Any + /// reply still on its way is dropped with the input queue. + pub(crate) fn shutdown(&self) { + let imp = self.imp(); + if let Some(pump) = imp.pump.take() { + pump.abort(); + } + let model = match imp.model.try_borrow_mut() { + Ok(mut model) => model.take(), + Err(_) => { + tracing::warn!("the workspace was shut down while it was handling input"); + return; + } + }; + if let Some(model) = model { + model.shutdown(); + } + } + + pub(crate) fn connect_dirty_changed(&self, f: F) -> glib::SignalHandlerId { + self.connect_closure( + "dirty-changed", + false, + glib::closure_local!(move |workspace: &Self, dirty: bool| f(workspace, dirty)), + ) + } + + pub(crate) fn connect_saves_in_flight_changed(&self, f: F) -> glib::SignalHandlerId { + self.connect_closure( + "saves-in-flight-changed", + false, + glib::closure_local!(move |workspace: &Self, count: u32| { + f(workspace, usize::try_from(count).unwrap_or(usize::MAX)); + }), + ) + } + + pub(crate) fn connect_title_changed(&self, f: F) -> glib::SignalHandlerId { + self.connect_closure( + "title-changed", + false, + glib::closure_local!(move |workspace: &Self, title: String| f(workspace, title)), + ) + } + + fn handle_input(&self, message: WorkspaceInput) { + self.with_model(|model| model.update(message)); + } + + fn with_model(&self, f: impl FnOnce(&mut WorkspaceModel) -> R) -> Option { + let mut model = self.imp().model.try_borrow_mut().ok()?; + model.as_mut().map(f) + } + + fn read(&self, f: impl FnOnce(&WorkspaceModel) -> R) -> Option { + let model = self.imp().model.try_borrow().ok()?; + model.as_ref().map(f) + } +} + +impl WorkspaceModel { + /// The window this workspace is in, for dialogs to sit over. + pub(super) fn window(&self) -> Option { + self.window.upgrade() + } + + /// What a dialog sits over: the window, or the workspace itself + /// while it is not in one. + pub(super) fn dialog_parent(&self) -> Option { + self.window() + .map(Cast::upcast) + .or_else(|| self.this.upgrade().map(Cast::upcast)) + } + + /// Show the session it was built for: the sidebar, the empty tab + /// area, and the tabs saved for it. + fn open(&mut self) { + self.reconnect_banner.set_revealed(false); + self.table_search.set_text(""); + tracing::info!(driver = %self.driver_id, "workspace ready"); + self.open_sidebar(); + self.refresh_window_title(); + self.restore_workspace_tabs(self.key.clone()); + self.refresh_unsaved(); + } + + /// The window is named after the tab in front of the user and the + /// connection; the sidebar's header names the connection and its + /// driver. + pub(super) fn refresh_window_title(&self) { + let metadata = self.context.active.metadata(); + let name = metadata.as_ref().map_or("", |metadata| metadata.name.as_str()); + let active = self.selected_workspace_tab_id().and_then(|id| self.tab_title(id)); + let title = window_title::resolve_window_title(active.as_ref(), name, &self.driver_name, self.read_only); + self.window_title.set_title(&title.heading); + self.window_title.set_subtitle(&title.subtitle); + self.sidebar_title.set_title(name); + self.sidebar_title.set_subtitle(&self.driver_name); + if let Some(workspace) = self.this.upgrade() { + workspace.emit_by_name::<()>("title-changed", &[&title.window]); + } + } + + /// The title tab `id` shows. + pub(super) fn tab_title(&self, id: Uuid) -> Option { + let identity = self.workspace_tabs.borrow().get(&id)?.identity(); + Some(tab_title::resolve_tab_title(&identity, self.naming.get())) + } + + /// Title tab `id` again, after what it shows changed. + pub(super) fn retitle_tab(&self, id: Uuid) { + let Some(title) = self.tab_title(id) else { + return; + }; + if let Some(tab) = self.workspace_tabs.borrow().get(&id) { + apply_tab_title(tab.page(), &title); + } + if self.selected_workspace_tab_id() == Some(id) { + self.refresh_window_title(); + } + } + + /// Title every tab again when the schemas change whether table tabs + /// name their schema. + pub(super) fn on_table_naming(&self, naming: table_naming::TableNaming) { + if self.naming.replace(naming) == naming { + return; + } + let ids: Vec = self.workspace_tabs.borrow().keys().copied().collect(); + for id in ids { + self.retitle_tab(id); + } + self.refresh_window_title(); + } + + fn shutdown(mut self) { + self.history_gate.abort(); + self.sidebar.shutdown(); + self.shell.release(); + // Their entries name this session's tables and queries. + if let Some(dialog) = self.history_dialog.take() { + close_if_presented(dialog.upcast_ref()); + } + if let Some(dialog) = self.saved_queries_dialog.take() { + close_if_presented(dialog.model().dialog()); + } + if let Some(dialog) = self.import_dialog.take() { + close_if_presented(dialog.model().dialog()); + } + if let Some(window) = self.window() { + for action in &self.actions { + window.remove_action(action); + } + } + self.close_tabs(); + // The close asked about them already; one started since has no + // window left to finish in. + self.exports.shut_down(); + self.imports.shut_down(); + self.context.active.take(); + } + + fn update(&mut self, msg: WorkspaceInput) { + match msg { + WorkspaceInput::HealthChanged(health) => self.refresh_health_banner(&health), + WorkspaceInput::ReviewHostKey => self.on_review_host_key(), + WorkspaceInput::HostKeyReviewed { failure, outcome } => self.on_host_key_reviewed(failure, outcome), + WorkspaceInput::OldHostKeyForgotten { failure, forgotten } => { + self.on_old_host_key_forgotten(failure, forgotten); + } + WorkspaceInput::CatalogChanged(snapshot) => self.on_catalog_changed(snapshot), + WorkspaceInput::CatalogReadFailed(message) => self.on_catalog_read_failed(&message), + WorkspaceInput::RefreshCatalog => self.refresh_catalog(), + WorkspaceInput::DefaultListCommitted(snapshot) => self.on_default_list_committed(snapshot), + WorkspaceInput::SidebarRead { read, objects, schemas } => self.on_sidebar_read(read, &objects, schemas), + WorkspaceInput::LinkChanged => self.on_link_changed(), + WorkspaceInput::TabCloseDecided { id, approved } => self.on_tab_close_decided(id, approved), + WorkspaceInput::SelectTable { + schema, + name, + open_mode, + } => self.dispatch_select_table(schema, name, open_mode), + WorkspaceInput::WorkspaceTabsChanged => self.on_workspace_tabs_changed(), + WorkspaceInput::SaveTab(id) => self.request_save(id), + WorkspaceInput::StopSaving(id) => self.stop_saving(id), + WorkspaceInput::Workspace(event) => self.on_workspace_event(event), + WorkspaceInput::SavesInFlightChanged(count) => self.on_saves_in_flight_changed(count), + WorkspaceInput::EditorTransactionChanged => self.refresh_unsaved(), + WorkspaceInput::WorkspaceTabClosed(id) => self.close_workspace_tab_by_id(id), + WorkspaceInput::CloseOtherWorkspaceTabs(id) => self.close_other_workspace_tabs(id), + WorkspaceInput::CloseWorkspaceTabsToRight(id) => self.close_workspace_tabs_to_right(id), + WorkspaceInput::CloseActiveWorkspaceTab => self.close_active_workspace_tab(), + WorkspaceInput::ShowAlert { title, body } => self.show_error_alert(&title, &body), + WorkspaceInput::ShowToast(msg) => self.show_toast(&msg), + WorkspaceInput::TabDirtyChanged => self.refresh_unsaved(), + WorkspaceInput::NewTableTab { schema } => self.on_new_table_tab(schema), + WorkspaceInput::EditStructureTab { schema, table } => self.on_edit_structure_tab(schema, table), + WorkspaceInput::ShowCreateTableForExisting { schema, table } => self.on_show_create_table(schema, table), + WorkspaceInput::ShowCreateTableLoaded(sql) => self.open_editor_tab(Some(sql)), + WorkspaceInput::DropTablePrompt { schema, table } => self.on_drop_table_prompt(schema, table), + WorkspaceInput::DropTableChecked { table, referencing } => self.on_drop_table_checked(table, &referencing), + WorkspaceInput::DropTableConfirmed { schema, table } => self.on_drop_table_confirmed(schema, table), + WorkspaceInput::DropTableSucceeded(table) => self.on_drop_table_succeeded(table), + WorkspaceInput::SchemaChanged { schema, table } => self.on_schema_changed(schema, table), + WorkspaceInput::NewEditorTab => self.open_editor_tab(None), + WorkspaceInput::EditorTabRunStateChanged(id, running) => self.on_editor_tab_run_state_changed(id, running), + WorkspaceInput::EditorTabRunFinished(id, outcome) => { + self.report_work(id, crate::workspace::WorkKind::Query, outcome); + self.on_editor_run_finished(id); + } + WorkspaceInput::EditorTabContentChanged(id) => self.on_editor_tab_content_changed(id), + WorkspaceInput::EditorTabFileChanged(id) => self.on_editor_tab_file_changed(id), + WorkspaceInput::WorkspaceLoaded(loaded) => self.on_workspace_loaded(loaded), + WorkspaceInput::ResetWorkspaceLayout => self.on_reset_workspace_layout(), + WorkspaceInput::WorkspaceLayoutReset(outcome) => self.on_workspace_layout_reset(outcome), + WorkspaceInput::DraftsNotSaved => self.show_toast(&crate::i18n::gettext( + "Editor text could not be saved to disk, so it may not come back after TablePro restarts.", + )), + WorkspaceInput::ShowHistory => self.on_show_history(), + WorkspaceInput::ShowSavedQueries => self.on_show_saved_queries(), + WorkspaceInput::ImportCsvRequested { schema, table } => self.on_import_csv_requested(schema, table), + WorkspaceInput::ImportCsvReady { + schema, + table, + columns, + bytes, + } => self.on_import_csv_ready(schema, table, columns, bytes), + WorkspaceInput::ImportCsvRun(import) => self.on_import_csv_run(*import), + WorkspaceInput::SaveActiveQuery => self.on_save_active_query(), + WorkspaceInput::SaveQueryNamed { name, query } => self.on_save_query_named(name, query), + WorkspaceInput::OpenHistoryQuery(text) => self.open_editor_tab(Some(text)), + WorkspaceInput::ReplaceActiveTabQuery(text) => self.on_replace_active_tab_query(text), + WorkspaceInput::RefreshPage => self.on_refresh_active_tab(), + WorkspaceInput::ExportResults { tab, offer } => { + if let Some(window) = self.window() { + let dialect = self.context.active.get().and_then(|session| session.current_dialect()); + super::export::dialog::present(super::export::dialog::ExportDialogInit { + parent: &window, + exports: &self.exports, + offer: *offer, + dialect, + settings: &self.settings, + tab, + }); + } + } + WorkspaceInput::ExportEnded { tab, outcome } => { + self.report_work(tab, crate::workspace::WorkKind::Export, outcome); + } + WorkspaceInput::ReopenClosedTab => self.on_reopen_closed_tab(), + WorkspaceInput::RefreshTab(id) => self.on_refresh_tab(id), + WorkspaceInput::FocusTab(id) => self.focus_tab(id), + } + } +} diff --git a/linux/crates/app/src/ui/workspace/row_ops.rs b/linux/crates/app/src/ui/workspace/row_ops.rs new file mode 100644 index 0000000000..06493739f9 --- /dev/null +++ b/linux/crates/app/src/ui/workspace/row_ops.rs @@ -0,0 +1,9 @@ +use gtk4::prelude::*; + +use super::WorkspaceModel; + +impl WorkspaceModel { + pub(super) fn set_row_op_in_flight(&self, in_flight: bool) { + self.row_op_spinner.set_visible(in_flight); + } +} diff --git a/linux/crates/app/src/ui/workspace/saves.rs b/linux/crates/app/src/ui/workspace/saves.rs new file mode 100644 index 0000000000..e1cb119244 --- /dev/null +++ b/linux/crates/app/src/ui/workspace/saves.rs @@ -0,0 +1,144 @@ +use gtk4::glib; +use gtk4::prelude::*; +use libadwaita as adw; +use uuid::Uuid; + +use tablepro_core::WriteKind; + +use super::{WorkspaceInput, WorkspaceModel}; +use crate::runtime::DeliverInput; +use crate::ui::{browse_tab, structure_tab}; +use crate::workspace::{ + CloseScope, DirtyTab, SaveOutcome, SaveSettled, SaveStart, WorkKind, WorkOutcome, WorkspaceEvent, +}; + +impl WorkspaceModel { + /// Save one tab, from its own Save button or Ctrl+S. A structure + /// save may wait on its review first. + pub(super) fn request_save(&self, tab: Uuid) { + let Some(parent) = self.dialog_parent() else { + return; + }; + let (handle, input) = (self.workspace_handle.clone(), self.input.clone()); + glib::spawn_future_local(async move { + for start in handle.save(&parent, &[tab]).await { + match start { + SaveStart::Started(save) => tracing::debug!(?save, %tab, "save started"), + SaveStart::Rejected(rejection) if rejection.needs_alert() => { + input.deliver(WorkspaceInput::ShowAlert { + title: crate::i18n::gettext("Cannot Save"), + body: rejection.message(), + }); + } + SaveStart::Saved | SaveStart::NothingToSave | SaveStart::Rejected(_) => {} + } + } + }); + } + + pub(super) fn stop_saving(&self, tab: Uuid) { + self.workspace_handle.stop_saves(CloseScope::Tab(tab)); + } + + pub(super) fn on_workspace_event(&mut self, event: WorkspaceEvent) { + match event { + WorkspaceEvent::SaveStarted { tab } => { + self.dispatch_to_tab(tab, browse_tab::SaveInput::SaveStarted); + self.send_to_structure_tab(tab, structure_tab::SaveInput::SaveStarted); + } + WorkspaceEvent::SaveSettled { settled, in_prompt } => self.on_save_settled(*settled, in_prompt), + WorkspaceEvent::Discarded { tabs, reload } => { + for tab in tabs { + let browse = match reload { + true => browse_tab::SaveInput::DiscardAndRefresh, + false => browse_tab::SaveInput::DiscardAll, + }; + self.dispatch_to_tab(tab, browse); + let structure = match reload { + true => structure_tab::SaveInput::Reload, + false => structure_tab::SaveInput::Discard, + }; + self.send_to_structure_tab(tab, structure); + } + } + } + self.refresh_unsaved(); + } + + pub(super) fn on_saves_in_flight_changed(&mut self, count: usize) { + self.set_row_op_in_flight(count > 0); + if let Some(workspace) = self.this.upgrade() { + let count = u32::try_from(count).unwrap_or(u32::MAX); + workspace.emit_by_name::<()>("saves-in-flight-changed", &[&count]); + } + self.refresh_unsaved(); + } + + fn on_save_settled(&mut self, settled: SaveSettled, in_prompt: bool) { + let SaveSettled { + tab, + kind, + outcome, + failing, + saved_table_name, + origins, + } = settled; + let work = WorkOutcome::from(&outcome); + match (kind, outcome) { + (WriteKind::RowChanges, SaveOutcome::Committed { .. }) => { + // No Undo button: the save has already committed, and + // Ctrl+Z is for edits before Save. A shorter timeout + // keeps the confirmation from outstaying the moment. + let toast = adw::Toast::builder() + .title(crate::i18n::gettext("Saved")) + .use_markup(false) + .timeout(4) + .build(); + self.toast_overlay.add_toast(toast); + self.dispatch_to_tab(tab, browse_tab::SaveInput::SaveCommitted(origins)); + } + (WriteKind::RowChanges, SaveOutcome::RolledBack(cause)) => { + // The row behind the failing step is flashed before the + // error shows, so the user sees which one it was. + if let Some(source) = failing { + self.dispatch_to_tab(tab, browse_tab::SaveInput::FlashErrorRow(source)); + } + self.dispatch_to_tab(tab, browse_tab::SaveInput::SaveFailed(cause.message())); + } + (WriteKind::RowChanges, SaveOutcome::Unknown(cause)) => { + let message = cause.sentence(&self.tab_name(tab)); + self.dispatch_to_tab(tab, browse_tab::SaveInput::SaveOutcomeUnknown(message)); + } + (WriteKind::SchemaChanges, SaveOutcome::Committed { .. }) => { + self.on_structure_save_completed(tab, saved_table_name); + } + (WriteKind::SchemaChanges, SaveOutcome::RolledBack(cause)) => { + let input = match cause.found_changes() { + true => structure_tab::SaveInput::TableChangedBeforeSave, + false => structure_tab::SaveInput::SaveFailed(cause.message()), + }; + self.send_to_structure_tab(tab, input); + } + (WriteKind::SchemaChanges, SaveOutcome::Unknown(cause)) => { + let message = cause.sentence(&self.tab_name(tab)); + self.send_to_structure_tab(tab, structure_tab::SaveInput::SaveOutcomeUnknown(message)); + } + } + // The close prompt waited for the save in front of the user, and + // goes on to close the tab or asks about it again. + if !in_prompt { + self.report_work(tab, WorkKind::Save, work); + } + } + + /// The tab's name for a sentence, quoted as the close prompts quote + /// it. + fn tab_name(&self, tab: Uuid) -> String { + let title = self + .workspace_tabs + .borrow() + .get(&tab) + .map(|slot| slot.page().title().to_string()); + DirtyTab::quote(&title.unwrap_or_default()) + } +} diff --git a/linux/crates/app/src/ui/workspace/session_watches.rs b/linux/crates/app/src/ui/workspace/session_watches.rs new file mode 100644 index 0000000000..cd55ee7ef9 --- /dev/null +++ b/linux/crates/app/src/ui/workspace/session_watches.rs @@ -0,0 +1,53 @@ +use std::sync::Arc; + +use tablepro_session::Session; +use tablepro_session::runtime::WatchForwarder; + +use super::{WorkspaceInput, WorkspaceSender, sidebar}; +use crate::runtime::DeliverInput; + +/// Feed the reconnect banner and the sidebar, and stop with the +/// workspace, so nothing reads for a session it let go of. +pub(super) struct SessionWatches { + _health: WatchForwarder, + _catalog: WatchForwarder, + _reconnect: WatchForwarder, + _link: WatchForwarder, +} + +impl SessionWatches { + pub(super) fn new(session: &Arc, input: &WorkspaceSender) -> Self { + let health_input = input.clone(); + let health = WatchForwarder::new( + session.health(), + move |health| health_input.deliver(WorkspaceInput::HealthChanged(health)), + sidebar::spawn_local, + ); + let (listed, failed) = (input.clone(), input.clone()); + let catalog = sidebar::catalog_watch( + session, + move |snapshot| listed.deliver(WorkspaceInput::CatalogChanged(snapshot)), + move |error| { + failed.deliver(WorkspaceInput::CatalogReadFailed( + crate::ui::error_text::operation_error_message(&error), + )); + }, + ); + let committed = input.clone(); + let reconnect = sidebar::reconnect_watch(session, move |snapshot| { + committed.deliver(WorkspaceInput::DefaultListCommitted(snapshot)); + }); + let relinked = input.clone(); + let link = WatchForwarder::new( + session.link_changes(), + move |_| relinked.deliver(WorkspaceInput::LinkChanged), + sidebar::spawn_local, + ); + Self { + _health: health, + _catalog: catalog, + _reconnect: reconnect, + _link: link, + } + } +} diff --git a/linux/crates/app/src/ui/workspace/shell.rs b/linux/crates/app/src/ui/workspace/shell.rs new file mode 100644 index 0000000000..5e7a3b23db --- /dev/null +++ b/linux/crates/app/src/ui/workspace/shell.rs @@ -0,0 +1,513 @@ +//! The workspace's two header bars, the sidebar's and the content's, +//! and what they need beyond the template: the main menu, the button +//! labels, the sidebar toggle and the overview's new tab. + +use std::rc::Rc; + +use gtk4 as gtk; +use gtk4::glib; +use gtk4::prelude::*; +use gtk4::subclass::prelude::*; +use libadwaita as adw; + +use super::Workspace; +use super::tab_factory::TabFactory; +use crate::ui::a11y::describe_icon_button; +use crate::ui::shell::{TAB_OVERVIEW_MENU, app_menu, primary_menu_button}; + +/// The window action that shows and hides the sidebar, as GNOME Files +/// names it. +pub(super) const SHOW_SIDEBAR: &str = "show-sidebar"; + +/// What `install` connected outside the template, let go of at shutdown. +pub(super) struct ShellHandles { + overview: glib::WeakRef, + create_tab: Option, + sidebar_toggle: Option, +} + +impl ShellHandles { + pub(super) fn release(&mut self) { + if let Some(watch) = self.sidebar_toggle.take() { + watch.unwatch(); + } + if let Some(handler) = self.create_tab.take() + && let Some(overview) = self.overview.upgrade() + { + overview.disconnect(handler); + } + } +} + +/// Run as the workspace is built, so its buttons are named before any +/// window holds it. +pub(super) fn describe_buttons(workspace: &Workspace) { + let imp = workspace.imp(); + describe_icon_button( + &imp.table_search_toggle.get(), + &crate::i18n::gettext("Search Tables"), + None, + ); + describe_icon_button( + &imp.refresh_tables_button.get(), + &crate::i18n::gettext("Refresh Tables"), + Some("win.refresh-tables"), + ); + describe_icon_button( + &imp.sidebar_toggle.get(), + &crate::i18n::gettext("Show Sidebar"), + Some("win.show-sidebar"), + ); + describe_icon_button( + &imp.new_tab_button.get(), + &crate::i18n::gettext("New Query"), + Some("win.new-editor-tab"), + ); + describe_icon_button(&imp.tab_button.get(), &crate::i18n::gettext("View Open Tabs"), None); +} + +pub(super) fn install( + workspace: &Workspace, + window: &adw::ApplicationWindow, + factory: &Rc, +) -> ShellHandles { + let imp = workspace.imp(); + let app = window.application(); + imp.sidebar_header.pack_end(&primary_menu_button(app.as_ref())); + + window.add_action(&gio::PropertyAction::new( + SHOW_SIDEBAR, + &imp.split_view.get(), + "show-sidebar", + )); + let sidebar_toggle = follow_sidebar(&imp.split_view, &imp.sidebar_toggle); + + imp.tab_view + .bind_property("n-pages", &imp.tab_stack.get(), "visible-child-name") + .transform_to(|_, pages: i32| Some(if pages > 0 { "tabs" } else { "empty" })) + .sync_create() + .build(); + + let overview = imp.tab_overview.get(); + if let Some(menu) = app_menu(app.as_ref(), TAB_OVERVIEW_MENU) { + overview.set_secondary_menu(Some(&menu)); + } + // The overview selects the page it gets back. + let factory = factory.clone(); + let create_tab = overview.connect_create_tab(move |_| factory.create_editor()); + ShellHandles { + overview: overview.downgrade(), + create_tab: Some(create_tab), + sidebar_toggle: Some(sidebar_toggle), + } +} + +/// The toggle shows only while the sidebar is out of view: collapsed +/// into an overlay, or hidden. Beside a shown sidebar it would only hide +/// the list the user is looking at. +fn follow_sidebar(split_view: &adw::OverlaySplitView, toggle: >k::ToggleButton) -> gtk::ExpressionWatch { + let collapsed = gtk::PropertyExpression::new( + adw::OverlaySplitView::static_type(), + None::<>k::Expression>, + "collapsed", + ); + let shown = gtk::PropertyExpression::new( + adw::OverlaySplitView::static_type(), + None::<>k::Expression>, + "show-sidebar", + ); + let visible = gtk::ClosureExpression::new::( + [collapsed, shown], + glib::closure!(|_: Option, collapsed: bool, shown: bool| collapsed || !shown), + ); + visible.bind(toggle, "visible", Some(split_view)) +} + +#[cfg(test)] +pub(super) mod tests { + use std::time::Duration; + + use libadwaita::prelude::*; + + use super::*; + use crate::test_support::{descendants, wait_until}; + use crate::ui::SessionWindowInput; + use crate::ui::session_window::tests::{Script, TestWindow}; + use crate::ui::workspace::WorkspaceTab; + + fn overview_new_tab_button(overview: &adw::TabOverview) -> gtk::Button { + descendants(overview) + .into_iter() + .find_map(|widget| { + let button = widget.downcast::().ok()?; + let content = button.child()?.downcast::().ok()?; + (content.icon_name() == "adw-tab-new-symbolic").then_some(button) + }) + .expect("the overview's new-tab button") + } + + fn top_header(toolbar: &adw::ToolbarView) -> Option { + let mut child = toolbar.first_child(); + while let Some(widget) = child { + if let Some(header) = descendants(&widget) + .into_iter() + .chain(std::iter::once(widget.clone())) + .find_map(|widget| widget.downcast::().ok()) + { + return Some(header); + } + child = widget.next_sibling(); + } + None + } + + fn sized(name: &str, width: i32, height: i32) -> TestWindow { + let test = TestWindow::with_script( + name, + Script { + window_size: Some((width, height)), + ..Script::default() + }, + ); + test.window + .send(SessionWindowInput::OpenSaved(Box::new(test.saved.clone()))); + test.wait_for_page("workspace"); + test + } + + #[gtk4::test] + fn each_pane_owns_its_header_bar() { + let test = TestWindow::connected("PaneHeaders"); + let workspace = test.workspace(); + let imp = workspace.imp(); + + let sidebar = top_header(&imp.sidebar_root).expect("the sidebar's header bar"); + let content = top_header(&imp.content_root).expect("the content's header bar"); + + assert_eq!(sidebar, imp.sidebar_header.get()); + assert_eq!(content, imp.content_header.get()); + // The overview draws a header bar of its own while it is open. + let headers: Vec = descendants(&imp.breakpoint_bin.get()) + .into_iter() + .filter_map(|widget| widget.downcast::().ok()) + .collect(); + assert_eq!(headers.len(), 2, "a header bar outside the two panes"); + assert!(headers.contains(&sidebar) && headers.contains(&content)); + assert!( + descendants(&imp.sidebar_header.get()).iter().any(|widget| { + widget + .downcast_ref::() + .is_some_and(gtk::MenuButton::is_primary) + }), + "the main menu is not in the sidebar's header bar" + ); + } + + #[gtk4::test] + fn tab_overview_is_the_window_content() { + let test = TestWindow::connected("OverviewRoot"); + + let content = test.window.content().expect("the window's content"); + let workspace = content.downcast::().expect("the workspace page"); + let overview = workspace + .child() + .and_downcast::() + .expect("the overview as the workspace's only child"); + + assert_eq!(overview.view(), Some(workspace.imp().tab_view.get())); + assert!( + overview.child().is_some_and(|child| child.is::()), + "the overview does not hold the header bars" + ); + } + + #[gtk4::test] + fn tab_bar_is_a_content_top_bar() { + let test = TestWindow::connected("TabBarTop"); + let workspace = test.workspace(); + let imp = workspace.imp(); + + let bar = imp.tab_bar.get(); + + assert_eq!(bar.view(), Some(imp.tab_view.get())); + assert!(bar.is_autohide()); + assert_eq!( + bar.ancestor(adw::ToolbarView::static_type()).as_ref(), + Some(imp.content_root.upcast_ref::()) + ); + assert!( + bar.ancestor(gtk::Stack::static_type()).is_none(), + "the tab bar sits in the tab area, not above it" + ); + } + + #[gtk4::test] + fn show_sidebar_action_toggles_split_view() { + let test = TestWindow::connected("ShowSidebar"); + let split = test.workspace().imp().split_view.get(); + assert!(split.shows_sidebar()); + + WidgetExt::activate_action(&test.window, "win.show-sidebar", None).expect("win.show-sidebar"); + assert!(!split.shows_sidebar()); + + WidgetExt::activate_action(&test.window, "win.show-sidebar", None).expect("win.show-sidebar"); + assert!(split.shows_sidebar()); + } + + #[gtk4::test] + fn sidebar_toggle_visible_only_when_collapsed_or_hidden() { + let test = TestWindow::connected("SidebarToggle"); + let workspace = test.workspace(); + let imp = workspace.imp(); + let (split, toggle) = (imp.split_view.get(), imp.sidebar_toggle.get()); + split.set_collapsed(false); + split.set_show_sidebar(true); + assert!(!toggle.is_visible(), "shown beside a sidebar in view"); + + split.set_show_sidebar(false); + assert!(toggle.is_visible(), "hidden with the sidebar hidden"); + + split.set_show_sidebar(true); + split.set_collapsed(true); + assert!(toggle.is_visible(), "hidden with the sidebar collapsed"); + assert_eq!(toggle.action_name().as_deref(), Some("win.show-sidebar")); + } + + #[gtk4::test] + fn narrow_window_swaps_tab_bar_for_tab_button() { + let test = sized("Narrow", 360, 294); + let workspace = test.workspace(); + let imp = workspace.imp(); + + wait_until(Duration::from_secs(2), || { + imp.breakpoint_bin.current_breakpoint().as_ref() == Some(&imp.narrow_breakpoint.get()) + }) + .expect("the narrow breakpoint to apply"); + + assert!(!imp.tab_bar.is_visible(), "the tab bar stayed"); + assert!(imp.tab_button.is_visible(), "no tab button took its place"); + assert!(imp.split_view.is_collapsed()); + } + + #[gtk4::test] + fn wide_window_shows_tab_bar() { + let test = sized("Wide", 1200, 760); + let workspace = test.workspace(); + let imp = workspace.imp(); + wait_until(Duration::from_secs(2), || imp.breakpoint_bin.width() > 0).expect("the workspace laid out"); + + assert_eq!(imp.breakpoint_bin.current_breakpoint(), None); + assert!(imp.tab_bar.is_visible()); + assert!(!imp.tab_button.is_visible()); + assert!(!imp.split_view.is_collapsed()); + } + + fn fits_the_floor(root: >k::Widget) { + let (min_width, ..) = root.measure(gtk::Orientation::Horizontal, -1); + let (min_height, ..) = root.measure(gtk::Orientation::Vertical, 360); + assert!(min_width <= 360, "{} needs {min_width} px wide", root.type_().name()); + assert!(min_height <= 294, "{} needs {min_height} px high", root.type_().name()); + } + + #[gtk4::test] + fn every_page_fits_the_window_floor() { + use tablepro_session::fakes::FakeEstablish; + use tablepro_test_support::ConnectionScript; + let test = TestWindow::with_script( + "Floor", + Script { + establish: vec![ + FakeEstablish::Late { + after: Duration::from_secs(3), + script: ConnectionScript::default(), + }, + crate::ui::session_window::tests::refused(), + ], + ..Script::default() + }, + ); + assert_eq!(test.window.size_request(), (360, 294)); + let content = || test.window.content().expect("a page"); + fits_the_floor(&content()); + + test.window + .send(SessionWindowInput::OpenSaved(Box::new(test.saved.clone()))); + test.wait_for_page("connecting"); + fits_the_floor(&content()); + // A cancel before the driver is asked leaves the late connect for + // the next attempt. + wait_until(Duration::from_secs(10), || !test.connector.calls().is_empty()) + .expect("the connect to reach the driver"); + + test.window.send(SessionWindowInput::CancelConnect); + test.wait_for_page("picker"); + test.window + .send(SessionWindowInput::OpenSaved(Box::new(test.saved.clone()))); + test.wait_for_page("connect-failed"); + fits_the_floor(&content()); + + test.window.send(SessionWindowInput::RetryConnect); + test.wait_for_page("workspace"); + fits_the_floor(&content()); + } + + /// A server with one keyed table, whose every page is the same + /// thousand rows. + pub(in crate::ui::workspace) fn served_table() -> tablepro_test_support::ConnectionScript { + use tablepro_core::column::{ColumnDefault, ColumnType}; + use tablepro_core::meta::{RowCountEstimate, RowIdentity, TableEstimate}; + use tablepro_core::{ColumnInfo, QueryResult, Value}; + use tablepro_test_support::FakeReply; + let column = |name: &str, primary_key: bool| ColumnInfo { + name: name.to_owned(), + column_type: ColumnType::untyped_text("text"), + nullable: !primary_key, + primary_key, + is_auto_increment: false, + is_generated: false, + default: ColumnDefault::None, + comment: None, + }; + let columns = vec![column("id", true), column("name", false)]; + let page = QueryResult::new( + columns.iter().map(ColumnInfo::result_column).collect::>(), + (0..1_000) + .map(|id| vec![Value::Int(id), Value::Text(format!("customer {id}"))]) + .collect(), + ); + let mut script = tablepro_test_support::ConnectionScript::default(); + script.fetch_columns.always(FakeReply::ok(columns)); + script.row_identity.always(FakeReply::ok(RowIdentity::UniqueKey { + columns: vec!["id".to_owned()], + })); + // Too many rows to count unasked, so Count Rows stays on the bar. + script.table_estimate.always(FakeReply::ok(TableEstimate { + rows: RowCountEstimate::Approximate(48_213_000), + heap_blocks: None, + })); + script.query_bound.always(FakeReply::ok(page)); + script + } + + /// The floor measured on the tab's own content, with its widest + /// bars up: the pager's longest label, pending changes and the + /// filter strip. + #[gtk4::test] + fn an_open_table_tab_fits_the_window_floor() { + use crate::ui::browse_tab::{BrowseTab, EditInput, PagingInput}; + let test = TestWindow::with_script( + "TableFloor", + Script { + connection: served_table(), + window_size: Some((360, 294)), + ..Script::default() + }, + ); + test.window + .send(SessionWindowInput::OpenSaved(Box::new(test.saved.clone()))); + test.wait_for_page("workspace"); + let workspace = test.workspace(); + let page = workspace + .with_model(|model| { + model.open_tab(super::super::tab_spec::TabSpec::Table { + table: tablepro_core::meta::TableRef::new(Some("public".to_owned()), "customers"), + page: super::super::tab_spec::TablePage::first(1_000), + }) + }) + .flatten() + .expect("a table tab"); + let tab = page.child().downcast::().expect("a browse tab"); + let wait = Duration::from_secs(10); + wait_until(wait, || { + descendants(&tab).iter().any(|widget| widget.is::()) + }) + .expect("the tab's rows"); + tab.send(EditInput::InsertRow); + let content = + crate::test_support::first_descendant_of_type::(&tab).expect("the tab's toolbar view"); + let needs = || { + let (width, ..) = content.measure(gtk::Orientation::Horizontal, -1); + if width > tab.width() { + return (width, i32::MAX); + } + (width, content.measure(gtk::Orientation::Vertical, tab.width()).0) + }; + + let fits = wait_until(wait, || { + let (width, height) = needs(); + tab.width() > 0 && width <= 360 && height <= tab.height() + }); + assert!( + fits.is_ok(), + "an open table tab needs {:?} px, the tab has {}x{}", + needs(), + tab.width(), + tab.height() + ); + + // Measured as it opens: at the floor's height the strip and the + // grid cannot both have room, so the strip is checked for width. + tab.send(PagingInput::ToggleFilterStrip); + let (with_filter, ..) = content.measure(gtk::Orientation::Horizontal, -1); + tab.send(PagingInput::ToggleFilterStrip); + assert!(with_filter <= 360, "the filter strip needs {with_filter} px"); + } + + #[gtk4::test] + fn overview_new_tab_uses_tab_factory() { + let test = TestWindow::connected("OverviewNewTab"); + let workspace = test.workspace(); + let overview = workspace.imp().tab_overview.get(); + + // As the overview's own button emits it: create-tab hands back a + // page it does not own, which emit_by_name would release. + overview_new_tab_button(&overview).emit_clicked(); + let page = workspace.imp().tab_view.selected_page().expect("the new tab's page"); + + let slot = workspace + .read(|model| { + let id = model.index.id_of(&page)?; + let tabs = model.workspace_tabs.borrow(); + Some(matches!(tabs.get(&id), Some(WorkspaceTab::Editor(_)))) + }) + .flatten(); + assert_eq!(slot, Some(true), "the page has no editor slot in the index"); + assert_eq!(page.title(), "Query 1"); + } + + #[gtk4::test] + fn primary_menu_has_no_quit() { + let test = TestWindow::picker("PrimaryMenu"); + let menu = test.app.menu_by_id("primary_menu").expect("the primary menu"); + let mut actions = Vec::new(); + for section in 0..menu.n_items() { + let Some(items) = menu.item_link(section, "section") else { + continue; + }; + for item in 0..items.n_items() { + if let Some(action) = items + .item_attribute_value(item, "action", None) + .and_then(|value| value.get::()) + { + actions.push(action); + } + } + } + + for expected in ["app.new-window", "app.preferences", "app.shortcuts", "app.about"] { + assert!(actions.iter().any(|action| action == expected), "{expected} is missing"); + } + assert!(!actions.iter().any(|action| action == "app.quit"), "the menu has Quit"); + assert!( + test.app.menu_by_id("tab_overview_menu").is_some(), + "the overview's menu is missing" + ); + } + + #[gtk4::test] + fn f9_toggles_sidebar() { + let test = TestWindow::picker("F9"); + + assert_eq!(test.app.accels_for_action("win.show-sidebar"), ["F9"]); + } +} diff --git a/linux/crates/app/src/ui/workspace/sidebar/catalog_watch.rs b/linux/crates/app/src/ui/workspace/sidebar/catalog_watch.rs new file mode 100644 index 0000000000..b236f53468 --- /dev/null +++ b/linux/crates/app/src/ui/workspace/sidebar/catalog_watch.rs @@ -0,0 +1,197 @@ +use std::sync::Arc; + +use gtk4::glib; +use tablepro_core::meta::ObjectScope; +use tablepro_session::runtime::WatchForwarder; +use tablepro_session::{ObjectListSnapshot, OperationError, Session}; + +pub(in crate::ui::workspace) fn spawn_local(future: futures::future::LocalBoxFuture<'static, ()>) { + glib::spawn_future_local(future); +} + +/// Deliver each object list the catalog commits for every schema, and +/// each refresh of it that failed, which keeps the list it had. The list +/// on screen when the watch starts is not delivered again. +pub(in crate::ui::workspace) fn catalog_watch( + session: &Arc, + mut listed: impl FnMut(ObjectListSnapshot) + 'static, + mut failed: impl FnMut(Arc) + 'static, +) -> WatchForwarder { + let objects = session.catalog().subscribe(ObjectScope::AllSchemas); + let (mut shown, mut reported) = { + let current = objects.borrow(); + (current.generation, current.last_error.clone()) + }; + WatchForwarder::new( + objects, + move |snapshot: ObjectListSnapshot| { + if snapshot.generation != shown { + shown = snapshot.generation; + listed(snapshot); + return; + } + let Some(error) = snapshot.last_error else { + return; + }; + if !reported.as_ref().is_some_and(|seen| Arc::ptr_eq(seen, &error)) { + reported = Some(error.clone()); + failed(error); + } + }, + spawn_local, + ) +} + +/// Deliver each list a reconnect commits for the default schema. The +/// reconnect marks every other list stale without reading it, so this is +/// when the sidebar's list is read again. +pub(in crate::ui::workspace) fn reconnect_watch( + session: &Arc, + mut committed: impl FnMut(ObjectListSnapshot) + 'static, +) -> WatchForwarder { + let objects = session.catalog().subscribe(ObjectScope::DefaultSchema); + let mut seen = objects.borrow().generation; + WatchForwarder::new( + objects, + move |snapshot: ObjectListSnapshot| { + if snapshot.generation != seen { + seen = snapshot.generation; + committed(snapshot); + } + }, + spawn_local, + ) +} + +#[cfg(test)] +mod tests { + use std::cell::RefCell; + use std::collections::VecDeque; + use std::rc::Rc; + use std::time::Duration; + + use tablepro_core::DriverError; + use tablepro_core::meta::{ObjectInfo, ObjectKind}; + use tablepro_test_support::{CallCounters, ConnectionScript, FakeReply}; + + use super::*; + use crate::test_support::FakeSession; + + fn names(snapshot: &ObjectListSnapshot) -> Vec { + snapshot.objects.iter().map(|object| object.name.clone()).collect() + } + + /// The workspace's input queue, holding what the catalog watch + /// posted until the workspace handles it. + type Queue = Rc>>; + + fn post_to(queue: &Queue) -> impl FnMut(T) + 'static { + let queue = queue.clone(); + move |update| queue.borrow_mut().push_back(update) + } + + fn refresh_objects( + session: &Arc, + runtime: &crate::runtime::AppRuntime, + ) -> impl Future + use<> { + let session = session.clone(); + let refreshing = runtime + .tasks() + .spawn_task(async move { session.refresh_objects(ObjectScope::AllSchemas).await }); + async move { + let _ = refreshing.await; + } + } + + fn object(name: &str, kind: ObjectKind) -> ObjectInfo { + ObjectInfo { + schema: Some("public".to_owned()), + name: name.to_owned(), + kind, + } + } + + /// A workspace drops its catalog watch with itself, so a list read + /// for its session after that reaches nothing. + #[gtk4::test] + fn f002_a_late_catalog_commit_after_the_watch_drops_is_not_delivered() { + let runtime = crate::test_support::test_runtime(); + let mut script = ConnectionScript::default(); + script.list_objects.always(FakeReply::Late { + after: Duration::from_millis(300), + outcome: Ok(vec![object("late", ObjectKind::Table)]), + }); + let fake = FakeSession::start(script, [], runtime.tasks()); + let queue = Queue::default(); + let failures = Queue::default(); + let watching = catalog_watch(&fake.session, post_to(&queue), post_to(&failures)); + let late = refresh_objects(&fake.session, &runtime); + let counters = fake.connection.counters(); + crate::test_support::wait_until(Duration::from_secs(5), || { + CallCounters::get(&counters.list_objects) == 1 + }) + .expect("the list was asked for"); + + drop(watching); + crate::test_support::finish_on_main(Duration::from_secs(5), late).expect("the list to land"); + crate::test_support::drain_main_context(); + + assert_eq!( + names(&fake.session.subscribe_objects(ObjectScope::AllSchemas).borrow()), + ["late"], + "the late list never landed, so the test proved nothing" + ); + assert!(queue.borrow().is_empty(), "a dropped watch still delivered"); + } + + #[gtk4::test] + fn a_new_list_is_delivered_once() { + let runtime = crate::test_support::test_runtime(); + let mut script = ConnectionScript::default(); + script + .list_objects + .always(FakeReply::ok(vec![object("users", ObjectKind::Table)])); + let fake = FakeSession::start(script, [], runtime.tasks()); + let queue = Queue::default(); + let failures = Queue::default(); + let _watching = catalog_watch(&fake.session, post_to(&queue), post_to(&failures)); + + crate::test_support::finish_on_main(Duration::from_secs(5), refresh_objects(&fake.session, &runtime)) + .expect("the list to land"); + crate::test_support::wait_until(Duration::from_secs(5), || queue.borrow().len() == 1) + .expect("the list was delivered"); + crate::test_support::drain_main_context(); + + assert_eq!(queue.borrow().len(), 1); + assert_eq!(names(&queue.borrow()[0]), ["users"]); + assert!(failures.borrow().is_empty()); + } + + #[gtk4::test] + fn a_failed_refresh_is_delivered_without_a_list() { + let runtime = crate::test_support::test_runtime(); + let mut script = ConnectionScript::default(); + script + .list_objects + .once(FakeReply::ok(vec![object("users", ObjectKind::Table)])) + .always(FakeReply::err(DriverError::Protocol("catalog gone".to_owned()))); + let fake = FakeSession::start(script, [], runtime.tasks()); + let queue = Queue::default(); + let failures = Queue::default(); + let _watching = catalog_watch(&fake.session, post_to(&queue), post_to(&failures)); + + crate::test_support::finish_on_main(Duration::from_secs(5), refresh_objects(&fake.session, &runtime)) + .expect("the list to land"); + crate::test_support::finish_on_main(Duration::from_secs(5), refresh_objects(&fake.session, &runtime)) + .expect("the refresh to fail"); + crate::test_support::wait_until(Duration::from_secs(5), || failures.borrow().len() == 1) + .expect("the failure was delivered"); + + assert_eq!(queue.borrow().len(), 1, "a failed refresh delivered a list"); + assert_eq!( + names(&fake.session.subscribe_objects(ObjectScope::AllSchemas).borrow()), + ["users"], + "the failure emptied the list" + ); + } +} diff --git a/linux/crates/app/src/ui/workspace/sidebar/mod.rs b/linux/crates/app/src/ui/workspace/sidebar/mod.rs new file mode 100644 index 0000000000..13b652787c --- /dev/null +++ b/linux/crates/app/src/ui/workspace/sidebar/mod.rs @@ -0,0 +1,265 @@ +//! The sidebar of the session's tables: every schema's tables and views +//! in one recycling list, a section per schema, with one shared menu. + +mod catalog_watch; +mod object_key; +mod sidebar_affordances; +mod sidebar_event; +mod sidebar_header_factory; +mod sidebar_menu; +mod sidebar_model; +mod sidebar_object; +mod sidebar_object_imp; +mod sidebar_row_factory; +mod sidebar_view; +#[cfg(test)] +mod tests; + +use std::collections::BTreeSet; +use std::sync::Arc; + +use gtk4::prelude::*; +use gtk4::subclass::prelude::*; +use libadwaita::prelude::*; +use tablepro_core::meta::{ObjectInfo, ObjectScope, SchemaInfo, TableRef}; +use tablepro_session::ObjectListSnapshot; + +pub(super) use catalog_watch::{catalog_watch, reconnect_watch, spawn_local}; +pub(super) use sidebar_affordances::SidebarAffordances; +pub(super) use sidebar_view::SidebarView; + +use super::table_naming::TableNaming; +use super::{OpenMode, Workspace, WorkspaceInput, WorkspaceModel, WorkspaceSender}; +use crate::runtime::DeliverInput; +use sidebar_event::SidebarEvent; + +/// Build the sidebar into the workspace's template, sending what the +/// user asks of it to `input`. +pub(super) fn build(workspace: &Workspace, input: &WorkspaceSender, affordances: SidebarAffordances) -> SidebarView { + let imp = workspace.imp(); + let events = input.clone(); + let view = SidebarView::new(move |event| events.deliver(workspace_input(event)), affordances); + imp.sidebar_slot.set_child(Some(view.widget())); + view.follow_search(&imp.table_search); + imp.table_search_bar.connect_entry(&*imp.table_search); + imp.table_search_bar.set_key_capture_widget(Some(&*imp.sidebar_root)); + imp.table_search_toggle + .bind_property("active", &*imp.table_search_bar, "search-mode-enabled") + .bidirectional() + .sync_create() + .build(); + view +} + +fn workspace_input(event: SidebarEvent) -> WorkspaceInput { + let select = |table: TableRef, open_mode| WorkspaceInput::SelectTable { + schema: table.schema, + name: table.name, + open_mode, + }; + match event { + SidebarEvent::Open(table) => select(table, OpenMode::SwitchOrAppend), + SidebarEvent::OpenInNewTab(table) => select(table, OpenMode::NewTab), + SidebarEvent::EditStructure(table) => WorkspaceInput::EditStructureTab { + schema: table.schema, + table: table.name, + }, + SidebarEvent::ShowCreateTable(table) => WorkspaceInput::ShowCreateTableForExisting { + schema: table.schema, + table: table.name, + }, + SidebarEvent::ImportCsv(table) => WorkspaceInput::ImportCsvRequested { + schema: table.schema, + table: table.name, + }, + SidebarEvent::DropTable(table) => WorkspaceInput::DropTablePrompt { + schema: table.schema, + table: table.name, + }, + SidebarEvent::NewTable(schema) => WorkspaceInput::NewTableTab { schema }, + } +} + +/// The schemas the sidebar shows sections for: the session's own, and +/// any an object names that the session does not list, as a server whose +/// databases are its schemas lists only the one it is in. +fn shown_schemas(schemas: &[SchemaInfo], objects: &[ObjectInfo]) -> Vec { + let known: BTreeSet<&str> = schemas.iter().map(|schema| schema.name.as_str()).collect(); + let extra: BTreeSet<&str> = objects + .iter() + .filter(|object| object.kind.is_browsable()) + .filter_map(|object| object.schema.as_deref()) + .filter(|schema| !known.contains(schema)) + .collect(); + schemas + .iter() + .cloned() + .chain(extra.into_iter().map(|name| SchemaInfo { + name: name.to_owned(), + is_default: false, + is_system: false, + })) + .collect() +} + +impl WorkspaceModel { + /// What the sidebar shows before the list of every schema lands: that + /// list when the session read it, or else the default schema's, which + /// the connect read. Then every schema's list is asked for. + pub(super) fn open_sidebar(&mut self) { + let Some(session) = self.context.active.get() else { + return; + }; + let all = session.catalog().subscribe(ObjectScope::AllSchemas).borrow().clone(); + self.all_schemas_listed = all.generation > 0; + let objects = if self.all_schemas_listed { + all.objects + } else { + session + .subscribe_objects(ObjectScope::DefaultSchema) + .borrow() + .objects + .clone() + }; + self.sidebar.apply_objects(&objects, &self.catalog_schemas); + self.read_sidebar(objects); + self.list_all_schemas(); + } + + fn list_all_schemas(&self) { + let Some(session) = self.context.active.get() else { + return; + }; + // The catalog watch delivers what lands, and a failure keeps the + // list there was. + drop( + self.context + .tasks + .spawn_task(async move { session.objects(ObjectScope::AllSchemas).await }), + ); + } + + /// Read the session's schemas, which place the sidebar's sections and + /// decide whether tab titles name their schema, then show `objects` + /// under them. Only the last read asked for is shown. + fn read_sidebar(&mut self, objects: Arc<[ObjectInfo]>) { + let Some(session) = self.context.active.get() else { + return; + }; + self.sidebar_read += 1; + let read = self.sidebar_read; + let input = self.input.clone(); + crate::runtime::spawn_and_deliver( + &self.context.tasks, + async move { session.schemas().await }, + move |schemas| { + let schemas = match schemas { + Ok(Ok(schemas)) => Some(schemas), + Ok(Err(error)) => { + tracing::debug!(%error, "the schemas could not be read for the sidebar"); + None + } + Err(failure) => { + tracing::debug!(%failure, "the schema read for the sidebar did not finish"); + None + } + }; + input.deliver(WorkspaceInput::SidebarRead { read, objects, schemas }); + }, + ); + } + + pub(super) fn on_sidebar_read( + &mut self, + read: u64, + objects: &Arc<[ObjectInfo]>, + schemas: Option>, + ) { + if read != self.sidebar_read { + return; + } + if let Some(schemas) = schemas { + self.catalog_schemas = shown_schemas(&schemas, objects).into(); + } + self.sidebar.apply_objects(objects, &self.catalog_schemas); + self.on_table_naming(TableNaming::of(&self.catalog_schemas)); + } + + /// Every schema's list landed: a reconnect, a schema change or the + /// first read. + pub(super) fn on_catalog_changed(&mut self, snapshot: ObjectListSnapshot) { + self.all_schemas_listed = true; + self.catalog_banner.set_revealed(false); + self.read_sidebar(snapshot.objects); + } + + /// The list on screen stays, under a banner that says why it could + /// not be read again and stays until a read lands. The banner wraps + /// to three lines, so the whole reason is in its tooltip too. + pub(super) fn on_catalog_read_failed(&self, message: &str) { + let title = crate::i18n::gettext_f("Could not refresh the table list: {error}", &[("error", message)]); + self.catalog_banner.set_title(&title); + self.catalog_banner.set_tooltip_text(Some(&title)); + self.catalog_banner.set_revealed(true); + } + + /// Read the session's tables again, and every table's columns on + /// their next ask: what changed the schema did not say which tables + /// it touched. + pub(super) fn refresh_catalog(&self) { + let Some(session) = self.context.active.get() else { + return; + }; + session.invalidate_columns(); + self.on_schema_changed(None, None); + } + + /// A run that sent DDL, or a commit that may publish it, changes what + /// the sidebar and the completion list. + pub(super) fn on_editor_run_finished(&self, id: uuid::Uuid) { + let changed = matches!( + self.workspace_tabs.borrow().get(&id), + Some(super::WorkspaceTab::Editor(slot)) if slot.editor.last_run_changes_catalog() + ); + if changed { + self.refresh_catalog(); + } + } + + /// A reconnect committed the default schema's list and its schemas, + /// and left every other list stale, so the sidebar's is read again. + /// The schemas are read now, under the list on screen, so they name + /// the tabs even when every schema's list cannot be read again. + /// Until every schema's list first lands, the default schema's is + /// what the sidebar shows. + pub(super) fn on_default_list_committed(&mut self, snapshot: ObjectListSnapshot) { + let objects = match (self.all_schemas_listed, self.context.active.get()) { + (true, Some(session)) => session + .catalog() + .subscribe(ObjectScope::AllSchemas) + .borrow() + .objects + .clone(), + _ => snapshot.objects, + }; + self.read_sidebar(objects); + self.list_all_schemas(); + } + + /// The connection was replaced, perhaps by one to another server + /// version, so what the sidebar offers is read again. + pub(super) fn on_link_changed(&self) { + if let Some(session) = self.context.active.get() { + self.sidebar.set_affordances(SidebarAffordances::for_session(&session)); + } + } + + /// With no table tab in front, no row stays selected: one would say + /// the editor shows that table. + pub(super) fn sync_sidebar_selection(&self) { + let table = self + .selected_browse_slot_table() + .map(|(schema, name)| TableRef::new(schema, name)); + self.sidebar.sync_selection(table.as_ref()); + } +} diff --git a/linux/crates/app/src/ui/workspace/sidebar/object_key.rs b/linux/crates/app/src/ui/workspace/sidebar/object_key.rs new file mode 100644 index 0000000000..aa95d78c35 --- /dev/null +++ b/linux/crates/app/src/ui/workspace/sidebar/object_key.rs @@ -0,0 +1,61 @@ +use std::borrow::Cow; + +use gtk4::glib; +use gtk4::prelude::*; +use tablepro_core::meta::TableRef; + +/// The table a sidebar menu item or action names, as its `(mss)` target: +/// the schema or nothing, then the name. The target travels through +/// GAction, so it is checked again where an action reads it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct ObjectKey(pub TableRef); + +impl ObjectKey { + pub(crate) fn variant_type() -> Cow<'static, glib::VariantTy> { + <(Option, String)>::static_variant_type() + } + + pub(crate) fn to_variant(&self) -> glib::Variant { + (self.0.schema.clone(), self.0.name.clone()).to_variant() + } + + /// `None` for a target of another shape, an empty name, or an empty + /// schema, which no engine has. + pub(crate) fn from_variant(variant: &glib::Variant) -> Option { + let (schema, name) = variant.get::<(Option, String)>()?; + if name.is_empty() || schema.as_deref().is_some_and(str::is_empty) { + return None; + } + Some(Self(TableRef::new(schema, name))) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn object_key_variant_round_trips() { + let qualified = ObjectKey(TableRef::new(Some("sales".to_owned()), "orders")); + let bare = ObjectKey(TableRef::unqualified("notes")); + + assert_eq!(qualified.to_variant().type_().as_str(), "(mss)"); + assert_eq!(ObjectKey::variant_type().as_str(), "(mss)"); + assert_eq!(ObjectKey::from_variant(&qualified.to_variant()), Some(qualified)); + assert_eq!(ObjectKey::from_variant(&bare.to_variant()), Some(bare)); + } + + #[test] + fn a_malformed_target_gives_no_key() { + assert_eq!(ObjectKey::from_variant(&"orders".to_variant()), None); + assert_eq!(ObjectKey::from_variant(&("sales", "orders").to_variant()), None); + assert_eq!( + ObjectKey::from_variant(&(None::, String::new()).to_variant()), + None + ); + assert_eq!( + ObjectKey::from_variant(&(Some(String::new()), "orders".to_owned()).to_variant()), + None + ); + } +} diff --git a/linux/crates/app/src/ui/workspace/sidebar/sidebar_affordances.rs b/linux/crates/app/src/ui/workspace/sidebar/sidebar_affordances.rs new file mode 100644 index 0000000000..bf22b4a11a --- /dev/null +++ b/linux/crates/app/src/ui/workspace/sidebar/sidebar_affordances.rs @@ -0,0 +1,74 @@ +use tablepro_core::dialect::SqlDialect; +use tablepro_session::Session; + +/// What the sidebar offers for the connection: the structure tab's +/// actions follow its DDL dialect, the way the structure tab does, and +/// a read-only connection is offered nothing that writes. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub(crate) struct SidebarAffordances { + pub read_only: bool, + pub can_create_table: bool, + pub can_edit_structure: bool, +} + +impl SidebarAffordances { + pub(crate) fn of(read_only: bool, dialect: Option<&dyn SqlDialect>) -> Self { + let ddl = dialect.and_then(|dialect| dialect.ddl()); + Self { + read_only, + can_create_table: ddl.is_some_and(|ddl| ddl.capabilities().create_table), + can_edit_structure: ddl.is_some(), + } + } + + /// Read again after each reconnect, which can reach another server + /// version. + pub(crate) fn for_session(session: &Session) -> Self { + Self::of(session.read_only(), session.current_dialect().as_deref()) + } + + /// Whether a section header has its New Table button. + pub(crate) fn offers_new_table(self) -> bool { + self.can_create_table && !self.read_only + } +} + +#[cfg(test)] +mod tests { + use tablepro_core::ServerInfo; + use tablepro_test_support::FakeDialect; + + use super::*; + + #[test] + fn affordances_from_ddl_capabilities() { + let postgres = tablepro_driver_postgres::PgDialect::new(&ServerInfo::new("PostgreSQL", "18.0", 18, 0, 0)); + let clickhouse = + tablepro_driver_clickhouse::ClickHouseDialect::new(&ServerInfo::new("ClickHouse", "25.8", 25, 8, 0)); + + let none = SidebarAffordances::of(false, Some(&FakeDialect)); + let creates = SidebarAffordances::of(false, Some(&postgres)); + let edits_only = SidebarAffordances::of(false, Some(&clickhouse)); + let gone = SidebarAffordances::of(false, None); + + assert!(!none.can_edit_structure && !none.offers_new_table()); + assert!(creates.can_edit_structure && creates.offers_new_table()); + assert!(edits_only.can_edit_structure); + assert!( + !edits_only.offers_new_table(), + "ClickHouse tables are created in the editor" + ); + assert_eq!(gone, SidebarAffordances::default()); + } + + #[test] + fn affordances_hide_drop_when_read_only() { + let postgres = tablepro_driver_postgres::PgDialect::new(&ServerInfo::new("PostgreSQL", "18.0", 18, 0, 0)); + + let read_only = SidebarAffordances::of(true, Some(&postgres)); + + assert!(read_only.read_only); + assert!(read_only.can_create_table, "the dialect still creates tables"); + assert!(!read_only.offers_new_table()); + } +} diff --git a/linux/crates/app/src/ui/workspace/sidebar/sidebar_event.rs b/linux/crates/app/src/ui/workspace/sidebar/sidebar_event.rs new file mode 100644 index 0000000000..ca7c294729 --- /dev/null +++ b/linux/crates/app/src/ui/workspace/sidebar/sidebar_event.rs @@ -0,0 +1,15 @@ +use tablepro_core::meta::TableRef; + +/// What the user asked the sidebar for. The workspace carries it out. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum SidebarEvent { + /// A click, Enter or a double click: the table's tab, or a new one. + Open(TableRef), + OpenInNewTab(TableRef), + EditStructure(TableRef), + ShowCreateTable(TableRef), + ImportCsv(TableRef), + DropTable(TableRef), + /// A new table in this schema, or the default one. + NewTable(Option), +} diff --git a/linux/crates/app/src/ui/workspace/sidebar/sidebar_header_factory.rs b/linux/crates/app/src/ui/workspace/sidebar/sidebar_header_factory.rs new file mode 100644 index 0000000000..e6eedc7031 --- /dev/null +++ b/linux/crates/app/src/ui/workspace/sidebar/sidebar_header_factory.rs @@ -0,0 +1,71 @@ +use std::rc::Weak; + +use gtk4 as gtk; +use gtk4::prelude::*; + +use super::sidebar_menu::{GROUP, NEW_TABLE}; +use super::sidebar_object::SidebarObject; +use super::sidebar_view::SidebarInner; +use crate::ui::a11y::describe_icon_button; + +/// A schema's heading, with its New Table button where the connection +/// creates tables. ClickHouse does not: a table there needs an engine and +/// a sorting key the structure tab has no place for, so the button would +/// only fail. +pub(super) fn header_factory(view: Weak) -> gtk::SignalListItemFactory { + let factory = gtk::SignalListItemFactory::new(); + factory.connect_setup(|_, object| { + let Some(header) = object.downcast_ref::() else { + return; + }; + let row = gtk::Box::builder() + .orientation(gtk::Orientation::Horizontal) + .spacing(6) + .margin_top(12) + .margin_bottom(6) + .margin_start(12) + .margin_end(12) + .build(); + let title = gtk::Label::builder().xalign(0.0).hexpand(true).build(); + // GtkPlacesSidebar's section header: small, bold and dim. + title.add_css_class("caption-heading"); + title.add_css_class("dimmed"); + let add = gtk::Button::builder() + .icon_name(crate::ui::icons::LIST_ADD) + .valign(gtk::Align::Center) + .build(); + add.add_css_class("flat"); + // The target first, or GTK warns that the action takes one. + add.set_action_target_value(Some(&None::.to_variant())); + add.set_action_name(Some(&format!("{GROUP}.{NEW_TABLE}"))); + row.append(&title); + row.append(&add); + header.set_child(Some(&row)); + }); + factory.connect_bind(move |_, object| { + let Some(header) = object.downcast_ref::() else { + return; + }; + let (Some(row), Some(object)) = (header.child(), header.item().and_downcast::()) else { + return; + }; + let title = object.section_title(); + if let Some(label) = row.first_child().and_downcast::() { + label.set_label(&title); + } + let Some(add) = row.last_child().and_downcast::() else { + return; + }; + let offered = view.upgrade().is_some_and(|view| view.affordances().offers_new_table()); + add.set_visible(offered); + let schema = object.schema(); + add.set_action_target_value(Some(&schema.to_variant())); + // The label says which schema the table goes in. + let label = match schema { + Some(_) => crate::i18n::gettext_f("New Table in {schema}…", &[("schema", &title)]), + None => crate::i18n::gettext("New Table…"), + }; + describe_icon_button(&add, &label, None); + }); + factory +} diff --git a/linux/crates/app/src/ui/workspace/sidebar/sidebar_menu.rs b/linux/crates/app/src/ui/workspace/sidebar/sidebar_menu.rs new file mode 100644 index 0000000000..44b98727eb --- /dev/null +++ b/linux/crates/app/src/ui/workspace/sidebar/sidebar_menu.rs @@ -0,0 +1,162 @@ +use gtk4::gio; +use tablepro_core::meta::ObjectKind; + +use super::object_key::ObjectKey; +use super::sidebar_affordances::SidebarAffordances; + +pub(super) const OPEN_IN_NEW_TAB: &str = "open-in-new-tab"; +pub(super) const EDIT_STRUCTURE: &str = "edit-structure"; +pub(super) const SHOW_CREATE_TABLE: &str = "show-create-table"; +pub(super) const IMPORT_CSV: &str = "import-csv"; +pub(super) const DROP_TABLE: &str = "drop-table"; +pub(super) const NEW_TABLE: &str = "new-table"; +pub(super) const CONTEXT_MENU: &str = "context-menu"; +pub(super) const GROUP: &str = "sidebar"; + +/// The menu of one table. Every item carries the table's key, so the one +/// popover serves every row and acts on the table it opened for, even +/// after the list changes under it. What writes sits in sections of its +/// own, and a read-only connection is not offered it. A view's shape +/// comes from its query, so only a table offers to change or drop it. +pub(crate) fn object_menu(key: &ObjectKey, kind: ObjectKind, affordances: SidebarAffordances) -> gio::Menu { + let menu = gio::Menu::new(); + let open = gio::Menu::new(); + open.append_item(&item(&crate::i18n::gettext("Open in New Tab"), OPEN_IN_NEW_TAB, key)); + menu.append_section(None, &open); + let table = kind.is_structure_editable(); + let structure = gio::Menu::new(); + if table && affordances.can_edit_structure { + structure.append_item(&item(&crate::i18n::gettext("Edit Structure"), EDIT_STRUCTURE, key)); + } + structure.append_item(&item( + &crate::i18n::gettext("Show CREATE TABLE"), + SHOW_CREATE_TABLE, + key, + )); + menu.append_section(None, &structure); + if affordances.read_only || !table { + return menu; + } + let data = gio::Menu::new(); + data.append_item(&item(&crate::i18n::gettext("Import CSV…"), IMPORT_CSV, key)); + menu.append_section(None, &data); + let danger = gio::Menu::new(); + danger.append_item(&item(&crate::i18n::gettext("Drop Table…"), DROP_TABLE, key)); + menu.append_section(None, &danger); + menu +} + +/// Whether `action` is offered for a `kind` on a connection with +/// `affordances`, as `object_menu` offers it. +pub(super) fn offers(action: &str, kind: ObjectKind, affordances: SidebarAffordances) -> bool { + let table = kind.is_structure_editable(); + match action { + OPEN_IN_NEW_TAB | SHOW_CREATE_TABLE => true, + EDIT_STRUCTURE => table && affordances.can_edit_structure, + IMPORT_CSV | DROP_TABLE => table && !affordances.read_only, + _ => false, + } +} + +fn item(label: &str, action: &str, key: &ObjectKey) -> gio::MenuItem { + let item = gio::MenuItem::new(Some(label), None); + item.set_action_and_target_value(Some(&format!("{GROUP}.{action}")), Some(&key.to_variant())); + item +} + +#[cfg(test)] +mod tests { + use gtk4::glib; + use gtk4::prelude::*; + use tablepro_core::meta::TableRef; + + use super::*; + + /// Each item's action and target, in menu order, sections flattened. + fn items(menu: &gio::MenuModel) -> Vec<(String, Option)> { + let mut found = Vec::new(); + for index in 0..menu.n_items() { + if let Some(action) = menu + .item_attribute_value(index, "action", None) + .and_then(|value| value.get::()) + { + found.push((action, menu.item_attribute_value(index, "target", None))); + } + if let Some(section) = menu.item_link(index, "section") { + found.extend(items(§ion)); + } + } + found + } + + fn actions(menu: &gio::Menu) -> Vec { + items(menu.upcast_ref()).into_iter().map(|(action, _)| action).collect() + } + + fn all() -> SidebarAffordances { + SidebarAffordances { + read_only: false, + can_create_table: true, + can_edit_structure: true, + } + } + + #[test] + fn every_item_carries_the_object_key() { + let key = ObjectKey(TableRef::new(Some("sales".to_owned()), "orders")); + + let menu = object_menu(&key, ObjectKind::Table, all()); + + let found = items(menu.upcast_ref()); + assert_eq!(found.len(), 5); + for (action, target) in found { + let target = target.unwrap_or_else(|| panic!("{action} has no target")); + assert_eq!(ObjectKey::from_variant(&target).as_ref(), Some(&key), "{action}"); + } + } + + #[test] + fn a_read_only_connection_is_offered_nothing_that_writes() { + let key = ObjectKey(TableRef::unqualified("orders")); + let read_only = SidebarAffordances { + read_only: true, + ..all() + }; + + assert_eq!( + actions(&object_menu(&key, ObjectKind::Table, read_only)), + [ + "sidebar.open-in-new-tab", + "sidebar.edit-structure", + "sidebar.show-create-table" + ] + ); + } + + #[test] + fn edit_structure_follows_the_dialect() { + let key = ObjectKey(TableRef::unqualified("orders")); + let without = SidebarAffordances { + can_edit_structure: false, + ..all() + }; + + assert!( + !actions(&object_menu(&key, ObjectKind::Table, without)).contains(&"sidebar.edit-structure".to_owned()) + ); + assert!(actions(&object_menu(&key, ObjectKind::Table, all())).contains(&"sidebar.drop-table".to_owned())); + } + + #[test] + fn a_view_offers_no_structure_change() { + let key = ObjectKey(TableRef::unqualified("active_users")); + + let offered = actions(&object_menu(&key, ObjectKind::View, all())); + + assert_eq!(offered, ["sidebar.open-in-new-tab", "sidebar.show-create-table"]); + for action in [EDIT_STRUCTURE, IMPORT_CSV, DROP_TABLE] { + assert!(!offers(action, ObjectKind::View, all()), "{action}"); + assert!(offers(action, ObjectKind::PartitionedTable, all()), "{action}"); + } + } +} diff --git a/linux/crates/app/src/ui/workspace/sidebar/sidebar_model.rs b/linux/crates/app/src/ui/workspace/sidebar/sidebar_model.rs new file mode 100644 index 0000000000..725f78c715 --- /dev/null +++ b/linux/crates/app/src/ui/workspace/sidebar/sidebar_model.rs @@ -0,0 +1,298 @@ +use gtk4 as gtk; +use gtk4::gio; +use gtk4::prelude::*; +use tablepro_core::meta::{ObjectInfo, ObjectKind, SchemaInfo, TableRef}; + +use super::sidebar_object::SidebarObject; + +/// The sidebar's objects, sorted into schema sections and filtered by +/// the search. A new object list replaces the store in one splice, and a +/// search changes only the filter, so neither builds a widget per table. +pub(crate) struct SidebarModel { + store: gio::ListStore, + filter: gtk::StringFilter, + filtered: gtk::FilterListModel, + selection: gtk::SingleSelection, +} + +impl SidebarModel { + pub(crate) fn new() -> Self { + let store = gio::ListStore::new::(); + let by_name = gtk::StringSorter::new(Some(property("name"))); + by_name.set_ignore_case(true); + let sorted = gtk::SortListModel::new(Some(store.clone()), Some(by_name)); + let sections = gtk::MultiSorter::new(); + sections.append(gtk::NumericSorter::new(Some(property("section-rank")))); + sections.append(gtk::StringSorter::new(Some(property("section-title")))); + sorted.set_section_sorter(Some(§ions)); + let filter = gtk::StringFilter::new(Some(property("search-text"))); + filter.set_match_mode(gtk::StringFilterMatchMode::Substring); + filter.set_ignore_case(true); + let filtered = gtk::FilterListModel::new(Some(sorted), Some(filter.clone())); + let selection = gtk::SingleSelection::new(Some(filtered.clone())); + selection.set_autoselect(false); + selection.set_can_unselect(true); + Self { + store, + filter, + filtered, + selection, + } + } + + /// Show `objects`, keeping only what the grid can page through. + pub(crate) fn replace(&self, objects: &[ObjectInfo], schemas: &[SchemaInfo]) { + let shown: Vec = objects + .iter() + .filter(|object| object.kind.is_browsable()) + .map(|object| SidebarObject::new(object, schemas)) + .collect(); + self.store.splice(0, self.store.n_items(), &shown); + } + + pub(crate) fn set_query(&self, query: &str) { + let query = query.trim(); + if self.filter.search().as_deref().unwrap_or_default() == query { + return; + } + self.filter.set_search(Some(query).filter(|query| !query.is_empty())); + } + + pub(crate) fn has_query(&self) -> bool { + self.filter.search().is_some_and(|search| !search.is_empty()) + } + + /// Where `table` shows under the search, if it does. + pub(crate) fn position_of(&self, table: &TableRef) -> Option { + (0..self.filtered.n_items()).find(|&position| { + self.object_at(position) + .is_some_and(|object| object.table_ref() == *table) + }) + } + + /// The kind of `table` when it is listed at all, searched for or not. + pub(crate) fn kind_of(&self, table: &TableRef) -> Option { + (0..self.store.n_items()).find_map(|position| { + self.store + .item(position) + .and_downcast::() + .filter(|object| object.table_ref() == *table) + .map(|object| object.kind()) + }) + } + + /// Select `table`'s row, or none. A table the search hides selects + /// none. + pub(crate) fn select(&self, table: Option<&TableRef>) { + let position = table + .and_then(|table| self.position_of(table)) + .unwrap_or(gtk::INVALID_LIST_POSITION); + if self.selection.selected() != position { + self.selection.set_selected(position); + } + } + + pub(crate) fn object_at(&self, position: u32) -> Option { + self.filtered.item(position).and_downcast::() + } + + pub(crate) fn selection(&self) -> >k::SingleSelection { + &self.selection + } + + pub(crate) fn shown(&self) -> u32 { + self.filtered.n_items() + } + + #[cfg(test)] + pub(crate) fn store(&self) -> &gio::ListStore { + &self.store + } +} + +fn property(name: &str) -> gtk::PropertyExpression { + gtk::PropertyExpression::new(SidebarObject::static_type(), None::<>k::Expression>, name) +} + +#[cfg(test)] +mod tests { + use std::cell::Cell; + use std::rc::Rc; + + use super::*; + + fn object(schema: &str, name: &str) -> ObjectInfo { + ObjectInfo { + schema: Some(schema.to_owned()), + name: name.to_owned(), + kind: ObjectKind::Table, + } + } + + fn schema(name: &str, is_default: bool, is_system: bool) -> SchemaInfo { + SchemaInfo { + name: name.to_owned(), + is_default, + is_system, + } + } + + /// Each section's title and its rows' names, in list order. + fn sections(model: &SidebarModel) -> Vec<(String, Vec)> { + let shown = model.selection(); + let mut found = Vec::new(); + let mut position = 0; + while position < shown.n_items() { + let (start, end) = shown.section(position); + let title = model + .object_at(start) + .map(|object| object.section_title()) + .unwrap_or_default(); + let names = (start..end) + .filter_map(|at| model.object_at(at)) + .map(|object| object.name()) + .collect(); + found.push((title, names)); + position = end; + } + found + } + + #[gtk4::test] + fn sections_put_default_schema_first_then_alphabetical_then_system() { + let model = SidebarModel::new(); + let schemas = [ + schema("public", true, false), + schema("information_schema", false, true), + schema("sales", false, false), + schema("audit", false, false), + ]; + + model.replace( + &[ + object("sales", "orders"), + object("information_schema", "tables"), + object("public", "users"), + object("audit", "log"), + object("public", "Accounts"), + object("sales", "Customers"), + ], + &schemas, + ); + + assert_eq!( + sections(&model), + [ + ("public".to_owned(), vec!["Accounts".to_owned(), "users".to_owned()]), + ("audit".to_owned(), vec!["log".to_owned()]), + ("sales".to_owned(), vec!["Customers".to_owned(), "orders".to_owned()]), + ("information_schema".to_owned(), vec!["tables".to_owned()]), + ] + ); + } + + #[gtk4::test] + fn replace_emits_one_items_changed() { + let model = SidebarModel::new(); + model.replace(&[object("public", "old")], &[]); + let objects: Vec = (0..5_000) + .map(|index| object(if index % 2 == 0 { "public" } else { "sales" }, &format!("t{index}"))) + .collect(); + let stored = Rc::new(Cell::new(0)); + let shown = Rc::new(Cell::new(0)); + let count = stored.clone(); + model + .store() + .connect_items_changed(move |_, _, _, _| count.set(count.get() + 1)); + let count = shown.clone(); + model + .selection() + .connect_items_changed(move |_, _, _, _| count.set(count.get() + 1)); + + model.replace(&objects, &[schema("public", true, false)]); + + assert_eq!(stored.get(), 1, "the store changed more than once"); + assert_eq!(shown.get(), 1, "the list view was told more than once"); + assert_eq!(model.shown(), 5_000); + } + + #[gtk4::test] + fn search_filters_case_insensitively_without_touching_store() { + let model = SidebarModel::new(); + model.replace( + &[ + object("public", "Users"), + object("auth", "tokens"), + object("public", "orders"), + ], + &[schema("public", true, false)], + ); + let changed = Rc::new(Cell::new(0)); + let count = changed.clone(); + model + .store() + .connect_items_changed(move |_, _, _, _| count.set(count.get() + 1)); + + model.set_query("USE"); + let names = |model: &SidebarModel| -> Vec { + (0..model.shown()) + .filter_map(|at| model.object_at(at)) + .map(|object| object.name()) + .collect() + }; + assert_eq!(names(&model), ["Users"]); + model.set_query("auth.TOK"); + assert_eq!(names(&model), ["tokens"]); + model.set_query("auth"); + assert_eq!(names(&model), ["tokens"], "the schema matches too"); + model.set_query(""); + assert_eq!(model.shown(), 3); + + assert_eq!(changed.get(), 0, "a search rebuilt the store"); + assert_eq!(model.store().n_items(), 3); + } + + #[gtk4::test] + fn only_what_the_grid_can_page_is_listed() { + let model = SidebarModel::new(); + let routine = ObjectInfo { + kind: ObjectKind::Function, + ..object("public", "refresh") + }; + let view = ObjectInfo { + kind: ObjectKind::View, + ..object("public", "active_users") + }; + + model.replace(&[object("public", "users"), routine, view], &[]); + + assert_eq!(model.shown(), 2); + assert_eq!( + model.kind_of(&TableRef::new(Some("public".to_owned()), "refresh")), + None + ); + assert_eq!( + model.kind_of(&TableRef::new(Some("public".to_owned()), "active_users")), + Some(ObjectKind::View) + ); + } + + #[gtk4::test] + fn select_finds_the_table_under_the_search_or_clears() { + let model = SidebarModel::new(); + model.replace(&[object("public", "orders"), object("public", "users")], &[]); + let users = TableRef::new(Some("public".to_owned()), "users"); + + model.select(Some(&users)); + assert_eq!(model.selection().selected(), 1); + + model.set_query("ord"); + model.select(Some(&users)); + assert_eq!(model.selection().selected(), gtk::INVALID_LIST_POSITION); + + model.set_query(""); + model.select(Some(&users)); + model.select(None); + assert_eq!(model.selection().selected(), gtk::INVALID_LIST_POSITION); + } +} diff --git a/linux/crates/app/src/ui/workspace/sidebar/sidebar_object.rs b/linux/crates/app/src/ui/workspace/sidebar/sidebar_object.rs new file mode 100644 index 0000000000..1b3148f956 --- /dev/null +++ b/linux/crates/app/src/ui/workspace/sidebar/sidebar_object.rs @@ -0,0 +1,160 @@ +use gtk4::glib; +use gtk4::subclass::prelude::*; +use tablepro_core::meta::{ObjectInfo, ObjectKind, SchemaInfo, TableRef}; + +use super::sidebar_object_imp as imp; + +glib::wrapper! { + /// One table or view as the sidebar's list model holds it. It never + /// changes: a new object list replaces every object. + pub struct SidebarObject(ObjectSubclass); +} + +/// The section a schema's objects go in. +const DEFAULT_RANK: u32 = 0; +const OTHER_RANK: u32 = 1; +const SYSTEM_RANK: u32 = 2; + +impl SidebarObject { + /// `schemas` places the object's section: an object without a + /// schema is in the connection's default one. + pub(crate) fn new(object: &ObjectInfo, schemas: &[SchemaInfo]) -> Self { + let this: Self = glib::Object::new(); + let imp = this.imp(); + let table = TableRef::new(object.schema.clone(), object.name.clone()); + let (title, rank) = section_of(object.schema.as_deref(), schemas); + let search = match &table.schema { + Some(schema) => format!("{schema}.{}", table.name), + None => table.name.clone(), + }; + let _ = imp.search.set(search.to_lowercase()); + let _ = imp.title.set(title); + imp.rank.set(rank); + let _ = imp.kind.set(object.kind); + let _ = imp.table.set(table); + this + } + + /// Always set: `new` is the only way to build one. + pub(crate) fn table_ref(&self) -> TableRef { + self.imp() + .table + .get() + .cloned() + .unwrap_or_else(|| TableRef::unqualified(String::new())) + } + + pub(crate) fn kind(&self) -> ObjectKind { + self.imp().kind.get().copied().unwrap_or(ObjectKind::Table) + } + + /// What the row's tooltip calls the table. + pub(crate) fn qualified_name(&self) -> String { + let table = self.table_ref(); + match table.schema { + Some(schema) => format!("{schema}.{}", table.name), + None => table.name, + } + } + + /// What a screen reader says the row is, which sighted users read + /// from its icon. + pub(crate) fn kind_name(&self) -> String { + match self.kind() { + ObjectKind::View => crate::i18n::pgettext("object kind", "View"), + ObjectKind::MaterializedView => crate::i18n::pgettext("object kind", "Materialized View"), + _ => crate::i18n::pgettext("object kind", "Table"), + } + } +} + +fn section_of(schema: Option<&str>, schemas: &[SchemaInfo]) -> (String, u32) { + let Some(schema) = schema else { + let title = schemas + .iter() + .find(|known| known.is_default) + .map_or_else(|| crate::i18n::gettext("Tables"), |known| known.name.clone()); + return (title, DEFAULT_RANK); + }; + let rank = match schemas.iter().find(|known| known.name == schema) { + Some(known) if known.is_default => DEFAULT_RANK, + Some(known) if known.is_system => SYSTEM_RANK, + _ => OTHER_RANK, + }; + (schema.to_owned(), rank) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn schema(name: &str, is_default: bool, is_system: bool) -> SchemaInfo { + SchemaInfo { + name: name.to_owned(), + is_default, + is_system, + } + } + + fn object(schema: Option<&str>, name: &str, kind: ObjectKind) -> ObjectInfo { + ObjectInfo { + schema: schema.map(str::to_owned), + name: name.to_owned(), + kind, + } + } + + #[gtk4::test] + fn an_object_carries_its_section_icon_and_search_text() { + let schemas = [ + schema("public", true, false), + schema("pg_catalog", false, true), + schema("sales", false, false), + ]; + + let default = SidebarObject::new(&object(Some("public"), "Users", ObjectKind::Table), &schemas); + let other = SidebarObject::new(&object(Some("sales"), "totals", ObjectKind::MaterializedView), &schemas); + let system = SidebarObject::new(&object(Some("pg_catalog"), "pg_class", ObjectKind::View), &schemas); + + assert_eq!( + (default.section_title(), default.section_rank()), + ("public".to_owned(), 0) + ); + assert_eq!((other.section_title(), other.section_rank()), ("sales".to_owned(), 1)); + assert_eq!(system.section_rank(), 2); + assert_eq!(default.search_text(), "public.users"); + assert_eq!(default.icon_name(), "view-grid-symbolic"); + assert_eq!(other.icon_name(), "view-paged-symbolic"); + assert_eq!(system.icon_name(), "view-list-symbolic"); + assert_eq!( + [default.kind_name(), other.kind_name(), system.kind_name()], + ["Table", "Materialized View", "View"] + ); + assert_eq!(default.table_ref(), TableRef::new(Some("public".to_owned()), "Users")); + } + + #[gtk4::test] + fn an_object_without_a_schema_is_in_the_default_section() { + let attached = [schema("main", true, false), schema("archive", false, false)]; + + let main = SidebarObject::new(&object(None, "notes", ObjectKind::Table), &attached); + let bare = SidebarObject::new(&object(None, "notes", ObjectKind::Table), &[]); + + assert_eq!((main.section_title(), main.section_rank()), ("main".to_owned(), 0)); + assert_eq!(main.schema(), None); + assert_eq!(main.search_text(), "notes"); + assert_eq!(bare.section_title(), "Tables"); + } + + #[gtk4::test] + fn a_schema_the_list_does_not_name_is_an_ordinary_section() { + let listed = [schema("shop", true, false)]; + + let other = SidebarObject::new(&object(Some("analytics"), "events", ObjectKind::Table), &listed); + + assert_eq!( + (other.section_title(), other.section_rank()), + ("analytics".to_owned(), 1) + ); + } +} diff --git a/linux/crates/app/src/ui/workspace/sidebar/sidebar_object_imp.rs b/linux/crates/app/src/ui/workspace/sidebar/sidebar_object_imp.rs new file mode 100644 index 0000000000..42f8674039 --- /dev/null +++ b/linux/crates/app/src/ui/workspace/sidebar/sidebar_object_imp.rs @@ -0,0 +1,73 @@ +use std::cell::{Cell, OnceCell}; +use std::marker::PhantomData; + +use gtk4::glib; +use gtk4::prelude::*; +use gtk4::subclass::prelude::*; +use tablepro_core::meta::{ObjectKind, TableRef}; + +#[derive(Default, glib::Properties)] +#[properties(wrapper_type = super::sidebar_object::SidebarObject)] +pub struct SidebarObject { + pub(super) table: OnceCell, + pub(super) kind: OnceCell, + pub(super) title: OnceCell, + pub(super) rank: Cell, + pub(super) search: OnceCell, + #[property(get = Self::schema)] + schema: PhantomData>, + #[property(get = Self::name)] + name: PhantomData, + #[property(get = Self::icon_name)] + icon_name: PhantomData, + /// The schema's heading, which groups the rows into sections. + #[property(get = Self::section_title)] + section_title: PhantomData, + /// 0 for the default schema, 1 for the others, 2 for the engine's + /// own, so sections sort in that order before their titles. + #[property(get = Self::section_rank)] + section_rank: PhantomData, + /// `schema.name` in lowercase, which the search matches against. + #[property(get = Self::search_text)] + search_text: PhantomData, +} + +#[glib::object_subclass] +impl ObjectSubclass for SidebarObject { + const NAME: &'static str = "TableProSidebarObject"; + type Type = super::sidebar_object::SidebarObject; +} + +#[glib::derived_properties] +impl ObjectImpl for SidebarObject {} + +impl SidebarObject { + fn schema(&self) -> Option { + self.table.get().and_then(|table| table.schema.clone()) + } + + fn name(&self) -> String { + self.table.get().map(|table| table.name.clone()).unwrap_or_default() + } + + fn icon_name(&self) -> String { + let icon = match self.kind.get() { + Some(ObjectKind::View) => crate::ui::icons::VIEW_LIST, + Some(ObjectKind::MaterializedView) => crate::ui::icons::VIEW_PAGED, + _ => crate::ui::icons::VIEW_GRID, + }; + icon.to_owned() + } + + fn section_title(&self) -> String { + self.title.get().cloned().unwrap_or_default() + } + + fn section_rank(&self) -> u32 { + self.rank.get() + } + + fn search_text(&self) -> String { + self.search.get().cloned().unwrap_or_default() + } +} diff --git a/linux/crates/app/src/ui/workspace/sidebar/sidebar_row_factory.rs b/linux/crates/app/src/ui/workspace/sidebar/sidebar_row_factory.rs new file mode 100644 index 0000000000..34b5dc8022 --- /dev/null +++ b/linux/crates/app/src/ui/workspace/sidebar/sidebar_row_factory.rs @@ -0,0 +1,91 @@ +use std::rc::Weak; + +use gtk4 as gtk; +use gtk4::prelude::*; +use gtk4::{gdk, graphene, pango}; + +use super::sidebar_object::SidebarObject; +use super::sidebar_view::SidebarInner; + +/// Builds the rows the list view recycles: an icon and the table's name, +/// with one click gesture for what activation does not cover. +pub(super) fn row_factory(view: Weak) -> gtk::SignalListItemFactory { + let factory = gtk::SignalListItemFactory::new(); + factory.connect_setup(move |_, object| { + let Some(item) = object.downcast_ref::() else { + return; + }; + let row = gtk::Box::builder() + .orientation(gtk::Orientation::Horizontal) + .spacing(8) + .build(); + // The name says what the row is; the icon only tells a view from + // a table at a glance. + let icon = gtk::Image::builder() + .pixel_size(16) + .accessible_role(gtk::AccessibleRole::Presentation) + .build(); + let label = gtk::Label::builder() + .xalign(0.0) + .hexpand(true) + .ellipsize(pango::EllipsizeMode::End) + .build(); + row.append(&icon); + row.append(&label); + row.add_controller(click_gesture(view.clone(), item)); + item.set_child(Some(&row)); + if let Some(view) = view.upgrade() { + view.track_row(item); + } + }); + factory.connect_bind(|_, object| { + let Some(item) = object.downcast_ref::() else { + return; + }; + let (Some(row), Some(object)) = (item.child(), item.item().and_downcast::()) else { + return; + }; + if let Some(icon) = row.first_child().and_downcast::() { + icon.set_icon_name(Some(&object.icon_name())); + } + if let Some(label) = row.last_child().and_downcast::() { + label.set_label(&object.name()); + } + row.set_tooltip_text(Some(&object.qualified_name())); + item.set_accessible_description(&object.kind_name()); + }); + factory +} + +/// Button 0, so one gesture sees every button. It claims only what it +/// handles, which cancels the row's own click for that press. +fn click_gesture(view: Weak, item: >k::ListItem) -> gtk::GestureClick { + let gesture = gtk::GestureClick::new(); + gesture.set_button(0); + let (pressed_view, pressed_item) = (view.clone(), item.downgrade()); + gesture.connect_pressed(move |gesture, _, x, y| { + let (Some(view), Some(item), Some(row)) = (pressed_view.upgrade(), pressed_item.upgrade(), gesture.widget()) + else { + return; + }; + let at = row.compute_point(&view.list_view, &graphene::Point::new(x as f32, y as f32)); + let pointing = at.map(|point| gdk::Rectangle::new(point.x() as i32, point.y() as i32, 1, 1)); + if view.pressed( + item.position(), + gesture.current_button(), + gesture.current_event_state(), + pointing, + ) { + gesture.set_state(gtk::EventSequenceState::Claimed); + } + }); + gesture.connect_released(move |gesture, _, _, _| { + let Some(view) = view.upgrade() else { + return; + }; + if view.released(gesture.current_button()) { + gesture.set_state(gtk::EventSequenceState::Claimed); + } + }); + gesture +} diff --git a/linux/crates/app/src/ui/workspace/sidebar/sidebar_view.rs b/linux/crates/app/src/ui/workspace/sidebar/sidebar_view.rs new file mode 100644 index 0000000000..8ede0c2578 --- /dev/null +++ b/linux/crates/app/src/ui/workspace/sidebar/sidebar_view.rs @@ -0,0 +1,465 @@ +use std::cell::{Cell, RefCell}; +use std::rc::{Rc, Weak}; + +use gtk4 as gtk; +use gtk4::prelude::*; +use gtk4::{gdk, gio, glib}; +use libadwaita as adw; +use tablepro_core::meta::{ObjectInfo, SchemaInfo, TableRef}; + +use super::object_key::ObjectKey; +use super::sidebar_affordances::SidebarAffordances; +use super::sidebar_event::SidebarEvent; +use super::sidebar_header_factory::header_factory; +use super::sidebar_menu::{ + CONTEXT_MENU, DROP_TABLE, EDIT_STRUCTURE, GROUP, IMPORT_CSV, NEW_TABLE, OPEN_IN_NEW_TAB, SHOW_CREATE_TABLE, + object_menu, offers, +}; +use super::sidebar_model::SidebarModel; +use super::sidebar_row_factory::row_factory; +use crate::application::{ShortcutOwner, local_shortcut_controller}; + +const LIST_PAGE: &str = "list"; +const EMPTY_PAGE: &str = "empty"; + +/// The session's tables in one list view that recycles its rows, in a +/// section per schema, with one shared menu. +#[derive(Clone)] +pub(crate) struct SidebarView { + inner: Rc, +} + +pub(crate) struct SidebarInner { + root: gtk::Stack, + pub(super) list_view: gtk::ListView, + model: SidebarModel, + pub(super) popover: gtk::PopoverMenu, + actions: gio::SimpleActionGroup, + empty: adw::StatusPage, + events: Box, + affordances: Cell, + header_factory: RefCell>, + /// The table of the tab in front. Activation on a single click also + /// selects rows on hover, so the selection returns here. + active: RefCell>, + /// The recycled rows, to point the keyboard's menu at one. + rows: RefCell>>, + /// The table under a middle press. A new list can bind its row to + /// another table before the release. + middle_pressed: RefCell>, + handlers: RefCell, glib::SignalHandlerId)>>, + hover: RefCell>, +} + +impl SidebarView { + pub(crate) fn new(events: impl Fn(SidebarEvent) + 'static, affordances: SidebarAffordances) -> Self { + let model = SidebarModel::new(); + let list_view = gtk::ListView::new(Some(model.selection().clone()), None::); + list_view.add_css_class("navigation-sidebar"); + // A click opens the table, as the list always did; Enter and a + // double click reach the same activate. + list_view.set_single_click_activate(true); + list_view.update_property(&[gtk::accessible::Property::Label(&crate::i18n::gettext("Tables"))]); + let scroll = gtk::ScrolledWindow::builder() + .hscrollbar_policy(gtk::PolicyType::Never) + .vexpand(true) + .child(&list_view) + .build(); + let empty = adw::StatusPage::new(); + empty.add_css_class("compact"); + let root = gtk::Stack::new(); + root.add_named(&scroll, Some(LIST_PAGE)); + root.add_named(&empty, Some(EMPTY_PAGE)); + let actions = gio::SimpleActionGroup::new(); + // Before the popover is parented: a popover menu finds its + // actions through the parent it had when it was set up. + list_view.insert_action_group(GROUP, Some(&actions)); + let popover = gtk::PopoverMenu::from_model(None::<&gio::MenuModel>); + popover.set_has_arrow(true); + popover.set_parent(&list_view); + let inner = Rc::new(SidebarInner { + root, + list_view, + model, + popover, + actions, + empty, + events: Box::new(events), + affordances: Cell::new(affordances), + header_factory: RefCell::new(None), + active: RefCell::new(None), + rows: RefCell::new(Vec::new()), + middle_pressed: RefCell::new(None), + handlers: RefCell::new(Vec::new()), + hover: RefCell::new(None), + }); + inner.list_view.set_factory(Some(&row_factory(Rc::downgrade(&inner)))); + let headers = header_factory(Rc::downgrade(&inner)); + inner.list_view.set_header_factory(Some(&headers)); + inner.header_factory.replace(Some(headers)); + inner + .list_view + .add_controller(local_shortcut_controller(ShortcutOwner::SidebarList)); + install_actions(&inner); + connect_view(&inner); + inner.refresh_placeholder(); + Self { inner } + } + + pub(crate) fn widget(&self) -> >k::Stack { + &self.inner.root + } + + /// Show a new object list. The selection goes back to the tab in + /// front's table, which the new list holds as a new object. + pub(crate) fn apply_objects(&self, objects: &[ObjectInfo], schemas: &[SchemaInfo]) { + self.inner.model.replace(objects, schemas); + self.inner.reselect(); + // An empty list replacing an empty list changes no item, so the + // list's signal never says the placeholder is out of date. + self.inner.refresh_placeholder(); + } + + pub(crate) fn set_query(&self, query: &str) { + self.inner.model.set_query(query); + self.inner.reselect(); + self.inner.refresh_placeholder(); + } + + /// Select the row of the table the tab in front shows, or none for + /// any other tab: a selected row would say the editor shows it. + pub(crate) fn sync_selection(&self, table: Option<&TableRef>) { + self.inner.active.replace(table.cloned()); + self.inner.model.select(table); + } + + /// A reconnect can reach a server that edits less, so the headers are + /// bound again with what it offers. + pub(crate) fn set_affordances(&self, affordances: SidebarAffordances) { + if self.inner.affordances.replace(affordances) == affordances { + return; + } + if let Some(headers) = self.inner.header_factory.borrow().as_ref() { + self.inner.list_view.set_header_factory(None::<>k::ListItemFactory>); + self.inner.list_view.set_header_factory(Some(headers)); + } + } + + /// Filter as the user types in `entry`. + pub(crate) fn follow_search(&self, entry: >k::SearchEntry) { + let view = Rc::downgrade(&self.inner); + let handler = entry.connect_search_changed(move |entry| { + if let Some(view) = view.upgrade() { + SidebarView { inner: view }.set_query(&entry.text()); + } + }); + self.inner.remember(entry, handler); + } + + /// Let go of the menu and the signals, before the widgets go. + pub(crate) fn shutdown(&self) { + let inner = &self.inner; + inner.popover.popdown(); + if inner.popover.parent().is_some() { + inner.popover.unparent(); + } + for (object, handler) in inner.handlers.take() { + if let Some(object) = object.upgrade() { + object.disconnect(handler); + } + } + if let Some(hover) = inner.hover.take() { + inner.list_view.remove_controller(&hover); + } + inner.list_view.insert_action_group(GROUP, None::<&gio::ActionGroup>); + inner.rows.take(); + } + + #[cfg(test)] + pub(crate) fn inner(&self) -> &Rc { + &self.inner + } + + #[cfg(test)] + pub(crate) fn shown(&self) -> u32 { + self.inner.model.shown() + } + + /// Each bound row's table name and what a screen reader says it is. + #[cfg(test)] + pub(crate) fn row_descriptions(&self) -> Vec<(String, String)> { + let mut found: Vec<(String, String)> = self + .inner + .bound_rows() + .filter_map(|item| { + let object = item.item().and_downcast::()?; + Some((object.name(), item.accessible_description().to_string())) + }) + .collect(); + found.sort(); + found + } + + #[cfg(test)] + pub(crate) fn selected_table(&self) -> Option { + let model = &self.inner.model; + model + .object_at(model.selection().selected()) + .map(|object| object.table_ref()) + } +} + +impl SidebarInner { + pub(super) fn affordances(&self) -> SidebarAffordances { + self.affordances.get() + } + + pub(super) fn track_row(&self, item: >k::ListItem) { + let mut rows = self.rows.borrow_mut(); + rows.retain(|row| row.upgrade().is_some()); + rows.push(item.downgrade()); + } + + /// A press on the row at `position`. True when the sidebar handled + /// it, so the row's own click does not select or open it too. + pub(crate) fn pressed( + &self, + position: u32, + button: u32, + modifiers: gdk::ModifierType, + pointing: Option, + ) -> bool { + match button { + gdk::BUTTON_SECONDARY => { + self.popup_menu(position, pointing); + true + } + gdk::BUTTON_PRIMARY if modifiers.contains(gdk::ModifierType::CONTROL_MASK) => { + self.open_in_new_tab(position); + true + } + gdk::BUTTON_MIDDLE => { + self.middle_pressed + .replace(self.model.object_at(position).map(|object| object.table_ref())); + false + } + _ => false, + } + } + + /// A middle click opens the table it was pressed on in a new tab, as + /// a link does. + pub(super) fn released(&self, button: u32) -> bool { + if button != gdk::BUTTON_MIDDLE { + return false; + } + let Some(table) = self.middle_pressed.take() else { + return false; + }; + if self.model.kind_of(&table).is_some() { + (self.events)(SidebarEvent::OpenInNewTab(table)); + } + true + } + + fn open_in_new_tab(&self, position: u32) { + if let Some(object) = self.model.object_at(position) { + (self.events)(SidebarEvent::OpenInNewTab(object.table_ref())); + } + } + + fn activate(&self, position: u32) { + if let Some(object) = self.model.object_at(position) { + (self.events)(SidebarEvent::Open(object.table_ref())); + } + } + + /// Select the row at `position` and open its menu, pointing at + /// `pointing` in the list view, or at the whole list. + pub(crate) fn popup_menu(&self, position: u32, pointing: Option) { + let Some(object) = self.model.object_at(position) else { + return; + }; + self.model.selection().set_selected(position); + let menu = object_menu(&ObjectKey(object.table_ref()), object.kind(), self.affordances.get()); + self.popover.set_menu_model(Some(&menu)); + self.popover.set_pointing_to(pointing.as_ref()); + self.popover.popup(); + } + + /// The Menu key and Shift+F10: the menu of the row the keyboard is + /// on, over that row. The selection follows the tab in front, not + /// the keyboard, so it stands in only when no row has focus. + fn popup_keyboard_menu(&self) { + let position = self + .focused_position() + .unwrap_or_else(|| self.model.selection().selected()); + if position == gtk::INVALID_LIST_POSITION { + return; + } + let pointing = self.row_bounds(position).or_else(|| { + self.list_view + .focus_child() + .and_then(|row| bounds_in(&row, &self.list_view)) + }); + self.popup_menu(position, pointing); + } + + fn row_bounds(&self, position: u32) -> Option { + let row = self.bound_rows().find(|item| item.position() == position)?.child()?; + bounds_in(&row, &self.list_view) + } + + fn focused_position(&self) -> Option { + let focused = self.list_view.focus_child()?; + self.bound_rows() + .find(|item| { + item.child() + .is_some_and(|row| row == focused || row.is_ancestor(&focused)) + }) + .map(|item| item.position()) + } + + fn bound_rows(&self) -> impl Iterator { + let rows: Vec = self.rows.borrow().iter().filter_map(glib::WeakRef::upgrade).collect(); + rows.into_iter().filter(|item| item.item().is_some()) + } + + /// Run a table action whose target came through GAction: a target of + /// the wrong shape, or for a table the list no longer has, does + /// nothing. + fn act_on(&self, action: &str, target: Option<&glib::Variant>) { + let Some(ObjectKey(table)) = target.and_then(ObjectKey::from_variant) else { + tracing::warn!(action, "a sidebar action came without a table"); + return; + }; + let Some(kind) = self.model.kind_of(&table) else { + tracing::warn!(action, "a sidebar action named a table the list no longer has"); + return; + }; + if !offers(action, kind, self.affordances.get()) { + tracing::warn!(action, "a sidebar action the table or the connection does not offer"); + return; + } + let event = match action { + OPEN_IN_NEW_TAB => SidebarEvent::OpenInNewTab(table), + EDIT_STRUCTURE => SidebarEvent::EditStructure(table), + SHOW_CREATE_TABLE => SidebarEvent::ShowCreateTable(table), + IMPORT_CSV => SidebarEvent::ImportCsv(table), + DROP_TABLE => SidebarEvent::DropTable(table), + _ => return, + }; + (self.events)(event); + } + + fn new_table(&self, target: Option<&glib::Variant>) { + let Some(schema) = target.and_then(|target| target.get::>()) else { + tracing::warn!("a new table was asked for without a schema"); + return; + }; + if !self.affordances.get().offers_new_table() { + return; + } + (self.events)(SidebarEvent::NewTable(schema.filter(|schema| !schema.is_empty()))); + } + + fn reselect(&self) { + self.model.select(self.active.borrow().as_ref()); + } + + fn refresh_placeholder(&self) { + if self.model.shown() > 0 { + self.root.set_visible_child_name(LIST_PAGE); + return; + } + if self.model.has_query() { + self.empty.set_icon_name(Some(crate::ui::icons::SYSTEM_SEARCH)); + self.empty.set_title(&crate::i18n::gettext("No Matching Tables")); + self.empty + .set_description(Some(&crate::i18n::gettext("Try a different search."))); + } else { + self.empty.set_icon_name(Some(crate::ui::icons::VIEW_GRID)); + self.empty.set_title(&crate::i18n::gettext("No Tables")); + self.empty.set_description(None); + } + self.root.set_visible_child_name(EMPTY_PAGE); + } + + fn remember(&self, object: &impl IsA, handler: glib::SignalHandlerId) { + self.handlers + .borrow_mut() + .push((object.upcast_ref::().downgrade(), handler)); + } +} + +fn install_actions(inner: &Rc) { + for name in [ + OPEN_IN_NEW_TAB, + EDIT_STRUCTURE, + SHOW_CREATE_TABLE, + IMPORT_CSV, + DROP_TABLE, + ] { + let action = gio::SimpleAction::new(name, Some(&ObjectKey::variant_type())); + let view = Rc::downgrade(inner); + action.connect_activate(move |_, target| { + if let Some(view) = view.upgrade() { + view.act_on(name, target); + } + }); + inner.actions.add_action(&action); + } + let new_table = gio::SimpleAction::new(NEW_TABLE, Some(&Option::::static_variant_type())); + let view = Rc::downgrade(inner); + new_table.connect_activate(move |_, target| { + if let Some(view) = view.upgrade() { + view.new_table(target); + } + }); + inner.actions.add_action(&new_table); + let menu = gio::SimpleAction::new(CONTEXT_MENU, None); + let view = Rc::downgrade(inner); + menu.connect_activate(move |_, _| { + if let Some(view) = view.upgrade() { + view.popup_keyboard_menu(); + } + }); + inner.actions.add_action(&menu); +} + +fn connect_view(inner: &Rc) { + let view: Weak = Rc::downgrade(inner); + let handler = inner.list_view.connect_activate(move |_, position| { + if let Some(view) = view.upgrade() { + view.activate(position); + } + }); + inner.remember(&inner.list_view, handler); + let view = Rc::downgrade(inner); + let handler = inner.model.selection().connect_items_changed(move |_, _, _, _| { + if let Some(view) = view.upgrade() { + view.refresh_placeholder(); + } + }); + inner.remember(inner.model.selection(), handler); + let hover = gtk::EventControllerMotion::new(); + let view = Rc::downgrade(inner); + hover.connect_leave(move |_| { + if let Some(view) = view.upgrade() { + view.reselect(); + } + }); + inner.list_view.add_controller(hover.clone()); + inner.hover.replace(Some(hover)); +} + +fn bounds_in(widget: &impl IsA, target: >k::ListView) -> Option { + widget.compute_bounds(target).map(|bounds| { + gdk::Rectangle::new( + bounds.x() as i32, + bounds.y() as i32, + bounds.width() as i32, + bounds.height() as i32, + ) + }) +} diff --git a/linux/crates/app/src/ui/workspace/sidebar/tests.rs b/linux/crates/app/src/ui/workspace/sidebar/tests.rs new file mode 100644 index 0000000000..4b68c19dc4 --- /dev/null +++ b/linux/crates/app/src/ui/workspace/sidebar/tests.rs @@ -0,0 +1,730 @@ +use std::cell::RefCell; +use std::rc::Rc; +use std::time::Duration; + +use gtk4 as gtk; +use gtk4::prelude::*; +use gtk4::subclass::prelude::*; +use gtk4::{gdk, gio, glib}; +use libadwaita as adw; +use tablepro_core::DriverError; +use tablepro_core::meta::{ObjectInfo, ObjectKind, ObjectScope, SchemaInfo, TableRef}; +use tablepro_test_support::FakeReply; + +use super::object_key::ObjectKey; +use super::sidebar_event::SidebarEvent; +use super::*; +use crate::test_support::{descendants, drain_main_context, wait_until}; +use crate::ui::session_window::tests::{TestWindow, WAIT}; +use crate::ui::workspace::WorkspaceTab; +use crate::ui::workspace::tab_spec::{TabSpec, TablePage}; + +type Events = Rc>>; + +fn object(schema: &str, name: &str) -> ObjectInfo { + ObjectInfo { + schema: Some(schema.to_owned()), + name: name.to_owned(), + kind: ObjectKind::Table, + } +} + +fn schema(name: &str, is_default: bool) -> SchemaInfo { + SchemaInfo { + name: name.to_owned(), + is_default, + is_system: false, + } +} + +fn public() -> [SchemaInfo; 1] { + [schema("public", true)] +} + +fn table(name: &str) -> TableRef { + TableRef::new(Some("public".to_owned()), name) +} + +fn every_affordance() -> SidebarAffordances { + SidebarAffordances { + read_only: false, + can_create_table: true, + can_edit_structure: true, + } +} + +/// A sidebar in a window of its own, with what it sent. +struct Shown { + view: SidebarView, + events: Events, + window: gtk::Window, +} + +impl Shown { + fn new(names: &[&str], affordances: SidebarAffordances) -> Self { + adw::init().expect("libadwaita initialises under the test backend"); + let events = Events::default(); + let sink = events.clone(); + let view = SidebarView::new(move |event| sink.borrow_mut().push(event), affordances); + let objects: Vec = names.iter().map(|name| object("public", name)).collect(); + view.apply_objects(&objects, &public()); + let window = gtk::Window::new(); + window.set_default_size(300, 400); + window.set_child(Some(view.widget())); + window.present(); + drain_main_context(); + Self { view, events, window } + } + + fn list_view(&self) -> gtk::ListView { + self.view.inner().list_view.clone() + } + + fn selection(&self) -> gtk::SingleSelection { + self.list_view() + .model() + .and_downcast::() + .expect("a single selection") + } + + fn popover(&self) -> gtk::PopoverMenu { + self.view.inner().popover.clone() + } + + fn taken(&self) -> Vec { + self.events.take() + } +} + +impl Drop for Shown { + fn drop(&mut self) { + self.view.shutdown(); + self.window.destroy(); + drain_main_context(); + } +} + +/// Each menu item's action and target, sections flattened. +fn menu_items(menu: &gio::MenuModel) -> Vec<(String, Option)> { + let mut found = Vec::new(); + for index in 0..menu.n_items() { + if let Some(action) = menu + .item_attribute_value(index, "action", None) + .and_then(|value| value.get::()) + { + found.push((action, menu.item_attribute_value(index, "target", None))); + } + if let Some(section) = menu.item_link(index, "section") { + found.extend(menu_items(§ion)); + } + } + found +} + +fn names(count: usize) -> Vec { + (0..count).map(|index| format!("t{index:05}")).collect() +} + +/// The placeholder's title and description, when it shows. +fn placeholder(view: &SidebarView) -> Option<(String, Option)> { + view.widget() + .visible_child() + .and_downcast::() + .map(|page| { + ( + page.title().to_string(), + page.description() + .filter(|text| !text.is_empty()) + .map(|text| text.to_string()), + ) + }) +} + +#[gtk4::test] +fn empty_filter_shows_placeholder() { + let shown = Shown::new(&["orders", "users"], every_affordance()); + assert_eq!(placeholder(&shown.view), None, "a list with rows shows its placeholder"); + + shown.view.set_query("nothing like it"); + assert_eq!( + placeholder(&shown.view), + Some(( + "No Matching Tables".to_owned(), + Some("Try a different search.".to_owned()) + )) + ); + + shown.view.set_query(""); + shown.view.apply_objects(&[], &public()); + assert_eq!(placeholder(&shown.view), Some(("No Tables".to_owned(), None))); +} + +#[gtk4::test] +fn clearing_a_search_over_an_emptied_list_says_there_are_no_tables() { + let shown = Shown::new(&["orders"], every_affordance()); + shown.view.set_query("zzz"); + // An empty list under a search that matched nothing: no row changes. + shown.view.apply_objects(&[], &public()); + + shown.view.set_query(""); + + assert_eq!(placeholder(&shown.view), Some(("No Tables".to_owned(), None))); +} + +#[gtk4::test] +fn rows_are_recycled_for_large_catalogs() { + let owned = names(10_000); + let borrowed: Vec<&str> = owned.iter().map(String::as_str).collect(); + let shown = Shown::new(&borrowed, every_affordance()); + wait_until(WAIT, || shown.list_view().height() > 0).expect("the list laid out"); + drain_main_context(); + + let labels = descendants(&shown.list_view()) + .into_iter() + .filter(|widget| widget.is::()) + .count(); + + assert!(labels > 0, "no row was built at all"); + // GtkListView keeps about 200 rows around its viewport, whatever the + // model holds, and the section header adds a label. + assert!(labels <= 210, "{labels} labels for 10,000 tables"); +} + +#[gtk4::test] +fn single_click_activation_opens_table() { + let shown = Shown::new(&["a", "b", "c", "d", "e"], every_affordance()); + + assert!(shown.list_view().is_single_click_activate()); + shown.list_view().emit_by_name::<()>("activate", &[&3u32]); + + assert_eq!(shown.taken(), [SidebarEvent::Open(table("d"))]); +} + +#[gtk4::test] +fn secondary_press_selects_row_and_opens_menu() { + let shown = Shown::new(&["a", "b", "c", "d", "e", "f", "g"], every_affordance()); + + let claimed = shown + .view + .inner() + .pressed(5, gdk::BUTTON_SECONDARY, gdk::ModifierType::empty(), None); + + assert!(claimed, "the row's own click would also run"); + assert_eq!(shown.selection().selected(), 5); + let popover = shown.popover(); + assert!(popover.is_visible(), "the menu did not open"); + let model = popover.menu_model().expect("a menu model"); + let targets: Vec> = menu_items(&model) + .into_iter() + .map(|(_, target)| target.as_ref().and_then(ObjectKey::from_variant)) + .collect(); + assert!( + targets.iter().all(|key| key.as_ref() == Some(&ObjectKey(table("f")))), + "{targets:?}" + ); + popover.popdown(); + assert!(shown.taken().is_empty(), "opening the menu opened the table"); +} + +#[gtk4::test] +fn ctrl_click_and_middle_click_open_a_new_tab() { + let shown = Shown::new(&["a", "b", "c"], every_affordance()); + let inner = shown.view.inner().clone(); + + assert!(inner.pressed(1, gdk::BUTTON_PRIMARY, gdk::ModifierType::CONTROL_MASK, None)); + assert!(!inner.pressed(1, gdk::BUTTON_PRIMARY, gdk::ModifierType::empty(), None)); + assert!(!inner.pressed(2, gdk::BUTTON_MIDDLE, gdk::ModifierType::empty(), None)); + assert!(inner.released(gdk::BUTTON_MIDDLE)); + + assert_eq!( + shown.taken(), + [ + SidebarEvent::OpenInNewTab(table("b")), + SidebarEvent::OpenInNewTab(table("c")) + ] + ); +} + +#[gtk4::test] +fn a_middle_click_opens_the_table_it_was_pressed_on() { + let shown = Shown::new(&["a", "b", "c"], every_affordance()); + let inner = shown.view.inner().clone(); + assert!( + !inner.released(gdk::BUTTON_MIDDLE), + "a release with no press opened a table" + ); + + inner.pressed(1, gdk::BUTTON_MIDDLE, gdk::ModifierType::empty(), None); + // A refresh lands between the press and the release, and binds the + // pressed row to another table. + shown.view.apply_objects( + &[ + object("public", "a"), + object("public", "aa"), + object("public", "b"), + object("public", "c"), + ], + &public(), + ); + + assert!(inner.released(gdk::BUTTON_MIDDLE)); + assert_eq!(shown.taken(), [SidebarEvent::OpenInNewTab(table("b"))]); +} + +#[gtk4::test] +fn context_menu_action_carries_object_key() { + let shown = Shown::new(&["orders", "users"], every_affordance()); + let list_view = shown.list_view(); + let key = ObjectKey(table("users")).to_variant(); + + for action in [ + "sidebar.open-in-new-tab", + "sidebar.edit-structure", + "sidebar.show-create-table", + "sidebar.import-csv", + "sidebar.drop-table", + ] { + list_view.activate_action(action, Some(&key)).expect(action); + } + list_view + .activate_action( + "sidebar.drop-table", + Some(&(None::, String::new()).to_variant()), + ) + .expect("the action"); + list_view + .activate_action("sidebar.drop-table", Some(&ObjectKey(table("gone")).to_variant())) + .expect("the action"); + + assert_eq!( + shown.taken(), + [ + SidebarEvent::OpenInNewTab(table("users")), + SidebarEvent::EditStructure(table("users")), + SidebarEvent::ShowCreateTable(table("users")), + SidebarEvent::ImportCsv(table("users")), + SidebarEvent::DropTable(table("users")), + ], + "a table the list no longer has was acted on" + ); +} + +#[gtk4::test] +fn a_read_only_connection_refuses_writes_from_a_stale_menu() { + let shown = Shown::new( + &["orders"], + SidebarAffordances { + read_only: true, + ..every_affordance() + }, + ); + let key = ObjectKey(table("orders")).to_variant(); + + shown + .list_view() + .activate_action("sidebar.drop-table", Some(&key)) + .expect("the action"); + shown + .list_view() + .activate_action("sidebar.new-table", Some(&Some("public".to_owned()).to_variant())) + .expect("the action"); + + assert!(shown.taken().is_empty()); +} + +#[gtk4::test] +fn keyboard_menu_falls_back_to_the_selected_row() { + let shown = Shown::new(&["a", "b", "c", "d"], every_affordance()); + // Showing the window focuses its first control, which may be a row. + gtk::prelude::GtkWindowExt::set_focus(&shown.window, None::<>k::Widget>); + shown.selection().set_selected(2); + + shown + .list_view() + .activate_action("sidebar.context-menu", None) + .expect("the action"); + + let popover = shown.popover(); + assert!(popover.is_visible(), "the menu did not open"); + let model = popover.menu_model().expect("a menu model"); + let (_, target) = menu_items(&model).into_iter().next().expect("an item"); + assert_eq!( + target.as_ref().and_then(ObjectKey::from_variant), + Some(ObjectKey(table("c"))) + ); + assert!(popover.pointing_to().0, "the menu points at nothing"); + popover.popdown(); +} + +/// The table the open menu acts on, if a menu is open. +fn menu_table(shown: &Shown) -> Option { + let popover = shown.popover(); + if !popover.is_visible() { + return None; + } + let (_, target) = menu_items(&popover.menu_model()?).into_iter().next()?; + target.as_ref().and_then(ObjectKey::from_variant) +} + +/// The name on the row the keyboard is on. +fn focused_name(list_view: >k::ListView) -> Option { + list_view + .focus_child() + .and_then(|item| item.first_child()) + .and_then(|row| row.last_child()) + .and_downcast::() + .map(|label| label.label().to_string()) +} + +/// The scroll marks the row the list focuses next; the grab carries the +/// focus there. +fn focus_row(list_view: >k::ListView, position: u32, name: &str) { + list_view.scroll_to(position, gtk::ListScrollFlags::FOCUS, None); + list_view.grab_focus(); + wait_until(WAIT, || focused_name(list_view).as_deref() == Some(name)).expect("the row focused"); +} + +#[gtk4::test] +fn keyboard_menu_opens_for_the_focused_row() { + let shown = Shown::new(&["a", "b", "c", "d"], every_affordance()); + let list_view = shown.list_view(); + wait_until(WAIT, || list_view.height() > 0).expect("the list laid out"); + + // An editor tab in front: no row is selected. + focus_row(&list_view, 2, "c"); + shown.view.sync_selection(None); + list_view + .activate_action("sidebar.context-menu", None) + .expect("the action"); + assert_eq!(menu_table(&shown), Some(ObjectKey(table("c")))); + shown.popover().popdown(); + + // A table tab in front selects its own row, not the focused one. + focus_row(&list_view, 2, "c"); + shown.view.sync_selection(Some(&table("a"))); + list_view + .activate_action("sidebar.context-menu", None) + .expect("the action"); + assert_eq!(menu_table(&shown), Some(ObjectKey(table("c")))); + shown.popover().popdown(); +} + +#[gtk4::test] +fn a_row_tells_a_screen_reader_its_kind() { + let shown = Shown::new(&[], every_affordance()); + shown.view.apply_objects( + &[ + object("public", "orders"), + ObjectInfo { + schema: Some("public".to_owned()), + name: "totals".to_owned(), + kind: ObjectKind::View, + }, + ], + &public(), + ); + + wait_until(WAIT, || shown.view.row_descriptions().len() == 2).expect("both rows bound"); + assert_eq!( + shown.view.row_descriptions(), + [ + ("orders".to_owned(), "Table".to_owned()), + ("totals".to_owned(), "View".to_owned()) + ] + ); +} + +#[gtk4::test] +fn the_menu_keys_reach_the_context_menu_action() { + let shown = Shown::new(&["a"], every_affordance()); + + let bound = crate::test_support::shortcut_keys::owner_controller( + &shown.list_view(), + crate::application::ShortcutOwner::SidebarList, + ) + .expect("the list's keys"); + let actions: Vec = crate::test_support::shortcut_keys::shortcuts(&bound) + .into_iter() + .filter_map(|shortcut| shortcut.action().and_downcast::()) + .map(|action| action.action_name().to_string()) + .collect(); + + assert_eq!(actions, ["sidebar.context-menu", "sidebar.context-menu"]); +} + +/// Each section header's title and whether its New Table button shows. +fn headers(list_view: >k::ListView) -> Vec<(String, bool)> { + descendants(list_view) + .into_iter() + .filter(|widget| widget.css_name() == "header") + .filter_map(|header| { + let row = header.first_child()?; + let title = row.first_child().and_downcast::()?.label().to_string(); + let add = row.last_child().and_downcast::()?; + Some((title, add.is_visible())) + }) + .collect() +} + +#[gtk4::test] +fn the_new_table_button_follows_the_dialect() { + let shown = Shown::new(&["orders"], every_affordance()); + wait_until(WAIT, || !headers(&shown.list_view()).is_empty()).expect("a section header"); + assert_eq!(headers(&shown.list_view()), [("public".to_owned(), true)]); + let button = descendants(&shown.list_view()) + .into_iter() + .find_map(|widget| widget.downcast::().ok()) + .expect("the New Table button"); + assert_eq!(button.tooltip_text().as_deref(), Some("New Table in public…")); + button.emit_clicked(); + assert_eq!(shown.taken(), [SidebarEvent::NewTable(Some("public".to_owned()))]); + + shown.view.set_affordances(SidebarAffordances { + can_create_table: false, + ..every_affordance() + }); + wait_until(WAIT, || headers(&shown.list_view()) == [("public".to_owned(), false)]) + .expect("the button hidden once the server cannot create tables"); +} + +#[gtk4::test] +fn sync_selection_follows_the_tab_and_survives_a_new_list() { + let shown = Shown::new(&["orders", "users"], every_affordance()); + + shown.view.sync_selection(Some(&table("users"))); + assert_eq!(shown.selection().selected(), 1); + shown.view.apply_objects( + &[ + object("public", "accounts"), + object("public", "orders"), + object("public", "users"), + ], + &public(), + ); + assert_eq!(shown.selection().selected(), 2, "the new list dropped the selection"); + + shown.view.sync_selection(None); + assert_eq!(shown.selection().selected(), gtk::INVALID_LIST_POSITION); +} + +fn sidebar_selected(workspace: &crate::ui::workspace::Workspace) -> Option { + workspace.read(|model| model.sidebar.selected_table()).flatten() +} + +#[gtk4::test] +fn sync_selection_selects_matching_row_and_clears_for_editor() { + let test = TestWindow::connected_with("SidebarSync", |script| { + script.list_objects.once(FakeReply::ok(vec![ + object("public", "orders"), + object("public", "users"), + ])); + }); + let workspace = test.workspace(); + wait_until(WAIT, || workspace.read(|model| model.sidebar.shown()) == Some(2)).expect("the tables listed"); + + workspace.with_model(|model| { + model.open_tab(TabSpec::Table { + table: table("users"), + page: TablePage::first(100), + }) + }); + wait_until(WAIT, || sidebar_selected(&workspace) == Some(table("users"))).expect("the table's row selected"); + + WidgetExt::activate_action(&test.window, "win.new-editor-tab", None).expect("win.new-editor-tab"); + wait_until(WAIT, || { + workspace + .read(|model| { + let tabs = model.workspace_tabs.borrow(); + model + .selected_workspace_tab_id() + .and_then(|id| tabs.get(&id).map(|tab| matches!(tab, WorkspaceTab::Editor(_)))) + }) + .flatten() + == Some(true) + }) + .expect("the editor in front"); + wait_until(WAIT, || sidebar_selected(&workspace).is_none()).expect("no row selected for the editor"); +} + +#[gtk4::test] +fn sidebar_lists_every_schema_after_the_connect() { + let test = TestWindow::connected_with("SidebarAllSchemas", |script| { + script.list_objects.once(FakeReply::ok(vec![ + object("public", "users"), + object("sales", "orders"), + ])); + }); + let workspace = test.workspace(); + + wait_until(WAIT, || workspace.read(|model| model.sidebar.shown()) == Some(2)) + .expect("the second schema's table listed"); + let session = workspace + .read(|model| model.context.active.get()) + .flatten() + .expect("the session"); + let listed = session.catalog().subscribe(ObjectScope::AllSchemas).borrow().clone(); + assert!(listed.generation > 0, "every schema's list was never asked for"); + assert_eq!(listed.objects.len(), 2); +} + +#[gtk4::test] +fn catalog_error_keeps_list_and_shows_why_until_a_read_lands() { + let test = TestWindow::connected_with("SidebarCatalogError", |script| { + script.list_objects.once(FakeReply::ok(vec![ + object("public", "orders"), + object("public", "users"), + ])); + for _ in 0..2 { + script + .list_objects + .once(FakeReply::err(DriverError::Protocol("catalog gone".to_owned()))); + } + for _ in 0..3 { + script.list_objects.once(FakeReply::ok(vec![ + object("public", "orders"), + object("public", "users"), + object("public", "carts"), + ])); + } + }); + let workspace = test.workspace(); + wait_until(WAIT, || workspace.read(|model| model.sidebar.shown()) == Some(2)).expect("the tables listed"); + let banner = workspace.imp().catalog_banner.get(); + assert!(!banner.is_revealed(), "a banner before anything failed"); + + WidgetExt::activate_action(&test.window, "win.refresh-tables", None).expect("win.refresh-tables"); + + wait_until(WAIT, || banner.is_revealed()).expect("a banner for the failed refresh"); + assert!( + banner.title().starts_with("Could not refresh the table list: "), + "{}", + banner.title() + ); + assert_eq!(banner.action_name().as_deref(), Some("win.refresh-tables")); + assert_eq!( + workspace.read(|model| model.sidebar.shown()), + Some(2), + "the failure emptied the list" + ); + + WidgetExt::activate_action(&test.window, "win.refresh-tables", None).expect("the banner's Retry"); + + wait_until(WAIT, || workspace.read(|model| model.sidebar.shown()) == Some(3)).expect("the list read again"); + assert!(!banner.is_revealed(), "the banner stayed after a read landed"); +} + +#[gtk4::test] +fn the_refresh_button_reads_the_tables_again() { + let test = TestWindow::connected_with("SidebarRefresh", |script| { + script.list_objects.once(FakeReply::ok(vec![object("public", "users")])); + for _ in 0..3 { + script.list_objects.once(FakeReply::ok(vec![ + object("public", "users"), + object("public", "orders"), + ])); + } + }); + let workspace = test.workspace(); + wait_until(WAIT, || workspace.read(|model| model.sidebar.shown()) == Some(1)).expect("the table listed"); + let button = workspace.imp().refresh_tables_button.get(); + assert_eq!(button.action_name().as_deref(), Some("win.refresh-tables")); + crate::test_support::assert_labelled(&button).expect("the refresh button is named"); + + button.emit_clicked(); + + wait_until(WAIT, || workspace.read(|model| model.sidebar.shown()) == Some(2)).expect("the new table listed"); +} + +#[gtk4::test] +fn ddl_run_in_the_editor_reads_the_tables_again() { + use tablepro_core::EditorSession; + use tablepro_test_support::{FakeEditorSession, FakeRun, Replies}; + + let mut runs = Replies::default(); + runs.always(FakeRun::completed(Vec::new())); + let editor_session: std::sync::Arc = std::sync::Arc::new(FakeEditorSession::new(runs)); + let test = TestWindow::connected_with("SidebarEditorDdl", |script| { + script.open_editor_session.always(FakeReply::ok(editor_session)); + script.list_objects.once(FakeReply::ok(vec![object("public", "users")])); + for _ in 0..3 { + script.list_objects.once(FakeReply::ok(vec![ + object("public", "users"), + object("public", "orders"), + ])); + } + }); + let workspace = test.workspace(); + wait_until(WAIT, || workspace.read(|model| model.sidebar.shown()) == Some(1)).expect("the table listed"); + workspace.with_model(|model| model.open_editor_tab(Some("CREATE TABLE orders (id int)".to_owned()))); + let editor = crate::ui::session_window::tests::editors(&test.window) + .pop() + .expect("the editor tab"); + + editor.run(); + + wait_until(WAIT, || workspace.read(|model| model.sidebar.shown()) == Some(2)) + .expect("the created table listed without a reconnect"); +} + +#[gtk4::test] +fn a_reconnect_reads_every_schema_again() { + let test = TestWindow::connected_with("SidebarReconnect", |script| { + script.list_objects.once(FakeReply::ok(vec![object("public", "users")])); + script.list_objects.once(FakeReply::ok(vec![ + object("public", "users"), + object("sales", "orders"), + ])); + }); + let workspace = test.workspace(); + wait_until(WAIT, || workspace.read(|model| model.sidebar.shown()) == Some(1)).expect("the table listed"); + let session = workspace + .read(|model| model.context.active.get()) + .flatten() + .expect("the session"); + + let listed = || session.catalog().subscribe(ObjectScope::AllSchemas).borrow().clone(); + let before = listed().generation; + + // What a reconnect does once the new connection read its catalog. + session.catalog().commit(Vec::new(), vec![object("public", "users")]); + + wait_until(Duration::from_secs(10), || { + workspace.read(|model| model.sidebar.shown()) == Some(2) + }) + .expect("every schema's list read again"); + assert!(listed().generation > before, "every schema's list was not read again"); + assert_eq!(listed().objects.len(), 2); +} + +#[gtk4::test] +fn a_reconnect_names_tabs_by_its_schemas_when_the_list_fails() { + let test = TestWindow::connected_with("SidebarReconnectNaming", |script| { + script.list_objects.once(FakeReply::ok(vec![object("public", "users")])); + script + .list_objects + .once(FakeReply::err(DriverError::Protocol("catalog gone".to_owned()))); + }); + let workspace = test.workspace(); + wait_until(WAIT, || workspace.read(|model| model.sidebar.shown()) == Some(1)).expect("the table listed"); + let naming = || workspace.read(|model| model.naming.get()); + assert_eq!(naming(), Some(TableNaming::Bare)); + let session = workspace + .read(|model| model.context.active.get()) + .flatten() + .expect("the session"); + let banner = workspace.imp().catalog_banner.get(); + + session.catalog().commit( + vec![schema("public", true), schema("sales", false)], + vec![object("public", "users")], + ); + + wait_until(WAIT, || banner.is_revealed()).expect("the list read to fail"); + wait_until(WAIT, || naming() == Some(TableNaming::SchemaQualified)) + .expect("the reconnect's schemas never named the tabs"); + assert_eq!( + workspace.read(|model| model.sidebar.shown()), + Some(1), + "the failure emptied the list" + ); +} diff --git a/linux/crates/app/src/ui/workspace/status_pages.rs b/linux/crates/app/src/ui/workspace/status_pages.rs new file mode 100644 index 0000000000..13789d750f --- /dev/null +++ b/linux/crates/app/src/ui/workspace/status_pages.rs @@ -0,0 +1,354 @@ +use libadwaita as adw; +use libadwaita::prelude::*; +use relm4::{Component, ComponentController}; + +use crate::ui::history_dialog::{HistoryDialog, HistoryDialogInit, QueryDestination}; +use crate::ui::saved_queries_dialog::{SavedQueriesDialog, SavedQueriesDialogInit, SavedQueriesDialogOutput}; + +use super::{WorkspaceInput, WorkspaceModel}; +use crate::runtime::DeliverInput; +use crate::ui::plain_toast; + +impl WorkspaceModel { + pub(super) fn show_toast(&self, msg: &str) { + self.toast_overlay.add_toast(plain_toast(msg)); + } + + pub(super) fn show_error_alert(&self, title: &str, message: &str) { + let dialog = adw::AlertDialog::new(Some(title), Some(message)); + // GNOME HIG dismiss-only alert: "Close" reads cleaner than "OK" + // (which implies acknowledgement of an action the user took) + // and matches GNOME Settings' info-alert convention. + dialog.add_response("close", &crate::i18n::gettext("Close")); + dialog.set_default_response(Some("close")); + dialog.set_close_response("close"); + dialog.present(self.dialog_parent().as_ref()); + } + + /// Say in an alert why the query database cannot be used. False + /// when it can. + fn alert_history_unavailable(&self, title: &str) -> bool { + let reason = self.history.availability().borrow().failure().map(str::to_owned); + let Some(reason) = reason else { + return false; + }; + self.show_error_alert(title, &reason); + true + } + + pub(super) fn on_show_history(&mut self) { + if self.alert_history_unavailable(&crate::i18n::gettext("Query History Unavailable")) { + return; + } + let dialog = HistoryDialog::new(HistoryDialogInit { + history: self.history.clone(), + connections: self.storage.connections().clone(), + preferred_connection: self.context.active.metadata().map(|metadata| metadata.id), + revealer: self.file_revealer.clone(), + tasks: self.tasks.clone(), + }); + let input = self.input.clone(); + dialog.connect_query_chosen(move |_, destination, text| { + input.deliver(match destination { + QueryDestination::NewTab => WorkspaceInput::OpenHistoryQuery(text), + QueryDestination::CurrentTab => WorkspaceInput::ReplaceActiveTabQuery(text), + }); + }); + dialog.present(self.dialog_parent().as_ref()); + self.history_dialog = Some(dialog); + } + + pub(super) fn on_show_saved_queries(&mut self) { + if self.alert_history_unavailable(&crate::i18n::gettext("Saved Queries Unavailable")) { + return; + } + let dialog = SavedQueriesDialog::builder() + .launch(SavedQueriesDialogInit { + store: self.history.store().map(|history| history.saved_queries()), + active_connection: self.context.active.metadata().map(|metadata| metadata.id), + }) + .connect_receiver({ + let input = self.input.clone(); + move |_, out| { + input.deliver(match out { + SavedQueriesDialogOutput::OpenInNewTab(text) => WorkspaceInput::OpenHistoryQuery(text), + SavedQueriesDialogOutput::ReplaceCurrentTabQuery(text) => { + WorkspaceInput::ReplaceActiveTabQuery(text) + } + }); + } + }); + dialog.model().dialog().present(self.dialog_parent().as_ref()); + self.saved_queries_dialog = Some(dialog); + } + + /// Ask what to call the SQL in front of the user, then keep it. + /// + /// The text is read now rather than when the dialog closes, so what + /// is saved is what the user was looking at when they asked. + pub(super) fn on_save_active_query(&self) { + if self.alert_history_unavailable(&crate::i18n::gettext("Could Not Save Query")) { + return; + } + let Some(query) = self.active_editor_query() else { + self.show_toast(&crate::i18n::gettext("Open a SQL editor tab to save a query.")); + return; + }; + if query.trim().is_empty() { + self.show_toast(&crate::i18n::gettext("There is nothing in the editor to save.")); + return; + } + + let dialog = adw::AlertDialog::new(Some(&crate::i18n::gettext("Save Query")), None); + let entry = adw::EntryRow::builder() + .title(crate::i18n::gettext("Name")) + .activates_default(true) + .build(); + entry.set_text(&suggested_query_name(&query)); + let group = adw::PreferencesGroup::new(); + group.add(&entry); + dialog.set_extra_child(Some(&group)); + dialog.add_response("cancel", &crate::i18n::gettext("Cancel")); + dialog.add_response("save", &crate::i18n::gettext("Save")); + dialog.set_response_appearance("save", adw::ResponseAppearance::Suggested); + dialog.set_default_response(Some("save")); + dialog.set_close_response("cancel"); + crate::ui::name_entry::require_name(&dialog, "save", &entry); + + let input = self.input.clone(); + dialog.connect_response(None, move |_, response| { + if response != "save" { + return; + } + let name = entry.text().to_string(); + if name.trim().is_empty() { + return; + } + input.deliver(WorkspaceInput::SaveQueryNamed { + name, + query: query.clone(), + }); + }); + dialog.present(self.dialog_parent().as_ref()); + } + + pub(super) fn on_save_query_named(&self, name: String, query: String) { + let Some(metadata) = self.context.active.metadata() else { + self.show_toast(&crate::i18n::gettext( + "Connect to a database first: a saved query is kept under its connection.", + )); + return; + }; + let new_query = tablepro_storage::saved_queries::NewSavedQuery { + name: name.clone(), + query, + connection_id: metadata.id, + connection_name: metadata.name, + }; + // The save lands even when the workspace is gone by then; only + // its toast is dropped. A database still opening is waited for. + let input = self.input.clone(); + let (ready, availability) = (self.history.ready_store(), self.history.availability()); + crate::runtime::spawn_held(&self.tasks, &*self.context.hold, async move { + let Some(store) = ready.await.map(|history| history.saved_queries()) else { + let reason = availability.borrow().failure().map(str::to_owned).unwrap_or_default(); + input.deliver(WorkspaceInput::ShowAlert { + title: crate::i18n::gettext("Could Not Save Query"), + body: reason, + }); + return; + }; + let message = match store.save(new_query).await { + Ok(outcome) if outcome.replaced => crate::i18n::gettext_f("Replaced “{name}”.", &[("name", &name)]), + Ok(_) => crate::i18n::gettext_f("Saved “{name}”.", &[("name", &name)]), + Err(error) => { + tracing::warn!(%error, "could not save the query"); + crate::i18n::gettext("Could not save that query.") + } + }; + input.deliver(WorkspaceInput::ShowToast(message)); + }); + } +} + +/// A name for a query the user has not named, taken from the first +/// line that is neither blank nor a comment. It is a starting point in +/// an entry the user can edit, not a final name. +fn suggested_query_name(query: &str) -> String { + const MAX_CHARS: usize = 40; + for line in query.lines() { + let trimmed = line.trim().trim_end_matches(';').trim(); + if trimmed.is_empty() || trimmed.starts_with("--") { + continue; + } + return trimmed + .chars() + .take(MAX_CHARS) + .collect::() + .trim_end() + .to_owned(); + } + String::new() +} + +#[cfg(test)] +mod tests { + use super::suggested_query_name; + use crate::test_support::{finish_on_main, wait_until}; + use crate::ui::session_window::tests::{TestWindow, WAIT}; + + #[gtk4::test] + fn a_saved_query_is_written_under_a_hold() { + let test = TestWindow::connected("SaveQueryHeld"); + let history = test.services.history(); + let availability = history.availability(); + wait_until(WAIT, || availability.borrow().is_ready()).expect("the query database to open"); + wait_until(WAIT, || test.hold.live() == 0).expect("nothing else to hold the process"); + + test.workspace() + .with_model(|model| model.on_save_query_named("Orders".to_owned(), "SELECT 1".to_owned())); + assert_eq!(test.hold.live(), 1, "the save started without holding the process"); + wait_until(WAIT, || test.hold.live() == 0).expect("the hold to end"); + + let store = history.store().expect("the query database").saved_queries(); + let listing = test.services.tasks.spawn_task(async move { store.list(None).await }); + let saved = finish_on_main(WAIT, listing) + .expect("the list to load") + .expect("the listing task") + .expect("the saved queries"); + assert!( + saved.iter().any(|query| query.name == "Orders"), + "the hold ended before the save" + ); + } + + #[gtk4::test] + fn a_history_dialog_closed_by_hand_is_not_closed_again_on_disconnect() { + use gtk4::glib; + use gtk4::prelude::*; + + let test = TestWindow::connected("HistoryClosedOnce"); + let dialog = test + .workspace() + .with_model(|model| { + model.on_show_history(); + model + .history_dialog + .as_ref() + .map(|dialog| dialog.clone().upcast::()) + }) + .flatten() + .expect("the history dialog"); + wait_until(WAIT, || dialog.parent().is_some()).expect("the dialog to show"); + libadwaita::prelude::AdwDialogExt::close(&dialog); + wait_until(WAIT, || dialog.parent().is_none()).expect("the dialog to close"); + + // Closing a dialog that is no longer presented logs a critical, + // which this makes abort the test. + let previous = glib::log_set_always_fatal(glib::LogLevels::LEVEL_CRITICAL); + test.window.send(crate::ui::SessionWindowInput::Disconnect); + let disconnected = wait_until(WAIT, || test.window.page_name() == "picker"); + glib::log_set_always_fatal(previous); + + disconnected.expect("the window back on the picker"); + } + + /// A history whose database cannot be read: a folder stands where + /// the file goes. + fn unreadable_history( + test: &TestWindow, + root: &tempfile::TempDir, + ) -> crate::services::history_service::HistoryService { + let paths = tablepro_storage::StoragePaths::under(root.path(), "tablepro-test", "app.tablepro.TablePro.Devel"); + std::fs::create_dir_all(paths.history_database()).expect("a folder where the database goes"); + let preferences = crate::services::history_preferences::HistoryPreferences { + retention_days: tokio::sync::watch::channel(30).1, + save: tokio::sync::watch::channel(true).1, + }; + let history = crate::services::history_service::HistoryService::start(paths, preferences, &test.services.tasks); + let availability = history.availability(); + wait_until(WAIT, || availability.borrow().is_settled()).expect("the open to end"); + history + } + + #[gtk4::test] + fn an_unreadable_history_says_why_where_it_is_opened() { + use gtk4::prelude::*; + use libadwaita::prelude::*; + + use crate::workspace::{answer, visible_alert}; + + let test = TestWindow::connected("HistoryUnreadable"); + let root = tempfile::tempdir().expect("tempdir"); + let history = unreadable_history(&test, &root); + test.workspace().with_model(|model| model.history = history); + + for (action, heading) in [ + ("win.show-history", "Query History Unavailable"), + ("win.show-saved-queries", "Saved Queries Unavailable"), + ] { + // The action follows the window's own history, which may still + // be opening. + let name = action.trim_start_matches("win."); + wait_until(WAIT, || { + test.window + .lookup_action(name) + .is_some_and(|action| action.is_enabled()) + }) + .unwrap_or_else(|_| panic!("{action} stayed off")); + WidgetExt::activate_action(&test.window, action, None).expect(action); + wait_until(WAIT, || { + visible_alert(&test.window).is_some_and(|alert| alert.heading().as_deref() == Some(heading)) + }) + .unwrap_or_else(|_| panic!("{action} said nothing")); + let body = visible_alert(&test.window).map(|alert| alert.body().to_string()); + assert_eq!( + body.as_deref(), + Some("The query history file could not be read. It may be damaged.") + ); + assert!(answer(&test.window, "close")); + wait_until(WAIT, || visible_alert(&test.window).is_none()).expect("the alert to close"); + } + } + + #[gtk4::test] + fn saving_a_query_into_an_unreadable_history_says_why() { + use libadwaita::prelude::*; + + use crate::workspace::visible_alert; + + let test = TestWindow::connected("SaveQueryUnreadable"); + let root = tempfile::tempdir().expect("tempdir"); + let history = unreadable_history(&test, &root); + test.workspace().with_model(|model| { + model.history = history; + model.on_save_query_named("Orders".to_owned(), "SELECT 1".to_owned()); + }); + + wait_until(WAIT, || { + visible_alert(&test.window).is_some_and(|alert| alert.heading().as_deref() == Some("Could Not Save Query")) + }) + .expect("the save said why it failed"); + } + + #[test] + fn the_suggested_name_is_the_first_real_line() { + assert_eq!( + suggested_query_name("\n-- yesterday's orders\nSELECT * FROM orders;\n"), + "SELECT * FROM orders" + ); + } + + #[test] + fn a_long_first_line_is_cut_rather_than_run_on() { + let name = suggested_query_name("SELECT a, b, c, d, e, f, g, h FROM a_table_with_a_long_name"); + + assert_eq!(name.chars().count(), 40); + assert!(!name.ends_with(' ')); + } + + #[test] + fn a_query_that_is_only_comments_suggests_nothing() { + assert_eq!(suggested_query_name("-- nothing here\n\n"), ""); + } +} diff --git a/linux/crates/app/src/ui/workspace/structure.rs b/linux/crates/app/src/ui/workspace/structure.rs new file mode 100644 index 0000000000..e785b47ef3 --- /dev/null +++ b/linux/crates/app/src/ui/workspace/structure.rs @@ -0,0 +1,696 @@ +//! Workspace handlers for the Structure (DDL) tab: opening +//! one, dropping a table, and following a save that landed. The save +//! itself runs through the workspace handle, like a row save. + +use std::sync::Arc; + +use libadwaita as adw; +use libadwaita::prelude::*; +use uuid::Uuid; + +use tablepro_core::ddl::{BuildDdlError, DdlOperation, DropTableOptions, InboundForeignKey, TableDefinition}; +use tablepro_core::dialect::SqlDialect; +use tablepro_core::meta::{ObjectScope, TableRef}; +use tablepro_core::{DriverError, WriteKind}; +use tablepro_session::runtime::TaskFailure; +use tablepro_session::{OperationError, OperationKind, Session}; + +use crate::runtime::DeliverInput; +use crate::ui::error_text; +use crate::ui::structure_tab::{LoadInput, SaveInput, StructureMode, StructureTabInput}; +use crate::ui::workspace::{WorkspaceInput, WorkspaceModel, WorkspaceTab}; + +use super::structure_target::StructureTarget; +use super::tab_spec::{TabSpec, TablePage}; +use crate::workspace::run_schema_plan; + +impl WorkspaceModel { + /// Sidebar right-click → "New Table…" or schema-header "+" button. + pub(super) fn on_new_table_tab(&mut self, schema: Option) { + let creates_tables = self + .context + .active + .get() + .and_then(|session| session.current_dialect()) + .and_then(|dialect| dialect.ddl().map(|ddl| ddl.capabilities().create_table)) + .unwrap_or(false); + if !creates_tables { + self.show_toast(&crate::i18n::gettext( + "Tables on this database are created in the SQL editor.", + )); + return; + } + self.open_tab(TabSpec::Structure { + target: StructureTarget::NewDraft { schema }, + }); + } + + /// Select the table's Structure tab, or open one. Its Table tab + /// stays open beside it. + pub(super) fn on_edit_structure_tab(&mut self, schema: Option, table: String) { + let existing_structure = self.workspace_tabs.borrow().iter().find_map(|(_, tab)| match tab { + WorkspaceTab::Structure(slot) + if slot.mode == StructureMode::Edit && slot.schema == schema && slot.table == table => + { + Some(slot.page.clone()) + } + _ => None, + }); + if let Some(page) = existing_structure { + self.tab_view.set_selected_page(&page); + return; + } + self.open_tab(TabSpec::Structure { + target: StructureTarget::Existing(TableRef::new(schema, table)), + }); + } + + /// Right-click → "Drop Table…", or in-tab destructive button. The + /// table's definition is read first, so a table other tables point + /// at is refused before the user is asked. + pub(super) fn on_drop_table_prompt(&mut self, schema: Option, table: String) { + let table = TableRef::new(schema, table); + let Some(session) = self.context.active.get() else { + self.present_drop_prompt(&table); + return; + }; + let work = read_definition(session, table.clone()); + let input = self.input.clone(); + crate::runtime::spawn_and_deliver(&self.context.tasks, work, move |result| { + // A definition that cannot be read leaves the refusal to the + // server, or to the precondition SQLite's plan carries. + let referencing = match settled(result) { + Ok((definition, _)) => referencing_keys(&definition), + Err(error) => { + tracing::debug!(%error, "the table's foreign keys could not be read before a drop"); + Vec::new() + } + }; + input.deliver(WorkspaceInput::DropTableChecked { table, referencing }); + }); + } + + pub(super) fn on_drop_table_checked(&mut self, table: TableRef, referencing: &[InboundForeignKey]) { + if referencing.is_empty() { + self.present_drop_prompt(&table); + return; + } + self.show_error_alert( + &crate::i18n::gettext("Cannot Drop Table"), + &crate::i18n::gettext_f( + "Foreign keys on other tables point at {table}: {keys}. Drop those foreign keys first.", + &[ + ("table", &display_name(&table)), + ("keys", &key_list(&table, referencing)), + ], + ), + ); + } + + fn present_drop_prompt(&self, table: &TableRef) { + let title = crate::i18n::gettext_f("Drop {table}?", &[("table", &display_name(table))]); + let body = crate::i18n::gettext( + "All rows and the table definition will be removed. This can't be undone from inside TablePro.", + ); + let dialog = adw::AlertDialog::new(Some(&title), Some(&body)); + dialog.add_response("cancel", &crate::i18n::gettext("Cancel")); + dialog.add_response("drop", &crate::i18n::gettext("Drop")); + dialog.set_response_appearance("drop", adw::ResponseAppearance::Destructive); + dialog.set_default_response(Some("cancel")); + dialog.set_close_response("cancel"); + let input = self.input.clone(); + let table = table.clone(); + dialog.connect_response(None, move |_, response| { + if response == "drop" { + input.deliver(WorkspaceInput::DropTableConfirmed { + schema: table.schema.clone(), + table: table.name.clone(), + }); + } + }); + dialog.present(self.dialog_parent().as_ref()); + } + + /// User confirmed the drop. Run DROP TABLE async; only close any + /// open Browse / Structure tabs for the table after the DROP + /// returns Ok. Tabs stay open if DROP fails (FK violation, + /// privileges) so the user doesn't lose their state on a failed + /// destructive action. + /// + /// The drop runs as a save of its own: a window close waits for it + /// or stops it, and a quit keeps the process until it answers. + pub(super) fn on_drop_table_confirmed(&mut self, schema: Option, table: String) { + let Some(session) = self.context.active.get() else { + self.show_error_alert( + &crate::i18n::gettext("Cannot Drop Table"), + &crate::i18n::gettext("No active connection."), + ); + return; + }; + let handle = self.workspace_handle.clone(); + let Some(ticket) = handle + .ledger() + .begin(&*self.context.hold, Uuid::new_v4(), WriteKind::SchemaChanges) + else { + return; + }; + let dropped = TableRef::new(schema, table); + let options = DropTableOptions { + if_exists: true, + cascade: false, + }; + let table = dropped.clone(); + let cancel = ticket.cancel.clone(); + let work = async move { + run_schema_plan(session, table, DdlOperation::DropTable, &cancel, move |ddl, table| { + ddl.plan_drop_table(table, options) + }) + .await + }; + // The answer goes through the workspace's input, so one that + // lands after a disconnect is dropped with the workspace. + let input = self.input.clone(); + crate::runtime::spawn_and_deliver(&self.context.tasks, work, move |result| { + match settled(result).and_then(|(_, written)| written) { + Ok(_) => input.deliver(WorkspaceInput::DropTableSucceeded(dropped)), + // The only guard a drop plan carries is SQLite's count of + // the keys pointing at the table, which one added since + // the prompt read them fails. + Err(OperationError::Driver(DriverError::RowGuardFailed { .. })) => { + input.deliver(WorkspaceInput::ShowAlert { + title: crate::i18n::gettext("Drop Failed"), + body: crate::i18n::gettext_f( + "A foreign key on another table points at {table}, so it was not dropped. Drop that foreign key first.", + &[("table", &display_name(&dropped))], + ), + }) + } + Err(error) => input.deliver(WorkspaceInput::ShowAlert { + title: crate::i18n::gettext("Drop Failed"), + body: error_text::operation_error_message(&error), + }), + } + handle.ledger().settle(ticket.id); + }); + } + + pub(super) fn on_drop_table_succeeded(&mut self, table: TableRef) { + self.close_tabs_for_table(table.schema.as_deref(), &table.name); + self.input.deliver(WorkspaceInput::SchemaChanged { + schema: table.schema, + table: Some(table.name), + }); + } + + /// Walk the tab map and force-close any Browse / Structure tabs + /// pointing at `(schema, table)`. Skips the per-tab pending-changes + /// dialog because the underlying table is gone. + pub(super) fn close_tabs_for_table(&mut self, schema: Option<&str>, table: &str) { + let tab_view = self.tab_view.clone(); + let mut targets: Vec = Vec::new(); + for (id, tab) in self.workspace_tabs.borrow().iter() { + match tab { + WorkspaceTab::Structure(s) if s.schema.as_deref() == schema && s.table == table => targets.push(*id), + WorkspaceTab::Table(s) if s.schema.as_deref() == schema && s.table == table => targets.push(*id), + _ => {} + } + } + for id in targets { + self.finish_close_workspace_tab(id, &tab_view); + } + } + + /// Save resolved successfully. Two cases: + /// + /// 1. **New-Table draft**: CreateTable succeeded so the table now + /// exists. Close the draft and open a Browse tab on the new + /// table. + /// 2. **Edit-mode tab**: the slot stays put, taking the new name + /// when the save renamed the table; `SaveCompleted` makes the + /// tab clear its pending ops and read the table again. + pub(super) fn on_structure_save_completed(&mut self, tab_id: Uuid, new_table_name: Option) { + // Decided first, so the tabs are not borrowed while they change. + enum SaveKind { + PromoteNewToTable(Option, String), + UpdateInPlace(Option, String), + Skip, + } + let kind = { + let mut tabs = self.workspace_tabs.borrow_mut(); + match tabs.get_mut(&tab_id) { + Some(WorkspaceTab::Structure(slot)) => match (slot.mode, new_table_name.clone()) { + (StructureMode::New, Some(name)) => SaveKind::PromoteNewToTable(slot.schema.clone(), name), + (StructureMode::New, None) => SaveKind::Skip, + (StructureMode::Edit, renamed) => { + // The refetch that follows reads the table by + // the slot's name, which a rename just changed. + if let Some(name) = renamed { + slot.table = name; + } + SaveKind::UpdateInPlace(slot.schema.clone(), slot.table.clone()) + } + }, + _ => SaveKind::Skip, + } + }; + + match kind { + SaveKind::PromoteNewToTable(schema, name) => { + let tab_view = self.tab_view.clone(); + self.finish_close_workspace_tab(tab_id, &tab_view); + // A table just designed is filled in next. + let page_size = u64::from(self.settings.default_page_size()); + self.open_tab(TabSpec::Table { + table: TableRef::new(schema.clone(), name.clone()), + page: TablePage::first(page_size), + }); + self.input.deliver(WorkspaceInput::SchemaChanged { + schema, + table: Some(name), + }); + } + SaveKind::UpdateInPlace(schema, table) => { + if new_table_name.is_some() { + self.retitle_tab(tab_id); + } + self.send_to_structure_tab(tab_id, SaveInput::SaveCompleted { new_table_name }); + self.input.deliver(WorkspaceInput::SchemaChanged { + schema, + table: Some(table), + }); + } + SaveKind::Skip => {} + } + } + + /// Sidebar "Show CREATE TABLE": the statements that recreate the + /// table as it is defined now, from the dialect's + /// `create_table_script`. The result lands in a fresh editor tab; no + /// Structure tab is touched. A failure can carry a long server + /// error, so it is an alert, which stays until it is read. + pub(super) fn on_show_create_table(&self, schema: Option, table: String) { + let title = crate::i18n::gettext("Could Not Show CREATE TABLE"); + let Some(session) = self.context.active.get() else { + self.show_error_alert(&title, &crate::i18n::gettext("No active connection.")); + return; + }; + let work = read_definition(session, TableRef::new(schema, table.clone())); + let input = self.input.clone(); + crate::runtime::spawn_and_deliver(&self.context.tasks, work, move |result| { + let message = match settled(result) { + Ok((definition, dialect)) => match create_table_sql(&definition, &*dialect) { + Ok(Some(sql)) => { + input.deliver(WorkspaceInput::ShowCreateTableLoaded(sql)); + return; + } + Ok(None) => crate::i18n::gettext_f("Table {table} has no columns.", &[("table", &table)]), + Err(error) => crate::i18n::gettext_f( + "Couldn't build SQL: {error}", + &[("error", &error_text::build_error_text(&error))], + ), + }, + Err(error) => crate::i18n::gettext_f( + "Couldn't read the table: {error}", + &[("error", &error_text::operation_error_message(&error))], + ), + }; + input.deliver(WorkspaceInput::ShowAlert { title, body: message }); + }); + } + + pub(super) fn send_to_structure_tab(&self, tab_id: Uuid, msg: impl Into) { + let structure = self + .workspace_tabs + .borrow() + .get(&tab_id) + .and_then(|tab| tab.structure_tab()) + .cloned(); + if let Some(structure) = structure { + structure.send(msg); + } + } + + /// The schema changed under the window. The catalog forgets the + /// table's columns and reads the sidebar's list again, and every tab + /// open on the table reads it again through its own requests, which + /// share the catalog's one column fetch. + pub(super) fn on_schema_changed(&self, schema: Option, table: Option) { + let Some(session) = self.context.active.get() else { + return; + }; + if let Some(table) = table { + session.invalidate_table(&TableRef::new(schema.clone(), table.clone())); + let (browse_tabs, structure_tabs): (Vec<_>, Vec<_>) = self + .workspace_tabs + .borrow() + .values() + .filter_map(|tab| match tab { + WorkspaceTab::Table(slot) if slot.schema == schema && slot.table == table => { + Some((Some(slot.browse.clone()), None)) + } + WorkspaceTab::Structure(slot) if slot.schema == schema && slot.table == table => { + Some((None, Some(slot.structure.clone()))) + } + _ => None, + }) + .unzip(); + for browse in browse_tabs.into_iter().flatten() { + browse.send(crate::ui::browse_tab::DataInput::SchemaChanged); + } + for structure in structure_tabs.into_iter().flatten() { + structure.send(LoadInput::SchemaChanged); + } + } + // The catalog watch takes the new list to the sidebar, and a + // failure to it as a toast. The default schema's list, which the + // editor's completion reads, follows, one read at a time. + self.context.tasks.spawn_task(async move { + let _ = session.refresh_objects(ObjectScope::AllSchemas).await; + let _ = session.refresh_objects(ObjectScope::DefaultSchema).await; + }); + } +} + +/// The table's definition and the dialect of the connection that read +/// it, so the DDL built from it is spelled for that same server. +async fn read_definition( + session: Arc, + table: TableRef, +) -> Result<(TableDefinition, Arc), OperationError> { + session + .run(OperationKind::Metadata, move |lease, options| { + let table = table.clone(); + async move { + let definition = lease.fetch_table_definition(&table, options).await?; + Ok((definition, lease.dialect())) + } + }) + .await +} + +/// The sidebar lists every schema, so two tables can share a name. +fn display_name(table: &TableRef) -> String { + match &table.schema { + Some(schema) => format!("{schema}.{}", table.name), + None => table.name.clone(), + } +} + +/// The keys of other tables that point at the table. A table's key on +/// itself goes with it, so it does not stop a drop. +fn referencing_keys(definition: &TableDefinition) -> Vec { + definition + .inbound_foreign_keys + .iter() + .filter(|key| key.table != definition.table) + .cloned() + .collect() +} + +/// Each key as its table and columns, the table qualified only where it +/// sits in another schema than the dropped one. +fn key_list(dropped: &TableRef, keys: &[InboundForeignKey]) -> String { + keys.iter() + .map(|key| { + let table = match key.table.schema == dropped.schema { + true => key.table.name.clone(), + false => display_name(&key.table), + }; + format!("{table} ({})", key.columns.join(", ")) + }) + .collect::>() + .join(", ") +} + +/// The CREATE statements for a table as it is defined now, joined so +/// the editor shows each on its own. `None` for a table with no +/// columns, which has nothing to create. +fn create_table_sql(definition: &TableDefinition, dialect: &dyn SqlDialect) -> Result, BuildDdlError> { + if definition.columns.is_empty() { + return Ok(None); + } + let ddl = dialect + .ddl() + .ok_or(BuildDdlError::Unsupported(DdlOperation::CreateTable))?; + let script = ddl.create_table_script(definition)?; + Ok((!script.is_empty()).then(|| script.preview())) +} + +/// A task that never answered is reported like the operation it ran: a +/// panic as the bug it is, an abort as the app stopping the work. +fn settled(result: Result, TaskFailure>) -> Result { + result.unwrap_or_else(|failure| { + if failure.is_panic() { + tracing::error!(%failure, "a structure task panicked"); + } + Err(failure.into()) + }) +} + +#[cfg(test)] +mod tests { + use tablepro_core::column::{ + CatalogType, ColumnDefault, ColumnKind, ColumnType, ReadForm, SqlTypeExpr, TextLength, + }; + use tablepro_core::ddl::{ColumnAttributes, ColumnDefinition, TableSource}; + use tablepro_core::{ColumnInfo, ForeignKeyInfo}; + + use super::*; + + #[expect( + clippy::disallowed_methods, + reason = "a test stands in for the catalogue a driver would read" + )] + fn text_column(name: &str) -> ColumnDefinition { + ColumnDefinition::new( + ColumnInfo { + name: name.to_owned(), + column_type: ColumnType { + name: "text".to_owned(), + kind: ColumnKind::Text { + length: TextLength::Unbounded, + }, + catalog: Some(CatalogType { + declaration: SqlTypeExpr::from_catalog_text("text"), + cast_target: SqlTypeExpr::from_catalog_text("text"), + }), + dynamic_storage: false, + read_form: ReadForm::Native, + }, + nullable: false, + primary_key: true, + is_auto_increment: false, + is_generated: false, + default: ColumnDefault::None, + comment: None, + }, + ColumnAttributes::default(), + ) + } + + fn definition(columns: Vec) -> TableDefinition { + TableDefinition { + table: TableRef::new(Some("public".to_owned()), "users"), + columns, + primary_key: None, + indexes: Vec::new(), + foreign_keys: Vec::::new(), + inbound_foreign_keys: Vec::new(), + source: None, + } + } + + fn postgres_dialect() -> tablepro_driver_postgres::PgDialect { + tablepro_driver_postgres::PgDialect::new(&tablepro_core::ServerInfo::new("PostgreSQL", "18.0", 18, 0, 0)) + } + + #[test] + fn create_table_sql_restates_the_definition() { + let dialect = postgres_dialect(); + + let sql = create_table_sql(&definition(vec![text_column("id")]), &dialect) + .expect("the SQL builds") + .expect("a table with columns"); + + assert!(sql.starts_with("CREATE TABLE \"public\".\"users\""), "{sql}"); + assert!(sql.ends_with(';'), "{sql}"); + } + + #[test] + fn create_table_sql_is_the_servers_own_text_where_it_keeps_one() { + let dialect = tablepro_driver_mysql::MySqlDialect::new( + tablepro_driver_mysql::MySqlFlavour::MySql, + &tablepro_core::ServerInfo::new("MySQL", "8.4.3", 8, 4, 3), + ); + let mut table = definition(vec![text_column("id")]); + table.source = Some(TableSource { + create_statement: "CREATE TABLE `users` (`id` text) ENGINE=InnoDB".to_owned(), + dependents: Vec::new(), + }); + + assert_eq!( + create_table_sql(&table, &dialect), + Ok(Some("CREATE TABLE `users` (`id` text) ENGINE=InnoDB;".to_owned())) + ); + } + + #[test] + fn a_table_without_columns_has_nothing_to_create() { + let dialect = postgres_dialect(); + + assert!(matches!(create_table_sql(&definition(Vec::new()), &dialect), Ok(None))); + } + + #[test] + fn an_aborted_task_reads_as_cancelled() { + let aborted: Result, TaskFailure> = Err(TaskFailure::Aborted); + + assert_eq!(settled(aborted), Err(OperationError::Cancelled)); + assert_eq!(settled(Ok(Ok(5))), Ok(5)); + } + + #[gtk4::test] + fn the_drop_prompt_closes_once() { + use crate::test_support::wait_until; + use crate::ui::session_window::tests::{TestWindow, WAIT}; + use crate::workspace::{answer, visible_alert}; + + let test = TestWindow::connected("DropPromptClosedOnce"); + test.workspace() + .with_model(|model| model.on_drop_table_prompt(None, "users".to_owned())); + wait_until(WAIT, || visible_alert(&test.window).is_some()).expect("the drop prompt"); + + // With animations off, closing the alert after its click already + // closed it logs a critical, which this makes abort the test. + let previous = gtk4::glib::log_set_always_fatal(gtk4::glib::LogLevels::LEVEL_CRITICAL); + let answered = answer(&test.window, "cancel"); + crate::test_support::drain_main_context(); + gtk4::glib::log_set_always_fatal(previous); + + assert!(answered, "the prompt has no Cancel button"); + assert!(visible_alert(&test.window).is_none(), "the prompt stayed open"); + } + + #[gtk4::test] + fn the_drop_prompt_names_the_schema() { + use crate::test_support::wait_until; + use crate::ui::session_window::tests::{TestWindow, WAIT}; + use crate::workspace::{answer, visible_alert}; + + let test = TestWindow::connected("DropPromptNamesSchema"); + test.workspace() + .with_model(|model| model.on_drop_table_prompt(Some("sales".to_owned()), "orders".to_owned())); + wait_until(WAIT, || visible_alert(&test.window).is_some()).expect("the drop prompt"); + + let heading = visible_alert(&test.window).and_then(|alert| alert.heading()); + + assert_eq!(heading.as_deref(), Some("Drop sales.orders?")); + assert!(answer(&test.window, "cancel"), "the prompt has no Cancel button"); + } + + /// A server's reason can be longer than a toast's one line. + #[gtk4::test] + fn a_create_table_that_cannot_be_read_says_why_in_an_alert() { + use tablepro_core::DriverError; + use tablepro_test_support::FakeReply; + + use crate::test_support::wait_until; + use crate::ui::session_window::tests::{TestWindow, WAIT}; + use crate::workspace::{answer, visible_alert}; + + let test = TestWindow::connected_with("ShowCreateFails", |script| { + script + .fetch_table_definition + .always(FakeReply::err(DriverError::server("permission denied for table users"))); + }); + test.workspace() + .with_model(|model| model.on_show_create_table(None, "users".to_owned())); + + wait_until(WAIT, || { + visible_alert(&test.window) + .is_some_and(|alert| alert.heading().as_deref() == Some("Could Not Show CREATE TABLE")) + }) + .expect("an alert for the failed read"); + let body = visible_alert(&test.window) + .map(|alert| alert.body().to_string()) + .unwrap_or_default(); + assert!(body.contains("permission denied for table users"), "{body}"); + assert!(answer(&test.window, "close")); + } + + #[gtk4::test] + fn a_table_other_tables_point_at_is_refused_before_the_prompt() { + use crate::test_support::wait_until; + use crate::ui::session_window::tests::{TestWindow, WAIT}; + use crate::workspace::{answer, visible_alert}; + use tablepro_core::ddl::InboundForeignKey; + use tablepro_test_support::FakeReply; + + let mut users = definition(vec![text_column("id")]); + let key = |name: &str, table: TableRef, column: &str| InboundForeignKey { + name: name.to_owned(), + table, + columns: vec![column.to_owned()], + referenced_columns: vec!["id".to_owned()], + }; + users.inbound_foreign_keys = vec![ + key( + "orders_user", + TableRef::new(Some("public".to_owned()), "orders"), + "user_id", + ), + key("audit_user", TableRef::new(Some("audit".to_owned()), "log"), "actor_id"), + key("users_parent", users.table.clone(), "parent_id"), + ]; + let test = TestWindow::connected_with("DropRefusedForInboundKeys", |script| { + script.fetch_table_definition.always(FakeReply::ok(users.clone())); + }); + + test.workspace() + .with_model(|model| model.on_drop_table_prompt(Some("public".to_owned()), "users".to_owned())); + wait_until(WAIT, || visible_alert(&test.window).is_some()).expect("an alert"); + + let alert = visible_alert(&test.window).expect("the alert"); + assert_eq!(alert.heading().as_deref(), Some("Cannot Drop Table")); + let body = alert.body(); + assert!(body.contains("orders (user_id), audit.log (actor_id)"), "{body}"); + assert!( + !body.contains("parent_id"), + "a key of the table on itself was named: {body}" + ); + assert!(!alert.has_response("drop"), "the refusal offered the drop"); + assert!(answer(&test.window, "close"), "the refusal has no Close button"); + } + + #[gtk4::test] + fn a_drop_runs_as_a_save_that_holds_the_process() { + use std::sync::Arc; + + use crate::test_support::wait_until; + use crate::ui::session_window::tests::{TestWindow, WAIT}; + use tablepro_core::WriteReport; + use tablepro_test_support::FakeReply; + use tokio_util::sync::CancellationToken; + + let release = CancellationToken::new(); + let script_release = release.clone(); + let test = TestWindow::connected_with("DropHeld", |script| { + script.dialect = Arc::new(postgres_dialect()); + script.write_batch.once(FakeReply::UntilReleased { + release: script_release, + outcome: Ok(WriteReport::new(vec![None])), + }); + }); + wait_until(WAIT, || test.hold.live() == 0).expect("nothing else to hold the process"); + let handle = test.workspace().handle().expect("the workspace handle"); + + test.workspace() + .with_model(|model| model.on_drop_table_confirmed(Some("public".to_owned()), "users".to_owned())); + + assert_eq!(handle.ledger().in_flight(), 1, "a close would not wait for the drop"); + assert_eq!(test.hold.live(), 1, "the drop started without holding the process"); + release.cancel(); + wait_until(WAIT, || handle.ledger().in_flight() == 0).expect("the drop to settle"); + assert_eq!(test.hold.live(), 0, "the settled drop still held the process"); + } +} diff --git a/linux/crates/app/src/ui/workspace/structure_target.rs b/linux/crates/app/src/ui/workspace/structure_target.rs new file mode 100644 index 0000000000..996571d2b7 --- /dev/null +++ b/linux/crates/app/src/ui/workspace/structure_target.rs @@ -0,0 +1,26 @@ +use tablepro_core::meta::TableRef; + +/// What a structure tab edits: a table that exists, or the draft of one +/// that does not yet. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum StructureTarget { + Existing(TableRef), + /// The schema the table will be created in. + NewDraft { + schema: Option, + }, +} + +impl StructureTarget { + /// A tab with no table name yet is a draft, so its title is never + /// empty. + pub(crate) fn of(schema: Option<&str>, table: &str) -> Self { + if table.trim().is_empty() { + Self::NewDraft { + schema: schema.map(str::to_owned), + } + } else { + Self::Existing(TableRef::new(schema.map(str::to_owned), table)) + } + } +} diff --git a/linux/crates/app/src/ui/workspace/tab_factory.rs b/linux/crates/app/src/ui/workspace/tab_factory.rs new file mode 100644 index 0000000000..ede0a9f16a --- /dev/null +++ b/linux/crates/app/src/ui/workspace/tab_factory.rs @@ -0,0 +1,429 @@ +use std::cell::Cell; +use std::collections::HashSet; +use std::rc::Rc; + +use gtk4 as gtk; +use gtk4::{gio, glib}; +use libadwaita as adw; +use libadwaita::prelude::*; +use tablepro_core::meta::TableRef; +use tablepro_storage::{DraftId, DraftScope}; +use uuid::Uuid; + +use super::editor_label::EditorLabel; +use super::structure_target::StructureTarget; +use super::tab_index::TabIndex; +use super::tab_spec::{TabSpec, TablePage}; +use super::tab_title::resolve_tab_title; +use super::table_naming::TableNaming; +use super::workspace_persistence::WorkspaceTabs; +use super::{ + EditorTabSlot, StructureTabSlot, TableTabSlot, WorkspaceInput, WorkspaceSender, WorkspaceTab, apply_tab_title, +}; +use crate::runtime::DeliverInput; +use crate::services::change_tracker_registry::ChangeTrackerRegistry; +use crate::services::history_service::HistoryService; +use crate::services::session_context::SessionContext; +use crate::services::structure_tracker_registry::StructureTrackerRegistry; +use crate::ui::browse_tab::{BrowseTab, BrowseTabInit}; +use crate::ui::editor::{SqlEditor, SqlEditorInit}; +use crate::ui::structure_tab::{StructureMode, StructureTab, StructureTabInit}; +use crate::ui::tab_load::FirstLoad; +use crate::workspace::{DraftWriter, EditorFileRecord}; + +/// What a factory builds its tabs from: the workspace's session, stores +/// and trackers. +pub(super) struct TabFactoryParts { + pub view: adw::TabView, + pub tabs: WorkspaceTabs, + pub index: Rc, + pub input: WorkspaceSender, + pub toasts: glib::WeakRef, + pub context: Rc, + pub settings: Rc, + pub storage: crate::storage::SharedStorage, + pub history: Option, + pub drafts: Rc, + pub draft_scope: DraftScope, + pub change_trackers: Rc, + pub structure_trackers: Rc, + pub read_only: bool, + /// Whether a tab's title names the table's schema. + pub naming: Rc>, +} + +/// The one place a tab is built: its widget, its page, its slot and its +/// index entry. The workspace's own calls and the overview's new-tab +/// button go through it alike. +pub(super) struct TabFactory { + parts: TabFactoryParts, +} + +impl TabFactory { + pub(super) fn new(parts: TabFactoryParts) -> Rc { + Rc::new(Self { parts }) + } + + /// Build the tab and append its page, unselected. `None` when the + /// spec needs a session and the workspace has none any more. + pub(super) fn create(&self, spec: TabSpec) -> Option { + self.build(spec, FirstLoad::Now) + } + + /// A saved tab, which reads its table the first time it is shown. + pub(super) fn restore(&self, spec: TabSpec) -> Option { + self.build(spec, FirstLoad::WhenShown) + } + + fn build(&self, spec: TabSpec, first_load: FirstLoad) -> Option { + let id = Uuid::new_v4(); + let slot = match spec { + TabSpec::Editor { + initial_query, + draft, + file, + } => self.editor(id, initial_query, draft, file), + TabSpec::Table { table, page } => self.table(id, table, page, first_load)?, + TabSpec::Structure { target } => self.structure(id, target, first_load)?, + }; + Some(self.insert(id, slot)) + } + + /// The overview's new-tab button needs its page back at once. + pub(super) fn create_editor(&self) -> adw::TabPage { + let id = Uuid::new_v4(); + let slot = self.editor(id, None, None, None); + self.insert(id, slot) + } + + fn insert(&self, id: Uuid, slot: WorkspaceTab) -> adw::TabPage { + let page = slot.page().clone(); + apply_tab_title(&page, &resolve_tab_title(&slot.identity(), self.parts.naming.get())); + self.parts.tabs.borrow_mut().insert(id, slot); + self.parts.input.deliver(WorkspaceInput::WorkspaceTabsChanged); + page + } + + fn append(&self, child: &impl IsA, id: Uuid) -> adw::TabPage { + let page = self.parts.view.append(child); + self.parts.index.insert(&page, id); + page + } + + fn editor( + &self, + id: Uuid, + initial_query: Option, + draft: Option, + file: Option, + ) -> WorkspaceTab { + let parts = &self.parts; + // Such a tab shows the editor's sample query, which names nothing. + let opened_empty = initial_query.is_none(); + let editor = SqlEditor::new(SqlEditorInit { + initial_query, + settings: parts.settings.clone(), + history: parts.history.clone(), + tasks: parts.context.tasks.clone(), + session: parts.context.active.clone(), + hold: parts.context.hold.clone(), + close_timeout: parts.context.close_timeout(), + }); + connect_editor(&editor, id, &parts.input, &parts.toasts); + // After the signals are connected, so the workspace marks a tab + // restored with unsaved changes. + if let Some(file) = file { + editor.restore_file(gio::File::for_uri(&file.uri), file.modified); + } + let ordinal = self.free_ordinal(); + let page = self.append(&editor, id); + let label = match (opened_empty, editor.file()) { + (_, Some(file)) => EditorLabel::file(&file), + (true, None) => EditorLabel::Untitled(ordinal), + (false, None) => EditorPreview::read(&editor.buffer()).label(ordinal), + }; + let draft = draft.unwrap_or_else(|| { + // Text a new tab opens with is written even if the user + // never types in it. + let draft = DraftId::new(); + parts.drafts.schedule(&parts.draft_scope, draft, &editor.buffer()); + draft + }); + WorkspaceTab::Editor(EditorTabSlot { + editor, + page, + draft, + ordinal, + label, + }) + } + + fn table(&self, id: Uuid, table: TableRef, page: TablePage, first_load: FirstLoad) -> Option { + let parts = &self.parts; + let session = parts.context.active.get()?; + let TableRef { schema, name } = table; + let browse = BrowseTab::new(BrowseTabInit { + tracker: parts.change_trackers.register(id), + schema: schema.clone(), + table: name.clone(), + connection_id: parts.context.active.metadata().map(|metadata| metadata.id), + read_only: parts.read_only, + page_size: page.page_size, + initial_offset: page.offset, + initial_sort: page.sort, + column_widths: parts.storage.column_widths().clone(), + filter_settings: parts.storage.filter_settings().clone(), + session, + tasks: parts.context.tasks.clone(), + deadlines: parts.context.deadlines.clone(), + settings: parts.settings.clone(), + first_load, + }); + connect_browse(&browse, id, &parts.input); + let tab_page = self.append(&browse, id); + let follow = follow_loading(&tab_page, browse.is_loading()); + browse.connect_loading_changed(move |_, loading| follow(loading)); + Some(WorkspaceTab::Table(TableTabSlot { + id, + page: tab_page, + schema, + table: name, + browse, + })) + } + + fn structure(&self, id: Uuid, target: StructureTarget, first_load: FirstLoad) -> Option { + let parts = &self.parts; + let session = parts.context.active.get()?; + let (schema, table, mode) = match target { + StructureTarget::Existing(TableRef { schema, name }) => (schema, name, StructureMode::Edit), + StructureTarget::NewDraft { schema } => (schema, String::new(), StructureMode::New), + }; + let structure = StructureTab::new(StructureTabInit { + tracker: parts.structure_trackers.register(id), + schema: schema.clone(), + table: table.clone(), + mode, + session, + tasks: parts.context.tasks.clone(), + first_load, + }); + connect_structure(&structure, id, &parts.input); + let page = self.append(&structure, id); + let follow = follow_loading(&page, structure.is_loading()); + structure.connect_loading_changed(move |_, loading| follow(loading)); + Some(WorkspaceTab::Structure(StructureTabSlot { + id, + structure, + page, + schema, + table, + mode, + })) + } + + /// The lowest number no open editor tab has, so two never share one. + fn free_ordinal(&self) -> usize { + let tabs = self.parts.tabs.borrow(); + let taken: HashSet = tabs + .values() + .filter_map(|tab| match tab { + WorkspaceTab::Editor(slot) => Some(slot.ordinal), + _ => None, + }) + .collect(); + let mut ordinal = 1; + while taken.contains(&ordinal) { + ordinal += 1; + } + ordinal + } +} + +pub(super) const PREVIEW_CHARS: i32 = 200; + +/// The start of an editor's script, read without copying the rest. +pub(super) struct EditorPreview { + pub text: String, + pub truncated: bool, +} + +impl EditorPreview { + pub(super) fn read(buffer: >k::TextBuffer) -> Self { + let end = buffer.iter_at_offset(PREVIEW_CHARS); + Self { + text: buffer.text(&buffer.start_iter(), &end, false).to_string(), + truncated: !end.is_end(), + } + } + + /// What tab number `ordinal` with this script is named after. + pub(super) fn label(&self, ordinal: usize) -> EditorLabel { + EditorLabel::of(ordinal, &self.text, self.truncated) + } +} + +/// The structure tab's signals, as the workspace's inputs for the tab. +fn connect_structure(structure: &StructureTab, tab_id: Uuid, input: &WorkspaceSender) { + let dirty = input.clone(); + structure.connect_dirty_changed(move |_, _| dirty.deliver(WorkspaceInput::TabDirtyChanged)); + let save = input.clone(); + structure.connect_save_requested(move |_| save.deliver(WorkspaceInput::SaveTab(tab_id))); + let stop = input.clone(); + structure.connect_stop_save_requested(move |_| stop.deliver(WorkspaceInput::StopSaving(tab_id))); + let drop_table = input.clone(); + structure.connect_drop_table_requested(move |_, schema, table| { + drop_table.deliver(WorkspaceInput::DropTablePrompt { schema, table }); + }); + let toast = input.clone(); + structure.connect_toast(move |_, text| toast.deliver(WorkspaceInput::ShowToast(text))); + let alert = input.clone(); + structure.connect_alert(move |_, title, body| alert.deliver(WorkspaceInput::ShowAlert { title, body })); +} + +/// The tab bar spins on a tab's page while the tab reads. The tab +/// started reading before it had a page, so the page starts from where +/// the read stands. The tab holds the handler and the page holds the +/// tab, so the handler holds the page weakly. +fn follow_loading(page: &adw::TabPage, loading: bool) -> impl Fn(bool) + 'static { + page.set_loading(loading); + let page = page.downgrade(); + move |loading| { + if let Some(page) = page.upgrade() { + page.set_loading(loading); + } + } +} + +/// The browse tab's signals, as the workspace's inputs for the tab. +fn connect_browse(browse: &BrowseTab, tab_id: Uuid, input: &WorkspaceSender) { + let state = input.clone(); + browse.connect_state_changed(move |_| state.deliver(WorkspaceInput::WorkspaceTabsChanged)); + let export = input.clone(); + browse.connect_export_results(move |_, offer| { + export.deliver(WorkspaceInput::ExportResults { + tab: tab_id, + offer: Box::new(offer), + }); + }); + let alert = input.clone(); + browse.connect_alert(move |_, title, body| alert.deliver(WorkspaceInput::ShowAlert { title, body })); + let toast = input.clone(); + browse.connect_toast(move |_, text| toast.deliver(WorkspaceInput::ShowToast(text))); + let dirty = input.clone(); + browse.connect_dirty_changed(move |_, _| dirty.deliver(WorkspaceInput::TabDirtyChanged)); + let save = input.clone(); + browse.connect_save_requested(move |_| save.deliver(WorkspaceInput::SaveTab(tab_id))); + let stop = input.clone(); + browse.connect_stop_save_requested(move |_| stop.deliver(WorkspaceInput::StopSaving(tab_id))); + let refresh = input.clone(); + browse.connect_refresh_requested(move |_| refresh.deliver(WorkspaceInput::RefreshTab(tab_id))); +} + +/// Forward an editor tab's signals into the workspace. The toast +/// overlay is the workspace's, so a replaced query's Undo button lives +/// there. +pub(super) fn connect_editor( + editor: &SqlEditor, + tab_id: Uuid, + input: &WorkspaceSender, + toasts: &glib::WeakRef, +) { + let run_state = input.clone(); + editor.connect_run_state_changed(move |_, running| { + run_state.deliver(WorkspaceInput::EditorTabRunStateChanged(tab_id, running)); + }); + let finished = input.clone(); + editor.connect_run_finished(move |_, outcome| { + finished.deliver(WorkspaceInput::EditorTabRunFinished(tab_id, outcome)) + }); + let content = input.clone(); + editor.connect_content_changed(move |_| content.deliver(WorkspaceInput::EditorTabContentChanged(tab_id))); + let file = input.clone(); + editor.connect_file_changed(move |_| file.deliver(WorkspaceInput::EditorTabFileChanged(tab_id))); + let transaction = input.clone(); + editor.connect_transaction_changed(move |_, _| transaction.deliver(WorkspaceInput::EditorTransactionChanged)); + let toast = input.clone(); + editor.connect_toast(move |_, text| toast.deliver(WorkspaceInput::ShowToast(text))); + let export = input.clone(); + editor.connect_export_results(move |_, offer| { + export.deliver(WorkspaceInput::ExportResults { + tab: tab_id, + offer: Box::new(offer), + }); + }); + // The overlay is an ancestor of the editor, so a strong capture + // would keep both alive once the window lets go of them. + let toasts = toasts.clone(); + editor.connect_undoable_toast(move |editor, title| { + let Some(toasts) = toasts.upgrade() else { + return; + }; + let toast = crate::ui::plain_toast(&title); + toast.set_button_label(Some(&crate::i18n::gettext("Undo"))); + let editor = editor.downgrade(); + toast.connect_button_clicked(move |_| { + if let Some(editor) = editor.upgrade() { + editor.undo(); + } + }); + toasts.add_toast(toast); + }); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_support::drain_main_context; + + #[gtk4::test] + fn an_editor_tab_is_named_from_the_start_of_its_script_only() { + let buffer = gtk::TextBuffer::new(None); + buffer.set_text(&format!("SELECT 1;\n{}", "-- é\n".repeat(10_000))); + + let preview = EditorPreview::read(&buffer); + + let limit = usize::try_from(PREVIEW_CHARS).expect("a positive limit"); + assert_eq!(preview.text.chars().count(), limit); + assert!(preview.truncated); + assert!(matches!(preview.label(1), EditorLabel::Query { truncated: true, .. })); + } + + #[gtk4::test] + fn a_blank_script_is_named_by_its_number() { + let buffer = gtk::TextBuffer::new(None); + buffer.set_text(" \n"); + + assert_eq!(EditorPreview::read(&buffer).label(4), EditorLabel::Untitled(4)); + } + + #[gtk4::test] + fn an_editor_tab_leaves_the_toast_overlay_free() { + crate::register_test_resources(); + let runtime = crate::test_support::test_runtime(); + let settings = crate::test_support::MemorySettings::new(); + let editor = SqlEditor::new(SqlEditorInit { + initial_query: None, + settings: settings.get().clone(), + history: None, + tasks: runtime.tasks(), + session: crate::services::active_session::ActiveSession::new(), + hold: Rc::new(tablepro_session::runtime::CountingHold::new()), + close_timeout: std::time::Duration::from_secs(5), + }); + let toasts = adw::ToastOverlay::new(); + toasts.set_child(Some(&editor)); + let (input, _receiver) = crate::runtime::input_queue::(); + connect_editor(&editor, Uuid::new_v4(), &input, &toasts.downgrade()); + let overlay = toasts.downgrade(); + + drop(editor); + drop(toasts); + drain_main_context(); + + assert!( + overlay.upgrade().is_none(), + "an editor tab kept the window's toast overlay alive" + ); + } +} diff --git a/linux/crates/app/src/ui/workspace/tab_identity.rs b/linux/crates/app/src/ui/workspace/tab_identity.rs new file mode 100644 index 0000000000..e28a375bb4 --- /dev/null +++ b/linux/crates/app/src/ui/workspace/tab_identity.rs @@ -0,0 +1,12 @@ +use tablepro_core::meta::TableRef; + +use super::editor_label::EditorLabel; +use super::structure_target::StructureTarget; + +/// What a tab shows, as far as its title goes. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum TabIdentity { + Table(TableRef), + Structure(StructureTarget), + Editor(EditorLabel), +} diff --git a/linux/crates/app/src/ui/workspace/tab_index.rs b/linux/crates/app/src/ui/workspace/tab_index.rs new file mode 100644 index 0000000000..74df7a1484 --- /dev/null +++ b/linux/crates/app/src/ui/workspace/tab_index.rs @@ -0,0 +1,74 @@ +use std::cell::RefCell; +use std::collections::HashMap; + +use libadwaita as adw; +use uuid::Uuid; + +/// Which tab each page of the tab view shows. A page is in it from the +/// moment it is appended until its close finishes. +#[derive(Debug, Default)] +pub(crate) struct TabIndex { + ids: RefCell>, +} + +impl TabIndex { + pub(crate) fn insert(&self, page: &adw::TabPage, id: Uuid) { + self.ids.borrow_mut().insert(page.clone(), id); + } + + pub(crate) fn id_of(&self, page: &adw::TabPage) -> Option { + self.ids.borrow().get(page).copied() + } + + pub(crate) fn remove(&self, page: &adw::TabPage) -> Option { + self.ids.borrow_mut().remove(page) + } + + /// Let go of every page, so a closed workspace holds none of them. + pub(crate) fn clear(&self) { + self.ids.borrow_mut().clear(); + } +} + +#[cfg(test)] +mod tests { + use gtk4 as gtk; + use gtk4::prelude::*; + + use super::*; + + #[gtk4::test] + fn tab_index_round_trips() { + adw::init().expect("libadwaita initialises under the test backend"); + let view = adw::TabView::new(); + let first = view.append(>k::Label::new(None)); + let second = view.append(>k::Label::new(None)); + let index = TabIndex::default(); + let (a, b) = (Uuid::new_v4(), Uuid::new_v4()); + + index.insert(&first, a); + index.insert(&second, b); + + assert_eq!(index.id_of(&first), Some(a)); + assert_eq!(index.id_of(&second), Some(b)); + assert_eq!(index.remove(&first), Some(a)); + assert_eq!(index.id_of(&first), None); + assert_eq!(index.id_of(&second), Some(b)); + } + + #[gtk4::test] + fn a_page_removed_from_the_index_is_freed() { + adw::init().expect("libadwaita initialises under the test backend"); + let view = adw::TabView::new(); + let page = view.append(>k::Label::new(None)); + let index = TabIndex::default(); + index.insert(&page, Uuid::new_v4()); + let freed = page.downgrade(); + + view.close_page(&page); + index.remove(&page); + drop(page); + + assert!(freed.upgrade().is_none(), "the index kept a closed page alive"); + } +} diff --git a/linux/crates/app/src/ui/workspace/tab_spec.rs b/linux/crates/app/src/ui/workspace/tab_spec.rs new file mode 100644 index 0000000000..4b4423f25c --- /dev/null +++ b/linux/crates/app/src/ui/workspace/tab_spec.rs @@ -0,0 +1,45 @@ +use tablepro_core::meta::TableRef; +use tablepro_storage::DraftId; + +use super::structure_target::StructureTarget; +use crate::workspace::EditorFileRecord; + +/// A tab to open. Every way a tab opens builds one of these: the sidebar, +/// the overview's new-tab button, a restore and a reopen. +#[derive(Debug, Clone)] +pub(crate) enum TabSpec { + Editor { + initial_query: Option, + /// The draft a restored tab keeps writing to. `None` gives a new + /// tab a new draft, written at once. + draft: Option, + /// The `.sql` file a restored or reopened tab is saved to. + file: Option, + }, + Table { + table: TableRef, + page: TablePage, + }, + Structure { + target: StructureTarget, + }, +} + +/// Where a table tab opens in its rows. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct TablePage { + pub offset: u64, + pub page_size: u64, + /// The column index and whether it sorts ascending. + pub sort: Option<(usize, bool)>, +} + +impl TablePage { + pub(crate) fn first(page_size: u64) -> Self { + Self { + offset: 0, + page_size, + sort: None, + } + } +} diff --git a/linux/crates/app/src/ui/workspace/tab_title.rs b/linux/crates/app/src/ui/workspace/tab_title.rs new file mode 100644 index 0000000000..95d77f1f48 --- /dev/null +++ b/linux/crates/app/src/ui/workspace/tab_title.rs @@ -0,0 +1,178 @@ +use tablepro_core::meta::TableRef; + +use super::editor_label::EditorLabel; +use super::structure_target::StructureTarget; +use super::tab_identity::TabIdentity; +use super::table_naming::TableNaming; +use crate::ui::editor::derive_tab_label; + +/// A tab's title and the tooltip that says more than it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct TabTitle { + pub label: String, + pub tooltip: Option, +} + +/// The one place a tab's title is made. Unsaved changes never change it; +/// the page's icon says so. +pub(crate) fn resolve_tab_title(identity: &TabIdentity, naming: TableNaming) -> TabTitle { + match identity { + TabIdentity::Table(table) => table_title(table, naming), + TabIdentity::Structure(StructureTarget::Existing(table)) => { + let structure = |name: &str| crate::i18n::gettext_f("{table} · Structure", &[("table", name)]); + let base = table_title(table, naming); + TabTitle { + label: structure(&base.label), + tooltip: base.tooltip.as_deref().map(structure), + } + } + TabIdentity::Structure(StructureTarget::NewDraft { .. }) => TabTitle { + label: crate::i18n::gettext("New Table"), + tooltip: None, + }, + TabIdentity::Editor(EditorLabel::Untitled(ordinal)) => TabTitle { + label: numbered_query(*ordinal), + tooltip: None, + }, + TabIdentity::Editor(EditorLabel::File { name, location }) => TabTitle { + label: name.clone(), + tooltip: Some(location.clone()), + }, + TabIdentity::Editor(EditorLabel::Query { prefix, truncated }) => { + let label = derive_tab_label(prefix); + let text = prefix.trim(); + let tooltip = match truncated { + true => format!("{}…", prefix.trim_start()), + false => text.to_owned(), + }; + let tooltip = (!text.is_empty() && tooltip != label).then_some(tooltip); + TabTitle { label, tooltip } + } + } +} + +pub(crate) fn numbered_query(ordinal: usize) -> String { + crate::i18n::gettext_f("Query {n}", &[("n", &ordinal.to_string())]) +} + +/// `schema.table` when the naming asks for it; a bare name has the +/// qualified one as its tooltip. +fn table_title(table: &TableRef, naming: TableNaming) -> TabTitle { + let qualified = table.schema.as_ref().map(|schema| format!("{schema}.{}", table.name)); + match (naming, qualified) { + (TableNaming::SchemaQualified, Some(qualified)) => TabTitle { + label: qualified, + tooltip: None, + }, + (_, qualified) => TabTitle { + label: table.name.clone(), + tooltip: qualified, + }, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn orders() -> TableRef { + TableRef::new(Some("public".to_owned()), "orders") + } + + #[test] + fn a_table_is_named_bare_with_its_schema_in_the_tooltip() { + let title = resolve_tab_title(&TabIdentity::Table(orders()), TableNaming::Bare); + + assert_eq!(title.label, "orders"); + assert_eq!(title.tooltip.as_deref(), Some("public.orders")); + assert_eq!( + resolve_tab_title( + &TabIdentity::Table(TableRef::unqualified("t")), + TableNaming::SchemaQualified + ), + TabTitle { + label: "t".to_owned(), + tooltip: None, + } + ); + } + + #[test] + fn structure_title_keeps_suffix_regardless_of_dirty_state() { + let identity = TabIdentity::Structure(StructureTarget::Existing(orders())); + + let title = resolve_tab_title(&identity, TableNaming::Bare); + + assert_eq!(title.label, "orders · Structure"); + assert_eq!(title.tooltip.as_deref(), Some("public.orders · Structure")); + assert!(!title.label.contains('•')); + } + + #[test] + fn structure_title_is_schema_qualified_with_multiple_schemas() { + let identity = TabIdentity::Structure(StructureTarget::Existing(orders())); + + let title = resolve_tab_title(&identity, TableNaming::SchemaQualified); + + assert_eq!(title.label, "public.orders · Structure"); + assert_eq!(title.tooltip, None); + } + + #[test] + fn blank_table_name_resolves_to_new_table() { + let identity = TabIdentity::Structure(StructureTarget::of(Some("public"), "")); + + assert_eq!(resolve_tab_title(&identity, TableNaming::Bare).label, "New Table"); + } + + #[test] + fn blank_query_resolves_to_query_ordinal() { + let identity = TabIdentity::Editor(EditorLabel::of(3, " \n\t", false)); + + let title = resolve_tab_title(&identity, TableNaming::Bare); + + assert_eq!(title.label, "Query 3"); + assert_eq!(title.tooltip, None); + } + + #[test] + fn a_query_is_named_from_its_start_with_the_rest_in_the_tooltip() { + let long = format!("SELECT * FROM orders WHERE id = 1;\n{}", "-- x\n".repeat(50)); + let prefix: String = long.chars().take(200).collect(); + + let title = resolve_tab_title( + &TabIdentity::Editor(EditorLabel::of(1, &prefix, true)), + TableNaming::Bare, + ); + + assert_eq!(title.label, derive_tab_label(&prefix)); + let tooltip = title.tooltip.expect("a tooltip"); + assert!(tooltip.starts_with("SELECT * FROM orders"), "{tooltip}"); + assert!(tooltip.ends_with('…'), "{tooltip}"); + } + + #[test] + fn a_tab_saved_to_a_file_is_named_after_it() { + let title = resolve_tab_title( + &TabIdentity::Editor(EditorLabel::File { + name: "daily.sql".to_owned(), + location: "/home/ada/reports/daily.sql".to_owned(), + }), + TableNaming::Bare, + ); + + assert_eq!(title.label, "daily.sql"); + assert_eq!(title.tooltip.as_deref(), Some("/home/ada/reports/daily.sql")); + } + + #[test] + fn a_script_its_label_shows_whole_has_no_tooltip() { + let title = resolve_tab_title( + &TabIdentity::Editor(EditorLabel::of(1, "SELECT 1;", false)), + TableNaming::Bare, + ); + + assert_eq!(title.label, "SELECT 1;"); + assert_eq!(title.tooltip, None); + } +} diff --git a/linux/crates/app/src/ui/workspace/table_naming.rs b/linux/crates/app/src/ui/workspace/table_naming.rs new file mode 100644 index 0000000000..0e9c5c604d --- /dev/null +++ b/linux/crates/app/src/ui/workspace/table_naming.rs @@ -0,0 +1,44 @@ +use tablepro_core::meta::SchemaInfo; + +/// Whether a table tab names the table's schema. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub(crate) enum TableNaming { + #[default] + Bare, + SchemaQualified, +} + +impl TableNaming { + /// Qualified once the user has two schemas of their own to tell + /// apart; the engine's own schemas do not count. + pub(crate) fn of(schemas: &[SchemaInfo]) -> Self { + match schemas.iter().filter(|schema| !schema.is_system).count() { + 0 | 1 => Self::Bare, + _ => Self::SchemaQualified, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn schema(name: &str, is_system: bool) -> SchemaInfo { + SchemaInfo { + name: name.to_owned(), + is_default: name == "public", + is_system, + } + } + + #[test] + fn system_schemas_do_not_make_names_qualified() { + let schemas = [schema("public", false), schema("pg_catalog", true)]; + + assert_eq!(TableNaming::of(&schemas), TableNaming::Bare); + assert_eq!( + TableNaming::of(&[schema("public", false), schema("sales", false)]), + TableNaming::SchemaQualified + ); + } +} diff --git a/linux/crates/app/src/ui/workspace/tests/close_tab.rs b/linux/crates/app/src/ui/workspace/tests/close_tab.rs new file mode 100644 index 0000000000..82e8320a19 --- /dev/null +++ b/linux/crates/app/src/ui/workspace/tests/close_tab.rs @@ -0,0 +1,254 @@ +use gtk4 as gtk; +use gtk4::glib; +use gtk4::prelude::*; +use gtk4::subclass::prelude::*; +use libadwaita::prelude::*; +use tablepro_core::column::{ColumnDefault, ColumnType}; +use tablepro_core::meta::{RowCountEstimate, RowIdentity, TableEstimate}; +use tablepro_core::{ColumnInfo, QueryResult, Value}; +use tablepro_test_support::{ConnectionScript, FakeReply}; +use uuid::Uuid; + +use super::super::{OpenMode, WorkspaceInput}; +use crate::runtime::DeliverInput; +use crate::test_support::{after_next_frame, descendants, drain_main_context, wait_until}; +use crate::ui::browse_tab::BrowseTab; +use crate::ui::grid::GridCell; +use crate::ui::session_window::tests::{TestWindow, WAIT}; +use crate::workspace::{answer, visible_alert}; + +fn column(name: &str, primary_key: bool) -> ColumnInfo { + ColumnInfo { + name: name.to_owned(), + column_type: ColumnType::untyped_text("text"), + nullable: !primary_key, + primary_key, + is_auto_increment: false, + is_generated: false, + default: ColumnDefault::None, + comment: None, + } +} + +/// Every table has the same two keyed rows. +fn serving_tables(script: &mut ConnectionScript) { + let columns = [column("id", true), column("name", false)]; + script.fetch_columns.always(FakeReply::ok(columns.to_vec())); + script.row_identity.always(FakeReply::ok(RowIdentity::UniqueKey { + columns: vec!["id".to_owned()], + })); + script.count_rows.always(FakeReply::ok(2)); + script.table_estimate.always(FakeReply::ok(TableEstimate { + rows: RowCountEstimate::Approximate(2), + heap_blocks: None, + })); + script.query_bound.always(FakeReply::ok(QueryResult::new( + columns.iter().map(ColumnInfo::result_column).collect::>(), + vec![ + vec![Value::Int(1), Value::Text("Ada".to_owned())], + vec![Value::Int(2), Value::Text("Grace".to_owned())], + ], + ))); +} + +/// Open `name` in a tab of its own, which comes to the front. +fn open_table(test: &TestWindow, name: &str) -> (Uuid, BrowseTab) { + let workspace = test.workspace(); + let before = workspace.selected_tab(); + workspace + .input() + .expect("the workspace's input") + .deliver(WorkspaceInput::SelectTable { + schema: None, + name: name.to_owned(), + open_mode: OpenMode::NewTab, + }); + let mut id = None; + wait_until(WAIT, || { + id = workspace.selected_tab().filter(|tab| Some(*tab) != before); + id.is_some() + }) + .expect("the table's tab"); + let tab = workspace + .imp() + .tab_view + .selected_page() + .and_then(|page| page.child().downcast::().ok()) + .expect("a table tab in front"); + (id.expect("the tab's id"), tab) +} + +/// Give the keyboard focus to the name of the first row. +fn focus_name(test: &TestWindow, tab: &BrowseTab) -> GridCell { + let mut grid = None; + wait_until(WAIT, || { + grid = descendants(tab) + .into_iter() + .find_map(|widget| widget.downcast::().ok()) + .filter(|view| view.model().is_some_and(|model| model.n_items() == 2)); + grid.is_some() + }) + .expect("the rows"); + let view = grid.expect("the grid"); + let name = crate::ui::grid::data_columns(&view) + .into_iter() + .nth(1) + .expect("the name column"); + view.scroll_to(0, Some(&name), gtk::ListScrollFlags::FOCUS, None); + view.grab_focus(); + let focused = || gtk::prelude::GtkWindowExt::focus(&test.window).and_then(|focus| GridCell::from_focus(&focus)); + wait_until(WAIT, || { + focused().is_some_and(|cell| cell.position() == Some(0) && cell.column() == 1) + }) + .expect("the cell takes the focus"); + focused().expect("the focused cell") +} + +/// What was typed is an unsaved change like any other, so the close asks +/// about it. GTK 4.22 loops forever, logging a critical, when the field +/// leaves its cell while the focus change is under way. +#[gtk4::test] +fn closing_a_tab_while_a_cell_is_edited_asks_about_the_edit() { + let test = TestWindow::connected_with("CloseWhileEditing", serving_tables); + let (id, tab) = open_table(&test, "users"); + let cell = focus_name(&test, &tab); + crate::ui::grid::begin_edit(&cell); + cell.inline_text().expect("a one-line field").set_text("Ada Lovelace"); + + let previous = glib::log_set_always_fatal(glib::LogLevels::LEVEL_CRITICAL); + WidgetExt::activate_action(&test.window, "win.close-current", None).expect("win.close-current"); + drain_main_context(); + glib::log_set_always_fatal(previous); + + wait_until(WAIT, || visible_alert(&test.window).is_some()).expect("the close asks about the edit"); + let tracker = test.workspace().row_tracker(id).expect("the tab's changes"); + assert_eq!(tracker.pending_count(), 1, "what was typed is not an unsaved change"); + assert!(!cell.is_editing()); + + let closed = tab.downgrade(); + drop((tab, cell)); + assert!(answer(&test.window, "discard")); + wait_until(WAIT, || test.workspace().row_tracker(id).is_none()).expect("the tab closes"); + after_next_frame(&test.window); + wait_until(WAIT, || closed.upgrade().is_none()).expect("the closed tab is freed"); +} + +#[gtk4::test] +fn closing_the_window_while_a_cell_is_edited_asks_about_the_edit() { + let test = TestWindow::connected_with("CloseWindowWhileEditing", serving_tables); + let (id, tab) = open_table(&test, "users"); + let cell = focus_name(&test, &tab); + crate::ui::grid::begin_edit(&cell); + cell.inline_text().expect("a one-line field").set_text("Ada Lovelace"); + + test.window.close(); + + wait_until(WAIT, || visible_alert(&test.window).is_some()).expect("the close asks about the edit"); + let tracker = test.workspace().row_tracker(id).expect("the tab's changes"); + assert_eq!(tracker.pending_count(), 1, "what was typed is not an unsaved change"); + assert!(answer(&test.window, "cancel")); + drain_main_context(); + assert_eq!(test.app.windows().len(), 1, "Cancel closed the window"); +} + +/// The focus leaves the tab before it goes. GTK 4.22 leaks a focus that +/// is hidden and then unparented in one go, with the tab around it, which +/// the close does when the tab shown next takes no focus. +#[gtk4::test] +fn a_tab_closed_with_the_focus_in_it_is_freed_when_the_next_takes_none() { + let test = TestWindow::connected_with("CloseFocusedTab", serving_tables); + let (_, next) = open_table(&test, "orders"); + next.set_can_focus(false); + let (_, tab) = open_table(&test, "users"); + drop(focus_name(&test, &tab)); + let closed = tab.downgrade(); + drop(tab); + + WidgetExt::activate_action(&test.window, "win.close-current", None).expect("win.close-current"); + drain_main_context(); + after_next_frame(&test.window); + + wait_until(WAIT, || closed.upgrade().is_none()).expect("the closed tab is freed"); + assert!( + gtk::prelude::GtkWindowExt::focus(&test.window).is_some_and(|focus| focus.root().is_some()), + "the focus was left nowhere" + ); +} + +/// An editor tab saved to `path`, whose text has changed since. +fn editor_on_file(test: &TestWindow, path: &std::path::Path) -> (Uuid, crate::ui::editor::SqlEditor) { + let workspace = test.workspace(); + workspace.with_model(|model| { + model.open_tab(super::super::tab_spec::TabSpec::Editor { + initial_query: Some("SELECT 2;".to_owned()), + draft: None, + file: Some(crate::workspace::EditorFileRecord { + uri: gtk::gio::File::for_path(path).uri().to_string(), + modified: true, + }), + }) + }); + let id = workspace.selected_tab().expect("the editor in front"); + let editor = workspace + .imp() + .tab_view + .selected_page() + .and_then(|page| page.child().downcast::().ok()) + .expect("an editor tab in front"); + (id, editor) +} + +fn tab_is_open(test: &TestWindow, id: Uuid) -> bool { + test.workspace() + .read(|model| model.workspace_tabs.borrow().contains_key(&id)) + .unwrap_or(false) +} + +#[gtk4::test] +fn closing_a_tab_with_unsaved_file_changes_saves_them_when_asked() { + let test = TestWindow::connected("CloseUnsavedFileSave"); + let path = test.dir().join("daily.sql"); + crate::test_support::write_text_file(&path, "SELECT 1;"); + let (id, _editor) = editor_on_file(&test, &path); + + WidgetExt::activate_action(&test.window, "win.close-current", None).expect("win.close-current"); + + wait_until(WAIT, || { + visible_alert(&test.window).is_some_and(|alert| alert.heading().as_deref() == Some("Save Changes?")) + }) + .expect("the close asks about the file"); + assert!(answer(&test.window, "save")); + wait_until(WAIT, || !tab_is_open(&test, id)).expect("the tab closes once saved"); + assert_eq!(crate::test_support::read_text_file(&path), "SELECT 2;"); +} + +#[gtk4::test] +fn discarding_unsaved_file_changes_closes_the_tab_and_leaves_the_file() { + let test = TestWindow::connected("CloseUnsavedFileDiscard"); + let path = test.dir().join("daily.sql"); + crate::test_support::write_text_file(&path, "SELECT 1;"); + let (id, _editor) = editor_on_file(&test, &path); + + WidgetExt::activate_action(&test.window, "win.close-current", None).expect("win.close-current"); + + wait_until(WAIT, || visible_alert(&test.window).is_some()).expect("the close asks about the file"); + assert!(answer(&test.window, "discard")); + wait_until(WAIT, || !tab_is_open(&test, id)).expect("the tab closes"); + assert!(visible_alert(&test.window).is_none(), "the close asked again"); + assert_eq!(crate::test_support::read_text_file(&path), "SELECT 1;"); +} + +/// The window keeps the text with the tab's draft and restores both. +#[gtk4::test] +fn closing_the_window_keeps_unsaved_file_changes_without_asking() { + let test = TestWindow::connected("CloseWindowUnsavedFile"); + let path = test.dir().join("daily.sql"); + crate::test_support::write_text_file(&path, "SELECT 1;"); + let (_id, editor) = editor_on_file(&test, &path); + assert!(editor.has_unsaved_file_changes()); + drop(editor); + + test.window.close(); + + wait_until(WAIT, || test.app.windows().is_empty()).expect("the window closes"); +} diff --git a/linux/crates/app/src/ui/workspace/tests/mod.rs b/linux/crates/app/src/ui/workspace/tests/mod.rs new file mode 100644 index 0000000000..b8d730306f --- /dev/null +++ b/linux/crates/app/src/ui/workspace/tests/mod.rs @@ -0,0 +1,2 @@ +mod close_tab; +mod tab_title; diff --git a/linux/crates/app/src/ui/workspace/tests/tab_title.rs b/linux/crates/app/src/ui/workspace/tests/tab_title.rs new file mode 100644 index 0000000000..07cc91af88 --- /dev/null +++ b/linux/crates/app/src/ui/workspace/tests/tab_title.rs @@ -0,0 +1,297 @@ +use std::sync::Arc; + +use gtk4::prelude::*; +use gtk4::subclass::prelude::*; +use libadwaita as adw; +use tablepro_core::ddl::{DraftColumn, DraftType, TableChanges, TableDraft}; +use tablepro_core::meta::TableRef; +use tablepro_core::{EditorSession, TransactionState}; +use tablepro_storage::DraftId; +use tablepro_test_support::{FakeEditorSession, FakeReply, FakeRun, Replies}; +use uuid::Uuid; + +use super::super::structure_target::StructureTarget; +use super::super::tab_spec::TabSpec; +use super::super::{Workspace, WorkspaceInput, WorkspaceTab}; +use crate::runtime::DeliverInput; +use crate::services::pending_structure::PendingStructure; +use crate::test_support::{finish_on_main, wait_until}; +use crate::ui::SessionWindowInput; +use crate::ui::session_window::tests::{TestWindow, WAIT, editors}; +use crate::ui::structure_tab::StructureMode; +use crate::workspace::{TabRecord, WorkspaceRecord}; + +fn modified(page: &adw::TabPage) -> bool { + page.icon().and_downcast::().is_some_and(|icon| { + icon.names() + .iter() + .any(|name| name == crate::ui::icons::DOCUMENT_MODIFIED) + }) +} + +/// A new table draft's tab, unselected behind the tab in front, and its +/// id. +fn new_table_tab(workspace: &Workspace) -> (adw::TabPage, Uuid) { + workspace + .with_model(|model| { + let page = model.factory.create(TabSpec::Structure { + target: StructureTarget::NewDraft { schema: None }, + })?; + let id = model.index.id_of(&page)?; + Some((page, id)) + }) + .flatten() + .expect("a structure tab") +} + +fn a_column_added() -> Option { + let mut draft = TableDraft::new(TableRef::unqualified("t")); + draft + .columns + .push(DraftColumn::new("a", DraftType::Typed("int".to_owned()))); + Some(PendingStructure { + original: None, + draft, + changes: TableChanges::default(), + unparsed_defaults: Vec::new(), + }) +} + +/// What a structure tab's dirty-changed signal does. +fn edit_structure(workspace: &Workspace, id: Uuid, pending: Option) { + let input = workspace + .with_model(|model| { + if let Some(tracker) = model.structure_trackers.get(id) { + tracker.set_pending(pending); + } + model.input.clone() + }) + .expect("the workspace's input"); + input.deliver(WorkspaceInput::TabDirtyChanged); +} + +#[gtk4::test] +fn unsaved_icon_follows_structure_tracker() { + let test = TestWindow::connected("UnsavedIcon"); + let workspace = test.workspace(); + let (page, id) = new_table_tab(&workspace); + assert!(!modified(&page)); + + edit_structure(&workspace, id, a_column_added()); + wait_until(WAIT, || modified(&page)).expect("the unsaved icon"); + assert_eq!(page.title(), "New Table", "the title changed with the edit"); + // A screen reader reads the tab's tooltip, never its icon. + let tooltip = || page.tooltip().map(|tooltip| tooltip.to_string()).unwrap_or_default(); + assert!(tooltip().ends_with("Unsaved changes"), "{}", tooltip()); + + edit_structure(&workspace, id, None); + wait_until(WAIT, || page.icon().is_none()).expect("the icon to go"); + assert!(!tooltip().contains("Unsaved changes"), "{}", tooltip()); +} + +#[gtk4::test] +fn unsaved_marker_never_sets_needs_attention() { + let test = TestWindow::connected("UnsavedAttention"); + let workspace = test.workspace(); + let (page, id) = new_table_tab(&workspace); + WidgetExt::activate_action(&test.window, "win.new-editor-tab", None).expect("win.new-editor-tab"); + wait_until(WAIT, || editors(&test.window).len() == 1).expect("an editor tab in front"); + assert_ne!(workspace.imp().tab_view.selected_page().as_ref(), Some(&page)); + + edit_structure(&workspace, id, a_column_added()); + wait_until(WAIT, || modified(&page)).expect("the unsaved icon"); + + assert!( + !page.needs_attention(), + "a background tab with changes asks for attention" + ); + assert!(page.indicator_icon().is_none()); +} + +#[gtk4::test] +fn open_transaction_marks_editor_tab() { + let mut runs = Replies::default(); + runs.always(FakeRun::completed(Vec::new()).leaving(TransactionState::InTransaction)); + let editor_session: Arc = Arc::new(FakeEditorSession::new(runs)); + let test = TestWindow::connected_with("OpenTransactionIcon", |script| { + script.open_editor_session.always(FakeReply::ok(editor_session)); + }); + + test.run_in_new_editor_tab(); + + let page = test + .workspace() + .imp() + .tab_view + .selected_page() + .expect("the editor's page"); + wait_until(WAIT, || modified(&page)).expect("the open transaction to mark the tab"); +} + +#[gtk4::test] +fn restored_workspace_selects_saved_active_tab() { + let test = TestWindow::picker("RestoreActive"); + let store = test.services.storage.workspaces().clone(); + let orders = TabRecord::Structure { + schema: None, + table: "orders".to_owned(), + }; + store + .schedule( + &test.key(), + WorkspaceRecord { + active_tab: Some(1), + tabs: vec![ + TabRecord::Editor { + draft: DraftId::new(), + file: None, + }, + orders, + ], + }, + ) + .expect("the record to queue"); + finish_on_main(WAIT, store.flush(&test.key())) + .expect("the flush to end") + .expect("the record written"); + + test.window + .send(SessionWindowInput::OpenSaved(Box::new(test.saved.clone()))); + test.wait_for_page("workspace"); + let workspace = test.workspace(); + wait_until(WAIT, || workspace.imp().tab_view.n_pages() == 2).expect("both tabs restored"); + + let selected = workspace + .read(|model| { + let id = model.selected_workspace_tab_id()?; + match model.workspace_tabs.borrow().get(&id)? { + WorkspaceTab::Structure(slot) => Some((slot.mode, slot.table.clone())), + _ => None, + } + }) + .flatten(); + assert_eq!(selected, Some((StructureMode::Edit, "orders".to_owned()))); +} + +#[gtk4::test] +fn a_restored_editor_tab_keeps_its_file_and_its_unsaved_changes() { + let test = TestWindow::picker("RestoreEditorFile"); + let path = test.dir().join("daily.sql"); + crate::test_support::write_text_file(&path, "SELECT 1;"); + let store = test.services.storage.workspaces().clone(); + store + .schedule( + &test.key(), + WorkspaceRecord { + active_tab: Some(0), + tabs: vec![TabRecord::Editor { + draft: DraftId::new(), + file: Some(crate::workspace::EditorFileRecord { + uri: gio::File::for_path(&path).uri().to_string(), + modified: true, + }), + }], + }, + ) + .expect("the record to queue"); + finish_on_main(WAIT, store.flush(&test.key())) + .expect("the flush to end") + .expect("the record written"); + + test.window + .send(SessionWindowInput::OpenSaved(Box::new(test.saved.clone()))); + test.wait_for_page("workspace"); + let workspace = test.workspace(); + wait_until(WAIT, || workspace.imp().tab_view.n_pages() == 1).expect("the editor tab restored"); + + let page = workspace.imp().tab_view.nth_page(0); + let editor = editors(&test.window).pop().expect("the editor"); + assert_eq!(editor.file().and_then(|file| file.path()), Some(path.clone())); + assert!( + editor.has_unsaved_file_changes(), + "the restore forgot the text was not saved" + ); + assert_eq!(page.title(), "daily.sql"); + wait_until(WAIT, || modified(&page)).expect("the unsaved icon"); + assert_eq!( + page.tooltip().as_deref(), + Some(format!("{}\nUnsaved changes", path.to_string_lossy()).as_str()) + ); +} + +#[gtk4::test] +fn window_title_follows_the_tab_in_front() { + let test = TestWindow::connected("WindowTitle"); + wait_until(WAIT, || test.window.title().as_deref() == Some("Fake - TablePro")).expect("the connection's title"); + + WidgetExt::activate_action(&test.window, "win.new-editor-tab", None).expect("win.new-editor-tab"); + + wait_until(WAIT, || { + test.window.title().as_deref() == Some("Query 1 (Fake) - TablePro") + }) + .expect("the tab's title"); + let heading = test.workspace().imp().window_title.get(); + assert_eq!(heading.title(), "Query 1"); + assert_eq!(heading.subtitle(), "Fake"); +} + +#[gtk4::test] +fn tabs_name_the_schema_once_the_session_has_two() { + let test = TestWindow::connected("NamingFollowsSchemas"); + let workspace = test.workspace(); + let page = workspace + .with_model(|model| { + model.open_tab(TabSpec::Table { + table: TableRef::new(Some("public".to_owned()), "orders"), + page: super::super::tab_spec::TablePage::first(100), + }) + }) + .flatten() + .expect("a table tab"); + assert_eq!(page.title(), "orders"); + assert_eq!(page.tooltip().as_deref(), Some("public.orders")); + + let schema = |name: &str| tablepro_core::meta::SchemaInfo { + name: name.to_owned(), + is_default: name == "public", + is_system: false, + }; + let session = workspace + .read(|model| model.context.active.get()) + .flatten() + .expect("the session"); + // What a reconnect to a database with a second schema commits. + session + .catalog() + .commit(vec![schema("public"), schema("sales")], Vec::new()); + + wait_until(WAIT, || page.title() == "public.orders").expect("the tab to name its schema"); + assert!( + page.tooltip().unwrap_or_default().is_empty(), + "the tooltip repeats the title" + ); +} + +#[gtk4::test] +fn a_new_editor_tab_takes_the_lowest_number_no_open_tab_has() { + let test = TestWindow::connected("LowestFreeQueryNumber"); + let view = test.workspace().imp().tab_view.get(); + let open = || { + let before = editors(&test.window).len(); + WidgetExt::activate_action(&test.window, "win.new-editor-tab", None).expect("win.new-editor-tab"); + wait_until(WAIT, || editors(&test.window).len() == before + 1).expect("a new editor tab"); + }; + open(); + open(); + open(); + + view.close_page(&view.nth_page(0)); + wait_until(WAIT, || view.n_pages() == 2).expect("Query 1 to close"); + open(); + + let mut titles: Vec = (0..view.n_pages()) + .map(|index| view.nth_page(index).title().to_string()) + .collect(); + titles.sort(); + assert_eq!(titles, ["Query 1", "Query 2", "Query 3"]); +} diff --git a/linux/crates/app/src/ui/workspace/unsaved_icon.rs b/linux/crates/app/src/ui/workspace/unsaved_icon.rs new file mode 100644 index 0000000000..2e6ec1348f --- /dev/null +++ b/linux/crates/app/src/ui/workspace/unsaved_icon.rs @@ -0,0 +1,13 @@ +use libadwaita as adw; + +use crate::ui::icons::DOCUMENT_MODIFIED; + +/// Mark a tab with unsaved changes by its page icon, as GNOME Text Editor +/// does, never by its title or needs-attention, which the tab bar draws +/// as a call to look at it. +pub(crate) fn apply_unsaved_icon(page: &adw::TabPage, unsaved: bool) { + match unsaved { + true => page.set_icon(Some(&gio::ThemedIcon::new(DOCUMENT_MODIFIED))), + false => page.set_icon(None::<&gio::Icon>), + } +} diff --git a/linux/crates/app/src/ui/workspace/window_tabs.rs b/linux/crates/app/src/ui/workspace/window_tabs.rs new file mode 100644 index 0000000000..8282c7ab84 --- /dev/null +++ b/linux/crates/app/src/ui/workspace/window_tabs.rs @@ -0,0 +1,68 @@ +use std::rc::Rc; + +use futures::future::LocalBoxFuture; +use gtk4::glib; +use libadwaita as adw; +use libadwaita::prelude::*; +use uuid::Uuid; + +use super::WorkspaceTab; +use super::tab_index::TabIndex; +use super::workspace_persistence::WorkspaceTabs; +use crate::ui::editor::SqlEditor; +use crate::workspace::{TabDirectory, TabEntry}; + +/// The window's tabs as the close prompts read them: in tab bar order, +/// by the names their tabs show. +pub(super) struct WindowTabs { + tabs: WorkspaceTabs, + index: Rc, + /// Held weakly: the workspace owns the view, and the close prompts + /// can outlive it. + view: glib::WeakRef, +} + +impl WindowTabs { + pub(super) fn new(tabs: WorkspaceTabs, index: Rc, view: &adw::TabView) -> Self { + Self { + tabs, + index, + view: view.downgrade(), + } + } +} + +impl TabDirectory for WindowTabs { + fn tabs(&self) -> Vec { + let Some(view) = self.view.upgrade() else { + return Vec::new(); + }; + let tabs = self.tabs.borrow(); + let pages = view.pages(); + (0..pages.n_items()) + .filter_map(|index| pages.item(index).and_downcast::()) + .filter_map(|page| { + let id = self.index.id_of(&page)?; + let editor = match tabs.get(&id)? { + WorkspaceTab::Editor(slot) => Some(&slot.editor), + WorkspaceTab::Structure(_) | WorkspaceTab::Table(_) => None, + }; + Some(TabEntry { + id, + title: page.title().to_string(), + open_transaction: editor.is_some_and(SqlEditor::has_open_transaction), + unsaved_file: editor.is_some_and(SqlEditor::has_unsaved_file_changes), + }) + }) + .collect() + } + + fn save_file(&self, tab: Uuid) -> Option>> { + let tabs = self.tabs.borrow(); + let WorkspaceTab::Editor(slot) = tabs.get(&tab)? else { + return None; + }; + slot.editor.file()?; + Some(Box::pin(slot.editor.save_to_file())) + } +} diff --git a/linux/crates/app/src/ui/workspace/window_title.rs b/linux/crates/app/src/ui/workspace/window_title.rs new file mode 100644 index 0000000000..54770dff03 --- /dev/null +++ b/linux/crates/app/src/ui/workspace/window_title.rs @@ -0,0 +1,88 @@ +use super::tab_title::TabTitle; + +/// What the window is called: in the window list and Alt+Tab, and in its +/// header bar's title and subtitle. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct WindowTitle { + pub window: String, + pub heading: String, + pub subtitle: String, +} + +/// Named the way GNOME Text Editor names its window, "[document] +/// ([location]) - Text Editor": the tab in front, then the connection. +pub(crate) fn resolve_window_title( + active: Option<&TabTitle>, + connection: &str, + driver: &str, + read_only: bool, +) -> WindowTitle { + let (window, heading, subtitle) = match active { + Some(tab) => ( + crate::i18n::gettext_f( + "{tab} ({connection}) - TablePro", + &[("tab", &tab.label), ("connection", connection)], + ), + tab.label.clone(), + connection.to_owned(), + ), + None => ( + crate::i18n::gettext_f("{connection} - TablePro", &[("connection", connection)]), + connection.to_owned(), + driver.to_owned(), + ), + }; + let subtitle = match read_only { + true => crate::i18n::gettext_f("{name} · Read-Only", &[("name", &subtitle)]), + false => subtitle, + }; + WindowTitle { + window, + heading, + subtitle, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn tab(label: &str) -> TabTitle { + TabTitle { + label: label.to_owned(), + tooltip: None, + } + } + + #[test] + fn window_title_names_tab_and_connection_without_em_dash() { + let title = resolve_window_title(Some(&tab("orders")), "Shop", "PostgreSQL", false); + + assert_eq!(title.window, "orders (Shop) - TablePro"); + assert_eq!(title.heading, "orders"); + assert_eq!(title.subtitle, "Shop"); + assert!(!title.window.contains('\u{2014}')); + assert!(!title.window.contains('•')); + } + + #[test] + fn with_no_tab_the_connection_heads_the_window() { + let title = resolve_window_title(None, "Shop", "PostgreSQL", false); + + assert_eq!(title.window, "Shop - TablePro"); + assert_eq!(title.heading, "Shop"); + assert_eq!(title.subtitle, "PostgreSQL"); + } + + #[test] + fn read_only_subtitle() { + assert_eq!( + resolve_window_title(None, "Shop", "PostgreSQL", true).subtitle, + "PostgreSQL · Read-Only" + ); + assert_eq!( + resolve_window_title(Some(&tab("orders")), "Shop", "PostgreSQL", true).subtitle, + "Shop · Read-Only" + ); + } +} diff --git a/linux/crates/app/src/ui/workspace/workspace_actions.rs b/linux/crates/app/src/ui/workspace/workspace_actions.rs new file mode 100644 index 0000000000..428627e42c --- /dev/null +++ b/linux/crates/app/src/ui/workspace/workspace_actions.rs @@ -0,0 +1,125 @@ +//! The window actions a workspace adds while it is open, and takes off +//! the window again when it goes. Their keys are application +//! accelerators; a tab's own keys belong to the tab. + +use gtk4::glib; +use gtk4::prelude::*; +use libadwaita as adw; + +use super::{WorkspaceInput, WorkspaceSender}; +use crate::runtime::DeliverInput; +use crate::services::history_availability::HistoryAvailability; + +/// Builds the input an action sends. +type ActionInput = fn() -> WorkspaceInput; + +/// The two actions that need the query database, which opens on a +/// background task. Once it failed to open they say why when used, so +/// they are not left greyed out with no reason. +const HISTORY_ACTIONS: [&str; 2] = ["show-history", "show-saved-queries"]; + +/// Add the workspace's actions to the window's own action map, which GTK +/// exports as `win`, and return their names for `shutdown` to remove. +/// Each one sends its input through `input`, so one activated after the +/// workspace is gone is dropped rather than handled. +pub(super) fn install(window: &adw::ApplicationWindow, input: &WorkspaceSender) -> Vec<&'static str> { + let entries: [(&'static str, ActionInput); 7] = [ + ("new-editor-tab", || WorkspaceInput::NewEditorTab), + ("refresh-tables", || WorkspaceInput::RefreshCatalog), + ("reopen-closed-tab", || WorkspaceInput::ReopenClosedTab), + ("save-query", || WorkspaceInput::SaveActiveQuery), + ("show-history", || WorkspaceInput::ShowHistory), + ("show-saved-queries", || WorkspaceInput::ShowSavedQueries), + (super::health_banner::REVIEW_HOST_KEY, || WorkspaceInput::ReviewHostKey), + ]; + let actions = entries.map(|(name, message)| { + let input = input.clone(); + gio::ActionEntry::::builder(name) + .activate(move |_, _, _| input.deliver(message())) + .build() + }); + window.add_action_entries(actions); + // Insensitive until the query database has opened or failed to. + for name in HISTORY_ACTIONS { + set_enabled(window, name, false); + } + // Only a session parked on a host key has one to review. + set_enabled(window, super::health_banner::REVIEW_HOST_KEY, false); + entries.iter().map(|(name, _)| *name).collect() +} + +pub(super) fn set_enabled(window: &adw::ApplicationWindow, name: &str, enabled: bool) { + if let Some(action) = window.lookup_action(name).and_downcast::() { + action.set_enabled(enabled); + } +} + +/// Flip the actions that need the query database whenever +/// `HistoryService` changes state. The watch is read on the GTK thread, +/// so the actions are only touched from the thread that owns them. +pub(super) fn spawn_history_gate( + window: &adw::ApplicationWindow, + mut availability: tokio::sync::watch::Receiver, +) -> glib::JoinHandle<()> { + let window = window.downgrade(); + glib::spawn_future_local(async move { + loop { + let state = availability.borrow_and_update().clone(); + let Some(window) = window.upgrade() else { + return; + }; + for name in HISTORY_ACTIONS { + set_enabled(&window, name, state.is_settled()); + } + drop(window); + if availability.changed().await.is_err() { + return; + } + } + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[gtk4::test] + fn the_history_gate_follows_availability() { + let window = crate::test_support::OwnedWindow::new(adw::ApplicationWindow::builder().build()); + let (input, _receiver) = crate::runtime::input_queue::(); + install(&window, &input); + let (sender, receiver) = tokio::sync::watch::channel(HistoryAvailability::Starting); + let gate = spawn_history_gate(&window, receiver); + let enabled = || { + window + .lookup_action("show-history") + .is_some_and(|action| action.is_enabled()) + }; + + crate::test_support::drain_main_context(); + assert!(!enabled(), "enabled while the database was still opening"); + + sender.send_replace(HistoryAvailability::Ready); + crate::test_support::drain_main_context(); + assert!(enabled(), "stayed disabled after the database opened"); + + sender.send_replace(HistoryAvailability::Failed("no disk".to_owned())); + crate::test_support::drain_main_context(); + assert!(enabled(), "a failed database left the action greyed out with no reason"); + + gate.abort(); + } + + #[gtk4::test] + fn workspace_actions_live_on_the_windows_own_map() { + let window = crate::test_support::OwnedWindow::new(adw::ApplicationWindow::builder().build()); + let (input, mut receiver) = crate::runtime::input_queue::(); + + let names = install(&window, &input); + + assert!(names.contains(&"new-editor-tab")); + WidgetExt::activate_action(&*window, "win.new-editor-tab", None).expect("win resolves to the window's own map"); + crate::test_support::drain_main_context(); + assert!(matches!(receiver.try_recv(), Ok(WorkspaceInput::NewEditorTab))); + } +} diff --git a/linux/crates/app/src/ui/workspace/workspace_persistence.rs b/linux/crates/app/src/ui/workspace/workspace_persistence.rs new file mode 100644 index 0000000000..aaca79771b --- /dev/null +++ b/linux/crates/app/src/ui/workspace/workspace_persistence.rs @@ -0,0 +1,455 @@ +use std::cell::RefCell; +use std::collections::HashMap; +use std::rc::Rc; +use std::time::Duration; + +use gtk4::glib; +use libadwaita as adw; +use libadwaita::prelude::*; +use tablepro_session::SessionKey; +use uuid::Uuid; + +use super::WorkspaceTab; +use super::tab_index::TabIndex; +use crate::ui::structure_tab::StructureMode; +use crate::workspace::{ + PersistDebouncer, SortRecord, TabRecord, TabSnapshot, WorkspaceRecord, WorkspaceSaveError, WorkspaceStore, +}; + +/// How long after the last tab change the tab list is written. +const PERSIST_DELAY: Duration = Duration::from_millis(500); + +pub(super) type WorkspaceTabs = Rc>>; + +/// Keeps the tab list of the session a window shows on disk, behind the +/// user. +pub(super) struct WorkspacePersistence { + store: WorkspaceStore, + debouncer: PersistDebouncer, + target: RefCell>, +} + +/// The session the tabs belong to. It is set only once that session's +/// saved tabs have been read, so nothing is written over them first. +struct Target { + key: SessionKey, + view: glib::WeakRef, + tabs: WorkspaceTabs, + index: Rc, +} + +impl Target { + fn record(&self) -> Option { + let view = self.view.upgrade()?; + Some(workspace_record(&view, &self.tabs.borrow(), &self.index)) + } +} + +impl WorkspacePersistence { + pub fn new(store: WorkspaceStore) -> Rc { + Rc::new(Self { + store, + debouncer: PersistDebouncer::new(PERSIST_DELAY), + target: RefCell::new(None), + }) + } + + /// Start writing the tabs of `key`, whose saved tabs have been read. + pub fn attach(&self, key: SessionKey, view: &adw::TabView, tabs: WorkspaceTabs, index: Rc) { + self.debouncer.cancel(); + *self.target.borrow_mut() = Some(Target { + key, + view: view.downgrade(), + tabs, + index, + }); + } + + /// Write the tab list a moment after the last change. + pub fn request(self: &Rc) { + // Taken now: the write belongs to the session open now, even if + // it fires after a switch to another one. + let Some(key) = self.target.borrow().as_ref().map(|target| target.key.clone()) else { + return; + }; + let this = Rc::downgrade(self); + self.debouncer.request(move || { + if let Some(this) = this.upgrade() { + this.write(&key); + } + }); + } + + /// Write the tabs as they are now and stop writing for this session. + /// The future resolves once the record is on disk. + pub fn detach(&self) -> Option> + use<>> { + // Taking the target is what keeps a pending request from saving + // the emptied tabs; this only stops its timer. + self.debouncer.cancel(); + let target = self.target.borrow_mut().take()?; + if let Some(record) = target.record() { + self.schedule(&target.key, record); + } + Some(self.store.flush(&target.key)) + } + + fn write(&self, key: &SessionKey) { + let record = match self.target.borrow().as_ref() { + Some(target) if &target.key == key => target.record(), + _ => None, + }; + if let Some(record) = record { + self.schedule(key, record); + } + } + + fn schedule(&self, key: &SessionKey, record: WorkspaceRecord) { + match self.store.schedule(key, record) { + Ok(()) => {} + Err(WorkspaceSaveError::Blocked) => { + tracing::debug!("the saved tabs could not be read, so the open ones are not written over them"); + } + Err(error) => tracing::warn!(%error, "could not save the open tabs"), + } + } +} + +/// The record of the tabs `view` shows, in its order. +pub(super) fn workspace_record( + view: &adw::TabView, + tabs: &HashMap, + index: &TabIndex, +) -> WorkspaceRecord { + let pages = view.pages(); + let snapshots: Vec = (0..pages.n_items()) + .filter_map(|position| pages.item(position).and_downcast::()) + .filter_map(|page| { + let id = index.id_of(&page)?; + let tab = tabs.get(&id)?; + Some(TabSnapshot { + id, + record: tab_record(tab), + }) + }) + .collect(); + let selected = view.selected_page().and_then(|page| index.id_of(&page)); + crate::workspace::workspace_record(&snapshots, selected) +} + +fn tab_record(tab: &WorkspaceTab) -> Option { + match tab { + WorkspaceTab::Editor(slot) => Some(TabRecord::Editor { + draft: slot.draft, + file: super::workspace_tabs::editor_file_record(&slot.editor), + }), + WorkspaceTab::Structure(slot) => match slot.mode { + // A draft for a table that does not exist yet has nothing to + // reopen. + StructureMode::New => None, + StructureMode::Edit => Some(TabRecord::Structure { + schema: slot.schema.clone(), + table: slot.table.clone(), + }), + }, + WorkspaceTab::Table(slot) => Some(TabRecord::Table { + schema: slot.schema.clone(), + table: slot.table.clone(), + offset: slot.browse.current_offset(), + page_size: slot.browse.page_size(), + sort: slot + .browse + .current_sort() + .map(|(column, ascending)| SortRecord { column, ascending }), + }), + } +} + +/// Open each saved tab through `open`, in order, then select the one +/// that was selected. A tab `open` could not build leaves nothing to +/// select. +pub(super) fn reopen( + view: &adw::TabView, + record: WorkspaceRecord, + mut open: impl FnMut(TabRecord) -> Option, +) { + let pages: Vec> = record.tabs.into_iter().map(&mut open).collect(); + let selected = record + .active_tab + .and_then(|index| usize::try_from(index).ok()) + .and_then(|index| pages.get(index).cloned().flatten()); + if let Some(page) = selected { + view.set_selected_page(&page); + } +} + +#[cfg(test)] +mod tests { + use std::time::Duration; + + use gtk4 as gtk; + use tablepro_storage::DraftId; + + use super::*; + use crate::test_support::{finish_on_main, wait_until}; + use crate::ui::editor::{SqlEditor, SqlEditorInit}; + use crate::ui::workspace::EditorTabSlot; + use crate::workspace::WorkspaceLoad; + + const WAIT: Duration = Duration::from_secs(5); + + struct Fixture { + runtime: crate::runtime::AppRuntime, + settings: crate::test_support::MemorySettings, + root: tempfile::TempDir, + store: WorkspaceStore, + view: adw::TabView, + tabs: WorkspaceTabs, + index: Rc, + } + + impl Fixture { + fn new() -> Self { + adw::init().expect("libadwaita initialises under the test backend"); + crate::register_test_resources(); + let runtime = crate::test_support::test_runtime(); + let root = tempfile::tempdir().expect("tempdir"); + let store = WorkspaceStore::start( + root.path().join("workspaces"), + tablepro_storage::DraftStore::new(root.path().join("drafts")), + &runtime.tasks(), + ); + Self { + runtime, + settings: crate::test_support::MemorySettings::new(), + root, + store, + view: adw::TabView::new(), + tabs: WorkspaceTabs::default(), + index: Rc::default(), + } + } + + fn add_editor(&self) -> (adw::TabPage, DraftId) { + let editor = SqlEditor::new(SqlEditorInit { + initial_query: None, + settings: self.settings.get().clone(), + history: None, + tasks: self.runtime.tasks(), + session: crate::services::active_session::ActiveSession::new(), + hold: Rc::new(tablepro_session::runtime::CountingHold::new()), + close_timeout: Duration::from_secs(5), + }); + let page = self.view.append(&editor); + let id = Uuid::new_v4(); + self.index.insert(&page, id); + let draft = DraftId::new(); + self.tabs.borrow_mut().insert( + id, + WorkspaceTab::Editor(EditorTabSlot { + editor, + page: page.clone(), + draft, + ordinal: 1, + label: crate::ui::workspace::editor_label::EditorLabel::Untitled(1), + }), + ); + (page, draft) + } + + fn load(&self, key: &SessionKey) -> WorkspaceLoad { + finish_on_main(WAIT, self.store.load(key)).expect("the load to end") + } + } + + /// Run the main loop for `duration`, long enough for a timer that + /// must not fire to have fired. + fn run_for(duration: Duration) { + let _ = wait_until(duration, || false); + } + + fn editor_drafts(record: &WorkspaceRecord) -> Vec { + record + .tabs + .iter() + .filter_map(|tab| match tab { + TabRecord::Editor { draft, .. } => Some(*draft), + _ => None, + }) + .collect() + } + + #[gtk4::test] + fn active_tab_indexes_saved_records_not_pages() { + let fixture = Fixture::new(); + // A page with no tab behind it is not saved, so it shifts the + // page index but not the record index. + fixture.view.append(>k::Label::new(None)); + let (_, first) = fixture.add_editor(); + let (selected, second) = fixture.add_editor(); + fixture.view.set_selected_page(&selected); + + let record = workspace_record(&fixture.view, &fixture.tabs.borrow(), &fixture.index); + + assert_eq!(editor_drafts(&record), [first, second]); + assert_eq!(record.active_tab, Some(1)); + } + + #[gtk4::test] + fn only_a_structure_tab_of_an_existing_table_is_saved() { + let fixture = Fixture::new(); + let session = crate::test_support::FakeSession::start( + tablepro_test_support::ConnectionScript::default(), + [], + fixture.runtime.tasks(), + ); + for (mode, table) in [(StructureMode::Edit, "users"), (StructureMode::New, "")] { + let structure = crate::ui::structure_tab::StructureTab::new(crate::ui::structure_tab::StructureTabInit { + tracker: Default::default(), + schema: Some("public".to_owned()), + table: table.to_owned(), + mode, + session: session.session.clone(), + tasks: fixture.runtime.tasks(), + first_load: crate::ui::tab_load::FirstLoad::Now, + }); + let page = fixture.view.append(&structure); + let id = Uuid::new_v4(); + fixture.index.insert(&page, id); + fixture.tabs.borrow_mut().insert( + id, + WorkspaceTab::Structure(crate::ui::workspace::StructureTabSlot { + id, + structure, + page, + schema: Some("public".to_owned()), + table: table.to_owned(), + mode, + }), + ); + } + + let record = workspace_record(&fixture.view, &fixture.tabs.borrow(), &fixture.index); + + assert_eq!( + record.tabs, + [TabRecord::Structure { + schema: Some("public".to_owned()), + table: "users".to_owned(), + }] + ); + for tab in fixture.tabs.borrow().values() { + if let WorkspaceTab::Structure(slot) = tab { + slot.structure.shutdown(); + } + } + } + + #[gtk4::test] + fn restore_selects_active_tab_and_reopens_structure_tabs() { + adw::init().expect("libadwaita initialises under the test backend"); + let view = adw::TabView::new(); + let record = WorkspaceRecord { + active_tab: Some(1), + tabs: vec![ + TabRecord::Table { + schema: None, + table: "orders".to_owned(), + offset: 0, + page_size: 1_000, + sort: None, + }, + TabRecord::Structure { + schema: Some("public".to_owned()), + table: "users".to_owned(), + }, + TabRecord::Editor { + draft: DraftId::new(), + file: None, + }, + ], + }; + let mut opened = Vec::new(); + + reopen(&view, record, |tab| { + let name = match &tab { + TabRecord::Table { table, .. } => format!("table {table}"), + TabRecord::Structure { table, .. } => format!("structure {table}"), + TabRecord::Editor { .. } => "editor".to_owned(), + }; + let page = view.append(>k::Label::new(Some(&name))); + opened.push(name); + Some(page) + }); + + assert_eq!(opened, ["table orders", "structure users", "editor"]); + let selected = view + .selected_page() + .and_then(|page| page.child().downcast::().ok()) + .map(|label| label.label().to_string()); + assert_eq!(selected.as_deref(), Some("structure users")); + } + + #[gtk4::test] + fn detach_writes_the_tabs_as_they_are_now() { + let fixture = Fixture::new(); + let persistence = WorkspacePersistence::new(fixture.store.clone()); + let key = SessionKey::Saved(Uuid::new_v4()); + let (_, draft) = fixture.add_editor(); + persistence.attach(key.clone(), &fixture.view, fixture.tabs.clone(), fixture.index.clone()); + + let flushing = persistence.detach().expect("an attached session"); + finish_on_main(WAIT, flushing) + .expect("the flush to end") + .expect("written"); + + let WorkspaceLoad::Restored(record) = fixture.load(&key) else { + panic!("nothing was saved"); + }; + assert_eq!(editor_drafts(&record), [draft]); + assert!(persistence.detach().is_none(), "still attached after a detach"); + } + + #[gtk4::test] + fn a_request_pending_at_teardown_does_not_save_the_emptied_tabs() { + let fixture = Fixture::new(); + let persistence = WorkspacePersistence::new(fixture.store.clone()); + let key = SessionKey::Saved(Uuid::new_v4()); + let (_, draft) = fixture.add_editor(); + persistence.attach(key.clone(), &fixture.view, fixture.tabs.clone(), fixture.index.clone()); + + persistence.request(); + let flushing = persistence.detach().expect("an attached session"); + assert!( + !persistence.debouncer.is_pending(), + "a write timer outlived the session" + ); + // What the window's teardown does next. + fixture.tabs.borrow_mut().clear(); + finish_on_main(WAIT, flushing) + .expect("the flush to end") + .expect("written"); + run_for(PERSIST_DELAY * 3); + + let WorkspaceLoad::Restored(record) = fixture.load(&key) else { + panic!("nothing was saved"); + }; + assert_eq!(editor_drafts(&record), [draft]); + } + + #[gtk4::test] + fn nothing_is_written_before_the_saved_tabs_are_read() { + let fixture = Fixture::new(); + let persistence = WorkspacePersistence::new(fixture.store.clone()); + fixture.add_editor(); + + persistence.request(); + + run_for(PERSIST_DELAY * 3); + + assert!(persistence.detach().is_none()); + assert!( + !fixture.root.path().join("workspaces").exists(), + "a tab list was written for no session" + ); + } +} diff --git a/linux/crates/app/src/ui/workspace/workspace_tab_view.rs b/linux/crates/app/src/ui/workspace/workspace_tab_view.rs new file mode 100644 index 0000000000..27881ba7f1 --- /dev/null +++ b/linux/crates/app/src/ui/workspace/workspace_tab_view.rs @@ -0,0 +1,98 @@ +use libadwaita as adw; + +/// AdwTabView runs its shortcuts in a capture-phase, global controller, +/// so Ctrl+Home and Ctrl+End would switch tabs before the SQL editor or +/// a grid saw them. GNOME Text Editor removes the same four. +const CHILD_NAVIGATION_KEYS: adw::TabViewShortcuts = adw::TabViewShortcuts::CONTROL_HOME + .union(adw::TabViewShortcuts::CONTROL_END) + .union(adw::TabViewShortcuts::CONTROL_SHIFT_HOME) + .union(adw::TabViewShortcuts::CONTROL_SHIFT_END); + +pub(super) fn keep_child_navigation_keys(view: &adw::TabView) { + view.remove_shortcuts(CHILD_NAVIGATION_KEYS); +} + +#[cfg(test)] +mod tests { + use super::*; + + fn new_workspace_tab_view() -> adw::TabView { + let view = adw::TabView::new(); + keep_child_navigation_keys(&view); + view + } + + #[gtk4::test] + fn workspace_tab_view_leaves_buffer_navigation_keys_to_children() { + adw::init().expect("libadwaita initialises under the test backend"); + + let shortcuts = new_workspace_tab_view().shortcuts(); + + assert!( + !shortcuts.intersects(CHILD_NAVIGATION_KEYS), + "the tab view still takes {:?}", + shortcuts & CHILD_NAVIGATION_KEYS + ); + let switching = adw::TabViewShortcuts::CONTROL_TAB + | adw::TabViewShortcuts::CONTROL_SHIFT_TAB + | adw::TabViewShortcuts::CONTROL_PAGE_UP + | adw::TabViewShortcuts::CONTROL_PAGE_DOWN + | adw::TabViewShortcuts::CONTROL_SHIFT_PAGE_UP + | adw::TabViewShortcuts::CONTROL_SHIFT_PAGE_DOWN + | adw::TabViewShortcuts::ALT_DIGITS + | adw::TabViewShortcuts::ALT_ZERO; + assert!( + shortcuts.contains(switching), + "a tab switching key was lost: {:?}", + switching - shortcuts + ); + } + + /// The table lists AdwTabView's keys as stock; it must list exactly + /// the ones the workspace's tab view keeps. + #[gtk4::test] + fn tab_view_keys_in_the_table_are_the_ones_it_keeps() { + use std::collections::HashSet; + + use crate::application::{ACCELERATORS, AcceleratorScope}; + use crate::test_support::shortcut_keys::keystroke; + adw::init().expect("libadwaita initialises under the test backend"); + let alt_digits: Vec = (1..=9).map(|digit| format!("{digit}")).collect(); + let keys_of = |flag: adw::TabViewShortcuts| -> Vec { + let accels: &[&str] = match flag { + adw::TabViewShortcuts::CONTROL_TAB => &["Tab"], + adw::TabViewShortcuts::CONTROL_SHIFT_TAB => &["Tab"], + adw::TabViewShortcuts::CONTROL_PAGE_UP => &["Page_Up"], + adw::TabViewShortcuts::CONTROL_PAGE_DOWN => &["Page_Down"], + adw::TabViewShortcuts::CONTROL_HOME => &["Home"], + adw::TabViewShortcuts::CONTROL_END => &["End"], + adw::TabViewShortcuts::CONTROL_SHIFT_PAGE_UP => &["Page_Up"], + adw::TabViewShortcuts::CONTROL_SHIFT_PAGE_DOWN => &["Page_Down"], + adw::TabViewShortcuts::CONTROL_SHIFT_HOME => &["Home"], + adw::TabViewShortcuts::CONTROL_SHIFT_END => &["End"], + adw::TabViewShortcuts::ALT_ZERO => &["0"], + _ => return alt_digits.clone(), + }; + accels.iter().map(|accel| (*accel).to_owned()).collect() + }; + let kept = new_workspace_tab_view().shortcuts(); + let mut expected = HashSet::new(); + for flag in adw::TabViewShortcuts::ALL_SHORTCUTS.iter() { + if kept.contains(flag) { + expected.extend(keys_of(flag).iter().map(|accel| keystroke(accel))); + } + } + + let listed: HashSet<_> = ACCELERATORS + .iter() + .filter(|entry| entry.scope == AcceleratorScope::Stock && entry.action.starts_with("tab-view.")) + .flat_map(|entry| entry.triggers.iter().map(|accel| keystroke(accel))) + .collect(); + + assert_eq!(listed, expected); + assert!( + !listed.contains(&keystroke("Home")), + "Ctrl+Home belongs to the tabs' content" + ); + } +} diff --git a/linux/crates/app/src/ui/workspace/workspace_tabs.rs b/linux/crates/app/src/ui/workspace/workspace_tabs.rs new file mode 100644 index 0000000000..2dd7f32418 --- /dev/null +++ b/linux/crates/app/src/ui/workspace/workspace_tabs.rs @@ -0,0 +1,818 @@ +use std::cell::RefCell; +use std::collections::{HashMap, HashSet}; +use std::rc::Rc; + +use gtk4 as gtk; +use gtk4::{gio, glib}; +use libadwaita as adw; +use libadwaita::prelude::*; + +use tablepro_core::meta::TableRef; +use tablepro_session::SessionKey; +use tablepro_storage::{DraftId, DraftScope}; +use uuid::Uuid; + +use crate::ui::browse_tab::{BrowseTabInput, PagingInput, SaveInput}; +use crate::ui::structure_tab::StructureMode; +use crate::workspace::{ + EditorFileRecord, RestoredDrafts, TabRecord, WorkspaceFileName, WorkspaceLoad, WorkspaceRecord, WorkspaceSaveError, +}; + +use super::editor_label::EditorLabel; +use super::structure_target::StructureTarget; +use super::tab_factory::EditorPreview; +use super::tab_index::TabIndex; +use super::tab_spec::{TabSpec, TablePage}; +use super::workspace_persistence::reopen; +use super::workspace_tab_view::keep_child_navigation_keys; +use super::{ + CLOSED_TABS_CAPACITY, ClosedTabDescriptor, OpenMode, WorkspaceInput, WorkspaceModel, WorkspaceSender, WorkspaceTab, +}; +use crate::runtime::DeliverInput; + +impl WorkspaceModel { + /// Read the saved tabs of the session just adopted, and the editor + /// text that goes with them, off the GTK thread. + pub(super) fn restore_workspace_tabs(&mut self, key: SessionKey) { + let input = self.input.clone(); + let loading = self.storage.workspaces().load(&key); + let store = self.drafts.store().clone(); + let scope = WorkspaceFileName::for_key(&key).draft_scope(); + let tasks = self.tasks.clone(); + glib::spawn_future_local(async move { + let load = loading.await; + let referenced: Vec = match &load { + WorkspaceLoad::Restored(record) => saved_drafts(record).collect(), + WorkspaceLoad::Empty | WorkspaceLoad::Blocked(_) => Vec::new(), + }; + let reading = tasks.spawn_blocking_task(move || RestoredDrafts::read_blocking(&store, &scope, referenced)); + let drafts = reading.await.unwrap_or_default(); + input.deliver(WorkspaceInput::WorkspaceLoaded(LoadedWorkspace { key, load, drafts })); + }); + } + + /// Rebuild the saved tabs, then start writing the tab list. + /// + /// Nothing is written for the session before this: a write would + /// replace the saved tabs before they were read. + pub(super) fn on_workspace_loaded(&mut self, loaded: LoadedWorkspace) { + let LoadedWorkspace { key, load, drafts } = loaded; + let RestoredDrafts { mut texts, unreadable } = drafts; + match load { + WorkspaceLoad::Restored(record) => { + self.retain_drafts(&key, saved_drafts(&record)); + let view = self.tab_view.clone(); + reopen(&view, record, |tab| self.reopen_tab(tab, &mut texts)); + } + WorkspaceLoad::Empty => self.retain_drafts(&key, std::iter::empty()), + // Which drafts the file names is unknown, so none is deleted. + WorkspaceLoad::Blocked(_) => self.layout_banner.set_revealed(true), + } + if unreadable > 0 { + let count = u32::try_from(unreadable).unwrap_or(u32::MAX); + self.show_toast(&crate::i18n::ngettext_f( + "Saved text of {n} query tab could not be read. The file was kept.", + "Saved text of {n} query tabs could not be read. The files were kept.", + count, + &[("n", &unreadable.to_string())], + )); + } + self.workspace + .attach(key, &self.tab_view, self.workspace_tabs.clone(), self.index.clone()); + self.persist_workspace_state(); + } + + /// Delete the session's drafts that neither the saved tabs nor an + /// open tab name: those of tabs closed in a session that did not + /// shut down cleanly. + fn retain_drafts(&self, key: &SessionKey, saved: impl Iterator) { + // A copy that Reset moved aside may name drafts the saved tabs + // do not, and it leads back to them only while they are kept. + if self.storage.workspaces().has_moved_aside_copy(key) { + return; + } + let scope = WorkspaceFileName::for_key(key).draft_scope(); + let mut keep: HashSet = saved.collect(); + keep.extend(self.workspace_tabs.borrow().values().filter_map(|tab| match tab { + WorkspaceTab::Editor(slot) => Some(slot.draft), + _ => None, + })); + self.drafts.retain(&scope, keep); + } + + fn reopen_tab(&mut self, tab: TabRecord, drafts: &mut HashMap) -> Option { + let spec = match tab { + TabRecord::Table { + schema, + table, + offset, + page_size, + sort, + } => TabSpec::Table { + table: TableRef::new(schema, table), + page: TablePage { + offset, + page_size, + sort: sort.map(|sort| (sort.column, sort.ascending)), + }, + }, + TabRecord::Structure { schema, table } => TabSpec::Structure { + target: StructureTarget::Existing(TableRef::new(schema, table)), + }, + // Its text is on disk already, or there is none to keep, so + // nothing is written until the user types. + TabRecord::Editor { draft, file } => TabSpec::Editor { + initial_query: Some(drafts.remove(&draft).unwrap_or_default()), + draft: Some(draft), + file, + }, + }; + // Selecting each tab as it opens would show, and so load, every + // one of them; `reopen` selects the one that was active. + self.factory.restore(spec) + } + + /// Move the unreadable saved tabs aside, so the open ones can be + /// saved in their place. + pub(super) fn on_reset_workspace_layout(&self) { + let resetting = self.storage.workspaces().reset(&self.key); + let input = self.input.clone(); + glib::spawn_future_local(async move { + let outcome = resetting.await; + input.deliver(WorkspaceInput::WorkspaceLayoutReset(outcome)); + }); + } + + pub(super) fn on_workspace_layout_reset(&self, outcome: Result, WorkspaceSaveError>) { + match outcome { + Ok(moved) => { + if let Some(moved) = moved { + tracing::info!(moved = %moved.display(), "moved the unreadable saved tabs aside"); + } + self.layout_banner.set_revealed(false); + self.persist_workspace_state(); + } + // The banner stays, so Reset can be tried again. + Err(error) => { + tracing::warn!(%error, "could not move the saved tabs aside"); + self.show_error_alert( + &crate::i18n::gettext("Could Not Reset Saved Tabs"), + &crate::i18n::gettext( + "The unreadable file could not be moved aside, so the open tabs are still not saved.", + ), + ); + } + } + } + + /// Build the tab `spec` names and select it. + pub(super) fn open_tab(&mut self, spec: TabSpec) -> Option { + let page = self.factory.create(spec)?; + self.tab_view.set_selected_page(&page); + Some(page) + } + + pub(super) fn open_editor_tab(&mut self, initial_query: Option) { + self.open_tab(TabSpec::Editor { + initial_query, + draft: None, + file: None, + }); + } + + /// The directory this session's editor drafts live in. + fn draft_scope(&self) -> DraftScope { + WorkspaceFileName::for_key(&self.key).draft_scope() + } + + /// Tear down a tab without prompting. Internal helper called once + /// the close-with-pending dialog (if any) has resolved. + pub(super) fn finish_close_workspace_tab(&mut self, id: Uuid, tab_view: &adw::TabView) { + let removed = self.workspace_tabs.borrow_mut().remove(&id); + let Some(removed) = removed else { + return; + }; + // Before the teardown, while the tab still has its page, sort + // and query. + if let Some(descriptor) = describe_closed_tab(&removed) { + push_closed_tab(&self.closed_tabs_stack, descriptor); + } + match &removed { + WorkspaceTab::Editor(slot) => { + slot.editor.shutdown(); + // The record no longer names this draft, so the file has + // nothing left pointing at it. + self.drafts.discard(&self.draft_scope(), slot.draft); + } + WorkspaceTab::Structure(slot) => { + slot.structure.shutdown(); + self.structure_trackers.unregister(slot.id); + } + WorkspaceTab::Table(slot) => { + slot.browse.shutdown(); + self.change_trackers.unregister(slot.id); + } + } + let page = removed.page().clone(); + self.index.remove(&page); + self.move_focus_off(&page); + tab_view.close_page_finish(&page, true); + drop(removed); + self.persist_workspace_state(); + self.refresh_window_title(); + self.refresh_unsaved(); + self.report_tabs_changed(); + } + + /// With no tab open, Ctrl+W closes the window, as in GNOME Text + /// Editor. + pub(super) fn close_active_workspace_tab(&mut self) { + match self.tab_view.selected_page() { + Some(page) => { + self.tab_view.close_page(&page); + } + // The window's close asks this workspace what it would + // lose, which it cannot answer in the middle of handling + // this, so the close waits for the main loop. + None => { + if let Some(window) = self.window() { + glib::idle_add_local_once(move || window.close()); + } + } + } + } + + /// Close every workspace tab except `keep_id`. Each close goes + /// through the per-tab close path so dirty browse tabs still + /// trigger the unsaved-changes alert; the user can cancel the + /// dialog and the tab stays open while the others continue + /// closing in the background. + pub(super) fn close_other_workspace_tabs(&mut self, keep_id: Uuid) { + let tab_view = self.tab_view.clone(); + let pages = tab_view.pages(); + let mut targets: Vec = Vec::with_capacity(pages.n_items() as usize); + for i in 0..pages.n_items() { + let Some(page) = pages.item(i).and_downcast::() else { + continue; + }; + if self.index.id_of(&page) == Some(keep_id) { + continue; + } + targets.push(page); + } + for page in targets { + tab_view.close_page(&page); + } + } + + /// Close every workspace tab whose display position is greater + /// than the targeted one. Useful for rapidly trimming a long + /// session of throwaway tabs the user accumulated. + pub(super) fn close_workspace_tabs_to_right(&mut self, anchor_id: Uuid) { + let tab_view = self.tab_view.clone(); + let pages = tab_view.pages(); + let mut anchor_idx: Option = None; + for i in 0..pages.n_items() { + let Some(page) = pages.item(i).and_downcast::() else { + continue; + }; + if self.index.id_of(&page) == Some(anchor_id) { + anchor_idx = Some(i); + break; + } + } + let Some(anchor_idx) = anchor_idx else { + return; + }; + let mut targets: Vec = Vec::new(); + for i in (anchor_idx + 1)..pages.n_items() { + let Some(page) = pages.item(i).and_downcast::() else { + continue; + }; + targets.push(page); + } + for page in targets { + tab_view.close_page(&page); + } + } + + /// A sidebar click never replaces the active tab: AdwTabView's + /// close animation overlapped the append and flashed both tabs. + pub(super) fn dispatch_select_table(&mut self, schema: Option, name: String, open_mode: OpenMode) { + if matches!(open_mode, OpenMode::SwitchOrAppend) { + // Multiple tabs may be open for the same (schema, table) + // pair (Ctrl+click duplicates). Prefer the currently- + // selected tab when it matches, then any other match. + let existing = { + let tabs = self.workspace_tabs.borrow(); + let selected_page = self.tab_view.selected_page(); + let matches_slot = |t: &WorkspaceTab| match t { + WorkspaceTab::Table(s) if s.schema.as_deref() == schema.as_deref() && s.table == name => { + Some(s.page.clone()) + } + _ => None, + }; + let selected_match = selected_page.and_then(|sp| { + let id = self.index.id_of(&sp)?; + matches_slot(tabs.get(&id)?) + }); + selected_match.or_else(|| tabs.values().find_map(matches_slot)) + }; + if let Some(page) = existing { + self.tab_view.set_selected_page(&page); + return; + } + } + // Default open path appends a Data-view Browse tab. Structure + // for the same table opens as its own separate tab via the + // sidebar right-click "Edit Structure" action. + let page_size = u64::from(self.settings.default_page_size()); + self.open_tab(TabSpec::Table { + table: TableRef::new(schema, name), + page: TablePage::first(page_size), + }); + } + + pub(super) fn on_refresh_active_tab(&self) { + if let Some(id) = self.selected_browse_tab_id() { + self.on_refresh_tab(id); + } + } + + pub(super) fn on_refresh_tab(&self, id: Uuid) { + let dirty = self + .change_trackers + .get(id) + .is_some_and(|tracker| tracker.pending_count() > 0); + if !dirty { + self.dispatch_to_tab(id, PagingInput::Refresh); + return; + } + // A refetch replaces the rows and with them every pending edit, + // so F5 mid-edit asks first, as closing a dirty tab does. + let dialog = adw::AlertDialog::new( + Some(&crate::i18n::gettext("Discard Pending Changes?")), + Some(&crate::i18n::gettext( + "Refreshing reloads the table from the database and drops every unsaved edit on this tab.", + )), + ); + dialog.add_response("cancel", &crate::i18n::gettext("Cancel")); + dialog.add_response("discard", &crate::i18n::gettext("Discard and Refresh")); + dialog.set_response_appearance("discard", adw::ResponseAppearance::Destructive); + dialog.set_default_response(Some("cancel")); + dialog.set_close_response("cancel"); + let workspace_tabs = self.workspace_tabs.clone(); + dialog.connect_response(None, move |_, response| { + if response != "discard" { + return; + } + let browse = workspace_tabs + .borrow() + .get(&id) + .and_then(|tab| tab.browse_tab()) + .cloned(); + if let Some(browse) = browse { + browse.send(SaveInput::DiscardAndRefresh); + } + }); + dialog.present(self.window().as_ref()); + } + + /// Write the tab list a moment after the last change. + pub(super) fn persist_workspace_state(&self) { + self.workspace.request(); + } +} + +impl WorkspaceModel { + /// Single handler for `WorkspaceTabsChanged`. Persists tab state, + /// refreshes the window title (so tab switches update the subtitle), + /// and syncs the sidebar selection to the active Browse tab's table. + pub(super) fn on_workspace_tabs_changed(&self) { + self.persist_workspace_state(); + self.refresh_window_title(); + self.sync_sidebar_selection(); + self.report_tabs_changed(); + } + + pub(super) fn selected_workspace_tab_id(&self) -> Option { + self.index.id_of(&self.tab_view.selected_page()?) + } + + pub(super) fn selected_browse_tab_id(&self) -> Option { + let id = self.selected_workspace_tab_id()?; + let tabs = self.workspace_tabs.borrow(); + let slot = tabs.get(&id)?; + if slot.browse_tab().is_some() { Some(id) } else { None } + } + + pub(super) fn selected_browse_slot_table(&self) -> Option<(Option, String)> { + let id = self.selected_browse_tab_id()?; + let tabs = self.workspace_tabs.borrow(); + let (schema, table) = tabs.get(&id)?.schema_table()?; + Some((schema.map(str::to_owned), table.to_string())) + } + + /// Stop every tab and let go of the tab view. The pending edits + /// name rows of the connection going away, so they cannot be saved + /// after it. + pub(super) fn close_tabs(&mut self) { + for tab in self.workspace_tabs.borrow().values() { + match tab { + WorkspaceTab::Editor(slot) => slot.editor.shutdown(), + WorkspaceTab::Structure(slot) => { + slot.structure.shutdown(); + self.structure_trackers.unregister(slot.id); + } + WorkspaceTab::Table(slot) => { + slot.browse.shutdown(); + self.change_trackers.unregister(slot.id); + } + } + } + self.index.clear(); + self.workspace_tabs.borrow_mut().clear(); + } + + /// Forward a per-tab Browse input to the right slot. Routes to the + /// Browse-side controller in both legacy `Browse` slots and the + /// unified `Table` slot. + pub(super) fn dispatch_to_tab(&self, tab_id: Uuid, msg: impl Into) { + let browse = self + .workspace_tabs + .borrow() + .get(&tab_id) + .and_then(|t| t.browse_tab()) + .cloned(); + if let Some(browse) = browse { + browse.send(msg); + } + } + + /// Name the tab after the start of its script, and write the script + /// a moment later. Only the first `PREVIEW_CHARS` are read here, so a + /// long script costs nothing to type in. + pub(super) fn on_editor_tab_content_changed(&self, id: Uuid) { + { + let mut tabs = self.workspace_tabs.borrow_mut(); + let Some(WorkspaceTab::Editor(slot)) = tabs.get_mut(&id) else { + return; + }; + let buffer = slot.editor.buffer(); + if slot.editor.file().is_none() { + slot.label = EditorPreview::read(&buffer).label(slot.ordinal); + } + self.drafts.schedule(&self.draft_scope(), slot.draft, &buffer); + } + self.retitle_tab(id); + } + + /// A tab saved to a file is named after it, shows whether its text + /// is saved, and is restored with it. + pub(super) fn on_editor_tab_file_changed(&self, id: Uuid) { + { + let mut tabs = self.workspace_tabs.borrow_mut(); + let Some(WorkspaceTab::Editor(slot)) = tabs.get_mut(&id) else { + return; + }; + if let Some(file) = slot.editor.file() { + slot.label = EditorLabel::file(&file); + } + } + self.retitle_tab(id); + self.refresh_unsaved(); + self.persist_workspace_state(); + } + + pub(super) fn on_editor_tab_run_state_changed(&self, id: Uuid, running: bool) { + if let Some(WorkspaceTab::Editor(slot)) = self.workspace_tabs.borrow().get(&id) { + slot.page.set_loading(running); + } + } + + pub(super) fn on_replace_active_tab_query(&mut self, text: String) { + if let Some(id) = self.selected_workspace_tab_id() + && let Some(WorkspaceTab::Editor(slot)) = self.workspace_tabs.borrow().get(&id) + { + slot.editor.replace_query(&text); + return; + } + self.open_editor_tab(Some(text)); + } + + /// The SQL in the editor tab the user is looking at, or `None` + /// when the active tab is not an editor. + pub(super) fn active_editor_query(&self) -> Option { + let id = self.selected_workspace_tab_id()?; + match self.workspace_tabs.borrow().get(&id) { + Some(WorkspaceTab::Editor(slot)) => Some(whole_text(&slot.editor.buffer())), + _ => None, + } + } +} + +/// The tab view's own signals, as the workspace's inputs. The view and +/// its tab bar are in the template. +pub(super) fn connect_tab_view(tab_view: &adw::TabView, index: &Rc, input: &WorkspaceSender) { + keep_child_navigation_keys(tab_view); + + // A tab with unsaved work asks first, so the workspace finishes the close. + let close_input = input.clone(); + let close_index = index.clone(); + tab_view.connect_close_page(move |_view, page| { + if let Some(id) = close_index.id_of(page) { + close_input.deliver(WorkspaceInput::WorkspaceTabClosed(id)); + } + glib::Propagation::Stop + }); + + // The view names the right-clicked page only while its menu is + // open, so the actions read it from here. + let menu_target: Rc>> = Rc::new(RefCell::new(None)); + let menu_target_setup = menu_target.clone(); + tab_view.connect_setup_menu(move |_view, page| { + *menu_target_setup.borrow_mut() = page.cloned(); + }); + let action_group = gio::SimpleActionGroup::new(); + let menu_action = |name: &str, message: fn(Uuid) -> WorkspaceInput| { + let action = gio::SimpleAction::new(name, None); + let (target, index, input) = (menu_target.clone(), index.clone(), input.clone()); + action.connect_activate(move |_, _| { + let page = target.borrow().clone(); + if let Some(id) = page.and_then(|page| index.id_of(&page)) { + input.deliver(message(id)); + } + }); + action + }; + action_group.add_action(&menu_action("close-others", WorkspaceInput::CloseOtherWorkspaceTabs)); + action_group.add_action(&menu_action("close-right", WorkspaceInput::CloseWorkspaceTabsToRight)); + tab_view.insert_action_group("tab", Some(&action_group)); + + let bulk_menu = gio::Menu::new(); + bulk_menu.append( + Some(&crate::i18n::gettext("Close Other Tabs")), + Some("tab.close-others"), + ); + bulk_menu.append( + Some(&crate::i18n::gettext("Close Tabs to the Right")), + Some("tab.close-right"), + ); + tab_view.set_menu_model(Some(&bulk_menu)); + + // A drag reorder changes only `pages`, and it has to persist too. + let selected_input = input.clone(); + tab_view.connect_selected_page_notify(move |_| selected_input.deliver(WorkspaceInput::WorkspaceTabsChanged)); + let pages_input = input.clone(); + tab_view.connect_pages_notify(move |_| pages_input.deliver(WorkspaceInput::WorkspaceTabsChanged)); +} + +fn describe_closed_tab(slot: &WorkspaceTab) -> Option { + match slot { + WorkspaceTab::Editor(s) => Some(ClosedTabDescriptor::Editor { + query: whole_text(&s.editor.buffer()), + file: editor_file_record(&s.editor), + }), + WorkspaceTab::Table(s) => Some(ClosedTabDescriptor::Table { + schema: s.schema.clone(), + table: s.table.clone(), + offset: s.browse.current_offset(), + page_size: s.browse.page_size(), + sort: s.browse.current_sort(), + }), + WorkspaceTab::Structure(s) => match s.mode { + // A draft names no table to reopen. + StructureMode::New => None, + StructureMode::Edit => Some(ClosedTabDescriptor::Structure { + schema: s.schema.clone(), + table: s.table.clone(), + }), + }, + } +} + +fn push_closed_tab( + stack: &std::rc::Rc>>, + descriptor: ClosedTabDescriptor, +) { + let mut q = stack.borrow_mut(); + if q.len() == CLOSED_TABS_CAPACITY { + q.pop_front(); + } + q.push_back(descriptor); +} + +impl WorkspaceModel { + pub(super) fn on_reopen_closed_tab(&mut self) { + let descriptor = self.closed_tabs_stack.borrow_mut().pop_back(); + let Some(descriptor) = descriptor else { + // Without a toast the shortcut would read as broken. + self.show_toast(&crate::i18n::gettext("No recently closed tab")); + return; + }; + match descriptor { + ClosedTabDescriptor::Editor { query, file } => { + let initial = if query.is_empty() { None } else { Some(query) }; + self.open_tab(TabSpec::Editor { + initial_query: initial, + draft: None, + file, + }); + } + ClosedTabDescriptor::Table { + schema, + table, + offset, + page_size, + sort, + } => { + self.open_tab(TabSpec::Table { + table: TableRef::new(schema, table), + page: TablePage { + offset, + page_size, + sort, + }, + }); + } + ClosedTabDescriptor::Structure { schema, table } => { + self.open_tab(TabSpec::Structure { + target: StructureTarget::Existing(TableRef::new(schema, table)), + }); + } + } + } +} + +/// The file an editor tab is saved to, as its record keeps it. +pub(super) fn editor_file_record(editor: &crate::ui::editor::SqlEditor) -> Option { + editor.file().map(|file| EditorFileRecord { + uri: file.uri().to_string(), + modified: editor.has_unsaved_file_changes(), + }) +} + +/// The whole script, for the few places that need all of it at once. +fn whole_text(buffer: >k::TextBuffer) -> String { + buffer.text(&buffer.start_iter(), &buffer.end_iter(), false).to_string() +} + +#[derive(Debug)] +pub(crate) struct LoadedWorkspace { + key: SessionKey, + load: WorkspaceLoad, + drafts: RestoredDrafts, +} + +fn saved_drafts(record: &WorkspaceRecord) -> impl Iterator + '_ { + record.tabs.iter().filter_map(|tab| match tab { + TabRecord::Editor { draft, .. } => Some(*draft), + _ => None, + }) +} + +#[cfg(test)] +mod tests { + use gtk4::subclass::prelude::*; + + use super::*; + use crate::test_support::{descendants, drain_main_context, wait_until}; + use crate::ui::SessionWindowInput; + use crate::ui::session_window::tests::{TestWindow, WAIT, editors}; + + fn overview_new_tab_button(overview: &adw::TabOverview) -> gtk::Button { + descendants(overview) + .into_iter() + .find_map(|widget| { + let button = widget.downcast::().ok()?; + let content = button.child()?.downcast::().ok()?; + (content.icon_name() == "adw-tab-new-symbolic").then_some(button) + }) + .expect("the overview's new-tab button") + } + + #[gtk4::test] + fn a_closed_tab_from_the_overview_is_freed() { + let test = TestWindow::connected("OverviewTabFreed"); + let workspace = test.workspace(); + let (view, overview) = (workspace.imp().tab_view.get(), workspace.imp().tab_overview.get()); + + overview_new_tab_button(&overview).emit_clicked(); + let page = view.selected_page().expect("the new-tab button made a page"); + let known = workspace + .read(|model| { + model + .index + .id_of(&page) + .is_some_and(|id| model.workspace_tabs.borrow().contains_key(&id)) + }) + .unwrap_or_default(); + assert!(known, "the workspace does not know the new tab"); + let editor = page.child().downgrade(); + let freed_page = page.downgrade(); + + view.close_page(&page); + drop(page); + wait_until(WAIT, || freed_page.upgrade().is_none()).expect("the closed page to be freed"); + + assert!(editor.upgrade().is_none(), "the closed page's editor is still alive"); + } + + #[gtk4::test] + fn unreadable_saved_tabs_are_announced_until_reset() { + let test = TestWindow::picker("UnreadableSavedTabs"); + let dir = test.services.storage.paths().workspaces_dir(); + std::fs::create_dir_all(&dir).expect("the workspaces folder"); + std::fs::write( + dir.join(crate::workspace::WorkspaceFileName::for_key(&test.key()).file_name()), + b"{ not json", + ) + .expect("an unreadable file"); + + test.window + .send(SessionWindowInput::OpenSaved(Box::new(test.saved.clone()))); + test.wait_for_page("workspace"); + let banner = test.workspace().imp().layout_banner.get(); + wait_until(WAIT, || banner.is_revealed()).expect("the banner says the tabs are not saved"); + assert_eq!(banner.action_name().as_deref(), Some("win.reset-workspace-layout")); + + WidgetExt::activate_action(&test.window, "win.reset-workspace-layout", None).expect("Reset"); + + wait_until(WAIT, || !banner.is_revealed()).expect("the banner goes once the file is moved aside"); + } + + #[gtk4::test] + fn tab_view_freed_after_teardown() { + let test = TestWindow::connected("TabViewFreed"); + for _ in 0..2 { + WidgetExt::activate_action(&test.window, "win.new-editor-tab", None).expect("win.new-editor-tab"); + } + wait_until(WAIT, || editors(&test.window).len() == 2).expect("two editor tabs"); + let workspace = test.workspace(); + let view = workspace.imp().tab_view.downgrade(); + let overview = workspace.imp().tab_overview.downgrade(); + let pages: Vec<_> = editors(&test.window).iter().map(|editor| editor.downgrade()).collect(); + drop(workspace); + + test.window.send(SessionWindowInput::Disconnect); + test.wait_for_page("picker"); + drain_main_context(); + + assert!(view.upgrade().is_none(), "the tab view outlived its teardown"); + assert!(overview.upgrade().is_none(), "the overview outlived its teardown"); + assert!( + pages.iter().all(|editor| editor.upgrade().is_none()), + "an editor outlived its teardown" + ); + } + + /// A restored session reads only the tab in front; each other tab + /// reads its table the first time it is shown, so they do not all + /// wait on the pool at once. + #[gtk4::test] + fn restored_tabs_read_their_tables_when_first_shown() { + let test = TestWindow::with_script( + "RestoreReadsWhenShown", + crate::ui::session_window::tests::Script { + connection: super::super::shell::tests::served_table(), + ..Default::default() + }, + ); + let store = test.services.storage.workspaces().clone(); + let table = |name: &str| TabRecord::Table { + schema: None, + table: name.to_owned(), + offset: 0, + page_size: 100, + sort: None, + }; + store + .schedule( + &test.key(), + WorkspaceRecord { + active_tab: Some(1), + tabs: vec![table("users"), table("orders"), table("items")], + }, + ) + .expect("the record to queue"); + crate::test_support::finish_on_main(WAIT, store.flush(&test.key())) + .expect("the flush to end") + .expect("the record written"); + + test.window + .send(SessionWindowInput::OpenSaved(Box::new(test.saved.clone()))); + test.wait_for_page("workspace"); + let workspace = test.workspace(); + let view = workspace.imp().tab_view.get(); + wait_until(WAIT, || view.n_pages() == 3).expect("the saved tabs"); + let column_reads = || tablepro_test_support::CallCounters::get(&test.connection().counters().fetch_columns); + let _ = wait_until(WAIT, || column_reads() > 0); + // Long enough for a tab not shown to have read its table too. + let _ = wait_until(std::time::Duration::from_millis(500), || false); + assert_eq!(column_reads(), 1, "only the tab in front reads its table"); + + view.set_selected_page(&view.nth_page(2)); + + wait_until(WAIT, || column_reads() == 2).expect("the tab read its table once shown"); + } +} diff --git a/linux/crates/app/src/workspace/close_blocker.rs b/linux/crates/app/src/workspace/close_blocker.rs new file mode 100644 index 0000000000..5b4c40a579 --- /dev/null +++ b/linux/crates/app/src/workspace/close_blocker.rs @@ -0,0 +1,21 @@ +use futures::future::LocalBoxFuture; + +/// Work a window's close would cut short that is not a tab's, such as a +/// running export. A close asks about it first, and either keeps the +/// window or stops the work and goes on. +pub trait CloseBlocker { + /// What to ask, while the work runs. + fn blocking(&self) -> Option; + + /// Stop the work. Resolves once it has stopped. + fn stop(&self) -> LocalBoxFuture<'static, ()>; +} + +/// The alert a blocker asks with. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CloseBlockPrompt { + pub heading: String, + pub body: String, + pub keep_label: String, + pub stop_label: String, +} diff --git a/linux/crates/app/src/workspace/close_blocker_dialog.rs b/linux/crates/app/src/workspace/close_blocker_dialog.rs new file mode 100644 index 0000000000..9d3cee551f --- /dev/null +++ b/linux/crates/app/src/workspace/close_blocker_dialog.rs @@ -0,0 +1,21 @@ +use gtk4 as gtk; +use libadwaita as adw; +use libadwaita::prelude::*; + +use super::CloseBlockPrompt; +use super::dialog_response::respond; + +const KEEP: &str = "keep"; +const STOP: &str = "stop"; + +/// Whether the user stops the work `prompt` asks about. Dismissing the +/// alert keeps it running. +pub(crate) async fn ask_stop(parent: >k::Widget, prompt: &CloseBlockPrompt) -> bool { + let dialog = adw::AlertDialog::new(Some(&prompt.heading), Some(&prompt.body)); + dialog.add_response(KEEP, &prompt.keep_label); + dialog.add_response(STOP, &prompt.stop_label); + dialog.set_response_appearance(STOP, adw::ResponseAppearance::Destructive); + dialog.set_default_response(Some(KEEP)); + dialog.set_close_response(KEEP); + respond(&dialog, parent).await == STOP +} diff --git a/linux/crates/app/src/workspace/close_decision.rs b/linux/crates/app/src/workspace/close_decision.rs new file mode 100644 index 0000000000..83899ed8cf --- /dev/null +++ b/linux/crates/app/src/workspace/close_decision.rs @@ -0,0 +1,7 @@ +/// How a close ended. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CloseDecision { + /// Nothing is left to lose, or the user agreed to lose it. + Approved, + Cancelled, +} diff --git a/linux/crates/app/src/workspace/close_guard.rs b/linux/crates/app/src/workspace/close_guard.rs new file mode 100644 index 0000000000..90b6009a1c --- /dev/null +++ b/linux/crates/app/src/workspace/close_guard.rs @@ -0,0 +1,463 @@ +use std::collections::HashSet; + +use gtk4 as gtk; +use libadwaita as adw; +use libadwaita::prelude::*; +use uuid::Uuid; + +use super::close_blocker_dialog::ask_stop; +use super::open_transaction_dialog::confirm_open_transactions; +use super::save_changes_dialog::{SaveChoice, ask_save_changes}; +use super::unknown_outcome_dialog::{UnknownChoice, resolve_unknown_outcome}; +use super::waiting_dialog::wait_for_saves; +use super::{CloseDecision, CloseScope, CloseStep, DirtyTab, SaveRejection, SaveStart, WorkspaceHandle}; + +/// Decides whether a tab, or every tab of a window, may close. +/// +/// It reads the workspace again after every answer and every settled +/// save rather than following a plan made up front, so a save that +/// rolls back is asked about again and one that lands is not. +pub(crate) struct CloseGuard; + +impl CloseGuard { + /// `acknowledged` holds the tabs whose open transactions or file + /// changes the user already agreed to lose, and gains the ones + /// agreed to here. + pub(crate) async fn run( + parent: >k::Widget, + handle: &WorkspaceHandle, + scope: CloseScope, + acknowledged: &mut HashSet, + ) -> CloseDecision { + let _asking = handle.claim_for_prompt(scope); + loop { + match handle.close_step(scope, acknowledged) { + CloseStep::Approve => return CloseDecision::Approved, + CloseStep::StopBlocker { blocker, prompt } => { + if !ask_stop(parent, &prompt).await { + return CloseDecision::Cancelled; + } + handle.stop_blocker(blocker).await; + } + CloseStep::WaitForSaves => { + if !wait_for_saves(parent, handle, scope).await { + return CloseDecision::Cancelled; + } + } + CloseStep::ResolveUnknownOutcome(tabs) => match resolve_unknown_outcome(parent, &tabs).await { + UnknownChoice::KeepOpen => return CloseDecision::Cancelled, + UnknownChoice::Reload => { + handle.discard(&ids(&tabs), true); + return CloseDecision::Cancelled; + } + UnknownChoice::Discard => handle.discard(&ids(&tabs), false), + }, + CloseStep::ConfirmOpenTransactions(tabs) => { + if !confirm_open_transactions(parent, &tabs).await { + return CloseDecision::Cancelled; + } + acknowledged.extend(ids(&tabs)); + } + CloseStep::AskSaveChanges(tabs) => match ask_save_changes(parent, &tabs).await { + SaveChoice::Cancel => return CloseDecision::Cancelled, + SaveChoice::Proceed { save, discard } => { + handle.discard(&discard, false); + // A file's text has no tracker to clear: it goes + // with the tab, once the user agreed to that. + acknowledged.extend(discard.iter().copied()); + let (refused, declined) = start_saves(parent, handle, &save).await; + if !refused.is_empty() { + show_refused(parent, &tabs, &refused); + } + if declined || !refused.is_empty() { + return CloseDecision::Cancelled; + } + } + }, + } + } + } +} + +/// Start each save in turn, and give back the ones refused and whether +/// the user declined a review. A declined review is the user keeping the +/// window open, not a save that failed, so the saves after it are not +/// started and their reviews never shown. +async fn start_saves( + parent: >k::Widget, + handle: &WorkspaceHandle, + tabs: &[Uuid], +) -> (Vec<(Uuid, SaveRejection)>, bool) { + let mut refused = Vec::new(); + for tab in tabs { + for start in handle.save(parent, &[*tab]).await { + match start { + SaveStart::Rejected(SaveRejection::ReviewDeclined) => return (refused, true), + SaveStart::Rejected(rejection) => refused.push((*tab, rejection)), + SaveStart::Started(_) | SaveStart::Saved | SaveStart::NothingToSave => {} + } + } + } + (refused, false) +} + +fn ids(tabs: &[DirtyTab]) -> Vec { + tabs.iter().map(|tab| tab.id).collect() +} + +/// A save that could not start keeps its tab open, with the reason. +fn show_refused(parent: >k::Widget, tabs: &[DirtyTab], refused: &[(Uuid, SaveRejection)]) { + let lines: Vec = refused + .iter() + .map(|(id, rejection)| { + let name = tabs + .iter() + .find(|tab| tab.id == *id) + .map(|tab| tab.title.clone()) + .unwrap_or_default(); + crate::i18n::gettext_f("“{tab}”: {reason}", &[("tab", &name), ("reason", &rejection.message())]) + }) + .collect(); + let dialog = adw::AlertDialog::new(Some(&crate::i18n::gettext("Could Not Save")), Some(&lines.join("\n"))); + dialog.add_response("close", &crate::i18n::gettext("_Close")); + dialog.set_close_response("close"); + dialog.set_default_response(Some("close")); + dialog.present(Some(parent)); +} + +#[cfg(test)] +mod tests { + use std::cell::RefCell; + use std::rc::Rc; + + use tablepro_core::{DriverError, LossPhase, WriteReport}; + use tablepro_test_support::{CallCounters, ConnectionScript, FakeReply}; + use tokio_util::sync::CancellationToken; + + use super::*; + use crate::test_support::wait_until; + use crate::workspace::dialog_response::{answer, visible_alert}; + use crate::workspace::test_workspace::{WAIT, Workspace, edited_table, window}; + use crate::workspace::{PendingState, WorkspaceEvent}; + + fn without_n(draft: &mut tablepro_core::ddl::TableDraft) { + draft.columns.retain(|column| column.name != "n"); + } + + fn guard(window: &adw::Window, handle: &WorkspaceHandle, scope: CloseScope) -> Rc>> { + let decision = Rc::new(RefCell::new(None)); + let sink = decision.clone(); + let parent = window.clone().upcast::(); + let handle = handle.clone(); + gtk::glib::spawn_future_local(async move { + let decided = CloseGuard::run(&parent, &handle, scope, &mut HashSet::new()).await; + sink.replace(Some(decided)); + }); + decision + } + + /// Wait for an alert with `heading` other than `seen`, and answer + /// it with `response`. Returns the alert answered. + fn answer_next( + window: &adw::Window, + heading: &str, + response: &str, + seen: Option<&adw::AlertDialog>, + ) -> adw::AlertDialog { + let mut shown = None; + wait_until(WAIT, || { + shown = visible_alert(window) + .filter(|alert| alert.heading().as_deref() == Some(heading) && Some(alert) != seen); + shown.is_some() + }) + .unwrap_or_else(|_| panic!("no “{heading}” alert")); + assert!(answer(window, response)); + shown.expect("the alert") + } + + fn decided(decision: &Rc>>) -> CloseDecision { + wait_until(WAIT, || decision.borrow().is_some()).expect("the guard to decide"); + decision.borrow().expect("a decision") + } + + fn rolled_back() -> DriverError { + DriverError::RolledBack { + statement_index: 0, + source: Box::new(DriverError::server("duplicate key")), + } + } + + #[gtk4::test] + fn cancel_keeps_window() { + let workspace = Workspace::open(ConnectionScript::default()); + let window = window(); + + let decision = guard(&window, &workspace.handle, CloseScope::Window); + answer_next(&window, "Save Changes?", "cancel", None); + + assert_eq!(decided(&decision), CloseDecision::Cancelled); + assert_eq!(workspace.tracker.pending_state(), PendingState::Unsaved); + assert_eq!(CallCounters::get(&workspace.counters().write_batch), 0); + } + + #[gtk4::test] + fn discard_closes() { + let workspace = Workspace::open(ConnectionScript::default()); + let window = window(); + + let decision = guard(&window, &workspace.handle, CloseScope::Window); + answer_next(&window, "Save Changes?", "discard", None); + + assert_eq!(decided(&decision), CloseDecision::Approved); + assert_eq!(workspace.tracker.pending_state(), PendingState::Clean); + assert!(workspace.events.borrow().iter().any(|event| matches!( + event, + WorkspaceEvent::Discarded { tabs, reload: false } if tabs == &[workspace.tab] + ))); + } + + #[gtk4::test] + fn save_closes_once_the_save_lands() { + let mut script = ConnectionScript::default(); + script + .write_batch + .always(FakeReply::ok(WriteReport::new(vec![Some(1)]))); + let workspace = Workspace::open(script); + let window = window(); + + let decision = guard(&window, &workspace.handle, CloseScope::Window); + answer_next(&window, "Save Changes?", "save", None); + + assert_eq!(decided(&decision), CloseDecision::Approved); + assert_eq!(workspace.tracker.pending_state(), PendingState::Clean); + assert_eq!(CallCounters::get(&workspace.counters().write_batch), 1); + assert_eq!( + workspace.settled_in_prompt(), + [true], + "the prompt's save settled outside it" + ); + } + + #[gtk4::test] + fn a_save_left_running_by_keep_open_settles_outside_the_prompt() { + let mut script = ConnectionScript::default(); + script + .write_batch + .always(FakeReply::StoppedWith(Err(DriverError::RolledBack { + statement_index: 0, + source: Box::new(DriverError::Cancelled), + }))); + let workspace = Workspace::open(script); + let window = window(); + + let decision = guard(&window, &workspace.handle, CloseScope::Window); + answer_next(&window, "Save Changes?", "save", None); + answer_next(&window, "Saving Changes", "keep", None); + assert_eq!(decided(&decision), CloseDecision::Cancelled); + workspace.handle.stop_saves(CloseScope::Window); + workspace.settle(); + + assert_eq!(workspace.settled_in_prompt(), [false]); + } + + #[gtk4::test] + fn rolled_back_save_represents() { + let mut script = ConnectionScript::default(); + script.write_batch.always(FakeReply::err(rolled_back())); + let workspace = Workspace::open(script); + let window = window(); + + let decision = guard(&window, &workspace.handle, CloseScope::Window); + let first = answer_next(&window, "Save Changes?", "save", None); + answer_next(&window, "Save Changes?", "cancel", Some(&first)); + + assert_eq!(decided(&decision), CloseDecision::Cancelled); + assert_eq!(CallCounters::get(&workspace.counters().write_batch), 1); + assert_eq!(workspace.tracker.pending_state(), PendingState::Unsaved); + } + + /// The save lands only once the dialog is up, so a slow machine + /// cannot settle it inside the quiet period the dialog waits out. + #[gtk4::test] + fn in_flight_save_waiting_dialog_force_closes_on_settle() { + let release = CancellationToken::new(); + let mut script = ConnectionScript::default(); + script.write_batch.always(FakeReply::UntilReleased { + release: release.clone(), + outcome: Ok(WriteReport::new(vec![Some(1)])), + }); + let workspace = Workspace::open(script); + let window = window(); + assert!(matches!(workspace.start().as_slice(), [SaveStart::Started(_)])); + + let decision = guard(&window, &workspace.handle, CloseScope::Window); + wait_until(WAIT, || { + visible_alert(&window).is_some_and(|alert| alert.heading().as_deref() == Some("Saving Changes")) + }) + .expect("the waiting dialog"); + assert!( + decision.borrow().is_none(), + "the guard decided with the save still running" + ); + release.cancel(); + + assert_eq!(decided(&decision), CloseDecision::Approved); + crate::test_support::drain_main_context(); + assert!(visible_alert(&window).is_none(), "the waiting dialog stayed up"); + assert_eq!(workspace.tracker.pending_state(), PendingState::Clean); + } + + #[gtk4::test] + fn unknown_reload_cancels_close_and_write_batch_count_stays_one() { + let mut script = ConnectionScript::default(); + script + .write_batch + .always(FakeReply::err(DriverError::CommitOutcomeUnknown { + token: None, + source: Box::new(DriverError::ConnectionLost { + during: LossPhase::Commit, + }), + })); + let workspace = Workspace::open(script); + let window = window(); + workspace.save(); + assert!(matches!( + workspace.tracker.pending_state(), + PendingState::OutcomeUnknown(_) + )); + + let decision = guard(&window, &workspace.handle, CloseScope::Window); + answer_next(&window, "Save Result Unknown", "reload", None); + + assert_eq!(decided(&decision), CloseDecision::Cancelled); + assert_eq!(CallCounters::get(&workspace.counters().write_batch), 1); + assert_eq!(workspace.tracker.pending_state(), PendingState::Clean); + assert!( + workspace + .events + .borrow() + .iter() + .any(|event| matches!(event, WorkspaceEvent::Discarded { reload: true, .. })) + ); + } + + #[gtk4::test] + fn stop_saving_settles_and_reevaluates() { + let mut script = ConnectionScript::default(); + script + .write_batch + .always(FakeReply::StoppedWith(Err(DriverError::RolledBack { + statement_index: 0, + source: Box::new(DriverError::Cancelled), + }))); + let workspace = Workspace::open(script); + let window = window(); + assert!(matches!(workspace.start().as_slice(), [SaveStart::Started(_)])); + + let decision = guard(&window, &workspace.handle, CloseScope::Window); + answer_next(&window, "Saving Changes", "stop", None); + answer_next(&window, "Save Changes?", "cancel", None); + + assert_eq!(decided(&decision), CloseDecision::Cancelled); + let observed = workspace.fake.connection.observed_cancels("write_batch"); + assert!(!observed.is_empty(), "the write never ran"); + assert!( + observed.iter().all(|cancel| cancel.is_cancelled()), + "the write never saw the stop" + ); + assert_eq!(workspace.handle.ledger().in_flight(), 0); + assert_eq!(workspace.tracker.pending_state(), PendingState::Unsaved); + assert!(visible_alert(&window).is_none(), "a waiting dialog stayed up"); + } + + #[gtk4::test] + fn window_close_asks_about_open_editor_transactions_once() { + let workspace = Workspace::open(ConnectionScript::default()); + workspace.open_transaction_tab(); + let window = window(); + + let decision = guard(&window, &workspace.handle, CloseScope::Window); + answer_next(&window, "Close With Open Transactions?", "close", None); + answer_next(&window, "Save Changes?", "discard", None); + + assert_eq!(decided(&decision), CloseDecision::Approved); + } + + #[gtk4::test] + fn an_open_transaction_cancel_keeps_the_window() { + let workspace = Workspace::open(ConnectionScript::default()); + workspace.handle.discard(&[workspace.tab], false); + workspace.open_transaction_tab(); + let window = window(); + + let decision = guard(&window, &workspace.handle, CloseScope::Window); + answer_next(&window, "Close With Open Transactions?", "cancel", None); + + assert_eq!(decided(&decision), CloseDecision::Cancelled); + } + + #[gtk4::test] + fn a_tab_close_asks_only_about_that_tab() { + let workspace = Workspace::open(ConnectionScript::default()); + let editor = workspace.open_transaction_tab(); + let window = window(); + + let decision = guard(&window, &workspace.handle, CloseScope::Tab(editor)); + answer_next(&window, "Close With Open Transactions?", "close", None); + + assert_eq!(decided(&decision), CloseDecision::Approved); + assert_eq!(workspace.tracker.pending_state(), PendingState::Unsaved); + } + + #[gtk4::test] + fn close_guard_save_also_reviews() { + let mut script = ConnectionScript::default(); + script.write_batch.always(FakeReply::ok(WriteReport::new(vec![None]))); + let workspace = Workspace::open_structure(script, edited_table(without_n)); + let window = window(); + + let decision = guard(&window, &workspace.handle, CloseScope::Window); + let asked = answer_next(&window, "Save Changes?", "save", None); + answer_next(&window, "Apply 1 Change to “t”?", "apply", Some(&asked)); + + assert_eq!(decided(&decision), CloseDecision::Approved); + assert_eq!(CallCounters::get(&workspace.counters().write_batch), 1); + assert_eq!(workspace.structure.pending_state(), PendingState::Clean); + } + + #[gtk4::test] + fn a_declined_review_keeps_the_window_open_without_an_alert() { + let workspace = Workspace::open_structure(ConnectionScript::default(), edited_table(without_n)); + let window = window(); + + let decision = guard(&window, &workspace.handle, CloseScope::Window); + let asked = answer_next(&window, "Save Changes?", "save", None); + answer_next(&window, "Apply 1 Change to “t”?", "cancel", Some(&asked)); + + assert_eq!(decided(&decision), CloseDecision::Cancelled); + crate::test_support::drain_main_context(); + assert!( + visible_alert(&window).is_none(), + "a declined review was reported as a failure" + ); + assert_eq!(CallCounters::get(&workspace.counters().write_batch), 0); + assert_eq!(workspace.structure.pending_state(), PendingState::Unsaved); + } + + #[gtk4::test] + fn a_declined_review_stops_the_saves_after_it() { + let workspace = Workspace::open_structure(ConnectionScript::default(), edited_table(without_n)); + let second = workspace.add_structure_tab("u", edited_table(without_n)); + let window = window(); + + let decision = guard(&window, &workspace.handle, CloseScope::Window); + let asked = answer_next(&window, "Save Changes?", "save", None); + answer_next(&window, "Apply 1 Change to “t”?", "cancel", Some(&asked)); + + assert_eq!(decided(&decision), CloseDecision::Cancelled); + crate::test_support::drain_main_context(); + assert!(visible_alert(&window).is_none(), "the next tab's review opened"); + assert_eq!(CallCounters::get(&workspace.counters().write_batch), 0); + assert_eq!(second.pending_state(), PendingState::Unsaved); + } +} diff --git a/linux/crates/app/src/workspace/close_guards.rs b/linux/crates/app/src/workspace/close_guards.rs new file mode 100644 index 0000000000..00e00ec4f3 --- /dev/null +++ b/linux/crates/app/src/workspace/close_guards.rs @@ -0,0 +1,469 @@ +use std::cell::{Cell, RefCell}; +use std::collections::HashSet; +use std::rc::Rc; + +use gtk4 as gtk; +use gtk4::glib; +use gtk4::prelude::*; +use uuid::Uuid; + +use super::{CloseDecision, CloseGuard, CloseScope, CloseStep, WorkspaceHandle}; + +/// Hears how the window's close ended. +type CloseWaiter = Box; + +/// Where the window's own close stands. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum WindowClose { + Open, + /// The prompts are deciding. + Asking, + /// Nothing is left to lose, so the next close request goes through. + Approved, +} + +/// A window's close prompts. One runs at a time, so a tab's close and +/// the window's never ask about the same tab at once: a second waits for +/// the first, then reads the workspace as the first left it. +pub(crate) struct CloseGuards { + turn: futures::lock::Mutex<()>, + window: Cell, + /// Close requests that reached the window's handler, so a caller can + /// tell when a dialog over the window took one instead. + requests: Cell, + /// Callers waiting to hear how the window's close ended. + waiting: RefCell>, + /// Open transactions the user agreed to lose in the current + /// disconnect or switch. A switch asks again once its connection is + /// ready, about what changed while it connected, but not about these. + teardown_acknowledged: RefCell>, +} + +impl CloseGuards { + pub(crate) fn new() -> Rc { + Rc::new(Self { + turn: futures::lock::Mutex::new(()), + window: Cell::new(WindowClose::Open), + requests: Cell::new(0), + waiting: RefCell::new(Vec::new()), + teardown_acknowledged: RefCell::new(HashSet::new()), + }) + } + + async fn run( + &self, + parent: >k::Widget, + handle: &WorkspaceHandle, + scope: CloseScope, + acknowledged: &mut HashSet, + ) -> CloseDecision { + let _turn = self.turn.lock().await; + CloseGuard::run(parent, handle, scope, acknowledged).await + } + + /// The window closes only once its prompts approve; `on_close` runs + /// as it does. A close request while they decide is dropped, since + /// their dialog is already on screen. `handle` gives the workspace + /// the window shows at the time, and a window with none closes at + /// once. + pub(crate) fn connect_window>( + self: &Rc, + window: &W, + handle: impl Fn() -> Option + 'static, + on_close: impl Fn(&W) + 'static, + ) { + let guards = self.clone(); + window.connect_close_request(move |window| { + guards.requests.set(guards.requests.get() + 1); + if let Some(handle) = handle() + && !guards.window_may_close(window.upcast_ref(), &handle) + { + return glib::Propagation::Stop; + } + on_close(window); + guards.settle(CloseDecision::Approved); + glib::Propagation::Proceed + }); + } + + /// Close the window through its prompts and tell `decided` how it + /// ended. A prompt already on screen answers for this request too. + pub(crate) fn request_window_close( + &self, + window: &impl IsA, + decided: impl FnOnce(CloseDecision) + 'static, + ) { + self.waiting.borrow_mut().push(Box::new(decided)); + if self.window.get() == WindowClose::Asking { + return; + } + let before = self.requests.get(); + window.close(); + if self.requests.get() == before { + // libadwaita takes a close request over one of its dialogs as + // dismissing the dialog, and the window stays open. + self.settle(CloseDecision::Cancelled); + } + } + + fn settle(&self, decision: CloseDecision) { + let waiting = self.waiting.take(); + for decided in waiting { + decided(decision); + } + } + + fn window_may_close(self: &Rc, window: >k::Window, handle: &WorkspaceHandle) -> bool { + match self.window.get() { + WindowClose::Asking => return false, + WindowClose::Approved => return true, + WindowClose::Open => {} + } + if handle.close_step(CloseScope::Window, &HashSet::new()) == CloseStep::Approve { + return true; + } + self.window.set(WindowClose::Asking); + let (guards, handle, window) = (self.clone(), handle.clone(), window.clone()); + glib::spawn_future_local(async move { + let decision = guards + .run(window.upcast_ref(), &handle, CloseScope::Window, &mut HashSet::new()) + .await; + match decision { + CloseDecision::Approved => { + guards.window.set(WindowClose::Approved); + window.close(); + } + CloseDecision::Cancelled => { + guards.window.set(WindowClose::Open); + guards.settle(CloseDecision::Cancelled); + } + } + }); + false + } + + pub(crate) fn may_close_tab(&self, handle: &WorkspaceHandle, tab: Uuid) -> bool { + handle.close_step(CloseScope::Tab(tab), &HashSet::new()) == CloseStep::Approve + } + + pub(crate) fn ask_close_tab( + self: &Rc, + parent: >k::Widget, + handle: &WorkspaceHandle, + tab: Uuid, + decided: impl FnOnce(CloseDecision) + 'static, + ) { + let (guards, handle, parent) = (self.clone(), handle.clone(), parent.clone()); + glib::spawn_future_local(async move { + let decision = guards + .run(&parent, &handle, CloseScope::Tab(tab), &mut HashSet::new()) + .await; + decided(decision); + }); + } + + /// A new disconnect or switch asks about every open transaction. + pub(crate) fn begin_teardown(&self) { + self.teardown_acknowledged.borrow_mut().clear(); + } + + /// Whether every tab may go with the connection without asking. + pub(crate) fn may_tear_down(&self, handle: &WorkspaceHandle) -> bool { + handle.close_step(CloseScope::Window, &self.teardown_acknowledged.borrow()) == CloseStep::Approve + } + + /// Ask whether every tab may go with the connection; `decided` hears + /// the answer. + pub(crate) fn ask_tear_down( + self: &Rc, + parent: >k::Widget, + handle: &WorkspaceHandle, + decided: impl FnOnce(CloseDecision) + 'static, + ) { + let (guards, handle, parent) = (self.clone(), handle.clone(), parent.clone()); + glib::spawn_future_local(async move { + let mut acknowledged = guards.teardown_acknowledged.borrow().clone(); + let decision = guards + .run(&parent, &handle, CloseScope::Window, &mut acknowledged) + .await; + if decision == CloseDecision::Approved { + guards.teardown_acknowledged.replace(acknowledged); + } + decided(decision); + }); + } +} + +#[cfg(test)] +mod tests { + use libadwaita as adw; + use libadwaita::prelude::*; + use tablepro_core::Value; + use tablepro_test_support::ConnectionScript; + + use super::*; + use crate::services::change_tracker::RowKey; + use crate::services::tracker_edit::TrackerEdit; + use crate::test_support::{OwnedWindow, drain_main_context, wait_until}; + use crate::workspace::dialog_response::{answer, visible_alert}; + use crate::workspace::test_workspace::{WAIT, Workspace}; + + fn window() -> OwnedWindow { + adw::init().expect("libadwaita initialises under the test backend"); + if let Some(settings) = gtk::Settings::default() { + settings.set_gtk_enable_animations(false); + } + OwnedWindow::presented(adw::Window::new()) + } + + /// A window whose close goes through `guards`, and how many times it + /// closed. + fn guarded_window(guards: &Rc, workspace: &Workspace) -> (OwnedWindow, Rc>) { + let window = window(); + let closes = Rc::new(Cell::new(0)); + let sink = closes.clone(); + let handle = workspace.handle.clone(); + guards.connect_window( + &*window, + move || Some(handle.clone()), + move |_| sink.set(sink.get() + 1), + ); + (window, closes) + } + + /// Wait for an alert with `heading` other than `seen`, and answer it + /// with `response`. Returns the alert answered. + fn answer_next( + window: &adw::Window, + heading: &str, + response: &str, + seen: Option<&adw::AlertDialog>, + ) -> adw::AlertDialog { + let mut shown = None; + wait_until(WAIT, || { + shown = visible_alert(window) + .filter(|alert| alert.heading().as_deref() == Some(heading) && Some(alert) != seen); + shown.is_some() + }) + .unwrap_or_else(|_| panic!("no “{heading}” alert")); + assert!(answer(window, response)); + shown.expect("the alert") + } + + fn edit_again(workspace: &Workspace) { + let _ = workspace.tracker.apply(TrackerEdit::Cell { + row_key: RowKey::from_pk_values(&[Value::Int(2)]).expect("a key"), + col: 1, + original: Value::Int(1), + value: Value::Int(7), + }); + } + + fn decision_sink() -> (Rc>>, impl FnOnce(CloseDecision) + 'static) { + let decision = Rc::new(Cell::new(None)); + let sink = decision.clone(); + (decision, move |decided| sink.set(Some(decided))) + } + + #[gtk4::test] + fn a_clean_window_closes_without_asking() { + let workspace = Workspace::open(ConnectionScript::default()); + workspace.handle.discard(&[workspace.tab], false); + let guards = CloseGuards::new(); + let (window, closes) = guarded_window(&guards, &workspace); + + window.close(); + + assert_eq!(closes.get(), 1); + assert!(visible_alert(&window).is_none()); + } + + #[gtk4::test] + fn a_window_with_no_workspace_closes_without_asking() { + let guards = CloseGuards::new(); + let window = window(); + let closes = Rc::new(Cell::new(0)); + let sink = closes.clone(); + guards.connect_window(&*window, || None, move |_| sink.set(sink.get() + 1)); + + window.close(); + + assert_eq!(closes.get(), 1); + assert!(visible_alert(&window).is_none()); + } + + /// libadwaita takes a close request over a dialog as dismissing the + /// dialog, which keeps the window open. + #[gtk4::test] + fn a_close_request_over_the_prompt_keeps_the_window() { + let workspace = Workspace::open(ConnectionScript::default()); + let guards = CloseGuards::new(); + let (window, closes) = guarded_window(&guards, &workspace); + + window.close(); + wait_until(WAIT, || visible_alert(&window).is_some()).expect("the prompt"); + let first = visible_alert(&window); + window.close(); + wait_until(WAIT, || guards.window.get() == WindowClose::Open).expect("the prompt to be dismissed"); + + assert_eq!(closes.get(), 0, "the window closed before the prompt approved"); + assert!(window.is_visible()); + window.close(); + answer_next(&window, "Save Changes?", "discard", first.as_ref()); + wait_until(WAIT, || closes.get() == 1).expect("the approved close to go through"); + drain_main_context(); + assert_eq!(closes.get(), 1); + } + + #[gtk4::test] + fn a_kept_window_asks_again_on_the_next_close() { + let workspace = Workspace::open(ConnectionScript::default()); + let guards = CloseGuards::new(); + let (window, closes) = guarded_window(&guards, &workspace); + + window.close(); + let kept = answer_next(&window, "Save Changes?", "cancel", None); + wait_until(WAIT, || guards.window.get() == WindowClose::Open).expect("the close to be dropped"); + assert!(window.is_visible() && closes.get() == 0); + window.close(); + answer_next(&window, "Save Changes?", "discard", Some(&kept)); + + wait_until(WAIT, || closes.get() == 1).expect("the approved close to go through"); + } + + #[gtk4::test] + fn a_requested_close_of_a_clean_window_is_approved_at_once() { + let workspace = Workspace::open(ConnectionScript::default()); + workspace.handle.discard(&[workspace.tab], false); + let guards = CloseGuards::new(); + let (window, closes) = guarded_window(&guards, &workspace); + let (decision, sink) = decision_sink(); + + guards.request_window_close(&*window, sink); + + assert_eq!(decision.get(), Some(CloseDecision::Approved)); + assert_eq!(closes.get(), 1); + } + + #[gtk4::test] + fn a_requested_close_hears_the_prompts_answer() { + let workspace = Workspace::open(ConnectionScript::default()); + let guards = CloseGuards::new(); + let (window, closes) = guarded_window(&guards, &workspace); + + let (kept, sink) = decision_sink(); + guards.request_window_close(&*window, sink); + let first = answer_next(&window, "Save Changes?", "cancel", None); + wait_until(WAIT, || kept.get().is_some()).expect("a decision"); + assert_eq!(kept.get(), Some(CloseDecision::Cancelled)); + assert!(window.is_visible() && closes.get() == 0); + + let (approved, sink) = decision_sink(); + guards.request_window_close(&*window, sink); + answer_next(&window, "Save Changes?", "discard", Some(&first)); + wait_until(WAIT, || approved.get().is_some()).expect("a decision"); + assert_eq!(approved.get(), Some(CloseDecision::Approved)); + assert_eq!(closes.get(), 1); + } + + /// A request that arrives while the user's own close is asking is + /// answered by that prompt, rather than dismissing it. + #[gtk4::test] + fn a_request_during_the_prompt_waits_for_its_answer() { + let workspace = Workspace::open(ConnectionScript::default()); + let guards = CloseGuards::new(); + let (window, closes) = guarded_window(&guards, &workspace); + window.close(); + wait_until(WAIT, || visible_alert(&window).is_some()).expect("the prompt"); + let shown = visible_alert(&window); + + let (decision, sink) = decision_sink(); + guards.request_window_close(&*window, sink); + drain_main_context(); + assert_eq!(visible_alert(&window), shown, "the request dismissed the prompt"); + assert_eq!(decision.get(), None); + answer_next(&window, "Save Changes?", "discard", None); + + wait_until(WAIT, || decision.get().is_some()).expect("a decision"); + assert_eq!(decision.get(), Some(CloseDecision::Approved)); + assert_eq!(closes.get(), 1); + } + + #[gtk4::test] + fn a_close_a_dialog_takes_is_reported_cancelled() { + let workspace = Workspace::open(ConnectionScript::default()); + workspace.handle.discard(&[workspace.tab], false); + let guards = CloseGuards::new(); + let (window, closes) = guarded_window(&guards, &workspace); + let other = adw::AlertDialog::new(Some("Elsewhere"), None); + other.add_response("close", "Close"); + other.present(Some(&*window)); + wait_until(WAIT, || visible_alert(&window).is_some()).expect("the dialog"); + + let (decision, sink) = decision_sink(); + guards.request_window_close(&*window, sink); + + assert_eq!(decision.get(), Some(CloseDecision::Cancelled)); + assert_eq!(closes.get(), 0); + assert!(window.is_visible()); + } + + #[gtk4::test] + fn a_teardown_proceeds_only_when_approved() { + let workspace = Workspace::open(ConnectionScript::default()); + let guards = CloseGuards::new(); + let window = window(); + let parent = window.clone().upcast::(); + assert!(!guards.may_tear_down(&workspace.handle)); + + let (kept, sink) = decision_sink(); + guards.ask_tear_down(&parent, &workspace.handle, sink); + let first = answer_next(&window, "Save Changes?", "cancel", None); + wait_until(WAIT, || kept.get().is_some()).expect("a decision"); + assert_eq!(kept.get(), Some(CloseDecision::Cancelled)); + assert!(!guards.may_tear_down(&workspace.handle)); + + let (approved, sink) = decision_sink(); + guards.ask_tear_down(&parent, &workspace.handle, sink); + answer_next(&window, "Save Changes?", "discard", Some(&first)); + wait_until(WAIT, || approved.get().is_some()).expect("a decision"); + assert_eq!(approved.get(), Some(CloseDecision::Approved)); + assert!(guards.may_tear_down(&workspace.handle)); + } + + /// A switch asks once before it connects and again before the new + /// connection replaces the tabs, about what changed in between. + #[gtk4::test] + fn a_switch_asks_again_only_about_what_changed_while_connecting() { + let workspace = Workspace::open(ConnectionScript::default()); + workspace.open_transaction_tab(); + let guards = CloseGuards::new(); + let window = window(); + let parent = window.clone().upcast::(); + + guards.begin_teardown(); + let (approved, sink) = decision_sink(); + guards.ask_tear_down(&parent, &workspace.handle, sink); + answer_next(&window, "Close With Open Transactions?", "close", None); + let first = answer_next(&window, "Save Changes?", "discard", None); + wait_until(WAIT, || approved.get().is_some()).expect("a decision"); + assert!(guards.may_tear_down(&workspace.handle)); + + edit_again(&workspace); + assert!( + !guards.may_tear_down(&workspace.handle), + "an edit made while connecting went unasked" + ); + let (again, sink) = decision_sink(); + guards.ask_tear_down(&parent, &workspace.handle, sink); + answer_next(&window, "Save Changes?", "discard", Some(&first)); + wait_until(WAIT, || again.get().is_some()).expect("a decision"); + assert_eq!(again.get(), Some(CloseDecision::Approved)); + + guards.begin_teardown(); + assert!( + !guards.may_tear_down(&workspace.handle), + "a new switch skipped the transaction" + ); + } +} diff --git a/linux/crates/app/src/workspace/close_reason.rs b/linux/crates/app/src/workspace/close_reason.rs new file mode 100644 index 0000000000..77803a5d4d --- /dev/null +++ b/linux/crates/app/src/workspace/close_reason.rs @@ -0,0 +1,11 @@ +use futures::channel::oneshot; + +use super::CloseDecision; + +/// Why a window is asked to close, and who hears how it ended. +#[derive(Debug)] +pub enum CloseReason { + /// The application is quitting. A dropped sender means the window + /// went without deciding. + Quit(oneshot::Sender), +} diff --git a/linux/crates/app/src/workspace/close_scope.rs b/linux/crates/app/src/workspace/close_scope.rs new file mode 100644 index 0000000000..4185750a53 --- /dev/null +++ b/linux/crates/app/src/workspace/close_scope.rs @@ -0,0 +1,19 @@ +use uuid::Uuid; + +/// What is closing. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CloseScope { + /// Every tab the window holds: the window closing, or the workspace + /// going with a disconnect or a switch to another connection. + Window, + Tab(Uuid), +} + +impl CloseScope { + pub fn covers(self, tab: Uuid) -> bool { + match self { + Self::Window => true, + Self::Tab(id) => id == tab, + } + } +} diff --git a/linux/crates/app/src/workspace/close_step.rs b/linux/crates/app/src/workspace/close_step.rs new file mode 100644 index 0000000000..1d5bd2b4c4 --- /dev/null +++ b/linux/crates/app/src/workspace/close_step.rs @@ -0,0 +1,127 @@ +use super::{CloseBlockPrompt, CloseScope, DirtyKind, DirtyTab}; + +/// What a close has to settle next, in the order it settles them: work +/// that is not a tab's is asked about before anything is discarded, a +/// save still running decides what the others are, and an outcome +/// nobody knows comes before anything is saved again. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum CloseStep { + /// The workspace's `blocker`th close blocker has work running. + StopBlocker { + blocker: usize, + prompt: CloseBlockPrompt, + }, + WaitForSaves, + ResolveUnknownOutcome(Vec), + ConfirmOpenTransactions(Vec), + AskSaveChanges(Vec), + Approve, +} + +/// The step a close in `scope` takes, given how many of its saves are +/// running and what its tabs would lose. +pub fn next_close_step(scope: CloseScope, saves_in_flight: usize, dirty: &[DirtyTab]) -> CloseStep { + if saves_in_flight > 0 { + return CloseStep::WaitForSaves; + } + let in_scope: Vec<&DirtyTab> = dirty.iter().filter(|tab| scope.covers(tab.id)).collect(); + let of_kind = |wanted: fn(&DirtyKind) -> bool| -> Vec { + in_scope + .iter() + .filter(|tab| wanted(&tab.kind)) + .map(|tab| (*tab).clone()) + .collect() + }; + let unknown = of_kind(|kind| matches!(kind, DirtyKind::OutcomeUnknown(_))); + if !unknown.is_empty() { + return CloseStep::ResolveUnknownOutcome(unknown); + } + let transactions = of_kind(|kind| matches!(kind, DirtyKind::OpenTransaction)); + if !transactions.is_empty() { + return CloseStep::ConfirmOpenTransactions(transactions); + } + let tab_close = matches!(scope, CloseScope::Tab(_)); + let unsaved: Vec = in_scope + .iter() + .filter(|tab| match tab.kind { + DirtyKind::Unsaved => true, + DirtyKind::UnsavedFile => tab_close, + _ => false, + }) + .map(|tab| (*tab).clone()) + .collect(); + if !unsaved.is_empty() { + return CloseStep::AskSaveChanges(unsaved); + } + CloseStep::Approve +} + +#[cfg(test)] +mod tests { + use uuid::Uuid; + + use super::*; + use crate::workspace::UnknownCause; + + fn tab(kind: DirtyKind) -> DirtyTab { + DirtyTab { + id: Uuid::new_v4(), + title: "users".to_owned(), + kind, + } + } + + #[test] + fn next_close_step_table() { + let unsaved = tab(DirtyKind::Unsaved); + let unknown = tab(DirtyKind::OutcomeUnknown(UnknownCause::Interrupted)); + let transaction = tab(DirtyKind::OpenTransaction); + let all = [unsaved.clone(), transaction.clone(), unknown.clone()]; + let window = CloseScope::Window; + + assert_eq!(next_close_step(window, 1, &all), CloseStep::WaitForSaves); + assert_eq!(next_close_step(window, 1, &[]), CloseStep::WaitForSaves); + assert_eq!( + next_close_step(window, 0, &all), + CloseStep::ResolveUnknownOutcome(vec![unknown.clone()]) + ); + assert_eq!( + next_close_step(window, 0, &[unsaved.clone(), transaction.clone()]), + CloseStep::ConfirmOpenTransactions(vec![transaction.clone()]) + ); + assert_eq!( + next_close_step(window, 0, std::slice::from_ref(&unsaved)), + CloseStep::AskSaveChanges(vec![unsaved.clone()]) + ); + assert_eq!(next_close_step(window, 0, &[]), CloseStep::Approve); + + assert_eq!( + next_close_step(CloseScope::Tab(unsaved.id), 0, &all), + CloseStep::AskSaveChanges(vec![unsaved.clone()]), + "another tab's unknown outcome held up this tab's close" + ); + assert_eq!( + next_close_step(CloseScope::Tab(transaction.id), 0, &all), + CloseStep::ConfirmOpenTransactions(vec![transaction]) + ); + assert_eq!( + next_close_step(CloseScope::Tab(Uuid::new_v4()), 0, &all), + CloseStep::Approve + ); + } + + #[test] + fn an_unsaved_file_is_asked_about_only_when_its_tab_closes() { + let file = tab(DirtyKind::UnsavedFile); + + assert_eq!( + next_close_step(CloseScope::Tab(file.id), 0, std::slice::from_ref(&file)), + CloseStep::AskSaveChanges(vec![file.clone()]) + ); + assert_eq!( + next_close_step(CloseScope::Window, 0, std::slice::from_ref(&file)), + CloseStep::Approve, + "the window keeps the text in its drafts, so it has nothing to ask" + ); + } +} diff --git a/linux/crates/app/src/workspace/completed_work.rs b/linux/crates/app/src/workspace/completed_work.rs new file mode 100644 index 0000000000..a4b274e10a --- /dev/null +++ b/linux/crates/app/src/workspace/completed_work.rs @@ -0,0 +1,11 @@ +use uuid::Uuid; + +use super::{WorkKind, WorkOutcome}; + +/// A run, save or export that ended in one of the workspace's tabs. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct CompletedWork { + pub tab: Uuid, + pub kind: WorkKind, + pub outcome: WorkOutcome, +} diff --git a/linux/crates/app/src/workspace/dialog_response.rs b/linux/crates/app/src/workspace/dialog_response.rs new file mode 100644 index 0000000000..ea62efdffd --- /dev/null +++ b/linux/crates/app/src/workspace/dialog_response.rs @@ -0,0 +1,109 @@ +use std::cell::RefCell; +use std::rc::Rc; + +use futures::channel::oneshot; +use gtk4 as gtk; +use gtk4::glib; +use libadwaita as adw; +use libadwaita::prelude::*; + +/// Present `dialog` on `parent` and wait for the response it closes +/// with. A dialog closed without one, torn down with its window or +/// forced shut, answers with its close response. +/// +/// `choose_future` is not used: its callback aborts the process when +/// the dialog goes away without a response. +pub(crate) async fn respond(dialog: &adw::AlertDialog, parent: >k::Widget) -> String { + let fallback = dialog.close_response().to_string(); + let (sender, receiver) = oneshot::channel(); + let sender = Rc::new(RefCell::new(Some(sender))); + let on_response = sender.clone(); + dialog.connect_response(None, move |_, response| { + if let Some(sender) = on_response.borrow_mut().take() { + let _ = sender.send(response.to_owned()); + } + }); + let closed_fallback = fallback.clone(); + dialog.connect_closed(move |_| { + // AdwAlertDialog emits `closed` before the response a click + // chose, so the close response waits until that has had its turn. + let sender = sender.clone(); + let fallback = closed_fallback.clone(); + glib::idle_add_local_once(move || { + if let Some(sender) = sender.borrow_mut().take() { + let _ = sender.send(fallback); + } + }); + }); + dialog.present(Some(parent)); + receiver.await.unwrap_or(fallback) +} + +/// Answer the alert `window` shows with `response` by clicking its +/// button, so the signals come in the order a user's click sends them. +#[cfg(test)] +pub(crate) fn answer(window: &impl AsRef, response: &str) -> bool { + visible_alert(window).is_some_and(|dialog| crate::test_support::click_response(&dialog, response)) +} + +/// The alert `window` shows, if any. `window` is an `AdwWindow` or an +/// `AdwApplicationWindow`, the two that host dialogs. +#[cfg(test)] +pub(crate) fn visible_alert(window: &impl AsRef) -> Option { + let widget = window.as_ref(); + let dialog = match widget.downcast_ref::() { + Some(window) => window.visible_dialog(), + None => widget.downcast_ref::()?.visible_dialog(), + }; + dialog.and_downcast::() +} + +#[cfg(test)] +mod tests { + use std::time::Duration; + + use super::*; + use crate::test_support::{OwnedWindow, wait_until}; + + const WAIT: Duration = Duration::from_secs(10); + + fn alert(window: &adw::Window) -> (adw::AlertDialog, Rc>>) { + let dialog = adw::AlertDialog::new(Some("Saving Changes"), None); + dialog.add_response("keep", "_Keep Open"); + dialog.add_response("stop", "_Stop Saving"); + dialog.set_close_response("keep"); + let answer = Rc::new(RefCell::new(None)); + let sink = answer.clone(); + let (shown, parent) = (dialog.clone(), window.clone().upcast::()); + glib::spawn_future_local(async move { + sink.replace(Some(respond(&shown, &parent).await)); + }); + wait_until(WAIT, || visible_alert(window).is_some()).expect("the alert"); + (dialog, answer) + } + + #[gtk4::test] + fn a_clicked_response_answers_and_closes_the_alert() { + adw::init().expect("libadwaita initialises under the test backend"); + let window = OwnedWindow::presented(adw::Window::new()); + let (_dialog, answered) = alert(&window); + + assert!(answer(&window, "stop")); + + wait_until(WAIT, || answered.borrow().is_some()).expect("an answer"); + assert_eq!(answered.borrow().as_deref(), Some("stop")); + wait_until(WAIT, || visible_alert(&window).is_none()).expect("the alert to close itself"); + } + + #[gtk4::test] + fn an_alert_forced_shut_answers_with_its_close_response() { + adw::init().expect("libadwaita initialises under the test backend"); + let window = OwnedWindow::presented(adw::Window::new()); + let (dialog, answer) = alert(&window); + + dialog.force_close(); + + wait_until(WAIT, || answer.borrow().is_some()).expect("an answer"); + assert_eq!(answer.borrow().as_deref(), Some("keep")); + } +} diff --git a/linux/crates/app/src/workspace/dirty_kind.rs b/linux/crates/app/src/workspace/dirty_kind.rs new file mode 100644 index 0000000000..de66ac9594 --- /dev/null +++ b/linux/crates/app/src/workspace/dirty_kind.rs @@ -0,0 +1,24 @@ +use super::UnknownCause; + +/// What closing a tab would lose. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum DirtyKind { + Unsaved, + /// A save that may have landed, which the user has to look at. + OutcomeUnknown(UnknownCause), + /// An editor tab's transaction, which the server rolls back when its + /// connection ends. + OpenTransaction, + /// An editor tab's text differs from its file. Closing the window + /// keeps the text with the tab's other drafts, so only closing the + /// tab loses it. + UnsavedFile, +} + +impl DirtyKind { + /// Whether closing the whole window loses it too, which a logout + /// would. + pub fn lost_with_the_window(&self) -> bool { + !matches!(self, Self::UnsavedFile) + } +} diff --git a/linux/crates/app/src/workspace/dirty_tab.rs b/linux/crates/app/src/workspace/dirty_tab.rs new file mode 100644 index 0000000000..42b03b10a3 --- /dev/null +++ b/linux/crates/app/src/workspace/dirty_tab.rs @@ -0,0 +1,23 @@ +use uuid::Uuid; + +use super::DirtyKind; + +/// A tab closing would lose something in, named the way its tab is. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DirtyTab { + pub id: Uuid, + pub title: String, + pub kind: DirtyKind, +} + +impl DirtyTab { + /// The tabs' names for a sentence, each quoted. + pub fn names(tabs: &[DirtyTab]) -> String { + let quoted: Vec = tabs.iter().map(|tab| Self::quote(&tab.title)).collect(); + quoted.join(", ") + } + + pub fn quote(title: &str) -> String { + crate::i18n::gettext_f("“{name}”", &[("name", title)]) + } +} diff --git a/linux/crates/app/src/workspace/draft_writer.rs b/linux/crates/app/src/workspace/draft_writer.rs new file mode 100644 index 0000000000..cd4c84a241 --- /dev/null +++ b/linux/crates/app/src/workspace/draft_writer.rs @@ -0,0 +1,407 @@ +use std::cell::RefCell; +use std::collections::{HashMap, HashSet}; +use std::rc::Rc; +use std::sync::{Arc, Mutex, PoisonError}; +use std::time::Duration; + +use gtk4::glib; +use gtk4::prelude::*; +use tablepro_session::runtime::{LatestWinsWriter, Tasks}; +use tablepro_storage::{DraftId, DraftScope, DraftStore}; +use tokio::sync::watch; + +use super::PersistDebouncer; + +/// How long after the last keystroke the editor text is written. +/// +/// Long enough that typing costs nothing, short enough that a crash +/// loses at most half a second of work. +const WRITE_DELAY: Duration = Duration::from_millis(500); + +/// Keeps every open editor tab's text on disk, behind the user. +/// +/// The buffer is read once per write rather than once per keystroke, +/// so a 5 MiB script costs nothing to type in, and the write itself +/// runs on a blocking thread. +pub struct DraftWriter { + store: DraftStore, + tasks: Tasks, + slots: RefCell>>, + failures: Arc>>, + /// Whether the last write of a draft failed. A run of failures is + /// one change of it, so the user is told once. + failing: Arc>, +} + +struct Slot { + buffer: glib::WeakRef, + debouncer: PersistDebouncer, + writer: LatestWinsWriter, +} + +impl DraftWriter { + pub fn new(store: DraftStore, tasks: Tasks) -> Rc { + Rc::new(Self { + store, + tasks, + slots: RefCell::new(HashMap::new()), + failures: Arc::new(Mutex::new(Vec::new())), + failing: Arc::new(watch::channel(false).0), + }) + } + + /// Whether drafts are failing to land, now and after every change. + pub fn failing(&self) -> watch::Receiver { + self.failing.subscribe() + } + + /// Note that this tab's text changed. The first call for an id also + /// starts tracking the buffer. + pub fn schedule(self: &Rc, scope: &DraftScope, id: DraftId, buffer: >k4::TextBuffer) { + let slot = self.slot(scope, id, buffer); + let writer = self.clone(); + slot.debouncer.request(move || writer.write_now(id)); + } + + /// Write every open tab's pending text and resolve once all of it + /// is on disk, reporting what could not be written. + pub fn flush_all(&self) -> impl Future> + Send + use<> { + let ids: Vec = self.slots.borrow().keys().copied().collect(); + for id in &ids { + self.write_now(*id); + } + let slots: Vec> = { + let open = self.slots.borrow(); + ids.iter().filter_map(|id| open.get(id).cloned()).collect() + }; + let pending: Vec<_> = slots.iter().map(|slot| slot.writer.flush()).collect(); + let failures = self.failures.clone(); + async move { + for write in pending { + write.await; + } + std::mem::take(&mut *failures.lock().unwrap_or_else(PoisonError::into_inner)) + } + } + + /// Stop tracking a tab and delete its draft, for a tab the user + /// closed. + pub fn discard(&self, scope: &DraftScope, id: DraftId) { + self.slots.borrow_mut().remove(&id); + let store = self.store.clone(); + let scope = scope.clone(); + let failures = self.failures.clone(); + self.tasks.spawn_blocking_task(move || { + if let Err(error) = store.delete_blocking(&scope, id) { + record(&failures, &error); + } + }); + } + + /// Delete every draft in the scope that no open tab references. + pub fn retain(&self, scope: &DraftScope, keep: HashSet) { + let store = self.store.clone(); + let scope = scope.clone(); + let failures = self.failures.clone(); + self.tasks + .spawn_blocking_task(move || match store.retain_blocking(&scope, &keep) { + Ok(0) => {} + Ok(removed) => tracing::info!(removed, scope = %scope, "removed drafts no tab references"), + Err(error) => record(&failures, &error), + }); + } + + /// Stop tracking the tabs whose buffers are gone. Their drafts stay + /// on disk for the tab to be restored from. Call it once what + /// `flush_all` started has landed, since a dropped slot stops its + /// writer. + pub fn release_closed(&self) { + self.slots + .borrow_mut() + .retain(|_, slot| slot.buffer.upgrade().is_some()); + } + + pub fn store(&self) -> &DraftStore { + &self.store + } + + fn slot(&self, scope: &DraftScope, id: DraftId, buffer: >k4::TextBuffer) -> Rc { + let mut slots = self.slots.borrow_mut(); + if let Some(slot) = slots.get(&id) { + // A tab restored after a reconnect keeps its draft id but has + // a new buffer, and the old one is gone. + if slot.buffer.upgrade().as_ref() != Some(buffer) { + slot.buffer.set(Some(buffer)); + } + return slot.clone(); + } + let store = self.store.clone(); + let scope_for_writes = scope.clone(); + let (failures, failing) = (self.failures.clone(), self.failing.clone()); + let writer = LatestWinsWriter::spawn(&self.tasks, move |text: &String| { + let written = store.write_blocking(&scope_for_writes, id, text); + failing.send_if_modified(|failing| std::mem::replace(failing, written.is_err()) != written.is_err()); + if let Err(error) = written { + record(&failures, &error); + } + }); + let slot = Rc::new(Slot { + buffer: { + let weak = glib::WeakRef::new(); + weak.set(Some(buffer)); + weak + }, + debouncer: PersistDebouncer::new(WRITE_DELAY), + writer, + }); + slots.insert(id, slot.clone()); + slot + } + + /// Read the buffer once and hand the text to the writer. A buffer + /// whose tab has gone leaves the draft as it was, so closing a tab + /// does not blank what it last held. + fn write_now(&self, id: DraftId) { + let Some(slot) = self.slots.borrow().get(&id).cloned() else { + return; + }; + slot.debouncer.cancel(); + let Some(buffer) = slot.buffer.upgrade() else { + return; + }; + let text = buffer.text(&buffer.start_iter(), &buffer.end_iter(), true).to_string(); + slot.writer.put(text); + } +} + +fn record(failures: &Arc>>, error: &tablepro_storage::StorageError) { + tracing::warn!(%error, "could not save the editor draft"); + failures + .lock() + .unwrap_or_else(PoisonError::into_inner) + .push(error.to_string()); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_support::{test_runtime, wait_until}; + + const WAIT: Duration = Duration::from_secs(5); + + struct Fixture { + _runtime: crate::runtime::AppRuntime, + _root: tempfile::TempDir, + writer: Rc, + scope: DraftScope, + } + + impl Fixture { + fn new() -> Self { + let runtime = test_runtime(); + let root = tempfile::tempdir().expect("tempdir"); + let store = DraftStore::new(root.path().join("drafts")); + let writer = DraftWriter::new(store, runtime.tasks()); + Self { + _runtime: runtime, + _root: root, + writer, + scope: DraftScope::new("workspace_state").expect("a scope"), + } + } + + fn read(&self, id: DraftId) -> Option { + self.writer + .store() + .read_blocking(&self.scope, id) + .expect("read the draft") + } + + /// Write everything pending and drive the GTK loop until it has + /// landed. + fn flush_all(&self) -> Vec { + let flushing = self.writer.flush_all(); + let failures = Rc::new(RefCell::new(None)); + let failures_for_task = failures.clone(); + glib::spawn_future_local(async move { + *failures_for_task.borrow_mut() = Some(flushing.await); + }); + wait_until(WAIT, || failures.borrow().is_some()).expect("flush never resolved"); + let taken = failures.borrow_mut().take(); + taken.unwrap_or_default() + } + + /// Drive the GTK loop until the draft file says what the buffer + /// does, or give up. + fn wait_for(&self, id: DraftId, expected: &str) { + wait_until(WAIT, || self.read(id).as_deref() == Some(expected)) + .unwrap_or_else(|_| panic!("the draft never became {expected:?}, it is {:?}", self.read(id))); + } + } + + #[gtk4::test] + fn ten_inserts_one_write_with_final_text() { + let fixture = Fixture::new(); + let id = DraftId::new(); + let buffer = gtk4::TextBuffer::new(None); + + for step in 0..10 { + buffer.set_text(&format!("SELECT {step}")); + fixture.writer.schedule(&fixture.scope, id, &buffer); + } + fixture.wait_for(id, "SELECT 9"); + } + + #[gtk4::test] + fn flush_writes_pending_immediately() { + let fixture = Fixture::new(); + let id = DraftId::new(); + let buffer = gtk4::TextBuffer::new(None); + buffer.set_text("SELECT 1"); + fixture.writer.schedule(&fixture.scope, id, &buffer); + assert_eq!(fixture.read(id), None, "the write happened before the delay"); + + let failures = fixture.flush_all(); + + assert!(failures.is_empty(), "{failures:?}"); + assert_eq!(fixture.read(id).as_deref(), Some("SELECT 1")); + } + + #[gtk4::test] + fn a_run_of_failed_writes_is_one_change_and_a_write_that_lands_ends_it() { + let runtime = test_runtime(); + let root = tempfile::tempdir().expect("tempdir"); + // A file where the drafts folder goes, so no draft can be written. + let blocked = root.path().join("drafts"); + std::fs::write(&blocked, b"not a folder").expect("seed"); + let writer = DraftWriter::new(DraftStore::new(blocked.clone()), runtime.tasks()); + let scope = DraftScope::new("workspace_state").expect("a scope"); + let mut failing = writer.failing(); + assert!(!*failing.borrow_and_update()); + let buffer = gtk4::TextBuffer::new(None); + let write = |text: &str| { + buffer.set_text(text); + writer.schedule(&scope, DraftId::new(), &buffer); + let flushing = writer.flush_all(); + crate::test_support::finish_on_main(WAIT, flushing).expect("the flush to end") + }; + + assert!(!write("SELECT 1").is_empty(), "the write landed"); + assert!(failing.has_changed().expect("the writer is there")); + assert!(*failing.borrow_and_update()); + assert!(!write("SELECT 2").is_empty()); + assert!( + !failing.has_changed().expect("the writer is there"), + "a second failure in the run changed it again" + ); + + std::fs::remove_file(&blocked).expect("unblock"); + assert!(write("SELECT 3").is_empty(), "the write failed"); + assert!(!*failing.borrow_and_update()); + } + + #[gtk4::test] + fn discard_deletes_file() { + let fixture = Fixture::new(); + let id = DraftId::new(); + let buffer = gtk4::TextBuffer::new(None); + buffer.set_text("SELECT 1"); + fixture.writer.schedule(&fixture.scope, id, &buffer); + fixture.wait_for(id, "SELECT 1"); + + fixture.writer.discard(&fixture.scope, id); + + wait_until(WAIT, || fixture.read(id).is_none()).expect("the draft file stayed"); + } + + #[gtk4::test] + fn restore_300_kib_script_is_byte_identical() { + let fixture = Fixture::new(); + let id = DraftId::new(); + // Past the 256 KiB the workspace file used to truncate at, with + // multibyte characters across the old boundary. + let script = "SELECT 'é☃𝄞', 1;\n".repeat(300 * 1024 / 20); + assert!(script.len() > 256 * 1024); + let buffer = gtk4::TextBuffer::new(None); + buffer.set_text(&script); + + fixture.writer.schedule(&fixture.scope, id, &buffer); + + fixture.wait_for(id, &script); + } + + #[gtk4::test] + fn a_closed_buffer_leaves_the_draft_as_it_was() { + let fixture = Fixture::new(); + let id = DraftId::new(); + { + let buffer = gtk4::TextBuffer::new(None); + buffer.set_text("SELECT 1"); + fixture.writer.schedule(&fixture.scope, id, &buffer); + fixture.wait_for(id, "SELECT 1"); + } + + // The tab is gone, so there is nothing to read the text from. + fixture.flush_all(); + + assert_eq!(fixture.read(id).as_deref(), Some("SELECT 1")); + } + + #[gtk4::test] + fn a_restored_tab_writes_through_its_new_buffer() { + let fixture = Fixture::new(); + let id = DraftId::new(); + { + let before = gtk4::TextBuffer::new(None); + before.set_text("SELECT 1"); + fixture.writer.schedule(&fixture.scope, id, &before); + fixture.wait_for(id, "SELECT 1"); + } + + let after = gtk4::TextBuffer::new(None); + after.set_text("SELECT 2"); + fixture.writer.schedule(&fixture.scope, id, &after); + + fixture.wait_for(id, "SELECT 2"); + } + + #[gtk4::test] + fn release_closed_keeps_open_tabs_and_their_drafts() { + let fixture = Fixture::new(); + let (closed, open) = (DraftId::new(), DraftId::new()); + let kept = gtk4::TextBuffer::new(None); + kept.set_text("SELECT 2"); + { + let gone = gtk4::TextBuffer::new(None); + gone.set_text("SELECT 1"); + fixture.writer.schedule(&fixture.scope, closed, &gone); + fixture.writer.schedule(&fixture.scope, open, &kept); + fixture.flush_all(); + } + + fixture.writer.release_closed(); + + let tracked: Vec = fixture.writer.slots.borrow().keys().copied().collect(); + assert_eq!(tracked, [open]); + assert_eq!(fixture.read(closed).as_deref(), Some("SELECT 1")); + assert_eq!(fixture.read(open).as_deref(), Some("SELECT 2")); + } + + #[gtk4::test] + fn retain_removes_the_drafts_no_tab_references() { + let fixture = Fixture::new(); + let kept = DraftId::new(); + let dropped = DraftId::new(); + for id in [kept, dropped] { + let buffer = gtk4::TextBuffer::new(None); + buffer.set_text("SELECT 1"); + fixture.writer.schedule(&fixture.scope, id, &buffer); + fixture.wait_for(id, "SELECT 1"); + } + + fixture.writer.retain(&fixture.scope, HashSet::from([kept])); + + wait_until(WAIT, || fixture.read(dropped).is_none()).expect("the unreferenced draft stayed"); + assert_eq!(fixture.read(kept).as_deref(), Some("SELECT 1")); + } +} diff --git a/linux/crates/app/src/workspace/editor_file_record.rs b/linux/crates/app/src/workspace/editor_file_record.rs new file mode 100644 index 0000000000..eb4771f95f --- /dev/null +++ b/linux/crates/app/src/workspace/editor_file_record.rs @@ -0,0 +1,11 @@ +use serde::{Deserialize, Serialize}; + +/// The `.sql` file an editor tab is saved to, kept with the tab. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct EditorFileRecord { + /// A URI rather than a path, so a file the document portal handed + /// over reads back the same way as a local one. + pub uri: String, + /// The tab's text, which its draft keeps, differs from the file's. + pub modified: bool, +} diff --git a/linux/crates/app/src/workspace/engine_profile.rs b/linux/crates/app/src/workspace/engine_profile.rs new file mode 100644 index 0000000000..a6c2feb78c --- /dev/null +++ b/linux/crates/app/src/workspace/engine_profile.rs @@ -0,0 +1,53 @@ +use tablepro_core::{DriverRegistry, WriteAtomicity}; + +/// What a structure review says about the engine the workspace's +/// session runs on. A workspace never changes session, so this is read +/// once. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct EngineProfile { + pub name: String, + pub ddl_writes: WriteAtomicity, +} + +impl EngineProfile { + /// A driver the registry does not know is taken to apply each + /// statement on its own, so every save of more than one statement + /// asks first. + pub fn of(drivers: &DriverRegistry, driver_id: &str) -> Self { + match drivers.get(driver_id) { + Some(driver) => Self { + name: driver.display_name().to_owned(), + ddl_writes: driver.capabilities().ddl_writes, + }, + None => Self { + name: driver_id.to_owned(), + ddl_writes: WriteAtomicity::StatementByStatement, + }, + } + } +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use super::*; + + #[test] + fn an_engine_comes_from_its_driver_and_an_unknown_one_asks() { + let mut drivers = DriverRegistry::new(); + drivers.register(Arc::new(tablepro_driver_mysql::MysqlDriver)); + + assert_eq!( + EngineProfile::of(&drivers, "mysql"), + EngineProfile { + name: "MySQL".to_owned(), + ddl_writes: WriteAtomicity::StatementByStatement, + } + ); + assert_eq!( + EngineProfile::of(&drivers, "gone").ddl_writes, + WriteAtomicity::StatementByStatement + ); + } +} diff --git a/linux/crates/app/src/workspace/inhibit_reason.rs b/linux/crates/app/src/workspace/inhibit_reason.rs new file mode 100644 index 0000000000..108cf27cab --- /dev/null +++ b/linux/crates/app/src/workspace/inhibit_reason.rs @@ -0,0 +1,42 @@ +use gtk4 as gtk; + +/// Why the session should not end under the window. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum InhibitReason { + UnsavedChanges, + /// A write is running. Suspending would cut it off mid-commit. + SavingChanges, + /// An export is writing a file, which a logout or suspend would + /// leave unfinished. + Exporting, +} + +impl InhibitReason { + /// What the window holds now, or `None` when a logout loses + /// nothing. + pub fn for_state(dirty: bool, saving: bool) -> Option { + match (dirty, saving) { + (_, true) => Some(Self::SavingChanges), + (true, false) => Some(Self::UnsavedChanges), + (false, false) => None, + } + } + + pub fn flags(self) -> gtk::ApplicationInhibitFlags { + match self { + Self::UnsavedChanges => gtk::ApplicationInhibitFlags::LOGOUT, + Self::SavingChanges | Self::Exporting => { + gtk::ApplicationInhibitFlags::LOGOUT | gtk::ApplicationInhibitFlags::SUSPEND + } + } + } + + /// Shown by the session when it asks the user to wait. + pub fn text(self) -> String { + match self { + Self::UnsavedChanges => crate::i18n::gettext("There are unsaved changes"), + Self::SavingChanges => crate::i18n::gettext("Saving changes"), + Self::Exporting => crate::i18n::gettext("Exporting data"), + } + } +} diff --git a/linux/crates/app/src/workspace/legacy_tab_file.rs b/linux/crates/app/src/workspace/legacy_tab_file.rs new file mode 100644 index 0000000000..5bafc4a8f7 --- /dev/null +++ b/linux/crates/app/src/workspace/legacy_tab_file.rs @@ -0,0 +1,55 @@ +use std::io; +use std::path::Path; + +/// The one tab file builds before per-session workspaces kept in the +/// state directory. Editor tabs wrote their SQL into it, and nothing +/// reads it any more, so it would keep that SQL on disk for good. +const LEGACY_TAB_FILE: &str = "workspace_state.json"; + +/// Removes the tab file an older build left in `state_dir`, and nothing +/// else there. Nothing writes the file any more, so the first start +/// after the upgrade is the one that finds it. +pub(crate) fn remove_legacy_tab_file(state_dir: &Path) -> io::Result { + match std::fs::remove_file(state_dir.join(LEGACY_TAB_FILE)) { + Ok(()) => Ok(true), + Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(false), + Err(error) => Err(error), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_legacy_tab_file_goes_and_the_rest_of_the_state_directory_stays() { + let state = tempfile::tempdir().expect("a state directory"); + let kept = ["history.db", "workspace_state.json.tmp", "workspaces/abc.json"]; + std::fs::create_dir(state.path().join("workspaces")).expect("the workspaces directory"); + for name in kept { + std::fs::write(state.path().join(name), b"kept").expect("a kept file"); + } + std::fs::write( + state.path().join(LEGACY_TAB_FILE), + br#"{"connections":{"c":{"tabs":[{"kind":"editor","query":"SELECT 1"}]}}}"#, + ) + .expect("the legacy file"); + + let first = remove_legacy_tab_file(state.path()).expect("the removal"); + let second = remove_legacy_tab_file(state.path()).expect("a second start"); + + assert!(first); + assert!(!second, "a start without the file found one"); + assert!(!state.path().join(LEGACY_TAB_FILE).exists()); + for name in kept { + assert!(state.path().join(name).exists(), "{name} was removed"); + } + } + + #[test] + fn a_missing_state_directory_has_nothing_to_remove() { + let root = tempfile::tempdir().expect("a root"); + + assert!(!remove_legacy_tab_file(&root.path().join("never-created")).expect("no error")); + } +} diff --git a/linux/crates/app/src/workspace/mod.rs b/linux/crates/app/src/workspace/mod.rs new file mode 100644 index 0000000000..81ec6cccd4 --- /dev/null +++ b/linux/crates/app/src/workspace/mod.rs @@ -0,0 +1,125 @@ +//! A window's workspace: which tabs were open and what the editor ones +//! held, kept between sessions, and the saves its tabs run and the +//! prompts that decide whether they may close. Also which sessions the +//! windows show, so a launch opens them again. + +mod close_blocker; +mod close_blocker_dialog; +mod close_decision; +mod close_guard; +mod close_guards; +mod close_reason; +mod close_scope; +mod close_step; +mod completed_work; +pub(crate) mod dialog_response; +mod dirty_kind; +mod dirty_tab; +mod draft_writer; +mod editor_file_record; +mod engine_profile; +mod inhibit_reason; +mod legacy_tab_file; +mod open_sessions; +mod open_sessions_load; +mod open_sessions_record; +mod open_transaction_dialog; +mod pending_state; +mod persist_debouncer; +mod ran_statements; +mod restored_drafts; +mod rollback_cause; +mod save_changes_dialog; +mod save_id; +mod save_ledger; +mod save_outcome; +mod save_rejection; +mod save_settled; +mod save_snapshot; +mod save_start; +mod save_ticket; +mod schema_write; +mod session_key_record; +mod sort_record; +mod tab_directory; +mod tab_entry; +mod tab_record; +mod tab_snapshot; +#[cfg(test)] +pub(crate) mod test_workspace; +#[cfg(test)] +mod tests; +mod unknown_cause; +mod unknown_outcome_dialog; +mod waiting_dialog; +mod window_inhibitor; +mod work_kind; +mod work_outcome; +mod workspace_event; +mod workspace_file_name; +mod workspace_handle; +mod workspace_load; +mod workspace_load_error; +mod workspace_record; +mod workspace_record_builder; +mod workspace_save_error; +mod workspace_store; +mod write_job; +mod writes_in_flight; + +pub use close_blocker::{CloseBlockPrompt, CloseBlocker}; +pub use close_decision::CloseDecision; +pub(crate) use close_guard::CloseGuard; +pub(crate) use close_guards::CloseGuards; +pub use close_reason::CloseReason; +pub use close_scope::CloseScope; +pub use close_step::{CloseStep, next_close_step}; +pub use completed_work::CompletedWork; +pub(crate) use dialog_response::respond; +#[cfg(test)] +pub(crate) use dialog_response::{answer, visible_alert}; +pub use dirty_kind::DirtyKind; +pub use dirty_tab::DirtyTab; +pub use draft_writer::DraftWriter; +pub use editor_file_record::EditorFileRecord; +pub use engine_profile::EngineProfile; +pub use inhibit_reason::InhibitReason; +pub(crate) use legacy_tab_file::remove_legacy_tab_file; +pub use open_sessions::OpenSessions; +pub use open_sessions_load::OpenSessionsLoad; +pub use open_sessions_record::OpenSessionsRecord; +pub use pending_state::PendingState; +pub use persist_debouncer::PersistDebouncer; +pub(crate) use ran_statements::RanStatements; +pub use restored_drafts::RestoredDrafts; +pub use rollback_cause::RollbackCause; +pub use save_id::SaveId; +pub use save_ledger::SaveLedger; +pub use save_outcome::SaveOutcome; +pub use save_rejection::SaveRejection; +pub use save_settled::SaveSettled; +pub use save_snapshot::SaveSnapshot; +pub use save_start::SaveStart; +pub use save_ticket::SaveTicket; +pub(crate) use schema_write::run_schema_plan; +pub use session_key_record::SessionKeyRecord; +pub use sort_record::SortRecord; +pub use tab_directory::TabDirectory; +pub use tab_entry::TabEntry; +pub use tab_record::TabRecord; +pub use tab_snapshot::TabSnapshot; +pub use unknown_cause::UnknownCause; +#[cfg(test)] +pub use window_inhibitor::InhibitTarget; +pub use window_inhibitor::{ApplicationInhibit, WindowInhibitor}; +pub use work_kind::WorkKind; +pub use work_outcome::WorkOutcome; +pub use workspace_event::WorkspaceEvent; +pub use workspace_file_name::WorkspaceFileName; +pub use workspace_handle::{WorkspaceDeps, WorkspaceHandle}; +pub use workspace_load::WorkspaceLoad; +pub use workspace_load_error::WorkspaceLoadError; +pub use workspace_record::WorkspaceRecord; +pub use workspace_record_builder::workspace_record; +pub use workspace_save_error::WorkspaceSaveError; +pub use workspace_store::WorkspaceStore; diff --git a/linux/crates/app/src/workspace/open_sessions.rs b/linux/crates/app/src/workspace/open_sessions.rs new file mode 100644 index 0000000000..eed75ed3d0 --- /dev/null +++ b/linux/crates/app/src/workspace/open_sessions.rs @@ -0,0 +1,315 @@ +use std::cell::{Cell, RefCell}; +use std::rc::Rc; + +use gtk4::glib; +use tablepro_session::SessionKey; +use tablepro_session::runtime::ProcessHold; + +use super::{OpenSessionsLoad, OpenSessionsRecord, WorkspaceStore}; + +/// The sessions the windows show, kept on disk so the next launch opens +/// them again. GTK-thread only. +/// +/// A quit freezes the list before it asks the first window, so a window +/// it closes stays on it; a cancelled quit thaws it to the windows still +/// open, and an approved one empties it once it is on disk. +pub struct OpenSessions { + store: WorkspaceStore, + /// Each write keeps the process up until it lands, since closing the + /// last window would otherwise end it, and the writer with it. + hold: Rc, + keys: RefCell>, + frozen: Cell, +} + +impl OpenSessions { + pub fn new(store: WorkspaceStore, hold: Rc) -> Rc { + Rc::new(Self { + store, + hold, + keys: RefCell::new(Vec::new()), + frozen: Cell::new(false), + }) + } + + pub fn load(&self) -> impl Future + use<> { + self.store.load_open_sessions() + } + + /// Take the recorded keys as open, ahead of any opened since launch, + /// and return the ones to reopen. Each restored window removes its + /// key again if it cannot open it. + pub fn restore(&self, record: OpenSessionsRecord) -> Vec { + let restored = record.keys(); + let opened_since = { + let mut keys = self.keys.borrow_mut(); + let opened_since: Vec = keys.drain(..).filter(|key| !restored.contains(key)).collect(); + keys.extend(restored.iter().cloned()); + keys.extend(opened_since.iter().cloned()); + opened_since + }; + if !opened_since.is_empty() { + self.write(); + } + restored + } + + pub fn add(&self, key: SessionKey) { + { + let mut keys = self.keys.borrow_mut(); + if keys.contains(&key) { + return; + } + keys.push(key); + } + self.write(); + } + + /// Ignored while a quit runs: the windows it closes are the ones the + /// next launch reopens. + pub fn remove(&self, key: &SessionKey) { + if self.frozen.get() { + return; + } + { + let mut keys = self.keys.borrow_mut(); + let before = keys.len(); + keys.retain(|kept| kept != key); + if keys.len() == before { + return; + } + } + self.write(); + } + + /// Stop removing keys until `thaw`, for the length of a quit or the + /// application's shutdown. + pub fn freeze_for_quit(&self) { + self.frozen.set(true); + } + + /// Keep the keys `live` still names, after a quit the user cancelled + /// partway, and start removing keys again. + pub fn thaw(&self, live: impl Fn(&SessionKey) -> bool) { + self.keys.borrow_mut().retain(|key| live(key)); + self.frozen.set(false); + self.write(); + } + + /// After an approved quit, go back to how a launch starts: nothing + /// open and nothing frozen, so the next restore reads the record + /// from disk. + pub fn end_quit(&self) { + self.keys.borrow_mut().clear(); + self.frozen.set(false); + } + + /// Write the keys as they are now; resolves once they are on disk. + pub fn flush(&self) -> impl Future + use<> { + let scheduled = self.store.schedule_open_sessions(self.record()); + let written = self.store.flush_open_sessions(); + async move { + if let Err(error) = scheduled.and(written.await) { + tracing::warn!(%error, "could not record which sessions are open"); + } + } + } + + /// Resolves once every write already scheduled is on disk. + #[cfg(test)] + pub(crate) fn settled(&self) -> impl Future + use<> { + let written = self.store.flush_open_sessions(); + async move { + let _ = written.await; + } + } + + #[cfg(test)] + pub(crate) fn keys(&self) -> Vec { + self.keys.borrow().clone() + } + + fn record(&self) -> OpenSessionsRecord { + OpenSessionsRecord::from_keys(self.keys.borrow().iter()) + } + + fn write(&self) { + let token = self.hold.acquire(); + let flushing = self.flush(); + glib::spawn_future_local(async move { + flushing.await; + drop(token); + }); + } +} + +impl std::fmt::Debug for OpenSessions { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("OpenSessions") + .field("keys", &self.keys.borrow()) + .field("frozen", &self.frozen.get()) + .finish_non_exhaustive() + } +} + +#[cfg(test)] +mod tests { + use std::path::{Path, PathBuf}; + use std::time::Duration; + + use tablepro_session::runtime::CountingHold; + use tablepro_storage::document::decode_document; + use uuid::Uuid; + + use super::*; + use crate::test_support::wait_until; + + const WAIT: Duration = Duration::from_secs(10); + + struct Fixture { + runtime: Option, + dir: tempfile::TempDir, + hold: Rc, + sessions: Rc, + } + + impl Fixture { + fn new() -> Self { + let runtime = crate::test_support::test_runtime(); + let dir = tempfile::tempdir().expect("tempdir"); + let store = WorkspaceStore::start( + dir.path().to_owned(), + tablepro_storage::DraftStore::new(dir.path().join("drafts")), + &runtime.tasks(), + ); + let hold = Rc::new(CountingHold::new()); + let sessions = OpenSessions::new(store, hold.clone()); + Self { + runtime: Some(runtime), + dir, + hold, + sessions, + } + } + + fn path(&self) -> PathBuf { + self.dir.path().join(OpenSessionsRecord::FILE_NAME) + } + + /// The keys on disk once every write has landed. + fn on_disk(&self) -> Vec { + wait_until(WAIT, || self.hold.live() == 0).expect("the writes to land"); + let path = self.path(); + let bytes = std::fs::read(&path).expect("the record was written"); + decode_document::(Path::new(&path), &bytes) + .expect("a record") + .keys() + } + } + + impl Drop for Fixture { + fn drop(&mut self) { + if let Some(runtime) = self.runtime.take() { + runtime.shutdown(); + } + } + } + + fn saved() -> SessionKey { + SessionKey::Saved(Uuid::new_v4()) + } + + #[gtk4::test] + fn add_records_each_key_once_in_order() { + let fixture = Fixture::new(); + let (first, second) = (saved(), SessionKey::File("file:///srv/app.db".to_owned())); + + fixture.sessions.add(first.clone()); + fixture.sessions.add(second.clone()); + fixture.sessions.add(first.clone()); + + assert_eq!(fixture.sessions.keys(), [first.clone(), second.clone()]); + assert_eq!(fixture.on_disk(), [first, second]); + } + + #[gtk4::test] + fn a_write_holds_the_process_until_it_lands() { + let fixture = Fixture::new(); + + fixture.sessions.add(saved()); + + assert!(fixture.hold.live() > 0, "the write started without holding the process"); + wait_until(WAIT, || fixture.hold.live() == 0).expect("the hold to end"); + assert!(fixture.path().exists(), "the hold ended before the write"); + } + + #[gtk4::test] + fn quit_freeze_keeps_keys_window_close_removes() { + let fixture = Fixture::new(); + let (closed, kept) = (saved(), saved()); + fixture.sessions.add(closed.clone()); + fixture.sessions.add(kept.clone()); + + fixture.sessions.remove(&closed); + assert_eq!( + fixture.sessions.keys(), + std::slice::from_ref(&kept), + "a window close kept its key" + ); + fixture.sessions.freeze_for_quit(); + fixture.sessions.remove(&kept); + + assert_eq!( + fixture.sessions.keys(), + std::slice::from_ref(&kept), + "the quit forgot a window it closed" + ); + assert_eq!(fixture.on_disk(), [kept]); + } + + #[gtk4::test] + fn cancelled_quit_thaws_to_live_keys() { + let fixture = Fixture::new(); + let (closed, open) = (saved(), saved()); + fixture.sessions.add(closed.clone()); + fixture.sessions.add(open.clone()); + fixture.sessions.freeze_for_quit(); + fixture.sessions.remove(&closed); + + fixture.sessions.thaw(|key| key == &open); + + assert_eq!(fixture.sessions.keys(), std::slice::from_ref(&open)); + assert_eq!(fixture.on_disk(), std::slice::from_ref(&open)); + fixture.sessions.remove(&open); + assert!( + fixture.sessions.keys().is_empty(), + "a thawed list still ignores removals" + ); + } + + #[gtk4::test] + fn restore_puts_the_recorded_keys_first() { + let fixture = Fixture::new(); + let (recorded, opened_since) = (saved(), saved()); + fixture.sessions.add(opened_since.clone()); + + let reopen = fixture.sessions.restore(OpenSessionsRecord::from_keys([&recorded])); + + assert_eq!(reopen, std::slice::from_ref(&recorded)); + assert_eq!(fixture.sessions.keys(), [recorded.clone(), opened_since.clone()]); + assert_eq!(fixture.on_disk(), [recorded, opened_since]); + } + + #[gtk4::test] + fn flush_writes_the_keys_as_they_are() { + let fixture = Fixture::new(); + let key = saved(); + fixture.sessions.add(key.clone()); + wait_until(WAIT, || fixture.hold.live() == 0).expect("the add to land"); + std::fs::remove_file(fixture.path()).expect("remove the record"); + + crate::test_support::finish_on_main(WAIT, fixture.sessions.flush()).expect("the flush to finish"); + + assert_eq!(fixture.on_disk(), [key]); + } +} diff --git a/linux/crates/app/src/workspace/open_sessions_load.rs b/linux/crates/app/src/workspace/open_sessions_load.rs new file mode 100644 index 0000000000..195a92fd82 --- /dev/null +++ b/linux/crates/app/src/workspace/open_sessions_load.rs @@ -0,0 +1,9 @@ +use super::OpenSessionsRecord; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum OpenSessionsLoad { + Restored(OpenSessionsRecord), + /// Nothing was recorded, or the record could not be used and was + /// moved aside. + Empty, +} diff --git a/linux/crates/app/src/workspace/open_sessions_record.rs b/linux/crates/app/src/workspace/open_sessions_record.rs new file mode 100644 index 0000000000..dd36c0340c --- /dev/null +++ b/linux/crates/app/src/workspace/open_sessions_record.rs @@ -0,0 +1,164 @@ +use std::io::ErrorKind; +use std::path::Path; + +use serde::{Deserialize, Serialize}; +use tablepro_session::SessionKey; +use tablepro_storage::document::{VersionedDocument, decode_document}; + +use super::workspace_store::{MAX_FILE_BYTES, read_capped}; +use super::{OpenSessionsLoad, SessionKeyRecord}; + +/// The sessions the windows showed, in the order they opened, so the +/// next launch opens them again. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct OpenSessionsRecord { + #[serde(default)] + pub sessions: Vec, +} + +impl VersionedDocument for OpenSessionsRecord { + const KIND: &'static str = "open-sessions"; + const VERSION: u32 = 1; +} + +impl OpenSessionsRecord { + /// Beside the sessions' own files, under a name none of them can + /// take. + pub const FILE_NAME: &'static str = "open-sessions.json"; + + pub fn from_keys<'a>(keys: impl IntoIterator) -> Self { + Self { + sessions: keys.into_iter().map(SessionKeyRecord::from).collect(), + } + } + + /// Each key once, in the recorded order: a key repeated in the file + /// would open a second window only to find the first. + pub fn keys(self) -> Vec { + let mut keys: Vec = Vec::with_capacity(self.sessions.len()); + for key in self.sessions.into_iter().map(SessionKeyRecord::into_key) { + if !keys.contains(&key) { + keys.push(key); + } + } + keys + } +} + +/// A file that cannot be used is moved aside, bytes and all, and loads +/// as empty. It holds nothing the user wrote, and the next write then +/// never lands on something nobody read. +pub(super) fn read_open_sessions_blocking(path: &Path) -> OpenSessionsLoad { + let problem = match read_capped(path) { + Ok(Some(bytes)) => match decode_document::(path, &bytes) { + Ok(record) => return OpenSessionsLoad::Restored(record), + Err(problem) => problem.to_string(), + }, + Ok(None) => format!("the file is larger than {MAX_FILE_BYTES} bytes"), + Err(error) if error.kind() == ErrorKind::NotFound => return OpenSessionsLoad::Empty, + Err(error) => error.to_string(), + }; + match tablepro_storage::fs::move_aside_blocking(path, std::time::SystemTime::now()) { + Ok(moved) => tracing::warn!(%problem, moved = %moved.display(), "moved the open sessions record aside"), + Err(error) => tracing::warn!(%problem, %error, "could not move the open sessions record aside"), + } + OpenSessionsLoad::Empty +} + +#[cfg(test)] +mod tests { + use tablepro_storage::document::encode_document; + use uuid::Uuid; + + use super::*; + + fn record() -> OpenSessionsRecord { + OpenSessionsRecord { + sessions: vec![ + SessionKeyRecord::Saved { id: Uuid::new_v4() }, + SessionKeyRecord::File { + uri: "file:///srv/app.db".to_owned(), + }, + ], + } + } + + fn moved_aside(dir: &Path) -> Vec { + std::fs::read_dir(dir) + .expect("list the directory") + .map(|entry| entry.expect("an entry").path()) + .filter(|path| { + path.file_name() + .is_some_and(|name| name.to_string_lossy().starts_with("open-sessions.json.corrupt-")) + }) + .collect() + } + + #[test] + fn round_trip() { + let record = record(); + + let bytes = encode_document(&record).expect("encode"); + let decoded: OpenSessionsRecord = decode_document(Path::new("/tmp/o.json"), &bytes).expect("decode"); + + assert_eq!(decoded, record); + let text = String::from_utf8_lossy(&bytes); + assert!(text.contains("\"version\": 1"), "{text}"); + assert!(text.contains("\"kind\": \"saved\""), "{text}"); + } + + #[test] + fn unsupported_version_moved_aside_and_loads_empty() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join(OpenSessionsRecord::FILE_NAME); + let original = br#"{"version": 2, "sessions": []}"#; + std::fs::write(&path, original).expect("seed"); + + let load = read_open_sessions_blocking(&path); + + assert_eq!(load, OpenSessionsLoad::Empty); + assert!(!path.exists(), "the unusable file was left where the next write goes"); + let moved = moved_aside(dir.path()); + assert_eq!(moved.len(), 1, "{moved:?}"); + assert_eq!(std::fs::read(&moved[0]).expect("read the moved file"), original); + } + + #[test] + fn a_corrupt_file_is_moved_aside() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join(OpenSessionsRecord::FILE_NAME); + std::fs::write(&path, b"{ not json").expect("seed"); + + assert_eq!(read_open_sessions_blocking(&path), OpenSessionsLoad::Empty); + assert_eq!(moved_aside(dir.path()).len(), 1); + } + + #[test] + fn a_missing_file_loads_empty_and_creates_nothing() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join(OpenSessionsRecord::FILE_NAME); + + assert_eq!(read_open_sessions_blocking(&path), OpenSessionsLoad::Empty); + assert_eq!(std::fs::read_dir(dir.path()).expect("list").count(), 0); + } + + #[test] + fn a_written_record_loads() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join(OpenSessionsRecord::FILE_NAME); + let record = record(); + std::fs::write(&path, encode_document(&record).expect("encode")).expect("seed"); + + assert_eq!(read_open_sessions_blocking(&path), OpenSessionsLoad::Restored(record)); + } + + #[test] + fn keys_keep_the_recorded_order_once_each() { + let first = SessionKey::Saved(Uuid::new_v4()); + let second = SessionKey::File("file:///srv/app.db".to_owned()); + + let keys = OpenSessionsRecord::from_keys([&first, &second, &first]).keys(); + + assert_eq!(keys, [first, second]); + } +} diff --git a/linux/crates/app/src/workspace/open_transaction_dialog.rs b/linux/crates/app/src/workspace/open_transaction_dialog.rs new file mode 100644 index 0000000000..0bf47d304e --- /dev/null +++ b/linux/crates/app/src/workspace/open_transaction_dialog.rs @@ -0,0 +1,27 @@ +use gtk4 as gtk; +use libadwaita as adw; +use libadwaita::prelude::*; + +use super::DirtyTab; +use super::dialog_response::respond; + +const CANCEL: &str = "cancel"; +const CLOSE: &str = "close"; + +/// Whether the user agrees to end the editor connections of `tabs`, +/// whose open transactions the server then rolls back. +pub(crate) async fn confirm_open_transactions(parent: >k::Widget, tabs: &[DirtyTab]) -> bool { + let dialog = adw::AlertDialog::new( + Some(&crate::i18n::gettext("Close With Open Transactions?")), + Some(&crate::i18n::gettext_f( + "Closing ends the database sessions of {tabs}. The server rolls back uncommitted work.", + &[("tabs", &DirtyTab::names(tabs))], + )), + ); + dialog.add_response(CANCEL, &crate::i18n::gettext("_Cancel")); + dialog.add_response(CLOSE, &crate::i18n::gettext("_Close Anyway")); + dialog.set_response_appearance(CLOSE, adw::ResponseAppearance::Destructive); + dialog.set_default_response(Some(CANCEL)); + dialog.set_close_response(CANCEL); + respond(&dialog, parent).await == CLOSE +} diff --git a/linux/crates/app/src/workspace/pending_state.rs b/linux/crates/app/src/workspace/pending_state.rs new file mode 100644 index 0000000000..c9739e9538 --- /dev/null +++ b/linux/crates/app/src/workspace/pending_state.rs @@ -0,0 +1,12 @@ +use super::UnknownCause; + +/// Where a tab's tracker stands with its unsaved changes. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PendingState { + Clean, + Unsaved, + Saving, + /// A save may have landed. The tracker refuses to save again until + /// the tab is reloaded or its changes are discarded. + OutcomeUnknown(UnknownCause), +} diff --git a/linux/crates/app/src/workspace/persist_debouncer.rs b/linux/crates/app/src/workspace/persist_debouncer.rs new file mode 100644 index 0000000000..d46900e94a --- /dev/null +++ b/linux/crates/app/src/workspace/persist_debouncer.rs @@ -0,0 +1,130 @@ +use std::cell::RefCell; +use std::rc::Rc; +use std::time::Duration; + +use gtk4::glib; + +/// Runs one callback a while after the last request, on the GTK thread. +/// +/// Trailing, not leading: every request cancels the pending one and +/// re-arms, so a run of keystrokes costs one callback at the end +/// rather than one each. +#[derive(Debug, Default)] +pub struct PersistDebouncer { + delay: Duration, + /// Shared with the callback, which clears it before running: a + /// one-shot source removes itself when it fires, and removing it + /// again panics. + pending: Rc>>, +} + +impl PersistDebouncer { + pub fn new(delay: Duration) -> Self { + Self { + delay, + pending: Rc::new(RefCell::new(None)), + } + } + + pub fn request(&self, fire: impl FnOnce() + 'static) { + self.cancel(); + let pending = self.pending.clone(); + let source = glib::timeout_add_local_once(self.delay, move || { + pending.borrow_mut().take(); + fire(); + }); + *self.pending.borrow_mut() = Some(source); + } + + pub fn cancel(&self) { + if let Some(source) = self.pending.borrow_mut().take() { + source.remove(); + } + } + + #[cfg(test)] + pub fn is_pending(&self) -> bool { + self.pending.borrow().is_some() + } +} + +impl Drop for PersistDebouncer { + fn drop(&mut self) { + // The callback borrows whatever the owner handed it, so it must + // not outlive the owner. + self.cancel(); + } +} + +#[cfg(test)] +mod tests { + use std::cell::Cell; + + use super::*; + + #[gtk4::test] + fn a_run_of_requests_fires_once_at_the_end() { + let debouncer = PersistDebouncer::new(Duration::from_millis(20)); + let fires = Rc::new(Cell::new(0)); + + for _ in 0..10 { + let fires = fires.clone(); + debouncer.request(move || fires.set(fires.get() + 1)); + } + crate::test_support::wait_until(Duration::from_secs(2), || fires.get() > 0).expect("the callback never ran"); + + assert_eq!(fires.get(), 1, "a run of requests fired more than once"); + } + + #[gtk4::test] + fn request_after_fire_emits_no_critical() { + // What g_test_init does: removing a source that already fired + // logs a critical, which then aborts the test. + let previous = glib::log_set_always_fatal(glib::LogLevels::LEVEL_CRITICAL | glib::LogLevels::LEVEL_WARNING); + let debouncer = PersistDebouncer::new(Duration::from_millis(20)); + let fires = Rc::new(Cell::new(0)); + + for expected in 1..=2 { + let fires_for_callback = fires.clone(); + debouncer.request(move || fires_for_callback.set(fires_for_callback.get() + 1)); + let fired = crate::test_support::wait_until(Duration::from_secs(2), || fires.get() == expected); + if fired.is_err() { + glib::log_set_always_fatal(previous); + } + fired.expect("the callback never ran"); + } + glib::log_set_always_fatal(previous); + + assert_eq!(fires.get(), 2); + } + + #[gtk4::test] + fn cancel_stops_a_pending_callback() { + let debouncer = PersistDebouncer::new(Duration::from_millis(20)); + let fires = Rc::new(Cell::new(0)); + let fires_for_callback = fires.clone(); + debouncer.request(move || fires_for_callback.set(fires_for_callback.get() + 1)); + + debouncer.cancel(); + assert!(!debouncer.is_pending()); + assert!( + crate::test_support::wait_until(Duration::from_millis(200), || fires.get() > 0).is_err(), + "a cancelled callback still ran" + ); + } + + #[gtk4::test] + fn dropping_the_debouncer_stops_a_pending_callback() { + let fires = Rc::new(Cell::new(0)); + { + let debouncer = PersistDebouncer::new(Duration::from_millis(20)); + let fires_for_callback = fires.clone(); + debouncer.request(move || fires_for_callback.set(fires_for_callback.get() + 1)); + } + + assert!( + crate::test_support::wait_until(Duration::from_millis(200), || fires.get() > 0).is_err(), + "a callback ran after its debouncer was dropped" + ); + } +} diff --git a/linux/crates/app/src/workspace/ran_statements.rs b/linux/crates/app/src/workspace/ran_statements.rs new file mode 100644 index 0000000000..ea31692339 --- /dev/null +++ b/linux/crates/app/src/workspace/ran_statements.rs @@ -0,0 +1,160 @@ +use std::sync::Arc; +use std::time::SystemTime; + +use tablepro_core::dialect::SqlDialect; +use tablepro_session::ConnectionMetadata; +use tablepro_storage::query_history::{NewEntry, Outcome}; + +use crate::services::history_service::HistoryService; + +/// What the app needs to record a statement batch it ran on the user's +/// behalf. +/// +/// A row save and a structure save change the database as surely as a +/// statement typed into the editor does, so the history that answers +/// "what did this to my table" has to carry them too. It is captured +/// before the work starts, because the connection can be gone by the +/// time the answer comes back. +#[derive(Clone)] +pub(crate) struct RanStatements { + history: Option, + connection: Option, + dialect: Option>, + sql: String, + started_at: SystemTime, +} + +impl RanStatements { + /// Capture the batch about to run. `sql` reads as one entry in the + /// history list, which is how the user thinks of one save. + pub(crate) fn starting( + history: Option, + connection: Option, + dialect: Option>, + statements: impl IntoIterator, + ) -> Self { + Self { + history, + connection, + dialect, + sql: join(statements), + started_at: SystemTime::now(), + } + } + + /// Record how it went, through the same gate as an editor run. Does + /// nothing when the history is closed or the batch was empty, and + /// never fails the save it describes: a history that cannot be + /// written is worth a log line, not a lost edit. + pub(crate) async fn finished(self, rows_affected: Option, outcome: Outcome) { + let (Some(history), Some(connection)) = (self.history, self.connection) else { + return; + }; + if self.sql.is_empty() { + return; + } + let entry = NewEntry { + query: self.sql, + driver_id: connection.driver_id, + connection_id: connection.id, + connection_name: connection.name, + executed_at: self.started_at, + duration_ms: self + .started_at + .elapsed() + .ok() + .and_then(|elapsed| i64::try_from(elapsed.as_millis()).ok()), + rows_affected, + outcome, + }; + history.record(entry, self.dialect).await; + } +} + +/// One entry carries the whole batch, because one save is what the +/// user did even when it took several statements. +fn join(statements: impl IntoIterator) -> String { + let parts: Vec = statements + .into_iter() + .map(|statement| statement.trim().trim_end_matches(';').to_owned()) + .filter(|statement| !statement.is_empty()) + .collect(); + match parts.is_empty() { + true => String::new(), + false => format!("{};", parts.join(";\n")), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_batch_reads_as_one_statement_list() { + let sql = join(["UPDATE t SET a = 1".to_owned(), "DELETE FROM t WHERE id = 2".to_owned()]); + + assert_eq!(sql, "UPDATE t SET a = 1;\nDELETE FROM t WHERE id = 2;"); + } + + #[test] + fn a_statement_that_already_ends_in_a_semicolon_does_not_gain_a_second() { + assert_eq!(join(["SELECT 1;".to_owned()]), "SELECT 1;"); + } + + #[tokio::test] + async fn a_save_goes_through_the_history_gate() { + use tokio::sync::watch; + + use crate::services::history_preferences::HistoryPreferences; + + let root = tempfile::tempdir().expect("tempdir"); + let paths = tablepro_storage::StoragePaths::under(root.path(), "tablepro-test", "app.tablepro.TablePro.Devel"); + let (save, receiver) = watch::channel(true); + let preferences = HistoryPreferences { + retention_days: watch::channel(30).1, + save: receiver, + }; + let history = HistoryService::start(paths, preferences, &crate::test_support::paused_tasks()); + let dialect: Arc = Arc::new(tablepro_test_support::FakeDialect); + let connection = ConnectionMetadata { + id: uuid::Uuid::new_v4(), + name: "local".to_owned(), + driver_id: "postgres".to_owned(), + }; + let save_of = |sql: &str| { + RanStatements::starting( + Some(history.clone()), + Some(connection.clone()), + Some(dialect.clone()), + [sql.to_owned()], + ) + }; + + save_of("UPDATE t SET a = 1").finished(Some(1), Outcome::Success).await; + save_of("CREATE USER u PASSWORD 'p'") + .finished(None, Outcome::Success) + .await; + save.send_replace(false); + save_of("UPDATE t SET a = 2").finished(Some(1), Outcome::Success).await; + + let store = history.ready_store().await.expect("the store"); + let queries: Vec = store + .search_page( + &tablepro_storage::query_history::HistoryQuery::default(), + tablepro_storage::query_history::HistoryPageRequest::first(100), + ) + .await + .expect("search") + .entries + .into_iter() + .map(|entry| entry.query) + .collect(); + assert_eq!(queries, vec!["UPDATE t SET a = 1;".to_owned()]); + } + + #[test] + fn an_empty_batch_records_nothing() { + assert_eq!(join(Vec::::new()), ""); + assert_eq!(join(["".to_owned(), " ".to_owned()]), ""); + } +} diff --git a/linux/crates/app/src/workspace/restored_drafts.rs b/linux/crates/app/src/workspace/restored_drafts.rs new file mode 100644 index 0000000000..1eda9801f0 --- /dev/null +++ b/linux/crates/app/src/workspace/restored_drafts.rs @@ -0,0 +1,81 @@ +use std::collections::HashMap; + +use tablepro_storage::{DraftId, DraftScope, DraftStore}; + +/// The text of a restored session's editor tabs. +#[derive(Default)] +pub struct RestoredDrafts { + pub texts: HashMap, + /// How many drafts could not be read. Each is moved aside, so its tab + /// opens empty and neither typing in it nor closing it touches the + /// bytes. + pub unreadable: usize, +} + +impl RestoredDrafts { + pub fn read_blocking(store: &DraftStore, scope: &DraftScope, drafts: impl IntoIterator) -> Self { + let mut restored = Self::default(); + for draft in drafts { + match store.read_blocking(scope, draft) { + Ok(Some(text)) => { + restored.texts.insert(draft, text); + } + Ok(None) => {} + Err(error) => { + tracing::warn!(%error, "could not read an editor draft"); + restored.unreadable += 1; + match store.move_aside_blocking(scope, draft, std::time::SystemTime::now()) { + Ok(moved) => tracing::info!(moved = %moved.display(), "moved the unreadable draft aside"), + Err(error) => tracing::warn!(%error, "could not move the unreadable draft aside"), + } + } + } + } + restored + } +} + +impl std::fmt::Debug for RestoredDrafts { + // The drafts are the user's SQL, which stays out of the journal. + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("RestoredDrafts") + .field("texts", &self.texts.len()) + .field("unreadable", &self.unreadable) + .finish() + } +} + +#[cfg(test)] +mod tests { + use std::collections::HashSet; + + use super::*; + + #[test] + fn an_unreadable_draft_is_moved_aside() { + let root = tempfile::tempdir().expect("tempdir"); + let store = DraftStore::new(root.path().join("drafts")); + let scope = DraftScope::new("session").expect("a scope"); + let (good, bad, missing) = (DraftId::new(), DraftId::new(), DraftId::new()); + store.write_blocking(&scope, good, "SELECT 1").expect("write"); + store.write_blocking(&scope, bad, "SELECT 2").expect("write"); + let bad_path = root + .path() + .join("drafts") + .join(scope.as_str()) + .join(format!("{bad}.sql")); + std::fs::write(&bad_path, b"SELECT \xff").expect("seed bytes that are not UTF-8"); + + let restored = RestoredDrafts::read_blocking(&store, &scope, [good, bad, missing]); + + assert_eq!(restored.texts, HashMap::from([(good, "SELECT 1".to_owned())])); + assert_eq!(restored.unreadable, 1); + assert!(!bad_path.exists(), "the unreadable draft is where its tab writes"); + store.retain_blocking(&scope, &HashSet::new()).expect("retain"); + let kept: Vec> = std::fs::read_dir(bad_path.parent().expect("the scope")) + .expect("list") + .map(|entry| std::fs::read(entry.expect("an entry").path()).expect("read")) + .collect(); + assert_eq!(kept, [b"SELECT \xff".to_vec()]); + } +} diff --git a/linux/crates/app/src/workspace/rollback_cause.rs b/linux/crates/app/src/workspace/rollback_cause.rs new file mode 100644 index 0000000000..6bb77b1d24 --- /dev/null +++ b/linux/crates/app/src/workspace/rollback_cause.rs @@ -0,0 +1,64 @@ +use tablepro_core::DriverError; +use tablepro_core::browse::BrowseSqlError; +use tablepro_core::dml::BuildSqlError; +use tablepro_core::statement::SqlBuildError; +use tablepro_session::OperationError; + +/// Why a save left the database as it was. +#[derive(Debug, Clone, PartialEq)] +pub enum RollbackCause { + /// The server refused it, or the driver undid it. + Driver(DriverError), + /// It could not be written as SQL, so nothing was sent. + Sql(SqlBuildError), + Build(BuildSqlError), + Browse(BrowseSqlError), + /// The commit's answer was lost, and the server said afterwards that + /// it never landed. + NotApplied, +} + +impl RollbackCause { + /// What to tell the user. + pub fn message(&self) -> String { + match self { + Self::Driver(error) => crate::ui::error_text::driver_message(error), + Self::Sql(error) => crate::ui::error_text::operation_error_message(&OperationError::Sql(error.clone())), + Self::Build(error) => crate::ui::error_text::operation_error_message(&OperationError::Build(error.clone())), + Self::Browse(error) => { + crate::ui::error_text::operation_error_message(&OperationError::Browse(error.clone())) + } + Self::NotApplied => crate::i18n::gettext( + "The connection was lost while saving, and the database says the changes were not saved.", + ), + } + } + + /// A guard found what the save was built from changed after it was + /// read: the row, for a row save, or the table, for a structure save. + pub fn found_changes(&self) -> bool { + matches!(self, Self::Driver(DriverError::RowGuardFailed { .. })) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn only_a_failed_guard_found_changes() { + let guard = RollbackCause::Driver(DriverError::RowGuardFailed { + statement_index: 0, + expected: 0, + actual: 2, + }); + let refused = RollbackCause::Driver(DriverError::RolledBack { + statement_index: 0, + source: Box::new(DriverError::server("UNIQUE constraint failed")), + }); + + assert!(guard.found_changes()); + assert!(!refused.found_changes()); + assert!(!RollbackCause::NotApplied.found_changes()); + } +} diff --git a/linux/crates/app/src/workspace/save_changes_dialog.rs b/linux/crates/app/src/workspace/save_changes_dialog.rs new file mode 100644 index 0000000000..7d2661b702 --- /dev/null +++ b/linux/crates/app/src/workspace/save_changes_dialog.rs @@ -0,0 +1,77 @@ +use gtk4 as gtk; +use libadwaita as adw; +use libadwaita::prelude::*; +use uuid::Uuid; + +use super::DirtyTab; +use super::dialog_response::respond; + +const CANCEL: &str = "cancel"; +const DISCARD: &str = "discard"; +const SAVE: &str = "save"; + +/// What the user chose for tabs with unsaved changes. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum SaveChoice { + Cancel, + /// Save the tabs that stayed checked and drop the rest. + Proceed { + save: Vec, + discard: Vec, + }, +} + +/// Ask which of `tabs` to save, as GNOME Text Editor does: every tab +/// listed with a check box, all checked. +pub(crate) async fn ask_save_changes(parent: >k::Widget, tabs: &[DirtyTab]) -> SaveChoice { + let dialog = adw::AlertDialog::new( + Some(&crate::i18n::gettext("Save Changes?")), + Some(&crate::i18n::gettext( + "Changes that are not saved are permanently lost.", + )), + ); + let group = adw::PreferencesGroup::new(); + let checks: Vec<(Uuid, gtk::CheckButton)> = tabs + .iter() + .map(|tab| { + let check = gtk::CheckButton::builder() + .active(true) + .valign(gtk::Align::Center) + .build(); + let row = adw::ActionRow::builder().title(&tab.title).use_markup(false).build(); + row.add_prefix(&check); + row.set_activatable_widget(Some(&check)); + let label: >k::Widget = row.upcast_ref(); + check.update_relation(&[gtk::accessible::Relation::LabelledBy(&[label.upcast_ref()])]); + group.add(&row); + (tab.id, check) + }) + .collect(); + dialog.set_extra_child(Some(&group)); + dialog.set_prefer_wide_layout(tabs.len() > 1); + let discard_label = match tabs.len() { + 1 => crate::i18n::gettext("_Discard"), + _ => crate::i18n::gettext("_Discard All"), + }; + dialog.add_response(CANCEL, &crate::i18n::gettext("_Cancel")); + dialog.add_response(DISCARD, &discard_label); + dialog.add_response(SAVE, &crate::i18n::gettext("_Save")); + dialog.set_response_appearance(DISCARD, adw::ResponseAppearance::Destructive); + dialog.set_response_appearance(SAVE, adw::ResponseAppearance::Suggested); + dialog.set_default_response(Some(CANCEL)); + dialog.set_close_response(CANCEL); + match respond(&dialog, parent).await.as_str() { + SAVE => { + let (save, discard): (Vec<_>, Vec<_>) = checks.iter().partition(|(_, check)| check.is_active()); + SaveChoice::Proceed { + save: save.into_iter().map(|(id, _)| *id).collect(), + discard: discard.into_iter().map(|(id, _)| *id).collect(), + } + } + DISCARD => SaveChoice::Proceed { + save: Vec::new(), + discard: tabs.iter().map(|tab| tab.id).collect(), + }, + _ => SaveChoice::Cancel, + } +} diff --git a/linux/crates/app/src/workspace/save_id.rs b/linux/crates/app/src/workspace/save_id.rs new file mode 100644 index 0000000000..d9e3a32ab3 --- /dev/null +++ b/linux/crates/app/src/workspace/save_id.rs @@ -0,0 +1,9 @@ +/// One save from the moment it starts to the moment it settles. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct SaveId(u64); + +impl SaveId { + pub(super) fn new(value: u64) -> Self { + Self(value) + } +} diff --git a/linux/crates/app/src/workspace/save_ledger.rs b/linux/crates/app/src/workspace/save_ledger.rs new file mode 100644 index 0000000000..dfa2f7041e --- /dev/null +++ b/linux/crates/app/src/workspace/save_ledger.rs @@ -0,0 +1,283 @@ +use std::cell::RefCell; +use std::collections::BTreeMap; +use std::future::Future; +use std::rc::Rc; + +use futures::channel::oneshot; +use tablepro_core::WriteKind; +use tablepro_session::runtime::{HoldToken, ProcessHold}; +use tokio_util::sync::CancellationToken; +use uuid::Uuid; + +use super::{CloseScope, SaveId, SaveTicket}; + +/// The saves a window has running, so closing it can wait for them or +/// stop them rather than poll. +/// +/// Each running save holds the process, so quitting while one runs +/// still lets it settle and report. +#[derive(Default)] +pub struct SaveLedger { + state: RefCell, + listeners: RefCell>, +} + +type Listener = Rc; + +#[derive(Default)] +struct LedgerState { + next: u64, + entries: BTreeMap, + waiters: Vec<(CloseScope, oneshot::Sender<()>)>, +} + +struct Entry { + tab: Uuid, + kind: WriteKind, + cancel: CancellationToken, + _hold: HoldToken, +} + +impl SaveLedger { + pub fn new() -> Self { + Self::default() + } + + /// Record a save of `tab`, or `None` when one of the same kind is + /// already running for it: a second would commit the same changes + /// twice. + pub fn begin(&self, hold: &dyn ProcessHold, tab: Uuid, kind: WriteKind) -> Option { + let ticket = { + let mut state = self.state.borrow_mut(); + if state + .entries + .values() + .any(|entry| entry.tab == tab && entry.kind == kind) + { + return None; + } + state.next += 1; + let id = SaveId::new(state.next); + let cancel = CancellationToken::new(); + state.entries.insert( + id, + Entry { + tab, + kind, + cancel: cancel.clone(), + _hold: hold.acquire(), + }, + ); + SaveTicket { id, cancel } + }; + self.notify(); + Some(ticket) + } + + /// The save ended. A second call for the same save does nothing and + /// returns false. + pub fn settle(&self, id: SaveId) -> bool { + let (entry, woken) = { + let mut state = self.state.borrow_mut(); + let Some(entry) = state.entries.remove(&id) else { + return false; + }; + let waiters = std::mem::take(&mut state.waiters); + let (idle, waiting): (Vec<_>, Vec<_>) = waiters + .into_iter() + .partition(|(scope, _)| !state.entries.values().any(|entry| scope.covers(entry.tab))); + state.waiters = waiting; + (entry, idle) + }; + // The hold goes only after the state is consistent, in case + // dropping it ends the main loop. + drop(entry); + for (_, waiter) in woken { + let _ = waiter.send(()); + } + self.notify(); + true + } + + pub fn in_flight(&self) -> usize { + self.state.borrow().entries.len() + } + + /// Saves running for the tabs `scope` covers. + pub fn in_flight_in(&self, scope: CloseScope) -> usize { + self.state + .borrow() + .entries + .values() + .filter(|entry| scope.covers(entry.tab)) + .count() + } + + /// Resolves once no save `scope` covers is running. + pub fn when_idle(&self, scope: CloseScope) -> impl Future + use<> { + let receiver = if self.in_flight_in(scope) == 0 { + None + } else { + let (sender, receiver) = oneshot::channel(); + self.state.borrow_mut().waiters.push((scope, sender)); + Some(receiver) + }; + async move { + if let Some(receiver) = receiver { + // A ledger dropped with saves running wakes nobody, and + // a waiter that outlived it has nothing left to wait on. + let _ = receiver.await; + } + } + } + + /// Stop the saves `scope` covers. Each settles when its driver + /// answers the cancel. + pub fn cancel(&self, scope: CloseScope) { + for entry in self.state.borrow().entries.values() { + if scope.covers(entry.tab) { + entry.cancel.cancel(); + } + } + } + + /// Call `listener` with the number of saves running whenever it + /// changes. + pub fn on_change(&self, listener: impl Fn(usize) + 'static) { + self.listeners.borrow_mut().push(Rc::new(listener)); + } + + fn notify(&self) { + let count = self.in_flight(); + let listeners: Vec = self.listeners.borrow().clone(); + for listener in listeners { + listener(count); + } + } +} + +impl std::fmt::Debug for SaveLedger { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("SaveLedger") + .field("in_flight", &self.in_flight()) + .finish_non_exhaustive() + } +} + +#[cfg(test)] +mod tests { + use std::cell::Cell; + use std::pin::{Pin, pin}; + use std::task::{Context, Poll, Waker}; + + use tablepro_session::runtime::CountingHold; + + use super::*; + + fn ready(future: impl Future) -> bool { + polled(pin!(future)) + } + + fn polled(future: Pin<&mut impl Future>) -> bool { + matches!(future.poll(&mut Context::from_waker(Waker::noop())), Poll::Ready(())) + } + + #[test] + fn settle_idempotent() { + let ledger = SaveLedger::new(); + let hold = CountingHold::new(); + let changes = Rc::new(Cell::new(0)); + let seen = changes.clone(); + ledger.on_change(move |_| seen.set(seen.get() + 1)); + let ticket = ledger + .begin(&hold, Uuid::new_v4(), WriteKind::RowChanges) + .expect("a ticket"); + + assert!(ledger.settle(ticket.id)); + assert!(!ledger.settle(ticket.id), "a second settle counted again"); + assert_eq!(ledger.in_flight(), 0); + assert_eq!(changes.get(), 2, "a second settle reported a change"); + } + + #[test] + fn when_idle_resolves_after_last_settle() { + let ledger = SaveLedger::new(); + let hold = CountingHold::new(); + let first = ledger + .begin(&hold, Uuid::new_v4(), WriteKind::RowChanges) + .expect("a ticket"); + let second = ledger + .begin(&hold, Uuid::new_v4(), WriteKind::SchemaChanges) + .expect("a ticket"); + let mut idle = pin!(ledger.when_idle(CloseScope::Window)); + + assert!(!polled(idle.as_mut())); + ledger.settle(first.id); + assert!(!polled(idle.as_mut()), "idle with a save still running"); + ledger.settle(second.id); + assert!(polled(idle.as_mut())); + assert!( + ready(ledger.when_idle(CloseScope::Window)), + "an idle ledger kept a waiter waiting" + ); + } + + #[test] + fn when_tab_idle_waits_only_for_that_tab() { + let ledger = SaveLedger::new(); + let hold = CountingHold::new(); + let (tab, other) = (Uuid::new_v4(), Uuid::new_v4()); + let mine = ledger.begin(&hold, tab, WriteKind::RowChanges).expect("a ticket"); + let _theirs = ledger.begin(&hold, other, WriteKind::RowChanges).expect("a ticket"); + let idle = pin!(ledger.when_idle(CloseScope::Tab(tab))); + + ledger.settle(mine.id); + + assert!(polled(idle)); + assert_eq!(ledger.in_flight_in(CloseScope::Tab(other)), 1); + } + + #[test] + fn begin_refuses_duplicate_kind() { + let ledger = SaveLedger::new(); + let hold = CountingHold::new(); + let tab = Uuid::new_v4(); + + let first = ledger.begin(&hold, tab, WriteKind::RowChanges).expect("a ticket"); + assert!(ledger.begin(&hold, tab, WriteKind::RowChanges).is_none()); + assert!(ledger.begin(&hold, tab, WriteKind::SchemaChanges).is_some()); + ledger.settle(first.id); + assert!(ledger.begin(&hold, tab, WriteKind::RowChanges).is_some()); + } + + #[test] + fn cancel_scope_cancels_only_scoped_tokens() { + let ledger = SaveLedger::new(); + let hold = CountingHold::new(); + let (tab, other) = (Uuid::new_v4(), Uuid::new_v4()); + let mine = ledger.begin(&hold, tab, WriteKind::RowChanges).expect("a ticket"); + let theirs = ledger.begin(&hold, other, WriteKind::RowChanges).expect("a ticket"); + + ledger.cancel(CloseScope::Tab(tab)); + assert!(mine.cancel.is_cancelled()); + assert!(!theirs.cancel.is_cancelled()); + + ledger.cancel(CloseScope::Window); + assert!(theirs.cancel.is_cancelled()); + } + + #[test] + fn entry_holds_counting_hold_until_settle() { + let ledger = SaveLedger::new(); + let hold = CountingHold::new(); + let ticket = ledger + .begin(&hold, Uuid::new_v4(), WriteKind::RowChanges) + .expect("a ticket"); + + assert_eq!(hold.live(), 1); + ledger.cancel(CloseScope::Window); + assert_eq!(hold.live(), 1, "a stopped save let go of the process before it settled"); + ledger.settle(ticket.id); + assert_eq!(hold.live(), 0); + } +} diff --git a/linux/crates/app/src/workspace/save_outcome.rs b/linux/crates/app/src/workspace/save_outcome.rs new file mode 100644 index 0000000000..f2b4c71743 --- /dev/null +++ b/linux/crates/app/src/workspace/save_outcome.rs @@ -0,0 +1,176 @@ +use tablepro_core::{DriverError, ErrorCategory, WriteReport}; +use tablepro_session::OperationError; + +use super::{RollbackCause, UnknownCause}; + +/// What a save left in the database, which decides what its tracker +/// does next. +#[derive(Debug, Clone, PartialEq)] +pub enum SaveOutcome { + /// Every statement landed. `rows` is the total the server reported, + /// when every statement reported one. + Committed { rows: Option }, + /// Nothing landed. + RolledBack(RollbackCause), + /// It may have landed, and only the server can say. + Unknown(UnknownCause), +} + +impl SaveOutcome { + /// Once a statement is sent, a driver reports every failure as + /// rolled back, partly applied or of unknown outcome. A bare error + /// in a category that can strike mid-commit cannot be trusted to + /// mean nothing landed; any other bare error means nothing was sent. + pub fn classify(result: Result) -> Self { + match result { + Ok(report) => Self::Committed { + rows: report.total_rows(), + }, + Err(OperationError::Driver(error)) => Self::from_driver(error), + Err(OperationError::Sql(error)) => Self::RolledBack(RollbackCause::Sql(error)), + Err(OperationError::Build(error)) => Self::RolledBack(RollbackCause::Build(error)), + Err(OperationError::Browse(error)) => Self::RolledBack(RollbackCause::Browse(error)), + Err(OperationError::Cancelled) => Self::Unknown(UnknownCause::Interrupted), + Err(OperationError::Panicked(_)) => Self::Unknown(UnknownCause::Panicked), + Err(OperationError::Closed) => Self::Unknown(UnknownCause::Closed), + } + } + + fn from_driver(error: DriverError) -> Self { + match error { + DriverError::RolledBack { .. } | DriverError::RowGuardFailed { .. } | DriverError::ReadOnly(_) => { + Self::RolledBack(RollbackCause::Driver(error)) + } + DriverError::CommitOutcomeUnknown { token, .. } => Self::Unknown(UnknownCause::CommitAckLost { token }), + DriverError::PartiallyApplied { .. } => Self::Unknown(UnknownCause::PartiallyApplied), + other => match other.category() { + ErrorCategory::Interrupted | ErrorCategory::Timeout | ErrorCategory::ConnectionLost => { + Self::Unknown(UnknownCause::Interrupted) + } + _ => Self::RolledBack(RollbackCause::Driver(other)), + }, + } + } +} + +#[cfg(test)] +mod tests { + use tablepro_core::dml::BuildSqlError; + use tablepro_core::statement::SqlBuildError; + use tablepro_core::{CommitToken, LossPhase, ReadOnlyRefusal, StatementOutcome, TimeoutPhase, TransportError}; + use tablepro_session::runtime::TaskPanic; + + use super::*; + + fn driver(error: DriverError) -> SaveOutcome { + SaveOutcome::classify(Err(OperationError::Driver(error))) + } + + fn rolled_back(outcome: &SaveOutcome) -> bool { + matches!(outcome, SaveOutcome::RolledBack(_)) + } + + #[test] + fn classify_maps_every_contract_case() { + let token = CommitToken::PostgresXid { + xid: 7, + legacy_txid: false, + }; + + assert_eq!( + SaveOutcome::classify(Ok(WriteReport::new(vec![Some(2), Some(1)]))), + SaveOutcome::Committed { rows: Some(3) } + ); + for error in [ + DriverError::RolledBack { + statement_index: 0, + source: Box::new(DriverError::server("duplicate key")), + }, + DriverError::RolledBack { + statement_index: 1, + source: Box::new(DriverError::Cancelled), + }, + DriverError::RolledBack { + statement_index: 1, + source: Box::new(DriverError::Timeout { + phase: TimeoutPhase::Statement, + server_cancelled: true, + }), + }, + DriverError::RowGuardFailed { + statement_index: 0, + expected: 1, + actual: 0, + }, + DriverError::ReadOnly(ReadOnlyRefusal::ClientGuard), + ] { + assert!(rolled_back(&driver(error.clone())), "{error:?}"); + } + assert_eq!( + driver(DriverError::CommitOutcomeUnknown { + token: Some(token.clone()), + source: Box::new(DriverError::ConnectionLost { + during: LossPhase::Commit + }), + }), + SaveOutcome::Unknown(UnknownCause::CommitAckLost { token: Some(token) }) + ); + assert_eq!( + driver(DriverError::PartiallyApplied { + applied: 1, + statement_index: 1, + failed_outcome: StatementOutcome::Unknown, + source: Box::new(DriverError::Cancelled), + }), + SaveOutcome::Unknown(UnknownCause::PartiallyApplied) + ); + for interrupted in [ + DriverError::Cancelled, + DriverError::Timeout { + phase: TimeoutPhase::Statement, + server_cancelled: false, + }, + DriverError::ConnectionLost { + during: LossPhase::Statement, + }, + ] { + assert_eq!( + driver(interrupted.clone()), + SaveOutcome::Unknown(UnknownCause::Interrupted), + "{interrupted:?}" + ); + } + assert_eq!( + SaveOutcome::classify(Err(OperationError::Cancelled)), + SaveOutcome::Unknown(UnknownCause::Interrupted) + ); + for refused in [ + DriverError::Busy, + DriverError::server("syntax error"), + DriverError::auth(None), + DriverError::Transport(TransportError::NameResolution { + host: "db".to_owned(), + detail: "no such host".to_owned(), + }), + DriverError::Protocol("garbled".to_owned()), + ] { + assert!(rolled_back(&driver(refused.clone())), "{refused:?}"); + } + assert_eq!( + SaveOutcome::classify(Err(OperationError::Sql(SqlBuildError::Ddl("no".to_owned())))), + SaveOutcome::RolledBack(RollbackCause::Sql(SqlBuildError::Ddl("no".to_owned()))) + ); + assert_eq!( + SaveOutcome::classify(Err(OperationError::Build(BuildSqlError::NoRowIdentity))), + SaveOutcome::RolledBack(RollbackCause::Build(BuildSqlError::NoRowIdentity)) + ); + assert_eq!( + SaveOutcome::classify(Err(OperationError::Panicked(TaskPanic::from_payload(Box::new("boom"))))), + SaveOutcome::Unknown(UnknownCause::Panicked) + ); + assert_eq!( + SaveOutcome::classify(Err(OperationError::Closed)), + SaveOutcome::Unknown(UnknownCause::Closed) + ); + } +} diff --git a/linux/crates/app/src/workspace/save_rejection.rs b/linux/crates/app/src/workspace/save_rejection.rs new file mode 100644 index 0000000000..7b5dce3c6f --- /dev/null +++ b/linux/crates/app/src/workspace/save_rejection.rs @@ -0,0 +1,121 @@ +use tablepro_core::ddl::BuildDdlError; +use tablepro_core::dml::BuildSqlError; +use tablepro_session::OperationError; + +use super::DirtyTab; +use crate::services::pending_structure::SaveBlocker; + +/// Why a save did not start. Nothing was sent. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SaveRejection { + /// The last save's outcome is unknown, and saving again could apply + /// the same changes twice. + OutcomeUnknown, + AlreadySaving, + ReadOnly, + /// The tab has not read the table's columns, which a row save is + /// planned against. + ColumnsNotRead, + /// The row changes cannot be written as SQL. + Build(BuildSqlError), + /// Edits made before the table's columns changed are for these + /// columns, which are gone or no longer take them. + ColumnsChanged(Vec), + /// Edits made before the table's primary key changed name their + /// rows by the old one. + KeyChanged, + /// The structure cannot be saved as it stands. + Blocked(SaveBlocker), + /// The structure cannot be written as SQL for the review to show. + Unplannable(BuildDdlError), + /// The user read the review of a structure save and kept it. + ReviewDeclined, + /// A review of the same changes is open. + InReview, + NoConnection, + /// An editor tab's file could not be written; the sentence says why. + FileNotSaved(String), +} + +impl SaveRejection { + /// What to tell the user. + pub fn message(&self) -> String { + match self { + Self::OutcomeUnknown => crate::i18n::gettext( + "The last save may already be in the database. Discard these changes to see what the table holds now.", + ), + Self::AlreadySaving => crate::i18n::gettext("These changes are already being saved."), + Self::ReadOnly => crate::i18n::gettext("This connection is read-only."), + Self::ColumnsNotRead => { + crate::i18n::gettext("The table's columns have not been read yet. Try again in a moment.") + } + Self::Build(error) => crate::ui::error_text::operation_error_message(&OperationError::Build(error.clone())), + Self::ColumnsChanged(columns) => { + let quoted: Vec = columns.iter().map(|name| DirtyTab::quote(name)).collect(); + crate::i18n::gettext_f( + "The table changed after these edits were made, and the edits to {columns} no longer fit it. Nothing was saved. Discard the changes and make them again.", + &[("columns", "ed.join(", "))], + ) + } + Self::KeyChanged => crate::i18n::gettext( + "The table's primary key changed after these edits were made, so the rows they change can no longer be found. Nothing was saved. Discard the changes and make them again.", + ), + Self::Blocked(blocker) => crate::ui::error_text::save_blocker_text(blocker), + Self::Unplannable(error) => crate::ui::error_text::build_error_text(error), + Self::ReviewDeclined => crate::i18n::gettext("The changes were not applied."), + Self::InReview => crate::i18n::gettext("The review of these changes is still open."), + Self::NoConnection => crate::i18n::gettext("No active connection."), + Self::FileNotSaved(message) => message.clone(), + } + } + + /// A Save pressed again while the first runs needs no alert: the tab + /// already shows the save running, or its review. Nor does a review + /// the user just declined. + pub fn needs_alert(&self) -> bool { + !matches!(self, Self::AlreadySaving | Self::InReview | Self::ReviewDeclined) + } +} + +#[cfg(test)] +mod tests { + use tablepro_core::ddl::DraftProblem; + + use super::*; + + #[test] + fn only_a_save_already_running_goes_untold() { + let told = [ + SaveRejection::OutcomeUnknown, + SaveRejection::ReadOnly, + SaveRejection::ColumnsNotRead, + SaveRejection::Blocked(SaveBlocker::Draft(DraftProblem::NoColumns)), + SaveRejection::NoConnection, + SaveRejection::ColumnsChanged(vec!["nickname".to_owned()]), + SaveRejection::KeyChanged, + ]; + + assert!(told.iter().all(SaveRejection::needs_alert)); + assert!(!SaveRejection::AlreadySaving.needs_alert()); + assert!(!SaveRejection::InReview.needs_alert()); + assert!(!SaveRejection::ReviewDeclined.needs_alert()); + } + + #[test] + fn edits_the_columns_left_behind_name_their_columns() { + let rejection = SaveRejection::ColumnsChanged(vec!["nickname".to_owned(), "age".to_owned()]); + + assert!( + rejection.message().contains("“nickname”, “age”"), + "{}", + rejection.message() + ); + } + + #[test] + fn a_blocked_structure_says_what_to_fix() { + let rejection = SaveRejection::Blocked(SaveBlocker::Draft(DraftProblem::NoColumns)); + + assert_eq!(rejection.message(), "At least one column is required."); + } +} diff --git a/linux/crates/app/src/workspace/save_settled.rs b/linux/crates/app/src/workspace/save_settled.rs new file mode 100644 index 0000000000..d37732f4ac --- /dev/null +++ b/linux/crates/app/src/workspace/save_settled.rs @@ -0,0 +1,22 @@ +use tablepro_core::WriteKind; +use uuid::Uuid; + +use super::SaveOutcome; +use crate::services::change_origins::ChangeOrigins; +use crate::services::change_tracker::StatementSource; + +/// A save that ended, after its tracker and the ledger took the outcome +/// in, for the tab to show. +#[derive(Debug, Clone)] +pub struct SaveSettled { + pub tab: Uuid, + pub kind: WriteKind, + pub outcome: SaveOutcome, + /// The grid row behind the statement a row save stopped at. + pub failing: Option, + /// The name a structure save leaves the table with, when it created + /// or renamed one. + pub saved_table_name: Option, + /// The rows a row save adds and removes. + pub origins: ChangeOrigins, +} diff --git a/linux/crates/app/src/workspace/save_snapshot.rs b/linux/crates/app/src/workspace/save_snapshot.rs new file mode 100644 index 0000000000..c38b49ad2b --- /dev/null +++ b/linux/crates/app/src/workspace/save_snapshot.rs @@ -0,0 +1,13 @@ +use crate::services::touch::Touch; + +/// Where a save's edits end: every edit touched before this one is in +/// the save, and an edit touched at or after it was made while the save +/// ran, so a commit leaves it pending. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct SaveSnapshot(pub Touch); + +impl SaveSnapshot { + pub fn includes(self, touched: Touch) -> bool { + touched < self.0 + } +} diff --git a/linux/crates/app/src/workspace/save_start.rs b/linux/crates/app/src/workspace/save_start.rs new file mode 100644 index 0000000000..bb16cecb62 --- /dev/null +++ b/linux/crates/app/src/workspace/save_start.rs @@ -0,0 +1,11 @@ +use super::{SaveId, SaveRejection}; + +/// What asking a tab to save did. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SaveStart { + Started(SaveId), + /// Written at once, with nothing left running: an editor tab's file. + Saved, + NothingToSave, + Rejected(SaveRejection), +} diff --git a/linux/crates/app/src/workspace/save_ticket.rs b/linux/crates/app/src/workspace/save_ticket.rs new file mode 100644 index 0000000000..ba73765611 --- /dev/null +++ b/linux/crates/app/src/workspace/save_ticket.rs @@ -0,0 +1,11 @@ +use tokio_util::sync::CancellationToken; + +use super::SaveId; + +/// What a started save runs under: its id, to settle it by, and the +/// token Stop Saving fires. +#[derive(Debug, Clone)] +pub struct SaveTicket { + pub id: SaveId, + pub cancel: CancellationToken, +} diff --git a/linux/crates/app/src/workspace/schema_write.rs b/linux/crates/app/src/workspace/schema_write.rs new file mode 100644 index 0000000000..ac0d70549e --- /dev/null +++ b/linux/crates/app/src/workspace/schema_write.rs @@ -0,0 +1,44 @@ +use std::sync::Arc; + +use tablepro_core::ddl::{BuildDdlError, DdlDialect, DdlOperation, DdlPlan}; +use tablepro_core::meta::TableRef; +use tablepro_core::statement::SqlBuildError; +use tablepro_core::{WriteKind, WriteReport}; +use tablepro_session::{OperationError, OperationKind, Session}; +use tokio_util::sync::CancellationToken; + +/// Plan a schema change with the dialect of the connection that runs +/// it, and run the plan as one write. The statements come back whether +/// or not the write went through, for the history. `operation` is what +/// a connection with no DDL dialect refuses. +pub(crate) async fn run_schema_plan

( + session: Arc, + table: TableRef, + operation: DdlOperation, + cancel: &CancellationToken, + plan: P, +) -> Result<(Vec, Result), OperationError> +where + P: Fn(&dyn DdlDialect, &TableRef) -> Result + Send + Sync, +{ + session + .run_cancellable( + OperationKind::Save(WriteKind::SchemaChanges), + cancel, + move |lease, options| { + let table = table.clone(); + let dialect = lease.dialect(); + let planned = match dialect.ddl() { + Some(ddl) => plan(ddl, &table).map_err(SqlBuildError::from), + None => Err(SqlBuildError::from(BuildDdlError::Unsupported(operation))), + }; + async move { + let plan = planned?; + let statements: Vec = plan.sql().into_iter().map(str::to_owned).collect(); + let written = lease.write_batch(&plan.into_write_batch(table), options).await; + Ok((statements, written.map_err(OperationError::from))) + } + }, + ) + .await +} diff --git a/linux/crates/app/src/workspace/session_key_record.rs b/linux/crates/app/src/workspace/session_key_record.rs new file mode 100644 index 0000000000..e9ccb5fcdb --- /dev/null +++ b/linux/crates/app/src/workspace/session_key_record.rs @@ -0,0 +1,44 @@ +use serde::{Deserialize, Serialize}; +use tablepro_session::SessionKey; +use uuid::Uuid; + +/// A session key as the open-sessions file holds it. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum SessionKeyRecord { + Saved { id: Uuid }, + File { uri: String }, +} + +impl From<&SessionKey> for SessionKeyRecord { + fn from(key: &SessionKey) -> Self { + match key { + SessionKey::Saved(id) => Self::Saved { id: *id }, + SessionKey::File(uri) => Self::File { uri: uri.clone() }, + } + } +} + +impl SessionKeyRecord { + pub fn into_key(self) -> SessionKey { + match self { + Self::Saved { id } => SessionKey::Saved(id), + Self::File { uri } => SessionKey::File(uri), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn each_key_comes_back_as_it_went_in() { + for key in [ + SessionKey::Saved(Uuid::new_v4()), + SessionKey::File("file:///srv/app.db".to_owned()), + ] { + assert_eq!(SessionKeyRecord::from(&key).into_key(), key); + } + } +} diff --git a/linux/crates/app/src/workspace/sort_record.rs b/linux/crates/app/src/workspace/sort_record.rs new file mode 100644 index 0000000000..f33f4cb370 --- /dev/null +++ b/linux/crates/app/src/workspace/sort_record.rs @@ -0,0 +1,8 @@ +use serde::{Deserialize, Serialize}; + +/// The column a table tab was sorted by, as its position in the result. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct SortRecord { + pub column: usize, + pub ascending: bool, +} diff --git a/linux/crates/app/src/workspace/tab_directory.rs b/linux/crates/app/src/workspace/tab_directory.rs new file mode 100644 index 0000000000..b237a570d5 --- /dev/null +++ b/linux/crates/app/src/workspace/tab_directory.rs @@ -0,0 +1,18 @@ +use futures::future::LocalBoxFuture; +use uuid::Uuid; + +use super::TabEntry; + +/// What the workspace needs from the window's tabs that no tracker +/// holds: their order, their names, their open transactions and the +/// files their text is saved to. +pub trait TabDirectory { + /// Every open tab, in the order the tab bar shows them. + fn tabs(&self) -> Vec; + + /// Write `tab`'s text to its file, or `None` when it has none. The + /// error is the sentence to show. + fn save_file(&self, _tab: Uuid) -> Option>> { + None + } +} diff --git a/linux/crates/app/src/workspace/tab_entry.rs b/linux/crates/app/src/workspace/tab_entry.rs new file mode 100644 index 0000000000..8abf624431 --- /dev/null +++ b/linux/crates/app/src/workspace/tab_entry.rs @@ -0,0 +1,13 @@ +use uuid::Uuid; + +/// One open tab, as the close prompts name it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TabEntry { + pub id: Uuid, + /// The title its tab shows. + pub title: String, + /// The tab's editor connection holds a transaction the user opened. + pub open_transaction: bool, + /// The tab's text differs from the `.sql` file it is saved to. + pub unsaved_file: bool, +} diff --git a/linux/crates/app/src/workspace/tab_record.rs b/linux/crates/app/src/workspace/tab_record.rs new file mode 100644 index 0000000000..d8d1e7fc56 --- /dev/null +++ b/linux/crates/app/src/workspace/tab_record.rs @@ -0,0 +1,115 @@ +use std::ops::RangeInclusive; + +use serde::{Deserialize, Serialize}; +use tablepro_storage::DraftId; + +use super::{EditorFileRecord, SortRecord}; + +/// What the page size preference allows. +const PAGE_SIZES: RangeInclusive = 100..=100_000; +const DEFAULT_PAGE_SIZE: u64 = 1_000; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum TabRecord { + Table { + schema: Option, + table: String, + offset: u64, + page_size: u64, + sort: Option, + }, + Structure { + schema: Option, + table: String, + }, + /// The text lives in the draft store, so a script of any length + /// costs the record one id. + Editor { + draft: DraftId, + #[serde(default, skip_serializing_if = "Option::is_none")] + file: Option, + }, +} + +impl TabRecord { + /// The record as a tab can be built from it, or `None` when it names + /// nothing that could exist. + pub(super) fn clamped(self) -> Option { + match self { + Self::Table { + schema, + table, + offset, + page_size, + sort, + } => { + if table.is_empty() { + return None; + } + let page_size = if PAGE_SIZES.contains(&page_size) { + page_size + } else { + DEFAULT_PAGE_SIZE + }; + Some(Self::Table { + schema, + table, + offset, + page_size, + sort, + }) + } + Self::Structure { schema, table } => (!table.is_empty()).then_some(Self::Structure { schema, table }), + Self::Editor { draft, file } => Some(Self::Editor { draft, file }), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn table(name: &str, page_size: u64) -> TabRecord { + TabRecord::Table { + schema: Some("public".to_owned()), + table: name.to_owned(), + offset: 0, + page_size, + sort: None, + } + } + + #[test] + fn a_page_size_outside_the_preference_range_falls_back_to_the_default() { + let Some(TabRecord::Table { page_size, .. }) = table("users", 999_999).clamped() else { + panic!("the table record was dropped"); + }; + + assert_eq!(page_size, DEFAULT_PAGE_SIZE); + assert_eq!(table("users", 250).clamped(), Some(table("users", 250))); + } + + #[test] + fn a_record_naming_no_table_is_dropped() { + assert_eq!(table("", 1_000).clamped(), None); + let structure = TabRecord::Structure { + schema: Some("public".to_owned()), + table: String::new(), + }; + assert_eq!(structure.clamped(), None); + } + + #[test] + fn a_long_name_is_kept() { + // SQLite sets no limit on a name's length. + let long = table(&"t".repeat(1_000), 1_000); + let structure = TabRecord::Structure { + schema: Some("s".repeat(1_000)), + table: "users".to_owned(), + }; + + assert_eq!(long.clone().clamped(), Some(long)); + assert_eq!(structure.clone().clamped(), Some(structure)); + } +} diff --git a/linux/crates/app/src/workspace/tab_snapshot.rs b/linux/crates/app/src/workspace/tab_snapshot.rs new file mode 100644 index 0000000000..b64434c86e --- /dev/null +++ b/linux/crates/app/src/workspace/tab_snapshot.rs @@ -0,0 +1,11 @@ +use uuid::Uuid; + +use super::TabRecord; + +/// One page of the tab view, in its order: the tab it shows and what of +/// it is saved. A tab with nothing to reopen has no record. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TabSnapshot { + pub id: Uuid, + pub record: Option, +} diff --git a/linux/crates/app/src/workspace/test_workspace.rs b/linux/crates/app/src/workspace/test_workspace.rs new file mode 100644 index 0000000000..33f3f15545 --- /dev/null +++ b/linux/crates/app/src/workspace/test_workspace.rs @@ -0,0 +1,307 @@ +use std::cell::RefCell; +use std::rc::Rc; +use std::sync::Arc; +use std::time::Duration; + +use gtk4 as gtk; +use gtk4::prelude::*; +use libadwaita as adw; +use tablepro_core::column::{ColumnDefault, ColumnKind, ColumnType, IntegerKind, IntegerWidth}; +use tablepro_core::ddl::{ColumnAttributes, ColumnDefinition, TableDefinition, TableDraft, TableSource, diff_table}; +use tablepro_core::meta::TableRef; +use tablepro_core::{ColumnInfo, Value, WriteAtomicity}; +use tablepro_session::runtime::CountingHold; +use tablepro_test_support::{CallCounters, ConnectionScript, FakeReply}; +use uuid::Uuid; + +use super::{ + EngineProfile, SaveSettled, SaveStart, TabDirectory, TabEntry, WorkspaceDeps, WorkspaceEvent, WorkspaceHandle, +}; +use crate::services::active_session::ActiveSession; +use crate::services::change_tracker::RowKey; +use crate::services::change_tracker_registry::ChangeTrackerRegistry; +use crate::services::pending_structure::PendingStructure; +use crate::services::save_batch::SaveTarget; +use crate::services::structure_tracker_handle::StructureTrackerHandle; +use crate::services::structure_tracker_registry::StructureTrackerRegistry; +use crate::services::tab_tracker_handle::TabTrackerHandle; +use crate::services::tracker_edit::TrackerEdit; +use crate::test_support::{FakeSession, OwnedWindow, wait_until}; + +pub(crate) const WAIT: Duration = Duration::from_secs(10); + +/// The tabs a test window has open. +#[derive(Default)] +pub(crate) struct Tabs(pub RefCell>); + +impl TabDirectory for Tabs { + fn tabs(&self) -> Vec { + self.0.borrow().clone() + } +} + +pub(crate) fn integer(name: &str, primary_key: bool) -> ColumnInfo { + let mut column_type = ColumnType::untyped_text("integer"); + column_type.kind = ColumnKind::Integer(IntegerKind { + width: IntegerWidth::W32, + signed: true, + }); + ColumnInfo { + name: name.to_owned(), + column_type, + nullable: false, + primary_key, + is_auto_increment: false, + is_generated: false, + default: ColumnDefault::None, + comment: None, + } +} + +pub(crate) fn sqlite() -> Arc { + Arc::new(tablepro_driver_sqlite::SqliteDialect::new( + tablepro_driver_sqlite::SqliteVersion::new(3, 45, 0), + )) +} + +/// `t (id INTEGER, n INTEGER)` as SQLite reads it, edited by `edit`. +pub(crate) fn edited_table(edit: impl FnOnce(&mut TableDraft)) -> PendingStructure { + let dialect = sqlite(); + let column = |name: &str| { + ColumnDefinition::new( + ColumnInfo { + column_type: dialect.parse_type("INTEGER").expect("a SQLite type"), + nullable: true, + ..integer(name, false) + }, + ColumnAttributes::default(), + ) + }; + let original = TableDefinition { + table: TableRef::unqualified("t"), + columns: vec![column("id"), column("n")], + primary_key: None, + indexes: Vec::new(), + foreign_keys: Vec::new(), + inbound_foreign_keys: Vec::new(), + source: Some(TableSource { + create_statement: "CREATE TABLE t (id INTEGER, n INTEGER)".to_owned(), + dependents: Vec::new(), + }), + }; + let mut draft = TableDraft::from_definition(&original); + edit(&mut draft); + PendingStructure { + changes: diff_table(&original, &draft), + original: Some(original), + draft, + unparsed_defaults: Vec::new(), + } +} + +/// A mapped window for a save's review and the close prompts. +pub(crate) fn window() -> OwnedWindow { + adw::init().expect("libadwaita initialises under the test backend"); + if let Some(settings) = gtk::Settings::default() { + settings.set_gtk_enable_animations(false); + } + OwnedWindow::presented(adw::Window::new()) +} + +/// A workspace with one tab, over a fake connection that writes as its +/// script says: a table tab holding one edited cell, or a structure tab +/// holding a pending structure. +pub(crate) struct Workspace { + pub handle: WorkspaceHandle, + pub tab: Uuid, + pub tracker: TabTrackerHandle, + pub structure: StructureTrackerHandle, + pub tabs: Rc, + structures: Rc, + pub settled: Rc>>, + pub events: Rc>>, + pub fake: FakeSession, + pub hold: CountingHold, + runtime: Option, +} + +impl Workspace { + pub(crate) fn open(script: ConnectionScript) -> Self { + Self::build(script, None) + } + + pub(crate) fn open_structure(script: ConnectionScript, pending: PendingStructure) -> Self { + Self::build(script, Some(pending)) + } + + fn build(mut script: ConnectionScript, structure: Option) -> Self { + script.dialect = sqlite(); + script.probe.always(FakeReply::ok(())); + let runtime = crate::test_support::test_runtime(); + let fake = FakeSession::start(script, [], runtime.tasks()); + let active = ActiveSession::new(); + active.replace(fake.session.clone()); + let rows = Rc::new(ChangeTrackerRegistry::default()); + let structures = Rc::new(StructureTrackerRegistry::default()); + let tab = Uuid::new_v4(); + let (tracker, structure) = match structure { + None => { + let tracker = rows.register(tab); + let _ = tracker.set_target(SaveTarget { + table: TableRef::unqualified("t"), + columns: vec![integer("id", true), integer("n", false)], + }); + edit(&tracker, 2); + (tracker, StructureTrackerHandle::default()) + } + Some(pending) => { + let structure = structures.register(tab); + structure.set_pending(Some(pending)); + (TabTrackerHandle::default(), structure) + } + }; + let tabs = Rc::new(Tabs::default()); + tabs.0.borrow_mut().push(TabEntry { + id: tab, + title: "t".to_owned(), + open_transaction: false, + unsaved_file: false, + }); + let settled = Rc::new(RefCell::new(Vec::new())); + let events = Rc::new(RefCell::new(Vec::new())); + let settled_sink = settled.clone(); + let event_sink = events.clone(); + let hold = CountingHold::new(); + let handle = WorkspaceHandle::new(WorkspaceDeps { + session: active, + tasks: runtime.tasks(), + hold: Rc::new(hold.clone()), + history: None, + rows, + structures: structures.clone(), + directory: tabs.clone(), + events: Rc::new(move |event| { + if let WorkspaceEvent::SaveSettled { settled, .. } = &event { + settled_sink.borrow_mut().push((**settled).clone()); + } + event_sink.borrow_mut().push(event); + }), + engine: EngineProfile { + name: "SQLite".to_owned(), + ddl_writes: WriteAtomicity::Transactional, + }, + }); + Self { + handle, + tab, + tracker, + structure, + tabs, + structures, + settled, + events, + fake, + hold, + runtime: Some(runtime), + } + } + + /// An editor tab beside the table tab, with a transaction open. + pub(crate) fn open_transaction_tab(&self) -> Uuid { + let id = Uuid::new_v4(); + self.tabs.0.borrow_mut().push(TabEntry { + id, + title: "Query 1".to_owned(), + open_transaction: true, + unsaved_file: false, + }); + id + } + + /// A structure tab after the first, holding `pending`. + pub(crate) fn add_structure_tab(&self, title: &str, pending: PendingStructure) -> StructureTrackerHandle { + let id = Uuid::new_v4(); + let tracker = self.structures.register(id); + tracker.set_pending(Some(pending)); + self.tabs.0.borrow_mut().push(TabEntry { + id, + title: title.to_owned(), + open_transaction: false, + unsaved_file: false, + }); + tracker + } + + /// Ask the tab to save, as its Save button does, for a save that + /// needs no review. + pub(crate) fn start(&self) -> Vec { + let parent = gtk::Label::new(None).upcast::(); + let (handle, tab) = (self.handle.clone(), self.tab); + crate::test_support::finish_on_main(WAIT, async move { handle.save(&parent, &[tab]).await }) + .expect("the save to be asked") + } + + /// Ask the tab to save with `window` as the review's parent. The + /// starts land in the cell once the review, if any, is answered. + pub(crate) fn start_on(&self, window: &adw::Window) -> Rc>>> { + let starts = Rc::new(RefCell::new(None)); + let sink = starts.clone(); + let (handle, tab) = (self.handle.clone(), self.tab); + let parent = window.clone().upcast::(); + gtk::glib::spawn_future_local(async move { + let started = handle.save(&parent, &[tab]).await; + sink.replace(Some(started)); + }); + starts + } + + pub(crate) fn save(&self) -> SaveSettled { + let started = self.start(); + assert!(matches!(started.as_slice(), [SaveStart::Started(_)]), "{started:?}"); + self.settle() + } + + pub(crate) fn settle(&self) -> SaveSettled { + wait_until(WAIT, || !self.settled.borrow().is_empty()).expect("the save to settle"); + self.settled.borrow_mut().remove(0) + } + + /// Change the edited cell again, for another save. + pub(crate) fn edit(&self, value: i64) { + edit(&self.tracker, value); + } + + /// Whether each save settled under a close prompt, in order. + pub(crate) fn settled_in_prompt(&self) -> Vec { + self.events + .borrow() + .iter() + .filter_map(|event| match event { + WorkspaceEvent::SaveSettled { in_prompt, .. } => Some(*in_prompt), + _ => None, + }) + .collect() + } + + pub(crate) fn counters(&self) -> CallCounters { + self.fake.connection.counters() + } +} + +fn edit(tracker: &TabTrackerHandle, value: i64) { + let _ = tracker.apply(TrackerEdit::Cell { + row_key: RowKey::from_pk_values(&[Value::Int(1)]).expect("a key"), + col: 1, + original: Value::Int(1), + value: Value::Int(value), + }); +} + +impl Drop for Workspace { + fn drop(&mut self) { + crate::test_support::drain_main_context(); + if let Some(runtime) = self.runtime.take() { + runtime.shutdown(); + } + } +} diff --git a/linux/crates/app/src/workspace/tests/mod.rs b/linux/crates/app/src/workspace/tests/mod.rs new file mode 100644 index 0000000000..4609a275bc --- /dev/null +++ b/linux/crates/app/src/workspace/tests/mod.rs @@ -0,0 +1 @@ +mod structure_review; diff --git a/linux/crates/app/src/workspace/tests/structure_review.rs b/linux/crates/app/src/workspace/tests/structure_review.rs new file mode 100644 index 0000000000..e73c9aae02 --- /dev/null +++ b/linux/crates/app/src/workspace/tests/structure_review.rs @@ -0,0 +1,202 @@ +//! The review a structure save asks for on its way to the ledger. + +use std::cell::RefCell; +use std::rc::Rc; + +use gtk4 as gtk; +use gtk4::prelude::*; +use libadwaita as adw; +use libadwaita::prelude::*; +use tablepro_core::WriteReport; +use tablepro_core::ddl::{BuildDdlError, DraftColumn, DraftType, TableDraft}; +use tablepro_test_support::{CallCounters, ConnectionScript, FakeReply}; + +use super::super::dialog_response::{answer, visible_alert}; +use super::super::test_workspace::{WAIT, Workspace, edited_table, window}; +use super::super::{PendingState, SaveOutcome, SaveRejection, SaveStart}; +use crate::services::pending_structure::PendingStructure; +use crate::test_support::wait_until; + +fn without_n(draft: &mut TableDraft) { + draft.columns.retain(|column| column.name != "n"); +} + +fn writing() -> ConnectionScript { + let mut script = ConnectionScript::default(); + script.write_batch.always(FakeReply::ok(WriteReport::new(vec![None]))); + script +} + +fn review_on(window: &adw::Window) -> adw::AlertDialog { + let mut shown = None; + wait_until(WAIT, || { + shown = visible_alert(window); + shown.is_some() + }) + .expect("the review"); + shown.expect("the review") +} + +fn review_sql(review: &adw::AlertDialog) -> String { + let buffer = review + .extra_child() + .and_then(|child| child.downcast::().ok()) + .and_then(|scrolled| scrolled.child()) + .and_then(|child| child.downcast::().ok()) + .expect("the SQL view") + .buffer(); + buffer.text(&buffer.start_iter(), &buffer.end_iter(), false).to_string() +} + +fn started(starts: &Rc>>>) -> Vec { + wait_until(WAIT, || starts.borrow().is_some()).expect("the save to be asked"); + starts.borrow().clone().expect("the starts") +} + +#[gtk4::test] +fn drop_column_save_presents_review_with_sql_child() { + let workspace = Workspace::open_structure(ConnectionScript::default(), edited_table(without_n)); + let window = window(); + + let starts = workspace.start_on(&window); + let review = review_on(&window); + + assert_eq!(review.heading().as_deref(), Some("Apply 1 Change to “t”?")); + assert_eq!(review.body(), "Drops column “n” and the data in it."); + assert_eq!(review_sql(&review), "ALTER TABLE \"t\" DROP COLUMN \"n\";"); + assert!( + starts.borrow().is_none(), + "the save started before the review was answered" + ); + assert_eq!(workspace.handle.ledger().in_flight(), 0); + assert!(answer(&window, "cancel")); + started(&starts); +} + +#[gtk4::test] +fn cancel_review_sends_nothing() { + let workspace = Workspace::open_structure(writing(), edited_table(without_n)); + let window = window(); + + let starts = workspace.start_on(&window); + review_on(&window); + assert!(answer(&window, "cancel")); + + assert_eq!(started(&starts), [SaveStart::Rejected(SaveRejection::ReviewDeclined)]); + crate::test_support::drain_main_context(); + assert_eq!(CallCounters::get(&workspace.counters().write_batch), 0); + assert_eq!(workspace.structure.pending_state(), PendingState::Unsaved); + assert_eq!(workspace.handle.ledger().in_flight(), 0); + assert!(workspace.settled.borrow().is_empty()); +} + +#[gtk4::test] +fn apply_review_runs_one_write_batch() { + let workspace = Workspace::open_structure(writing(), edited_table(without_n)); + let window = window(); + + let starts = workspace.start_on(&window); + review_on(&window); + assert!(answer(&window, "apply")); + + assert!(matches!(started(&starts).as_slice(), [SaveStart::Started(_)])); + assert!(matches!(workspace.settle().outcome, SaveOutcome::Committed { .. })); + assert_eq!(CallCounters::get(&workspace.counters().write_batch), 1); + assert_eq!(workspace.structure.pending_state(), PendingState::Clean); +} + +#[gtk4::test] +fn transactional_additive_save_starts_without_a_review() { + let pending = edited_table(|draft| { + draft + .columns + .push(DraftColumn::new("c", DraftType::Typed("TEXT".to_owned()))); + }); + let workspace = Workspace::open_structure(writing(), pending); + + assert!(matches!(workspace.start().as_slice(), [SaveStart::Started(_)])); + workspace.settle(); + assert_eq!(CallCounters::get(&workspace.counters().write_batch), 1); +} + +#[gtk4::test] +fn a_rebuild_save_says_it_rebuilds_the_table() { + let pending = edited_table(|draft| { + if let Some(n) = draft.columns.iter_mut().find(|column| column.name == "n") { + n.column_type = DraftType::Typed("TEXT".to_owned()); + } + }); + let workspace = Workspace::open_structure(ConnectionScript::default(), pending); + let window = window(); + + let starts = workspace.start_on(&window); + let review = review_on(&window); + + assert_eq!( + review.body(), + "Rebuilds the table: copies its rows into a new table and drops the old one.\nChanges column “n” from \ + INTEGER to TEXT. A value the new type can't hold is converted or refused." + ); + assert!( + review_sql(&review).contains("DROP TABLE \"t\";"), + "{}", + review_sql(&review) + ); + assert_eq!( + review.response_appearance("apply"), + adw::ResponseAppearance::Destructive + ); + assert!(answer(&window, "cancel")); + started(&starts); +} + +#[gtk4::test] +fn a_draft_changed_during_the_review_is_not_saved() { + let workspace = Workspace::open_structure(writing(), edited_table(without_n)); + let window = window(); + + let starts = workspace.start_on(&window); + review_on(&window); + let renamed: PendingStructure = edited_table(|draft| { + without_n(draft); + draft.table.name = "u".to_owned(); + }); + workspace.structure.set_pending(Some(renamed)); + assert!(answer(&window, "apply")); + + let rejection = SaveRejection::Unplannable(BuildDdlError::NotAsReviewed); + assert_eq!(started(&starts), [SaveStart::Rejected(rejection.clone())]); + assert!(rejection.needs_alert()); + assert_eq!( + rejection.message(), + "The changes were edited or the connection changed during the review, so nothing was saved. Save again to \ + review them." + ); + crate::test_support::drain_main_context(); + assert_eq!(CallCounters::get(&workspace.counters().write_batch), 0); + assert_eq!(workspace.structure.pending_state(), PendingState::Unsaved); + assert_eq!(workspace.handle.ledger().in_flight(), 0); +} + +#[gtk4::test] +fn a_save_asked_again_during_the_review_opens_no_second_one() { + let workspace = Workspace::open_structure(writing(), edited_table(without_n)); + let window = window(); + + let first = workspace.start_on(&window); + let review = review_on(&window); + let again = workspace.start_on(&window); + + assert_eq!(started(&again), [SaveStart::Rejected(SaveRejection::InReview)]); + assert_eq!(window.dialogs().n_items(), 1, "a second review opened"); + assert!(answer(&window, "cancel")); + assert_eq!(started(&first), [SaveStart::Rejected(SaveRejection::ReviewDeclined)]); + + let after = workspace.start_on(&window); + wait_until(WAIT, || visible_alert(&window).is_some_and(|shown| shown != review)) + .expect("a review once the first was answered"); + assert!(answer(&window, "apply")); + assert!(matches!(started(&after).as_slice(), [SaveStart::Started(_)])); + workspace.settle(); + assert_eq!(CallCounters::get(&workspace.counters().write_batch), 1); +} diff --git a/linux/crates/app/src/workspace/unknown_cause.rs b/linux/crates/app/src/workspace/unknown_cause.rs new file mode 100644 index 0000000000..5d476b5810 --- /dev/null +++ b/linux/crates/app/src/workspace/unknown_cause.rs @@ -0,0 +1,60 @@ +use tablepro_core::CommitToken; + +/// Why nobody knows whether a save landed. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum UnknownCause { + /// The commit was sent and its answer never came. The token, where + /// the engine gives one, is what asks the server afterwards. + CommitAckLost { + token: Option, + }, + /// Some statements landed on an engine that cannot undo them. + PartiallyApplied, + /// The save was stopped, or timed out or lost its connection, before + /// the server confirmed it. + Interrupted, + Panicked, + /// The session closed under the save. + Closed, +} + +/// The causes the user is told apart. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Wording { + Stopped, + Internal, + ConnectionLost, +} + +impl UnknownCause { + /// What happened to `tabs`, the tab names already joined. + pub fn sentence(&self, tabs: &str) -> String { + match self.wording() { + Wording::Stopped => crate::i18n::gettext_f( + "Saving {tabs} was stopped before the database confirmed it. The changes may already be in the database.", + &[("tabs", tabs)], + ), + Wording::Internal => crate::i18n::gettext_f( + "TablePro hit an internal error while saving {tabs}. The changes may already be in the database.", + &[("tabs", tabs)], + ), + Wording::ConnectionLost => crate::i18n::gettext_f( + "The connection was lost while saving {tabs}. The changes may already be in the database.", + &[("tabs", tabs)], + ), + } + } + + /// Whether the user is told the same thing about both. + pub fn reads_as(&self, other: &Self) -> bool { + self.wording() == other.wording() + } + + fn wording(&self) -> Wording { + match self { + Self::Interrupted => Wording::Stopped, + Self::Panicked => Wording::Internal, + Self::CommitAckLost { .. } | Self::PartiallyApplied | Self::Closed => Wording::ConnectionLost, + } + } +} diff --git a/linux/crates/app/src/workspace/unknown_outcome_dialog.rs b/linux/crates/app/src/workspace/unknown_outcome_dialog.rs new file mode 100644 index 0000000000..8856e3da56 --- /dev/null +++ b/linux/crates/app/src/workspace/unknown_outcome_dialog.rs @@ -0,0 +1,86 @@ +use gtk4 as gtk; +use libadwaita as adw; +use libadwaita::prelude::*; + +use super::dialog_response::respond; +use super::{DirtyKind, DirtyTab, UnknownCause}; + +const KEEP: &str = "cancel"; +const RELOAD: &str = "reload"; +const DISCARD: &str = "discard"; + +/// What the user chose for saves that may have landed. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum UnknownChoice { + KeepOpen, + /// Read the tables again to see what is there, and stay open. + Reload, + /// Drop the changes and go on closing. + Discard, +} + +pub(crate) async fn resolve_unknown_outcome(parent: >k::Widget, tabs: &[DirtyTab]) -> UnknownChoice { + let dialog = adw::AlertDialog::new(Some(&crate::i18n::gettext("Save Result Unknown")), Some(&body(tabs))); + dialog.add_response(KEEP, &crate::i18n::gettext("_Keep Open")); + dialog.add_response(RELOAD, &crate::i18n::gettext("_Reload Table")); + dialog.add_response(DISCARD, &crate::i18n::gettext("_Discard Changes")); + dialog.set_response_appearance(DISCARD, adw::ResponseAppearance::Destructive); + dialog.set_default_response(Some(RELOAD)); + dialog.set_close_response(KEEP); + match respond(&dialog, parent).await.as_str() { + RELOAD => UnknownChoice::Reload, + DISCARD => UnknownChoice::Discard, + _ => UnknownChoice::KeepOpen, + } +} + +/// One sentence for each cause the user is told apart, naming the tabs +/// it happened to. +fn body(tabs: &[DirtyTab]) -> String { + let mut groups: Vec<(&UnknownCause, Vec)> = Vec::new(); + for tab in tabs { + let DirtyKind::OutcomeUnknown(cause) = &tab.kind else { + continue; + }; + match groups.iter_mut().find(|(seen, _)| seen.reads_as(cause)) { + Some((_, members)) => members.push(tab.clone()), + None => groups.push((cause, vec![tab.clone()])), + } + } + let sentences: Vec = groups + .iter() + .map(|(cause, members)| cause.sentence(&DirtyTab::names(members))) + .collect(); + sentences.join("\n\n") +} + +#[cfg(test)] +mod tests { + use uuid::Uuid; + + use super::*; + + fn tab(title: &str, cause: UnknownCause) -> DirtyTab { + DirtyTab { + id: Uuid::new_v4(), + title: title.to_owned(), + kind: DirtyKind::OutcomeUnknown(cause), + } + } + + #[test] + fn each_tab_is_told_its_own_cause() { + let tabs = [ + tab("orders", UnknownCause::Interrupted), + tab("users", UnknownCause::CommitAckLost { token: None }), + tab("items", UnknownCause::Closed), + ]; + + assert_eq!( + body(&tabs), + "Saving “orders” was stopped before the database confirmed it. The changes may already be in the \ + database.\n\nThe connection was lost while saving “users”, “items”. The changes may already be in the \ + database." + ); + } +} diff --git a/linux/crates/app/src/workspace/waiting_dialog.rs b/linux/crates/app/src/workspace/waiting_dialog.rs new file mode 100644 index 0000000000..bea038f283 --- /dev/null +++ b/linux/crates/app/src/workspace/waiting_dialog.rs @@ -0,0 +1,64 @@ +use std::pin::pin; +use std::time::Duration; + +use futures::future::{Either, select}; +use gtk4 as gtk; +use gtk4::glib; +use libadwaita as adw; +use libadwaita::prelude::*; + +use super::dialog_response::respond; +use super::{CloseScope, WorkspaceHandle}; + +const KEEP: &str = "keep"; +const STOP: &str = "stop"; +/// A save that lands within this needs no dialog to say so. +const QUIET: Duration = Duration::from_millis(250); + +/// Wait for the saves `scope` covers, with a dialog the user can stop +/// them from. True once none is running; false when the user chose to +/// keep the window open instead. +/// +/// A schema save has no deadline, so Stop Saving is the only way out of +/// a hung one besides Keep Open. +pub(crate) async fn wait_for_saves(parent: >k::Widget, handle: &WorkspaceHandle, scope: CloseScope) -> bool { + if let Either::Left(_) = select(pin!(handle.ledger().when_idle(scope)), glib::timeout_future(QUIET)).await { + return true; + } + let mut stopping = false; + loop { + if handle.ledger().in_flight_in(scope) == 0 { + return true; + } + let dialog = waiting_dialog(stopping); + let idle = handle.ledger().when_idle(scope); + match select(pin!(respond(&dialog, parent)), pin!(idle)).await { + Either::Right(((), _)) => { + dialog.force_close(); + return true; + } + Either::Left((response, _)) if response == STOP => { + handle.stop_saves(scope); + stopping = true; + } + Either::Left(_) => return false, + } + } +} + +/// The alert a response closes, so a stop already sent shows as a new +/// one that can only be kept open. +fn waiting_dialog(stopping: bool) -> adw::AlertDialog { + let body = match stopping { + true => crate::i18n::gettext("Stopping the save."), + false => crate::i18n::gettext("Wait for the save to finish, or stop it."), + }; + let dialog = adw::AlertDialog::new(Some(&crate::i18n::gettext("Saving Changes")), Some(&body)); + dialog.add_response(KEEP, &crate::i18n::gettext("_Keep Open")); + dialog.add_response(STOP, &crate::i18n::gettext("_Stop Saving")); + dialog.set_response_appearance(STOP, adw::ResponseAppearance::Destructive); + dialog.set_response_enabled(STOP, !stopping); + dialog.set_close_response(KEEP); + dialog.set_default_response(Some(KEEP)); + dialog +} diff --git a/linux/crates/app/src/workspace/window_inhibitor.rs b/linux/crates/app/src/workspace/window_inhibitor.rs new file mode 100644 index 0000000000..2c85681f57 --- /dev/null +++ b/linux/crates/app/src/workspace/window_inhibitor.rs @@ -0,0 +1,240 @@ +use std::cell::RefCell; + +use gtk4 as gtk; +use gtk4::glib; +use gtk4::prelude::*; + +use super::InhibitReason; + +/// What holds the session back: the window's application, or a test's +/// counter. +pub trait InhibitTarget { + /// The cookie to release it with, or 0 when nothing was inhibited. + fn inhibit(&self, flags: gtk::ApplicationInhibitFlags, reason: &str) -> u32; + fn uninhibit(&self, cookie: u32); +} + +/// Holds logout, and suspend while a save runs, for as long as the +/// window has something a logout would lose. At most one inhibition is +/// held at a time, and dropping the inhibitor releases it. +pub struct WindowInhibitor { + target: Box, + reason: Option, + /// 0 when nothing is held, which is also what a session without an + /// inhibit service answers. + cookie: u32, +} + +impl WindowInhibitor { + pub fn new(target: impl InhibitTarget + 'static) -> Self { + Self { + target: Box::new(target), + reason: None, + cookie: 0, + } + } + + /// Hold the session back for `reason`, or let it go with `None`. The + /// old inhibition is released before a new one is taken. + pub fn update(&mut self, reason: Option) { + if reason == self.reason { + return; + } + self.release(); + self.reason = reason; + if let Some(reason) = reason { + self.cookie = self.target.inhibit(reason.flags(), &reason.text()); + } + } + + /// Whether the session is being held back now. A session without an + /// inhibit service never holds it. + pub fn is_held(&self) -> bool { + self.cookie != 0 + } + + fn release(&mut self) { + if self.cookie != 0 { + self.target.uninhibit(std::mem::take(&mut self.cookie)); + } + } +} + +impl Drop for WindowInhibitor { + fn drop(&mut self) { + self.release(); + } +} + +impl std::fmt::Debug for WindowInhibitor { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("WindowInhibitor") + .field("reason", &self.reason) + .field("cookie", &self.cookie) + .finish_non_exhaustive() + } +} + +/// Inhibits through the window's application. The application is +/// looked up when it is needed, since a window is built before it is +/// added to one, and remembered so the release reaches the same one. +#[derive(Debug)] +pub struct ApplicationInhibit { + window: glib::WeakRef, + application: RefCell>, +} + +impl ApplicationInhibit { + pub fn new(window: &impl IsA) -> Self { + Self { + window: window.upcast_ref::().downgrade(), + application: RefCell::new(glib::WeakRef::new()), + } + } +} + +impl InhibitTarget for ApplicationInhibit { + fn inhibit(&self, flags: gtk::ApplicationInhibitFlags, reason: &str) -> u32 { + let Some(window) = self.window.upgrade() else { + return 0; + }; + let Some(application) = window.application() else { + return 0; + }; + self.application.replace(application.downgrade()); + application.inhibit(Some(&window), flags, Some(reason)) + } + + fn uninhibit(&self, cookie: u32) { + if let Some(application) = self.application.borrow().upgrade() { + application.uninhibit(cookie); + } + } +} + +#[cfg(test)] +mod tests { + use std::cell::RefCell; + use std::rc::Rc; + + use super::*; + + /// Hands out cookies and keeps the ones still held. + #[derive(Default, Clone)] + struct Session { + held: Rc>>, + next: Rc>, + refuse: bool, + } + + impl InhibitTarget for Session { + fn inhibit(&self, flags: gtk::ApplicationInhibitFlags, _reason: &str) -> u32 { + if self.refuse { + return 0; + } + let cookie = { + let mut next = self.next.borrow_mut(); + *next += 1; + *next + }; + self.held.borrow_mut().push((cookie, flags)); + cookie + } + + fn uninhibit(&self, cookie: u32) { + assert_ne!(cookie, 0, "released a cookie that was never held"); + self.held.borrow_mut().retain(|(held, _)| *held != cookie); + } + } + + #[test] + fn for_state_table() { + assert_eq!(InhibitReason::for_state(false, false), None); + assert_eq!( + InhibitReason::for_state(true, false), + Some(InhibitReason::UnsavedChanges) + ); + assert_eq!( + InhibitReason::for_state(false, true), + Some(InhibitReason::SavingChanges) + ); + assert_eq!(InhibitReason::for_state(true, true), Some(InhibitReason::SavingChanges)); + assert_eq!( + InhibitReason::UnsavedChanges.flags(), + gtk::ApplicationInhibitFlags::LOGOUT + ); + assert_eq!( + InhibitReason::SavingChanges.flags(), + gtk::ApplicationInhibitFlags::LOGOUT | gtk::ApplicationInhibitFlags::SUSPEND + ); + assert_eq!( + InhibitReason::Exporting.flags(), + gtk::ApplicationInhibitFlags::LOGOUT | gtk::ApplicationInhibitFlags::SUSPEND + ); + } + + #[gtk4::test] + fn update_keeps_single_cookie() { + let session = Session::default(); + let mut inhibitor = WindowInhibitor::new(session.clone()); + + inhibitor.update(Some(InhibitReason::UnsavedChanges)); + inhibitor.update(Some(InhibitReason::UnsavedChanges)); + assert_eq!(session.held.borrow().len(), 1); + assert_eq!(*session.next.borrow(), 1, "the same reason inhibited again"); + + inhibitor.update(Some(InhibitReason::SavingChanges)); + assert_eq!( + session.held.borrow().as_slice(), + [(2, InhibitReason::SavingChanges.flags())] + ); + + inhibitor.update(None); + assert!(session.held.borrow().is_empty()); + + inhibitor.update(Some(InhibitReason::UnsavedChanges)); + drop(inhibitor); + assert!( + session.held.borrow().is_empty(), + "dropping the inhibitor kept the session held" + ); + } + + #[gtk4::test] + fn a_refused_inhibit_is_never_released() { + let session = Session { + refuse: true, + ..Session::default() + }; + let mut inhibitor = WindowInhibitor::new(session.clone()); + + inhibitor.update(Some(InhibitReason::SavingChanges)); + assert!(!inhibitor.is_held(), "a refused inhibit counted as held"); + inhibitor.update(None); + drop(inhibitor); + + assert!(session.held.borrow().is_empty()); + } + + #[gtk4::test] + fn is_held_follows_the_cookie() { + let session = Session::default(); + let mut inhibitor = WindowInhibitor::new(session.clone()); + assert!(!inhibitor.is_held()); + + inhibitor.update(Some(InhibitReason::UnsavedChanges)); + assert!(inhibitor.is_held()); + + inhibitor.update(None); + assert!(!inhibitor.is_held()); + } + + #[gtk4::test] + fn a_window_without_an_application_inhibits_nothing() { + let window = crate::test_support::OwnedWindow::new(gtk::Window::new()); + let target = ApplicationInhibit::new(&*window); + + assert_eq!(target.inhibit(gtk::ApplicationInhibitFlags::LOGOUT, "unsaved"), 0); + target.uninhibit(3); + } +} diff --git a/linux/crates/app/src/workspace/work_kind.rs b/linux/crates/app/src/workspace/work_kind.rs new file mode 100644 index 0000000000..699c250040 --- /dev/null +++ b/linux/crates/app/src/workspace/work_kind.rs @@ -0,0 +1,8 @@ +/// What a tab finished doing. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum WorkKind { + /// An editor tab's run. + Query, + Save, + Export, +} diff --git a/linux/crates/app/src/workspace/work_outcome.rs b/linux/crates/app/src/workspace/work_outcome.rs new file mode 100644 index 0000000000..7874c6f4e9 --- /dev/null +++ b/linux/crates/app/src/workspace/work_outcome.rs @@ -0,0 +1,50 @@ +use gtk4::glib; + +use super::SaveOutcome; + +/// How a tab's run, save or export ended. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, glib::Enum)] +#[enum_type(name = "TableProWorkOutcome")] +pub enum WorkOutcome { + Succeeded, + Failed, + /// The user stopped it. + Cancelled, +} + +/// A save whose outcome nobody knows did not save what the user asked +/// it to, as far as the user can tell. +impl From<&SaveOutcome> for WorkOutcome { + fn from(outcome: &SaveOutcome) -> Self { + match outcome { + SaveOutcome::Committed { .. } => Self::Succeeded, + SaveOutcome::RolledBack(_) | SaveOutcome::Unknown(_) => Self::Failed, + } + } +} + +#[cfg(test)] +mod tests { + use tablepro_core::DriverError; + + use super::*; + use crate::workspace::{RollbackCause, UnknownCause}; + + #[test] + fn only_a_commit_is_a_saved_change() { + assert_eq!( + WorkOutcome::from(&SaveOutcome::Committed { rows: Some(1) }), + WorkOutcome::Succeeded + ); + assert_eq!( + WorkOutcome::from(&SaveOutcome::RolledBack(RollbackCause::Driver(DriverError::server( + "duplicate key" + )))), + WorkOutcome::Failed + ); + assert_eq!( + WorkOutcome::from(&SaveOutcome::Unknown(UnknownCause::Interrupted)), + WorkOutcome::Failed + ); + } +} diff --git a/linux/crates/app/src/workspace/workspace_event.rs b/linux/crates/app/src/workspace/workspace_event.rs new file mode 100644 index 0000000000..370614c1ea --- /dev/null +++ b/linux/crates/app/src/workspace/workspace_event.rs @@ -0,0 +1,23 @@ +use uuid::Uuid; + +use super::SaveSettled; + +/// What the workspace did that its tabs have to show. +#[derive(Debug, Clone)] +pub enum WorkspaceEvent { + SaveStarted { + tab: Uuid, + }, + SaveSettled { + settled: Box, + /// A close prompt covering the tab was deciding as the save + /// settled, and says itself how it went. + in_prompt: bool, + }, + /// The tabs' pending changes were dropped. `reload` asks each to read + /// its table again, for a save whose outcome nobody knew. + Discarded { + tabs: Vec, + reload: bool, + }, +} diff --git a/linux/crates/app/src/workspace/workspace_file_name.rs b/linux/crates/app/src/workspace/workspace_file_name.rs new file mode 100644 index 0000000000..79127d7327 --- /dev/null +++ b/linux/crates/app/src/workspace/workspace_file_name.rs @@ -0,0 +1,76 @@ +use tablepro_session::SessionKey; +use tablepro_storage::DraftScope; + +/// The name one session's saved tabs are kept under, which also names +/// the directory its editor drafts live in. +/// +/// A file key is hashed: its URI can be longer than a file name may be, +/// and holds slashes. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct WorkspaceFileName { + stem: String, +} + +impl WorkspaceFileName { + pub fn for_key(key: &SessionKey) -> Self { + let stem = match key { + SessionKey::Saved(id) => id.to_string(), + SessionKey::File(uri) => { + // GLib returns no digest only for a checksum type it + // does not know, which SHA-256 is not. + let digest = glib::compute_checksum_for_string(glib::ChecksumType::Sha256, uri.as_str()) + .map(|digest| digest.to_string()) + .unwrap_or_default(); + format!("file-{digest}") + } + }; + Self { stem } + } + + pub fn file_name(&self) -> String { + format!("{}.json", self.stem) + } + + pub fn draft_scope(&self) -> DraftScope { + DraftScope::new(&self.stem).unwrap_or_else(|error| { + tracing::warn!(%error, "a workspace name is not a usable draft scope"); + DraftScope::default() + }) + } +} + +#[cfg(test)] +mod tests { + use uuid::Uuid; + + use super::*; + + #[test] + fn names_for_saved_and_file_keys() { + let id = Uuid::parse_str("4f0c6a52-3b7e-4d55-9a3c-2a8f3f7e9b10").expect("a uuid"); + + let saved = WorkspaceFileName::for_key(&SessionKey::Saved(id)); + let file = WorkspaceFileName::for_key(&SessionKey::File("file:///tmp/app.db".to_owned())); + + assert_eq!(saved.file_name(), "4f0c6a52-3b7e-4d55-9a3c-2a8f3f7e9b10.json"); + assert_eq!( + file.file_name(), + "file-35a613072ab5ce7e8aff29b0edfd55feeaff4c9c99b6df5572945f4bc16a71f7.json" + ); + assert_ne!( + file, + WorkspaceFileName::for_key(&SessionKey::File("file:///tmp/other.db".to_owned())) + ); + } + + #[test] + fn each_name_is_its_own_draft_scope() { + let id = Uuid::new_v4(); + let saved = WorkspaceFileName::for_key(&SessionKey::Saved(id)); + let file = WorkspaceFileName::for_key(&SessionKey::File("file:///tmp/app.db".to_owned())); + + assert_eq!(saved.draft_scope().as_str(), id.to_string()); + assert!(file.draft_scope().as_str().starts_with("file-")); + assert_ne!(saved.draft_scope(), DraftScope::default()); + } +} diff --git a/linux/crates/app/src/workspace/workspace_handle.rs b/linux/crates/app/src/workspace/workspace_handle.rs new file mode 100644 index 0000000000..86782a2c11 --- /dev/null +++ b/linux/crates/app/src/workspace/workspace_handle.rs @@ -0,0 +1,861 @@ +use std::cell::{Cell, RefCell}; +use std::collections::HashSet; +use std::rc::Rc; +use std::sync::Arc; + +use futures::future::LocalBoxFuture; +use gtk4 as gtk; +use tablepro_core::ddl::{BuildDdlError, ChangeReview}; +use tablepro_core::save::{SavePlan, plan_save}; +use tablepro_core::{CommitResolution, DriverError, WriteKind, WriteReport}; +use tablepro_session::runtime::{ProcessHold, Tasks}; +use tablepro_session::{OperationError, OperationKind, Session}; +use tablepro_storage::query_history::Outcome; +use uuid::Uuid; + +use super::ran_statements::RanStatements; +use super::schema_write::run_schema_plan; +use super::{ + CloseBlocker, CloseScope, CloseStep, DirtyKind, DirtyTab, EngineProfile, PendingState, RollbackCause, SaveId, + SaveLedger, SaveOutcome, SaveRejection, SaveSettled, SaveStart, SaveTicket, TabDirectory, UnknownCause, + WorkspaceEvent, next_close_step, +}; +use crate::services::active_session::ActiveSession; +use crate::services::change_origins::ChangeOrigins; +use crate::services::change_tracker::{StatementSource, TrackedChanges, pk_identity}; +use crate::services::change_tracker_registry::ChangeTrackerRegistry; +use crate::services::history_service::HistoryService; +use crate::services::structure_tracker_handle::StructureTrackerHandle; +use crate::services::structure_tracker_registry::StructureTrackerRegistry; +use crate::services::tab_tracker_handle::TabTrackerHandle; +use crate::ui::review_dialog::{self, ReviewDecision}; + +/// What a window's workspace saves and closes with. +pub struct WorkspaceDeps { + pub session: ActiveSession, + pub tasks: Tasks, + pub hold: Rc, + pub history: Option, + pub rows: Rc, + pub structures: Rc, + pub directory: Rc, + pub events: Rc, + /// What a structure save's review says about the session's engine. + pub engine: EngineProfile, +} + +/// Starts, stops and settles a window's saves, and says what closing +/// any part of it would lose. Clones share one workspace. +#[derive(Clone)] +pub struct WorkspaceHandle(Rc); + +struct Inner { + deps: WorkspaceDeps, + ledger: SaveLedger, + /// The structure tabs whose save waits on a review. + reviewing: RefCell>, + /// Work a window close asks about before it closes any tab. + blockers: RefCell>>, + /// What the close prompt deciding now covers. + asking: Cell>, +} + +impl Inner { + /// The tracker has taken the outcome in, so the ledger lets the + /// save go, which may wake a close waiting on it, and the tab is + /// told. + fn settled(&self, id: SaveId, settled: SaveSettled) { + // Read here: the prompt this wakes can end before the tab hears. + let in_prompt = self.asking.get().is_some_and(|scope| scope.covers(settled.tab)); + self.ledger.settle(id); + (self.deps.events)(WorkspaceEvent::SaveSettled { + settled: Box::new(settled), + in_prompt, + }); + } +} + +/// Marks a close prompt as deciding until it is dropped. +pub struct PromptClaim { + inner: Rc, + previous: Option, +} + +impl Drop for PromptClaim { + fn drop(&mut self) { + self.inner.asking.set(self.previous); + } +} + +/// A structure tab's place in `Inner::reviewing`, given up however the +/// save ends. +struct ReviewClaim<'a> { + reviewing: &'a RefCell>, + tab: Uuid, +} + +impl<'a> ReviewClaim<'a> { + fn take(reviewing: &'a RefCell>, tab: Uuid) -> Option { + let claimed = reviewing.borrow_mut().insert(tab); + claimed.then(|| Self { reviewing, tab }) + } +} + +impl Drop for ReviewClaim<'_> { + fn drop(&mut self) { + self.reviewing.borrow_mut().remove(&self.tab); + } +} + +/// What a save's work brings back to the GTK thread. +struct SaveWork { + written: Result, + /// The server's answer about a commit whose acknowledgement was lost. + resolution: Option>, + plan: Option, +} + +impl SaveWork { + fn failed(error: OperationError) -> Self { + Self { + written: Err(error), + resolution: None, + plan: None, + } + } +} + +impl WorkspaceHandle { + pub fn new(deps: WorkspaceDeps) -> Self { + Self(Rc::new(Inner { + deps, + ledger: SaveLedger::new(), + reviewing: RefCell::default(), + blockers: RefCell::default(), + asking: Cell::new(None), + })) + } + + /// Count a close prompt over `scope` as deciding until the claim + /// drops. A save of a tab it covers settles in front of the prompt, + /// which waited for it and says itself how it went. + pub fn claim_for_prompt(&self, scope: CloseScope) -> PromptClaim { + let previous = self.0.asking.replace(Some(scope)); + PromptClaim { + inner: self.0.clone(), + previous, + } + } + + /// Have every close of the whole window ask about `blocker`'s work + /// while it runs. + pub fn register_close_blocker(&self, blocker: Rc) { + self.0.blockers.borrow_mut().push(blocker); + } + + /// Stop the work of the blocker `close_step` named. A blocker that + /// is not there has nothing to stop. + pub fn stop_blocker(&self, blocker: usize) -> LocalBoxFuture<'static, ()> { + let blocker = self.0.blockers.borrow().get(blocker).cloned(); + match blocker { + Some(blocker) => blocker.stop(), + None => Box::pin(std::future::ready(())), + } + } + + pub fn ledger(&self) -> &SaveLedger { + &self.0.ledger + } + + /// Save each tab, in order. A structure save that needs the user's + /// review shows it on `parent` and starts only once they apply it. A + /// tab with nothing pending, or that is not a table or structure tab, + /// has nothing to save. + pub async fn save(&self, parent: >k::Widget, tabs: &[Uuid]) -> Vec { + let mut starts = Vec::with_capacity(tabs.len()); + for tab in tabs { + starts.push(self.save_tab(parent, *tab).await); + } + starts + } + + /// Stop the saves `scope` covers. Each settles when its driver + /// answers the cancel, as rolled back or with an unknown outcome. + pub fn stop_saves(&self, scope: CloseScope) { + self.0.ledger.cancel(scope); + } + + /// Drop the tabs' pending changes, and with them a save whose + /// outcome was unknown. `reload` has each tab read its table again. + pub fn discard(&self, tabs: &[Uuid], reload: bool) { + for tab in tabs { + if let Some(tracker) = self.0.deps.rows.get(*tab) { + let _ = tracker.discard(); + } + if let Some(tracker) = self.0.deps.structures.get(*tab) { + tracker.clear(); + } + } + self.emit(WorkspaceEvent::Discarded { + tabs: tabs.to_vec(), + reload, + }); + } + + /// Every tab closing would lose something in, in tab order. An + /// editor tab can have both an open transaction and an unsaved file, + /// and is listed once for each. + pub fn dirty_tabs(&self) -> Vec { + self.0 + .deps + .directory + .tabs() + .into_iter() + .flat_map(|entry| { + let pending = match self.pending_state(entry.id) { + PendingState::OutcomeUnknown(cause) => Some(DirtyKind::OutcomeUnknown(cause)), + PendingState::Unsaved | PendingState::Saving => Some(DirtyKind::Unsaved), + PendingState::Clean if entry.open_transaction => Some(DirtyKind::OpenTransaction), + PendingState::Clean => None, + }; + let file = entry.unsaved_file.then_some(DirtyKind::UnsavedFile); + [pending, file].into_iter().flatten().map(move |kind| DirtyTab { + id: entry.id, + title: entry.title.clone(), + kind, + }) + }) + .collect() + } + + /// What closing `scope` has to settle next. Open transactions the + /// user already agreed to lose in this close are not asked again. + pub fn close_step(&self, scope: CloseScope, acknowledged: &HashSet) -> CloseStep { + if scope == CloseScope::Window { + let blocked = + self.0.blockers.borrow().iter().enumerate().find_map(|(blocker, work)| { + work.blocking().map(|prompt| CloseStep::StopBlocker { blocker, prompt }) + }); + if let Some(step) = blocked { + return step; + } + } + let dirty: Vec = self + .dirty_tabs() + .into_iter() + .filter(|tab| { + let agreed_to_lose = matches!(tab.kind, DirtyKind::OpenTransaction | DirtyKind::UnsavedFile); + !(agreed_to_lose && acknowledged.contains(&tab.id)) + }) + .collect(); + next_close_step(scope, self.0.ledger.in_flight_in(scope), &dirty) + } + + fn pending_state(&self, tab: Uuid) -> PendingState { + if let Some(tracker) = self.0.deps.rows.get(tab) { + return tracker.pending_state(); + } + self.0 + .deps + .structures + .get(tab) + .map_or(PendingState::Clean, |tracker| tracker.pending_state()) + } + + fn emit(&self, event: WorkspaceEvent) { + (self.0.deps.events)(event); + } + + async fn save_tab(&self, parent: >k::Widget, tab: Uuid) -> SaveStart { + if let Some(tracker) = self.0.deps.rows.get(tab) { + return self.save_rows(tab, &tracker); + } + if let Some(tracker) = self.0.deps.structures.get(tab) { + // A close that saves its tabs can ask again while the review is + // open, and the review on screen is the one to answer. + let Some(_claim) = ReviewClaim::take(&self.0.reviewing, tab) else { + return SaveStart::Rejected(SaveRejection::InReview); + }; + return match self.review_structure(parent, &tracker).await { + Ok(reviewed) => self.save_structure(tab, &tracker, reviewed), + Err(start) => start, + }; + } + match self.0.deps.directory.save_file(tab) { + Some(saving) => match saving.await { + Ok(()) => SaveStart::Saved, + Err(message) => SaveStart::Rejected(SaveRejection::FileNotSaved(message)), + }, + None => SaveStart::NothingToSave, + } + } + + /// The session to save through, refused before anything is taken + /// from the tracker when it cannot write. + fn writable_session(&self, state: PendingState) -> Result>, SaveRejection> { + match state { + PendingState::Clean => return Ok(None), + PendingState::OutcomeUnknown(_) => return Err(SaveRejection::OutcomeUnknown), + PendingState::Saving => return Err(SaveRejection::AlreadySaving), + PendingState::Unsaved => {} + } + let session = self.0.deps.session.get().ok_or(SaveRejection::NoConnection)?; + if session.read_only() { + return Err(SaveRejection::ReadOnly); + } + Ok(Some(session)) + } + + /// Take what `tab` saves from its tracker, with a ledger entry held + /// for as long as the save runs. + fn begin( + &self, + tab: Uuid, + kind: WriteKind, + state: PendingState, + take: impl FnOnce() -> Result, SaveRejection>, + ) -> Result<(Arc, SaveTicket, T), SaveStart> { + let session = match self.writable_session(state) { + Ok(Some(session)) => session, + Ok(None) => return Err(SaveStart::NothingToSave), + Err(rejection) => return Err(SaveStart::Rejected(rejection)), + }; + let Some(ticket) = self.0.ledger.begin(&*self.0.deps.hold, tab, kind) else { + return Err(SaveStart::Rejected(SaveRejection::AlreadySaving)); + }; + let taken = match take() { + Ok(Some(taken)) => taken, + Ok(None) => { + self.0.ledger.settle(ticket.id); + return Err(SaveStart::NothingToSave); + } + Err(rejection) => { + self.0.ledger.settle(ticket.id); + return Err(SaveStart::Rejected(rejection)); + } + }; + self.emit(WorkspaceEvent::SaveStarted { tab }); + Ok((session, ticket, taken)) + } + + fn save_rows(&self, tab: Uuid, tracker: &TabTrackerHandle) -> SaveStart { + let (session, ticket, batch) = match self.begin(tab, WriteKind::RowChanges, tracker.pending_state(), || { + tracker.begin_save() + }) { + Ok(begun) => begun, + Err(start) => return start, + }; + let history = self.history_entry(&session); + let cancel = ticket.cancel.clone(); + let (table, columns, change_set) = (batch.table, batch.columns, batch.changes.changes.clone()); + let work = async move { + let planned = session + .run_cancellable( + OperationKind::Save(WriteKind::RowChanges), + &cancel, + move |lease, options| { + let (table, columns, changes) = (table.clone(), columns.clone(), change_set.clone()); + async move { + let plan = + plan_save(&*lease.dialect(), &table, &columns, &pk_identity(&columns), &changes)?; + let written = lease.write_batch(&plan.batch, options).await; + Ok((plan, written)) + } + }, + ) + .await; + let (plan, written) = match planned { + Ok((plan, written)) => (Some(plan), written.map_err(OperationError::from)), + Err(error) => (None, Err(error)), + }; + let resolution = resolve_commit(&session, &written).await; + let statements: Vec = plan + .iter() + .flat_map(|plan| plan.batch.steps.iter().map(|step| step.statement.sql().to_owned())) + .collect(); + record(history, statements, &written, resolution.as_ref()).await; + SaveWork { + written, + resolution, + plan, + } + }; + + let inner = self.0.clone(); + let tracker = tracker.clone(); + let (changes, snapshot, id) = (batch.changes, batch.snapshot, ticket.id); + let origins = ChangeOrigins::of(&changes.changes); + let _delivery = crate::runtime::spawn_and_deliver(&self.0.deps.tasks, work, move |finished| { + let work = finished.unwrap_or_else(|failure| SaveWork::failed(OperationError::from(failure))); + let failing = failing_step(&work, &changes); + let outcome = outcome_of(work.written, work.resolution); + let _settled = tracker.settle_save(&outcome, snapshot); + inner.settled( + id, + SaveSettled { + tab, + kind: WriteKind::RowChanges, + outcome, + failing, + saved_table_name: None, + origins, + }, + ); + }); + SaveStart::Started(id) + } + + /// What the structure save would run, agreed to by the user where it + /// loses something or can stop halfway. Nothing is taken from the + /// tracker yet. + async fn review_structure( + &self, + parent: >k::Widget, + tracker: &StructureTrackerHandle, + ) -> Result { + let (table, review) = self.plan_review(tracker)?; + let engine = &self.0.deps.engine; + if review.requires_confirmation(engine.ddl_writes) { + if review_dialog::confirm(parent, &table, &review, engine).await == ReviewDecision::Cancel { + return Err(SaveStart::Rejected(SaveRejection::ReviewDeclined)); + } + // The draft and the connection can still change while the + // review is open. + let (_, now) = self.plan_review(tracker)?; + if now.statements != review.statements { + return Err(SaveStart::Rejected(SaveRejection::Unplannable( + BuildDdlError::NotAsReviewed, + ))); + } + } + Ok(review) + } + + /// The structure save planned with the session's dialect, and the + /// name of its table. + fn plan_review(&self, tracker: &StructureTrackerHandle) -> Result<(String, ChangeReview), SaveStart> { + let session = match self.writable_session(tracker.pending_state()) { + Ok(Some(session)) => session, + Ok(None) => return Err(SaveStart::NothingToSave), + Err(rejection) => return Err(SaveStart::Rejected(rejection)), + }; + let pending = match tracker.pending_save() { + Ok(Some(pending)) => pending, + Ok(None) => return Err(SaveStart::NothingToSave), + Err(rejection) => return Err(SaveStart::Rejected(rejection)), + }; + let dialect = session + .current_dialect() + .ok_or(SaveStart::Rejected(SaveRejection::NoConnection))?; + let review = dialect + .ddl() + .ok_or(BuildDdlError::Unsupported(pending.operation())) + .and_then(|ddl| pending.review(ddl)) + .map_err(|error| SaveStart::Rejected(SaveRejection::Unplannable(error)))?; + Ok((pending.table().name, review)) + } + + /// Runs only the statements reviewed: a connection replaced since the + /// review plans others, and the save is refused before anything is + /// sent. + fn save_structure(&self, tab: Uuid, tracker: &StructureTrackerHandle, reviewed: ChangeReview) -> SaveStart { + let (session, ticket, pending) = + match self.begin(tab, WriteKind::SchemaChanges, tracker.pending_state(), || { + tracker.begin_save() + }) { + Ok(begun) => begun, + Err(start) => return start, + }; + let history = self.history_entry(&session); + let cancel = ticket.cancel.clone(); + let saved_table_name = pending.saved_table_name(); + let (table, operation) = (pending.table(), pending.operation()); + let work = async move { + let planned = run_schema_plan(session.clone(), table, operation, &cancel, move |ddl, _| { + let plan = pending.plan(ddl)?; + match reviewed.matches(&plan) { + true => Ok(plan), + false => Err(BuildDdlError::NotAsReviewed), + } + }) + .await; + let (statements, written) = planned.unwrap_or_else(|error| (Vec::new(), Err(error))); + let resolution = resolve_commit(&session, &written).await; + record(history, statements, &written, resolution.as_ref()).await; + SaveWork { + written, + resolution, + plan: None, + } + }; + + let inner = self.0.clone(); + let tracker = tracker.clone(); + let id = ticket.id; + let _delivery = crate::runtime::spawn_and_deliver(&self.0.deps.tasks, work, move |finished| { + let work = finished.unwrap_or_else(|failure| SaveWork::failed(OperationError::from(failure))); + let outcome = outcome_of(work.written, work.resolution); + tracker.settle_save(&outcome); + inner.settled( + id, + SaveSettled { + tab, + kind: WriteKind::SchemaChanges, + outcome, + failing: None, + saved_table_name, + origins: ChangeOrigins::default(), + }, + ); + }); + SaveStart::Started(id) + } + + /// Captured before the work starts: the connection may be gone by + /// the time the answer comes back. + fn history_entry(&self, session: &Session) -> HistoryEntry { + HistoryEntry { + history: self.0.deps.history.clone(), + connection: Some(session.metadata().clone()), + dialect: session.current_dialect(), + } + } +} + +impl std::fmt::Debug for WorkspaceHandle { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("WorkspaceHandle") + .field("ledger", &self.0.ledger) + .finish_non_exhaustive() + } +} + +struct HistoryEntry { + history: Option, + connection: Option, + dialect: Option>, +} + +/// A save changes the database as surely as a statement typed into the +/// editor, so it goes in the same history. A history that cannot be +/// written is worth a log line, never a lost save. +async fn record( + entry: HistoryEntry, + statements: Vec, + written: &Result, + resolution: Option<&Result>, +) { + let ran = RanStatements::starting(entry.history, entry.connection, entry.dialect, statements); + let (rows, outcome) = match (written, resolution) { + (Ok(report), _) => ( + report.total_rows().and_then(|rows| i64::try_from(rows).ok()), + Outcome::Success, + ), + (Err(_), Some(Ok(CommitResolution::Applied))) => (None, Outcome::Success), + (Err(error), _) => ( + None, + Outcome::Error(crate::ui::error_text::operation_error_message(error)), + ), + }; + ran.finished(rows, outcome).await; +} + +/// Ask the server what became of a commit whose acknowledgement was +/// lost, when the engine left a way to ask. +async fn resolve_commit( + session: &Session, + written: &Result, +) -> Option> { + let Err(OperationError::Driver(DriverError::CommitOutcomeUnknown { token: Some(token), .. })) = written else { + return None; + }; + let token = token.clone(); + let resolved = session + .run(OperationKind::Metadata, move |lease, options| { + let token = token.clone(); + async move { + lease + .resolve_commit(&token, options) + .await + .map_err(OperationError::from) + } + }) + .await; + if let Err(error) = &resolved { + tracing::warn!(%error, "could not ask the server whether a save landed"); + } + Some(resolved) +} + +/// The outcome once the server has answered for a lost acknowledgement. +/// An answer that is not final, or no answer, leaves it unknown. +fn outcome_of( + written: Result, + resolution: Option>, +) -> SaveOutcome { + let outcome = SaveOutcome::classify(written); + let SaveOutcome::Unknown(UnknownCause::CommitAckLost { .. }) = &outcome else { + return outcome; + }; + match resolution { + Some(Ok(CommitResolution::Applied)) => SaveOutcome::Committed { rows: None }, + Some(Ok(CommitResolution::NotApplied)) => SaveOutcome::RolledBack(RollbackCause::NotApplied), + Some(Ok(CommitResolution::PartiallyApplied)) => SaveOutcome::Unknown(UnknownCause::PartiallyApplied), + _ => outcome, + } +} + +/// The grid row behind the statement a row save stopped at, when the +/// engine said which one it was. +fn failing_step(work: &SaveWork, changes: &TrackedChanges) -> Option { + let (Some(plan), Err(OperationError::Driver(error))) = (&work.plan, &work.written) else { + return None; + }; + failing_source(plan, changes, error) +} + +fn failing_source(plan: &SavePlan, changes: &TrackedChanges, error: &DriverError) -> Option { + let step = match error { + DriverError::RolledBack { statement_index, .. } + | DriverError::RowGuardFailed { statement_index, .. } + | DriverError::GuardUnsupported { statement_index } + | DriverError::PartiallyApplied { statement_index, .. } => *statement_index, + _ => return None, + }; + changes.source(plan.origin(step)?) +} + +#[cfg(test)] +mod tests { + use tablepro_core::edit::CellInput; + use tablepro_core::meta::TableRef; + use tablepro_core::{CommitToken, LossPhase, Value}; + use tablepro_test_support::{CallCounters, ConnectionScript, FakeReply}; + + use super::*; + use crate::services::change_tracker::RowKey; + use crate::services::tracker_edit::TrackerEdit; + use crate::test_support::wait_until; + use crate::workspace::test_workspace::{WAIT, Workspace, integer, sqlite}; + + fn commit_ack_lost(resolution: Result) -> ConnectionScript { + let mut script = ConnectionScript::default(); + script + .write_batch + .always(FakeReply::err(DriverError::CommitOutcomeUnknown { + token: Some(CommitToken::PostgresXid { + xid: 812, + legacy_txid: false, + }), + source: Box::new(DriverError::ConnectionLost { + during: LossPhase::Commit, + }), + })); + script.resolve_commit.always(FakeReply::Ready(resolution)); + script + } + + #[gtk4::test] + fn commit_ack_lost_applied_reloads() { + let workspace = Workspace::open(commit_ack_lost(Ok(CommitResolution::Applied))); + + let settled = workspace.save(); + + assert_eq!(settled.outcome, SaveOutcome::Committed { rows: None }); + assert_eq!(CallCounters::get(&workspace.counters().resolve_commit), 1); + assert_eq!(workspace.tracker.pending_state(), PendingState::Clean); + assert_eq!(workspace.handle.ledger().in_flight(), 0); + assert_eq!(workspace.hold.live(), 0, "the settled save still held the process"); + } + + #[gtk4::test] + fn not_applied_rolls_back() { + let workspace = Workspace::open(commit_ack_lost(Ok(CommitResolution::NotApplied))); + + let settled = workspace.save(); + + assert_eq!(settled.outcome, SaveOutcome::RolledBack(RollbackCause::NotApplied)); + assert_eq!(workspace.tracker.pending_state(), PendingState::Unsaved); + assert!(matches!(workspace.start().as_slice(), [SaveStart::Started(_)])); + workspace.settle(); + } + + #[gtk4::test] + fn unresolvable_freezes() { + for resolution in [ + Ok(CommitResolution::Unresolvable), + Ok(CommitResolution::StillRunning), + Err(DriverError::ConnectionLost { + during: LossPhase::Statement, + }), + ] { + let workspace = Workspace::open(commit_ack_lost(resolution.clone())); + + let settled = workspace.save(); + + assert!( + matches!( + settled.outcome, + SaveOutcome::Unknown(UnknownCause::CommitAckLost { token: Some(_) }) + ), + "{resolution:?}: {:?}", + settled.outcome + ); + assert!(matches!( + workspace.tracker.pending_state(), + PendingState::OutcomeUnknown(_) + )); + assert_eq!(workspace.start(), [SaveStart::Rejected(SaveRejection::OutcomeUnknown)]); + assert_eq!(CallCounters::get(&workspace.counters().write_batch), 1); + assert!(matches!( + workspace.handle.dirty_tabs().as_slice(), + [DirtyTab { + kind: DirtyKind::OutcomeUnknown(_), + .. + }] + )); + } + } + + #[gtk4::test] + fn stop_saves_cancels_write_batch_token() { + let mut script = ConnectionScript::default(); + script + .write_batch + .always(FakeReply::StoppedWith(Err(DriverError::RolledBack { + statement_index: 0, + source: Box::new(DriverError::Cancelled), + }))); + let workspace = Workspace::open(script); + let started = workspace.start(); + assert!(matches!(started.as_slice(), [SaveStart::Started(_)])); + wait_until(WAIT, || { + !workspace.fake.connection.observed_cancels("write_batch").is_empty() + }) + .expect("the write to start"); + let observed = workspace.fake.connection.observed_cancels("write_batch"); + assert!(!observed[0].is_cancelled()); + assert_eq!(workspace.hold.live(), 1); + + workspace.handle.stop_saves(CloseScope::Tab(workspace.tab)); + let settled = workspace.settle(); + + assert!(observed[0].is_cancelled(), "the write never saw the stop"); + assert!(matches!(settled.outcome, SaveOutcome::RolledBack(_))); + assert_eq!(workspace.tracker.pending_state(), PendingState::Unsaved); + assert_eq!(workspace.handle.ledger().in_flight(), 0); + } + + #[gtk4::test] + fn a_save_settles_under_a_prompt_only_while_one_covers_its_tab() { + let mut script = ConnectionScript::default(); + script + .write_batch + .always(FakeReply::ok(WriteReport::new(vec![Some(1)]))); + let workspace = Workspace::open(script); + let edits = Cell::new(10); + let save_under = |scope: Option| { + let _claim = scope.map(|scope| workspace.handle.claim_for_prompt(scope)); + edits.set(edits.get() + 1); + workspace.edit(edits.get()); + workspace.save(); + workspace.settled_in_prompt().pop().expect("a settled save") + }; + + assert!(!save_under(None)); + assert!( + !save_under(Some(CloseScope::Tab(Uuid::new_v4()))), + "another tab's prompt took the save" + ); + assert!(save_under(Some(CloseScope::Tab(workspace.tab)))); + assert!(save_under(Some(CloseScope::Window))); + let window = workspace.handle.claim_for_prompt(CloseScope::Window); + drop(workspace.handle.claim_for_prompt(CloseScope::Tab(Uuid::new_v4()))); + assert!(save_under(None), "an inner claim ended the outer one"); + drop(window); + assert!(!save_under(None), "a prompt that ended still took the save"); + } + + #[gtk4::test] + fn a_read_only_connection_refuses_before_taking_the_changes() { + let workspace = Workspace::open(ConnectionScript { + read_only: true, + ..ConnectionScript::default() + }); + + assert_eq!(workspace.start(), [SaveStart::Rejected(SaveRejection::ReadOnly)]); + assert_eq!(workspace.tracker.pending_state(), PendingState::Unsaved); + assert_eq!(workspace.handle.ledger().in_flight(), 0); + } + + fn planned() -> (SavePlan, TrackedChanges, RowKey) { + let columns = vec![integer("id", true), integer("n", false)]; + let edited = RowKey::from_pk_values(&[Value::Int(3)]).unwrap(); + let tracker = TabTrackerHandle::default(); + let _ = tracker.apply(TrackerEdit::InsertDrafts { + rows: vec![ + vec![Value::Int(10), Value::Int(1)] + .into_iter() + .map(CellInput::Value) + .collect(), + ], + }); + let _ = tracker.apply(TrackerEdit::Cell { + row_key: edited.clone(), + col: 1, + original: Value::Int(1), + value: Value::Int(2), + }); + let changes = tracker.change_set(&columns).unwrap(); + let plan = plan_save( + &*sqlite(), + &TableRef::unqualified("t"), + &columns, + &pk_identity(&columns), + &changes.changes, + ) + .unwrap(); + (plan, changes, edited) + } + + #[test] + fn a_guard_failure_flashes_the_row_it_was_about() { + let (plan, changes, edited) = planned(); + let error = DriverError::RowGuardFailed { + statement_index: 1, + expected: 1, + actual: 0, + }; + + let source = failing_source(&plan, &changes, &error); + + assert!(matches!(source, Some(StatementSource::Update { row_key }) if row_key == edited)); + } + + #[test] + fn a_rolled_back_insert_flashes_its_draft() { + let (plan, changes, _) = planned(); + let error = DriverError::RolledBack { + statement_index: 0, + source: Box::new(DriverError::server("duplicate key")), + }; + + assert!(matches!( + failing_source(&plan, &changes, &error), + Some(StatementSource::Insert { .. }) + )); + } + + #[test] + fn an_error_that_names_no_statement_flashes_nothing() { + let (plan, changes, _) = planned(); + + assert!(failing_source(&plan, &changes, &DriverError::Busy).is_none()); + let past_the_end = DriverError::RowGuardFailed { + statement_index: 7, + expected: 1, + actual: 0, + }; + assert!(failing_source(&plan, &changes, &past_the_end).is_none()); + } +} diff --git a/linux/crates/app/src/workspace/workspace_load.rs b/linux/crates/app/src/workspace/workspace_load.rs new file mode 100644 index 0000000000..4fe4fb534d --- /dev/null +++ b/linux/crates/app/src/workspace/workspace_load.rs @@ -0,0 +1,11 @@ +use super::{WorkspaceLoadError, WorkspaceRecord}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum WorkspaceLoad { + Restored(WorkspaceRecord), + /// Nothing was saved for this session yet. + Empty, + /// There is a file, and it cannot be used. The store refuses to + /// write over it until it is reset. + Blocked(WorkspaceLoadError), +} diff --git a/linux/crates/app/src/workspace/workspace_load_error.rs b/linux/crates/app/src/workspace/workspace_load_error.rs new file mode 100644 index 0000000000..5c80b22114 --- /dev/null +++ b/linux/crates/app/src/workspace/workspace_load_error.rs @@ -0,0 +1,39 @@ +use tablepro_storage::document::VersionedDocument; +use tablepro_storage::{DocumentProblem, DocumentProblemKind}; +use thiserror::Error; + +use super::WorkspaceRecord; + +/// Why a session's saved tabs could not be used. The file is left as it +/// is in every case, so a fix or an upgrade can still bring them back. +#[derive(Debug, Clone, PartialEq, Eq, Error)] +pub enum WorkspaceLoadError { + #[error("the saved tabs could not be read: {0}")] + Io(String), + #[error("the saved tabs are not valid: {0}")] + Corrupt(String), + #[error("the saved tabs are in a format this version does not read (found {found:?}, reads {expected})")] + UnsupportedVersion { found: Option, expected: u32 }, + #[error("the saved tabs file is larger than {limit} bytes")] + TooLarge { limit: u64 }, +} + +impl From for WorkspaceLoadError { + fn from(problem: DocumentProblem) -> Self { + match problem.kind { + DocumentProblemKind::Unreadable { detail } => Self::Io(detail), + DocumentProblemKind::Corrupt { detail, .. } | DocumentProblemKind::UpgradeFailed { detail, .. } => { + Self::Corrupt(detail) + } + DocumentProblemKind::MissingVersion => Self::UnsupportedVersion { + found: None, + expected: WorkspaceRecord::VERSION, + }, + DocumentProblemKind::UnsupportedVersion { found, expected } + | DocumentProblemKind::NewerVersion { found, expected } => Self::UnsupportedVersion { + found: Some(found), + expected, + }, + } + } +} diff --git a/linux/crates/app/src/workspace/workspace_record.rs b/linux/crates/app/src/workspace/workspace_record.rs new file mode 100644 index 0000000000..1d58bdd802 --- /dev/null +++ b/linux/crates/app/src/workspace/workspace_record.rs @@ -0,0 +1,206 @@ +use serde::{Deserialize, Serialize}; +use tablepro_storage::document::VersionedDocument; + +use super::TabRecord; + +/// One session's saved tabs, in the order the window showed them. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct WorkspaceRecord { + /// An index into `tabs`, not into the window's pages: a tab that is + /// not saved has no record to point at. + #[serde(default)] + pub active_tab: Option, + #[serde(default)] + pub tabs: Vec, +} + +impl VersionedDocument for WorkspaceRecord { + const KIND: &'static str = "workspace"; + const VERSION: u32 = 1; +} + +impl WorkspaceRecord { + /// The record as a window can rebuild it: no tab naming something + /// that could not exist, and `active_tab` still pointing at the tab + /// it named, or at none. + /// + /// The tab count is not capped: the window saves every tab it has, + /// and an editor tab cut here would have its draft deleted as one + /// no tab names. The store's cap on the file size bounds a load. + pub fn clamped(self) -> Self { + let active = self.active_tab.and_then(|index| usize::try_from(index).ok()); + let mut tabs = Vec::with_capacity(self.tabs.len()); + let mut active_tab = None; + for (index, tab) in self.tabs.into_iter().enumerate() { + let Some(tab) = tab.clamped() else { + continue; + }; + if active == Some(index) { + active_tab = u32::try_from(tabs.len()).ok(); + } + tabs.push(tab); + } + Self { active_tab, tabs } + } +} + +#[cfg(test)] +mod tests { + use std::path::Path; + + use tablepro_storage::DraftId; + use tablepro_storage::document::{decode_document, encode_document}; + + use super::*; + use crate::workspace::SortRecord; + + fn table(name: &str) -> TabRecord { + TabRecord::Table { + schema: Some("public".to_owned()), + table: name.to_owned(), + offset: 0, + page_size: 1_000, + sort: None, + } + } + + #[test] + fn round_trip() { + let record = WorkspaceRecord { + active_tab: Some(1), + tabs: vec![ + TabRecord::Table { + schema: Some("public".to_owned()), + table: "orders".to_owned(), + offset: 5_000, + page_size: 5_000, + sort: Some(SortRecord { + column: 2, + ascending: false, + }), + }, + TabRecord::Structure { + schema: None, + table: "users".to_owned(), + }, + TabRecord::Editor { + draft: DraftId::new(), + file: None, + }, + ], + }; + + let bytes = encode_document(&record).expect("encode"); + let decoded: WorkspaceRecord = decode_document(Path::new("/tmp/w.json"), &bytes).expect("decode"); + + assert_eq!(decoded, record); + let text = String::from_utf8_lossy(&bytes); + assert!(text.contains("\"version\": 1"), "{text}"); + assert!(!text.contains("SELECT"), "an editor record carries text: {text}"); + } + + /// A version 1 workspace file, the format 0.1.0 ships. Never edit the + /// fixture: a format change adds an upgrade step and updates the + /// model here. + #[test] + fn a_version_1_workspace_reads_as_it_was_written() { + let bytes = include_bytes!("../../tests/fixtures/workspace-v1.json"); + let draft = uuid::Uuid::parse_str("7d0c5b1e-9a3f-4e2d-8b6c-1f0e9d8c7b6a").expect("a uuid"); + + let decoded: WorkspaceRecord = decode_document(Path::new("/tmp/w.json"), bytes).expect("decode"); + + assert_eq!( + decoded, + WorkspaceRecord { + active_tab: Some(2), + tabs: vec![ + TabRecord::Table { + schema: Some("public".to_owned()), + table: "orders".to_owned(), + offset: 5_000, + page_size: 5_000, + sort: Some(SortRecord { + column: 2, + ascending: false, + }), + }, + TabRecord::Table { + schema: None, + table: "events".to_owned(), + offset: 0, + page_size: 1_000, + sort: None, + }, + TabRecord::Editor { + draft: DraftId::from_uuid(draft), + file: None, + }, + TabRecord::Structure { + schema: Some("sales".to_owned()), + table: "customers".to_owned(), + }, + TabRecord::Structure { + schema: None, + table: "users".to_owned(), + }, + ], + } + ); + assert_eq!( + decoded.clone().clamped(), + decoded, + "the fixture holds only tabs a window can open" + ); + } + + #[test] + fn clamped_keeps_every_tab_however_many() { + let record = WorkspaceRecord { + active_tab: Some(35), + tabs: (0..40) + .map(|index| match index % 2 { + 0 => TabRecord::Editor { + draft: DraftId::new(), + file: None, + }, + _ => table(&format!("t{index}")), + }) + .collect(), + }; + + assert_eq!(record.clone().clamped(), record); + } + + #[test] + fn clamped_resets_an_active_tab_past_the_end() { + let record = WorkspaceRecord { + active_tab: Some(3), + tabs: vec![table("a"), table("b")], + }; + + assert_eq!(record.clamped().active_tab, None); + } + + #[test] + fn clamped_keeps_active_tab_on_the_tab_it_named() { + let record = WorkspaceRecord { + active_tab: Some(2), + tabs: vec![table("a"), table(""), table("c")], + }; + + let clamped = record.clamped(); + + assert_eq!(clamped.tabs, vec![table("a"), table("c")]); + assert_eq!(clamped.active_tab, Some(1)); + } + + #[test] + fn clamped_drops_an_active_tab_that_named_a_dropped_record() { + let record = WorkspaceRecord { + active_tab: Some(1), + tabs: vec![table("a"), table("")], + }; + + assert_eq!(record.clamped().active_tab, None); + } +} diff --git a/linux/crates/app/src/workspace/workspace_record_builder.rs b/linux/crates/app/src/workspace/workspace_record_builder.rs new file mode 100644 index 0000000000..3617e7bb14 --- /dev/null +++ b/linux/crates/app/src/workspace/workspace_record_builder.rs @@ -0,0 +1,85 @@ +use uuid::Uuid; + +use super::{TabSnapshot, WorkspaceRecord}; + +/// The record of `pages`, in their order. The active tab is the index of +/// the selected page's record among the saved ones, not among the pages, +/// and none when the selected page saves nothing. +pub fn workspace_record(pages: &[TabSnapshot], selected: Option) -> WorkspaceRecord { + let mut record = WorkspaceRecord::default(); + for page in pages { + let Some(saved) = &page.record else { + continue; + }; + if selected == Some(page.id) { + record.active_tab = u32::try_from(record.tabs.len()).ok(); + } + record.tabs.push(saved.clone()); + } + record +} + +#[cfg(test)] +mod tests { + use tablepro_storage::DraftId; + + use super::*; + use crate::workspace::TabRecord; + + fn page(record: Option) -> TabSnapshot { + TabSnapshot { + id: Uuid::new_v4(), + record, + } + } + + fn table(name: &str) -> TabRecord { + TabRecord::Table { + schema: Some("public".to_owned()), + table: name.to_owned(), + offset: 0, + page_size: 1_000, + sort: None, + } + } + + fn structure(name: &str) -> TabRecord { + TabRecord::Structure { + schema: Some("public".to_owned()), + table: name.to_owned(), + } + } + + #[test] + fn active_tab_counts_only_persisted_records() { + let pages = [page(None), page(Some(table("a"))), page(Some(structure("b")))]; + + let record = workspace_record(&pages, Some(pages[2].id)); + + assert_eq!(record.tabs, [table("a"), structure("b")]); + assert_eq!(record.active_tab, Some(1)); + } + + #[test] + fn selected_new_draft_gives_no_active_tab() { + let pages = [ + page(Some(TabRecord::Editor { + draft: DraftId::new(), + file: None, + })), + page(None), + ]; + + let record = workspace_record(&pages, Some(pages[1].id)); + + assert_eq!(record.tabs.len(), 1); + assert_eq!(record.active_tab, None); + } + + #[test] + fn nothing_selected_gives_no_active_tab() { + let pages = [page(Some(table("a")))]; + + assert_eq!(workspace_record(&pages, None).active_tab, None); + } +} diff --git a/linux/crates/app/src/workspace/workspace_save_error.rs b/linux/crates/app/src/workspace/workspace_save_error.rs new file mode 100644 index 0000000000..4ab7de3830 --- /dev/null +++ b/linux/crates/app/src/workspace/workspace_save_error.rs @@ -0,0 +1,16 @@ +use thiserror::Error; + +#[derive(Debug, Clone, PartialEq, Eq, Error)] +pub enum WorkspaceSaveError { + /// The saved tabs could not be read, and writing would destroy them. + #[error("the saved tabs could not be read, so they are not written over")] + Blocked, + #[error("could not save the open tabs: {0}")] + Write(String), + #[error("could not move the saved tabs aside: {0}")] + MoveAside(String), + #[error("the workspace writer has stopped")] + WriterStopped, + #[error("could not delete the saved tabs: {0}")] + Purge(String), +} diff --git a/linux/crates/app/src/workspace/workspace_store.rs b/linux/crates/app/src/workspace/workspace_store.rs new file mode 100644 index 0000000000..91c86534e2 --- /dev/null +++ b/linux/crates/app/src/workspace/workspace_store.rs @@ -0,0 +1,903 @@ +use std::cell::RefCell; +use std::collections::{BTreeMap, HashMap, HashSet}; +use std::io::{ErrorKind, Read}; +use std::path::{Path, PathBuf}; +use std::rc::Rc; +use std::sync::Arc; + +use futures::future::Either; +use tablepro_session::SessionKey; +use tablepro_session::runtime::Tasks; +use tablepro_storage::document::{VersionedDocument, decode_document, encode_document}; +use tablepro_storage::{DraftScope, DraftStore, StorageError}; +use tokio::sync::{mpsc, oneshot}; + +use super::open_sessions_record::read_open_sessions_blocking; +use super::write_job::WriteJob; +use super::writes_in_flight::{OutsideWrites, WritesInFlight}; +use super::{ + OpenSessionsLoad, OpenSessionsRecord, WorkspaceFileName, WorkspaceLoad, WorkspaceLoadError, WorkspaceRecord, + WorkspaceSaveError, +}; + +type WriteFile = dyn Fn(&Path, &[u8]) -> Result<(), StorageError> + Send + Sync; + +/// Thousands of tabs encode to less than this. A file past it is read +/// no further, so a runaway one costs no memory to refuse. +pub(super) const MAX_FILE_BYTES: u64 = 1024 * 1024; + +/// Every session's saved tabs, one file each, and which sessions the +/// windows show. +/// +/// Writes go to one task that keeps only the newest record per file, so +/// a burst of tab changes costs one write, and each write runs on a +/// blocking thread. A file that could not be read is never written over +/// until the user resets it. +#[derive(Clone)] +pub struct WorkspaceStore { + inner: Rc, +} + +struct StoreInner { + dir: PathBuf, + tasks: Tasks, + writer: mpsc::UnboundedSender, + blocked: RefCell>, + moved_aside: RefCell>, + outside: Rc, +} + +impl WorkspaceStore { + /// `drafts` is where the editor tabs of these workspaces keep their + /// text, which a purge deletes with the workspace. + pub fn start(dir: PathBuf, drafts: DraftStore, tasks: &Tasks) -> Self { + Self::start_with( + dir, + drafts, + tasks, + Arc::new(tablepro_storage::fs::write_private_blocking), + ) + } + + fn start_with(dir: PathBuf, drafts: DraftStore, tasks: &Tasks, write: Arc) -> Self { + let (writer, jobs) = mpsc::unbounded_channel(); + // Detached, so the last store going closes the channel and the + // task ends once what it was handed is written. + drop(tasks.spawn_task(run_writer(jobs, tasks.clone(), drafts, write))); + Self { + inner: Rc::new(StoreInner { + dir, + tasks: tasks.clone(), + writer, + blocked: RefCell::default(), + moved_aside: RefCell::default(), + outside: Rc::default(), + }), + } + } + + /// Read a session's saved tabs, after any write already queued for + /// it has landed. + pub fn load(&self, key: &SessionKey) -> impl Future + use<> { + let inner = self.inner.clone(); + let key = key.clone(); + let path = self.inner.path(&key); + let written = self.inner.queue_flush(&path); + async move { + // A failed write still leaves whatever is on disk to read. + let _ = written.await; + let reading = path.clone(); + let (load, moved_aside) = inner + .tasks + .spawn_blocking_task(move || (read_record(&reading), moved_aside_exists(&reading))) + .await + .unwrap_or_else(|failure| { + ( + WorkspaceLoad::Blocked(WorkspaceLoadError::Io(failure.to_string())), + true, + ) + }); + if moved_aside { + inner.moved_aside.borrow_mut().insert(key.clone()); + } else { + inner.moved_aside.borrow_mut().remove(&key); + } + match &load { + WorkspaceLoad::Blocked(error) => { + tracing::warn!(%error, path = %path.display(), "keeping the saved tabs as they are"); + inner.blocked.borrow_mut().insert(key); + } + WorkspaceLoad::Restored(_) | WorkspaceLoad::Empty => { + inner.blocked.borrow_mut().remove(&key); + } + } + load + } + } + + /// Queue `record` as the session's saved tabs. A newer record for the + /// same session replaces it if the writer has not reached it yet. + pub fn schedule(&self, key: &SessionKey, record: WorkspaceRecord) -> Result<(), WorkspaceSaveError> { + if self.is_blocked(key) { + return Err(WorkspaceSaveError::Blocked); + } + self.inner + .writer + .send(WriteJob::Write { + path: self.inner.path(key), + record, + }) + .map_err(|_| WorkspaceSaveError::WriterStopped) + } + + /// Resolves once every record queued so far for the session is on + /// disk, with how the last write of it went. + pub fn flush(&self, key: &SessionKey) -> impl Future> + use<> { + let written = (!self.is_blocked(key)).then(|| self.inner.queue_flush(&self.inner.path(key))); + async move { + let Some(written) = written else { + return Err(WorkspaceSaveError::Blocked); + }; + written.await.unwrap_or(Err(WorkspaceSaveError::WriterStopped)) + } + } + + /// Move a file that could not be read aside, bytes and all, and let + /// the session save again. Gives the name the file now has, or `None` + /// when nothing was moved: there was no file, or it could be read. + pub fn reset(&self, key: &SessionKey) -> impl Future, WorkspaceSaveError>> + use<> { + // Checked now: a Reset clicked after a switch to another session + // names that session, whose file may be fine. + let blocked = self.is_blocked(key); + let inner = self.inner.clone(); + let key = key.clone(); + let path = self.inner.path(&key); + async move { + if !blocked { + return Ok(None); + } + let moved = inner + .tasks + .spawn_blocking_task(move || move_aside(&path)) + .await + .unwrap_or_else(|failure| Err(WorkspaceSaveError::MoveAside(failure.to_string())))?; + inner.blocked.borrow_mut().remove(&key); + if moved.is_some() { + inner.moved_aside.borrow_mut().insert(key); + } + Ok(moved) + } + } + + /// Note writes for `key` that run outside the store, such as the + /// drafts of a workspace being closed. A purge of `key` waits for + /// them until the returned value drops. + pub fn writes_in_flight(&self, key: &SessionKey) -> WritesInFlight { + self.inner.outside.begin(key) + } + + /// Delete everything kept for a session whose connection is gone: + /// its saved tabs, any copy of them Reset moved aside, and its + /// drafts. A record still queued for it is never written. + /// + /// It goes through the writer, so a write queued before it cannot + /// land after it and bring the file back. It also waits for the + /// session's writes in flight outside the store, or a draft landing + /// late would bring its directory back. + pub fn purge(&self, key: &SessionKey) -> impl Future> + use<> { + self.inner.blocked.borrow_mut().remove(key); + self.inner.moved_aside.borrow_mut().remove(key); + let path = self.inner.path(key); + let drafts = WorkspaceFileName::for_key(key).draft_scope(); + let purging = match self.inner.outside.landing(key) { + // Queued at once, so a record scheduled before the purge is + // dropped in the same round rather than written. + None => Either::Left(queue_purge(&self.inner.writer, path, drafts)), + Some(landing) => { + let writer = self.inner.writer.clone(); + Either::Right(async move { + // A store that went drops the sender, which ends the + // wait just the same. + let _ = landing.await; + queue_purge(&writer, path, drafts).await + }) + } + }; + async move { purging.await.unwrap_or(Err(WorkspaceSaveError::WriterStopped)) } + } + + /// Queue `record` as the sessions the windows show. A newer one + /// replaces it if the writer has not reached it yet. + pub fn schedule_open_sessions(&self, record: OpenSessionsRecord) -> Result<(), WorkspaceSaveError> { + self.inner + .writer + .send(WriteJob::OpenSessions { + path: self.inner.open_sessions_path(), + record, + }) + .map_err(|_| WorkspaceSaveError::WriterStopped) + } + + /// Resolves once every open-sessions record queued so far is on + /// disk, with how the last write of it went. + pub fn flush_open_sessions(&self) -> impl Future> + use<> { + let written = self.inner.queue_flush(&self.inner.open_sessions_path()); + async move { written.await.unwrap_or(Err(WorkspaceSaveError::WriterStopped)) } + } + + /// Read which sessions the windows showed, after any record already + /// queued has landed. + pub fn load_open_sessions(&self) -> impl Future + use<> { + let inner = self.inner.clone(); + let path = self.inner.open_sessions_path(); + let written = self.inner.queue_flush(&path); + async move { + let _ = written.await; + inner + .tasks + .spawn_blocking_task(move || read_open_sessions_blocking(&path)) + .await + .unwrap_or_else(|failure| { + tracing::warn!(%failure, "could not read which sessions were open"); + OpenSessionsLoad::Empty + }) + } + } + + pub fn is_blocked(&self, key: &SessionKey) -> bool { + self.inner.blocked.borrow().contains(key) + } + + /// Whether a copy of the session's file that Reset moved aside is + /// still there, as of the last load or reset. It may name drafts + /// that no saved tab does. + pub fn has_moved_aside_copy(&self, key: &SessionKey) -> bool { + self.inner.moved_aside.borrow().contains(key) + } +} + +impl StoreInner { + fn path(&self, key: &SessionKey) -> PathBuf { + self.dir.join(WorkspaceFileName::for_key(key).file_name()) + } + + fn open_sessions_path(&self) -> PathBuf { + self.dir.join(OpenSessionsRecord::FILE_NAME) + } + + fn queue_flush(&self, path: &Path) -> oneshot::Receiver> { + let (written, receiver) = oneshot::channel(); + // A writer that has stopped drops the job, and with it the + // sender, which the receiver reports. + let _ = self.writer.send(WriteJob::Flush { + path: path.to_owned(), + written, + }); + receiver + } +} + +type Reply = oneshot::Sender>; + +fn queue_purge( + writer: &mpsc::UnboundedSender, + path: PathBuf, + drafts: DraftScope, +) -> oneshot::Receiver> { + let (purged, receiver) = oneshot::channel(); + let _ = writer.send(WriteJob::Purge { path, drafts, purged }); + receiver +} + +/// The jobs that arrived while the writer was busy. Only the newest +/// record per file is kept, and every waiter is answered after the +/// writes it queued behind. +#[derive(Default)] +struct Round { + records: BTreeMap, + /// A purge wins over a record queued for its file in the same + /// round, whichever came first. + purges: Vec<(PathBuf, DraftScope, Reply)>, + open_sessions: Option<(PathBuf, OpenSessionsRecord)>, + waiting: Vec<(PathBuf, Reply)>, +} + +impl Round { + fn take(&mut self, job: WriteJob) { + match job { + WriteJob::Write { path, record } => { + self.records.insert(path, record); + } + WriteJob::OpenSessions { path, record } => self.open_sessions = Some((path, record)), + WriteJob::Flush { path, written } => self.waiting.push((path, written)), + WriteJob::Purge { path, drafts, purged } => self.purges.push((path, drafts, purged)), + } + } +} + +async fn run_writer( + mut jobs: mpsc::UnboundedReceiver, + tasks: Tasks, + drafts: DraftStore, + write: Arc, +) { + let mut last: HashMap> = HashMap::new(); + while let Some(job) = jobs.recv().await { + let mut round = Round::default(); + round.take(job); + while let Ok(job) = jobs.try_recv() { + round.take(job); + } + let purged: HashSet = round.purges.iter().map(|(path, ..)| path.clone()).collect(); + for (path, record) in round.records { + if purged.contains(&path) { + continue; + } + let outcome = write_record(&tasks, &write, path.clone(), record).await; + last.insert(path, outcome); + } + if let Some((path, record)) = round.open_sessions { + let outcome = write_record(&tasks, &write, path.clone(), record).await; + last.insert(path, outcome); + } + for (path, scope, reply) in round.purges { + last.remove(&path); + let store = drafts.clone(); + let outcome = tasks + .spawn_blocking_task(move || purge_blocking(&path, &store, &scope)) + .await + .unwrap_or_else(|failure| Err(WorkspaceSaveError::Purge(failure.to_string()))); + if let Err(error) = &outcome { + tracing::warn!(%error, "could not delete a deleted connection's saved tabs"); + } + let _ = reply.send(outcome); + } + for (path, written) in round.waiting { + let _ = written.send(last.get(&path).cloned().unwrap_or(Ok(()))); + } + } +} + +/// Every part is tried, so one that fails leaves no more behind than +/// it has to; the first failure is the one reported. +fn purge_blocking(path: &Path, drafts: &DraftStore, scope: &DraftScope) -> Result<(), WorkspaceSaveError> { + let file = match std::fs::remove_file(path) { + Err(error) if error.kind() != ErrorKind::NotFound => Err(error.to_string()), + _ => Ok(()), + }; + let copies = tablepro_storage::fs::remove_moved_aside_blocking(path) + .map(drop) + .map_err(|error| error.to_string()); + let drafts = drafts.delete_scope_blocking(scope).map_err(|error| error.to_string()); + file.and(copies).and(drafts).map_err(WorkspaceSaveError::Purge) +} + +async fn write_record( + tasks: &Tasks, + write: &Arc, + path: PathBuf, + record: D, +) -> Result<(), WorkspaceSaveError> { + let write = write.clone(); + let shown = path.display().to_string(); + let outcome = tasks + .spawn_blocking_task(move || { + let bytes = encode_document(&record).map_err(|error| WorkspaceSaveError::Write(error.to_string()))?; + write(&path, &bytes).map_err(|error| WorkspaceSaveError::Write(error.to_string())) + }) + .await + .unwrap_or_else(|failure| Err(WorkspaceSaveError::Write(failure.to_string()))); + if let Err(error) = &outcome { + tracing::warn!(%error, path = %shown, kind = D::KIND, "could not write a workspace file"); + } + outcome +} + +fn read_record(path: &Path) -> WorkspaceLoad { + let bytes = match read_capped(path) { + Ok(Some(bytes)) => bytes, + Ok(None) => return WorkspaceLoad::Blocked(WorkspaceLoadError::TooLarge { limit: MAX_FILE_BYTES }), + Err(error) if error.kind() == ErrorKind::NotFound => return WorkspaceLoad::Empty, + Err(error) => return WorkspaceLoad::Blocked(WorkspaceLoadError::Io(error.to_string())), + }; + match decode_document::(path, &bytes) { + Ok(record) => WorkspaceLoad::Restored(record.clamped()), + Err(problem) => WorkspaceLoad::Blocked(problem.into()), + } +} + +/// The whole file, or `None` when it is larger than `MAX_FILE_BYTES`. +pub(super) fn read_capped(path: &Path) -> std::io::Result>> { + let file = std::fs::File::open(path)?; + if file.metadata()?.len() > MAX_FILE_BYTES { + return Ok(None); + } + let mut bytes = Vec::new(); + // The file can grow after the size was read. + file.take(MAX_FILE_BYTES + 1).read_to_end(&mut bytes)?; + Ok((bytes.len() as u64 <= MAX_FILE_BYTES).then_some(bytes)) +} + +/// A directory that cannot be listed counts as holding a copy, so +/// nothing is deleted on a guess. +fn moved_aside_exists(path: &Path) -> bool { + tablepro_storage::fs::moved_aside_exists_blocking(path).unwrap_or_else(|error| { + tracing::warn!(%error, "could not look for saved tabs moved aside"); + true + }) +} + +fn move_aside(path: &Path) -> Result, WorkspaceSaveError> { + if let Err(error) = std::fs::symlink_metadata(path) + && error.kind() == ErrorKind::NotFound + { + return Ok(None); + } + tablepro_storage::fs::move_aside_blocking(path, std::time::SystemTime::now()) + .map(Some) + .map_err(|error| WorkspaceSaveError::MoveAside(error.to_string())) +} + +#[cfg(test)] +mod tests { + use std::os::unix::fs::PermissionsExt; + use std::sync::Mutex; + + use tablepro_storage::{DraftId, DraftStore}; + use uuid::Uuid; + + use super::*; + use crate::test_support::paused_tasks; + use crate::workspace::TabRecord; + + fn key() -> SessionKey { + SessionKey::Saved(Uuid::new_v4()) + } + + fn record(tables: usize) -> WorkspaceRecord { + WorkspaceRecord { + active_tab: Some(0), + tabs: (0..tables) + .map(|index| TabRecord::Table { + schema: None, + table: format!("t{index}"), + offset: 0, + page_size: 1_000, + sort: None, + }) + .collect(), + } + } + + struct Fixture { + root: tempfile::TempDir, + store: WorkspaceStore, + } + + fn drafts(root: &Path) -> DraftStore { + DraftStore::new(root.join("drafts")) + } + + impl Fixture { + fn new() -> Self { + let root = tempfile::tempdir().expect("tempdir"); + let store = WorkspaceStore::start(root.path().join("workspaces"), drafts(root.path()), &paused_tasks()); + Self { root, store } + } + + fn path(&self, key: &SessionKey) -> PathBuf { + self.root + .path() + .join("workspaces") + .join(WorkspaceFileName::for_key(key).file_name()) + } + + fn seed(&self, key: &SessionKey, bytes: &[u8]) -> PathBuf { + let path = self.path(key); + std::fs::create_dir_all(path.parent().expect("a parent")).expect("mkdir"); + std::fs::write(&path, bytes).expect("seed"); + path + } + + fn on_disk(&self, key: &SessionKey) -> WorkspaceRecord { + let path = self.path(key); + let bytes = std::fs::read(&path).expect("the file was written"); + decode_document(&path, &bytes).expect("a record") + } + } + + #[tokio::test] + async fn notfound_loads_empty() { + let fixture = Fixture::new(); + let key = key(); + + assert_eq!(fixture.store.load(&key).await, WorkspaceLoad::Empty); + assert!(!fixture.store.is_blocked(&key)); + } + + #[tokio::test] + async fn flush_waits_for_pending_write() { + let fixture = Fixture::new(); + let key = key(); + + fixture.store.schedule(&key, record(2)).expect("scheduled"); + fixture.store.flush(&key).await.expect("written"); + + assert_eq!(fixture.on_disk(&key), record(2)); + } + + #[tokio::test] + async fn load_reads_a_record_still_queued() { + let fixture = Fixture::new(); + let key = key(); + let editor = WorkspaceRecord { + active_tab: Some(0), + tabs: vec![TabRecord::Editor { + draft: DraftId::new(), + file: None, + }], + }; + + fixture.store.schedule(&key, editor.clone()).expect("scheduled"); + + assert_eq!(fixture.store.load(&key).await, WorkspaceLoad::Restored(editor)); + } + + #[tokio::test] + async fn written_file_mode_0600() { + let fixture = Fixture::new(); + let key = key(); + + fixture.store.schedule(&key, record(1)).expect("scheduled"); + fixture.store.flush(&key).await.expect("written"); + + let mode = std::fs::metadata(fixture.path(&key)) + .expect("metadata") + .permissions() + .mode(); + assert_eq!(mode & 0o777, 0o600); + } + + #[tokio::test] + async fn unsupported_version_blocks_key() { + let fixture = Fixture::new(); + let key = key(); + fixture.seed(&key, br#"{"version": 2, "tabs": []}"#); + + let load = fixture.store.load(&key).await; + + assert_eq!( + load, + WorkspaceLoad::Blocked(WorkspaceLoadError::UnsupportedVersion { + found: Some(2), + expected: 1 + }) + ); + assert_eq!( + fixture.store.schedule(&key, record(1)), + Err(WorkspaceSaveError::Blocked) + ); + } + + #[tokio::test] + async fn blocked_key_leaves_bytes_unchanged() { + let fixture = Fixture::new(); + let (key, other) = (key(), key()); + let original = b"{ not json"; + let path = fixture.seed(&key, original); + + let load = fixture.store.load(&key).await; + let scheduled = fixture.store.schedule(&key, record(1)); + let flushed = fixture.store.flush(&key).await; + fixture + .store + .schedule(&other, record(1)) + .expect("another key still saves"); + fixture.store.flush(&other).await.expect("written"); + + assert!( + matches!(load, WorkspaceLoad::Blocked(WorkspaceLoadError::Corrupt(_))), + "{load:?}" + ); + assert_eq!(scheduled, Err(WorkspaceSaveError::Blocked)); + assert_eq!(flushed, Err(WorkspaceSaveError::Blocked)); + assert_eq!(std::fs::read(&path).expect("read"), original); + } + + #[tokio::test] + async fn reset_moves_aside_and_unblocks() { + let fixture = Fixture::new(); + let key = key(); + let original = b"{ not json"; + let path = fixture.seed(&key, original); + assert!(matches!(fixture.store.load(&key).await, WorkspaceLoad::Blocked(_))); + + let moved = fixture.store.reset(&key).await.expect("reset").expect("a moved file"); + + let moved_name = moved.file_name().expect("a name").to_string_lossy().into_owned(); + let name = WorkspaceFileName::for_key(&key).file_name(); + assert!(moved_name.starts_with(&format!("{name}.corrupt-")), "{moved_name}"); + assert_eq!(std::fs::read(&moved).expect("read the moved file"), original); + assert!(!path.exists()); + assert!(!fixture.store.is_blocked(&key)); + fixture.store.schedule(&key, record(1)).expect("saves again"); + fixture.store.flush(&key).await.expect("written"); + assert_eq!(fixture.on_disk(&key), record(1)); + } + + #[tokio::test] + async fn reset_without_a_file_unblocks() { + let fixture = Fixture::new(); + let key = key(); + let path = fixture.seed(&key, b"{ not json"); + assert!(matches!(fixture.store.load(&key).await, WorkspaceLoad::Blocked(_))); + std::fs::remove_file(&path).expect("remove"); + + assert_eq!(fixture.store.reset(&key).await, Ok(None)); + assert!(!fixture.store.is_blocked(&key)); + } + + #[tokio::test] + async fn reset_leaves_a_readable_file_alone() { + let fixture = Fixture::new(); + let key = key(); + fixture.store.schedule(&key, record(1)).expect("scheduled"); + assert_eq!(fixture.store.load(&key).await, WorkspaceLoad::Restored(record(1))); + + assert_eq!(fixture.store.reset(&key).await, Ok(None)); + + assert_eq!(fixture.on_disk(&key), record(1)); + assert!(!fixture.store.has_moved_aside_copy(&key)); + } + + #[tokio::test] + async fn a_copy_moved_aside_is_remembered() { + let fixture = Fixture::new(); + let (key, other) = (key(), key()); + fixture.seed(&key, b"{ not json"); + assert!(matches!(fixture.store.load(&key).await, WorkspaceLoad::Blocked(_))); + assert!(!fixture.store.has_moved_aside_copy(&key)); + + fixture.store.reset(&key).await.expect("reset"); + assert!(fixture.store.has_moved_aside_copy(&key)); + // As a later run finds it on disk. + let restarted = WorkspaceStore::start( + fixture.root.path().join("workspaces"), + drafts(fixture.root.path()), + &paused_tasks(), + ); + assert_eq!(restarted.load(&key).await, WorkspaceLoad::Empty); + restarted.load(&other).await; + + assert!(restarted.has_moved_aside_copy(&key)); + assert!(!restarted.has_moved_aside_copy(&other)); + } + + #[tokio::test] + async fn a_record_of_many_tabs_loads_whole() { + let fixture = Fixture::new(); + let key = key(); + let many = WorkspaceRecord { + active_tab: Some(39), + tabs: (0..40) + .map(|_| TabRecord::Editor { + draft: DraftId::new(), + file: None, + }) + .collect(), + }; + fixture.store.schedule(&key, many.clone()).expect("scheduled"); + + assert_eq!(fixture.store.load(&key).await, WorkspaceLoad::Restored(many)); + } + + #[tokio::test] + async fn an_oversized_file_blocks_key() { + let fixture = Fixture::new(); + let key = key(); + let padding = " ".repeat(usize::try_from(MAX_FILE_BYTES).expect("a size")); + let path = fixture.seed(&key, format!(r#"{{"version": 1, "tabs": []{padding}}}"#).as_bytes()); + let size = std::fs::metadata(&path).expect("metadata").len(); + + let load = fixture.store.load(&key).await; + + assert_eq!( + load, + WorkspaceLoad::Blocked(WorkspaceLoadError::TooLarge { limit: MAX_FILE_BYTES }) + ); + assert_eq!(std::fs::metadata(&path).expect("metadata").len(), size); + } + + #[tokio::test] + async fn writer_coalesces_latest_record_per_key() { + let root = tempfile::tempdir().expect("tempdir"); + let written: Arc>> = Arc::default(); + let (started, mut starts) = mpsc::unbounded_channel::<()>(); + // Holds the first write open, so the rest arrive while it runs. + let (release, hold) = std::sync::mpsc::channel::<()>(); + let hold = Mutex::new(hold); + let log = written.clone(); + let write: Arc = Arc::new(move |path: &Path, bytes: &[u8]| { + let record: WorkspaceRecord = decode_document(path, bytes).expect("a record"); + let first = { + let mut log = log.lock().expect("the log"); + log.push((path.to_owned(), record.tabs.len())); + log.len() == 1 + }; + let _ = started.send(()); + if first { + let _ = hold.lock().expect("the hold").recv(); + } + Ok(()) + }); + let store = WorkspaceStore::start_with(root.path().to_owned(), drafts(root.path()), &paused_tasks(), write); + let (a, b) = (key(), key()); + let path_a = store.inner.path(&a); + let path_b = store.inner.path(&b); + + store.schedule(&a, record(1)).expect("scheduled"); + starts.recv().await.expect("the first write started"); + for tables in [2, 3] { + store.schedule(&a, record(tables)).expect("scheduled"); + } + store.schedule(&b, record(4)).expect("scheduled"); + release.send(()).expect("release the first write"); + store.flush(&a).await.expect("a written"); + store.flush(&b).await.expect("b written"); + + let written = written.lock().expect("the log").clone(); + let for_path = |path: &PathBuf| -> Vec { + written + .iter() + .filter(|(written, _)| written == path) + .map(|(_, tables)| *tables) + .collect() + }; + assert_eq!(for_path(&path_a), [1, 3], "{written:?}"); + assert_eq!(for_path(&path_b), [4], "{written:?}"); + } + + #[tokio::test] + async fn purge_removes_the_file_its_moved_aside_copies_and_drafts() { + let fixture = Fixture::new(); + let (key, other) = (key(), key()); + let path = fixture.seed(&key, b"{ not json"); + assert!(matches!(fixture.store.load(&key).await, WorkspaceLoad::Blocked(_))); + fixture.store.reset(&key).await.expect("reset"); + fixture.store.schedule(&key, record(1)).expect("scheduled"); + fixture.store.flush(&key).await.expect("written"); + let store = drafts(fixture.root.path()); + let scope = WorkspaceFileName::for_key(&key).draft_scope(); + store + .write_blocking(&scope, DraftId::new(), "SELECT 1") + .expect("a draft"); + let kept_scope = WorkspaceFileName::for_key(&other).draft_scope(); + store + .write_blocking(&kept_scope, DraftId::new(), "SELECT 2") + .expect("a draft"); + fixture.store.schedule(&other, record(2)).expect("scheduled"); + + fixture.store.purge(&key).await.expect("purged"); + + assert!(!path.exists(), "the saved tabs outlived the purge"); + assert!( + !tablepro_storage::fs::moved_aside_exists_blocking(&path).expect("list"), + "a copy Reset moved aside outlived the purge" + ); + assert!( + !store.root().join(scope.as_str()).exists(), + "the drafts outlived the purge" + ); + assert!( + store.root().join(kept_scope.as_str()).exists(), + "another session's drafts went" + ); + assert!(!fixture.store.has_moved_aside_copy(&key)); + fixture.store.flush(&other).await.expect("written"); + assert_eq!(fixture.on_disk(&other), record(2)); + } + + #[tokio::test] + async fn purge_drops_a_record_still_queued() { + let root = tempfile::tempdir().expect("tempdir"); + let written: Arc>> = Arc::default(); + let (started, mut starts) = mpsc::unbounded_channel::<()>(); + let (release, hold) = std::sync::mpsc::channel::<()>(); + let hold = Mutex::new(hold); + let log = written.clone(); + // Holds the first write open, so the record and the purge after + // it arrive in one round. + let write: Arc = Arc::new(move |path: &Path, bytes: &[u8]| { + let first = { + let mut log = log.lock().expect("the log"); + log.push(path.to_owned()); + log.len() == 1 + }; + let _ = started.send(()); + if first { + let _ = hold.lock().expect("the hold").recv(); + } + tablepro_storage::fs::write_private_blocking(path, bytes) + }); + let store = WorkspaceStore::start_with(root.path().to_owned(), drafts(root.path()), &paused_tasks(), write); + let (busy, purged) = (key(), key()); + + store.schedule(&busy, record(1)).expect("scheduled"); + starts.recv().await.expect("the first write started"); + store.schedule(&purged, record(2)).expect("scheduled"); + let purging = store.purge(&purged); + release.send(()).expect("release the first write"); + purging.await.expect("purged"); + + let path = store.inner.path(&purged); + assert!(!path.exists(), "the queued record was written after all"); + assert!( + !written.lock().expect("the log").contains(&path), + "the queued record was written and then removed" + ); + } + + #[tokio::test] + async fn a_purge_waits_for_drafts_still_landing() { + let fixture = Fixture::new(); + let key = key(); + let store = drafts(fixture.root.path()); + let scope = WorkspaceFileName::for_key(&key).draft_scope(); + let closing = fixture.store.writes_in_flight(&key); + + let mut purging = Box::pin(fixture.store.purge(&key)); + let early = tokio::time::timeout(std::time::Duration::from_millis(50), &mut purging).await; + assert!(early.is_err(), "the purge ran with a draft still landing"); + store + .write_blocking(&scope, DraftId::new(), "SELECT 1") + .expect("the late draft"); + drop(closing); + purging.await.expect("purged"); + + assert!( + !store.root().join(scope.as_str()).exists(), + "a draft that landed late outlived the purge" + ); + } + + #[tokio::test] + async fn purging_a_session_with_nothing_saved_succeeds() { + let fixture = Fixture::new(); + + assert_eq!(fixture.store.purge(&key()).await, Ok(())); + } + + #[tokio::test] + async fn a_purge_that_cannot_delete_the_drafts_says_so() { + let fixture = Fixture::new(); + let key = key(); + let scope = WorkspaceFileName::for_key(&key).draft_scope(); + let drafts_root = fixture.root.path().join("drafts"); + std::fs::create_dir_all(&drafts_root).expect("mkdir"); + // A file where the scope's directory goes cannot be removed as + // one. + std::fs::write(drafts_root.join(scope.as_str()), b"not a directory").expect("seed"); + + assert!(matches!( + fixture.store.purge(&key).await, + Err(WorkspaceSaveError::Purge(_)) + )); + } + + #[tokio::test] + async fn a_failed_write_is_reported_by_flush() { + let root = tempfile::tempdir().expect("tempdir"); + let write: Arc = Arc::new(|path: &Path, _: &[u8]| { + Err(StorageError::Io { + path: path.to_owned(), + source: std::io::Error::other("disk full"), + }) + }); + let store = WorkspaceStore::start_with(root.path().to_owned(), drafts(root.path()), &paused_tasks(), write); + let key = key(); + + store.schedule(&key, record(1)).expect("scheduled"); + + assert!(matches!(store.flush(&key).await, Err(WorkspaceSaveError::Write(_)))); + } +} diff --git a/linux/crates/app/src/workspace/write_job.rs b/linux/crates/app/src/workspace/write_job.rs new file mode 100644 index 0000000000..2e85c6a5a9 --- /dev/null +++ b/linux/crates/app/src/workspace/write_job.rs @@ -0,0 +1,32 @@ +use std::path::PathBuf; + +use tablepro_storage::DraftScope; +use tokio::sync::oneshot; + +use super::{OpenSessionsRecord, WorkspaceRecord, WorkspaceSaveError}; + +/// What the writer task is asked to do, in the order it was asked. +pub(super) enum WriteJob { + Write { + path: PathBuf, + record: WorkspaceRecord, + }, + OpenSessions { + path: PathBuf, + record: OpenSessionsRecord, + }, + /// Answered once every record queued before it for `path` is on + /// disk, with how the last write of that file went. + Flush { + path: PathBuf, + written: oneshot::Sender>, + }, + /// Delete the file, every copy of it moved aside and the drafts in + /// `drafts`, for a connection that was deleted. A record queued for + /// `path` in the same round is dropped rather than written. + Purge { + path: PathBuf, + drafts: DraftScope, + purged: oneshot::Sender>, + }, +} diff --git a/linux/crates/app/src/workspace/writes_in_flight.rs b/linux/crates/app/src/workspace/writes_in_flight.rs new file mode 100644 index 0000000000..4984e59b35 --- /dev/null +++ b/linux/crates/app/src/workspace/writes_in_flight.rs @@ -0,0 +1,116 @@ +use std::cell::RefCell; +use std::collections::HashMap; +use std::rc::{Rc, Weak}; + +use tablepro_session::SessionKey; +use tokio::sync::oneshot; + +/// Writes for a session that run outside the workspace store, such as +/// the editor drafts of a workspace being closed, counted per session. +#[derive(Default)] +pub(super) struct OutsideWrites { + sessions: RefCell>, +} + +#[derive(Default)] +struct Landing { + writes: usize, + waiters: Vec>, +} + +impl OutsideWrites { + pub(super) fn begin(self: &Rc, key: &SessionKey) -> WritesInFlight { + self.sessions.borrow_mut().entry(key.clone()).or_default().writes += 1; + WritesInFlight { + outside: Rc::downgrade(self), + key: key.clone(), + } + } + + /// Resolves once every write for `key` has landed, or `None` when + /// none is in flight. + pub(super) fn landing(&self, key: &SessionKey) -> Option> { + let mut sessions = self.sessions.borrow_mut(); + let landing = sessions.get_mut(key)?; + let (landed, receiver) = oneshot::channel(); + landing.waiters.push(landed); + Some(receiver) + } + + fn end(&self, key: &SessionKey) { + let waiters = { + let mut sessions = self.sessions.borrow_mut(); + let Some(landing) = sessions.get_mut(key) else { + return; + }; + landing.writes = landing.writes.saturating_sub(1); + if landing.writes > 0 { + return; + } + sessions.remove(key).map(|landing| landing.waiters).unwrap_or_default() + }; + for waiter in waiters { + let _ = waiter.send(()); + } + } +} + +/// Counts writes for one session as still landing until it drops. +#[must_use = "the writes count as landed once this drops"] +pub struct WritesInFlight { + outside: Weak, + key: SessionKey, +} + +impl Drop for WritesInFlight { + fn drop(&mut self) { + if let Some(outside) = self.outside.upgrade() { + outside.end(&self.key); + } + } +} + +impl std::fmt::Debug for WritesInFlight { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("WritesInFlight").field("key", &self.key).finish() + } +} + +#[cfg(test)] +mod tests { + use uuid::Uuid; + + use super::*; + + fn key() -> SessionKey { + SessionKey::Saved(Uuid::new_v4()) + } + + #[test] + fn nothing_in_flight_has_nothing_to_wait_for() { + let outside = Rc::new(OutsideWrites::default()); + + assert!(outside.landing(&key()).is_none()); + } + + #[tokio::test] + async fn the_wait_ends_when_the_last_write_lands() { + let outside = Rc::new(OutsideWrites::default()); + let (key, other) = (key(), key()); + let first = outside.begin(&key); + let second = outside.begin(&key); + let _unrelated = outside.begin(&other); + let mut landing = outside.landing(&key).expect("writes in flight"); + + drop(first); + assert!(landing.try_recv().is_err(), "the wait ended with a write still landing"); + drop(second); + + assert!(landing.await.is_ok()); + assert!(outside.landing(&key).is_none()); + assert!( + outside.landing(&other).is_some(), + "another session's writes were counted as landed" + ); + } +} diff --git a/linux/crates/app/tests/accessibility/audit.rs b/linux/crates/app/tests/accessibility/audit.rs new file mode 100644 index 0000000000..c724d4e68c --- /dev/null +++ b/linux/crates/app/tests/accessibility/audit.rs @@ -0,0 +1,246 @@ +//! What a screen reader needs from each control the app puts on a page. + +use std::fmt; + +use gtk4::glib; +use gtk4::prelude::*; +use gtk4::{AccessibleProperty, AccessibleRelation, AccessibleRole}; +use libadwaita as adw; +use libadwaita::prelude::*; + +/// What a control lacks for a screen reader to name it. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Missing { + /// Neither an accessible label nor a labelled-by relation. + Label, + /// A row's title is its name. + RowTitle, + /// An entry row's text field is named through its title. + EntryText, +} + +impl fmt::Display for Missing { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(match self { + Self::Label => "no accessible label or labelled-by relation", + Self::RowTitle => "an empty row title", + Self::EntryText => "a text field the row title does not label", + }) + } +} + +/// A control a screen reader reaches and cannot name. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Finding { + type_name: String, + css_classes: Vec, + /// Its id in the template that declares it, or its tooltip. + hint: Option, + /// The app's widget it sits in. + owner: Option, + missing: Missing, +} + +impl fmt::Display for Finding { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.type_name)?; + for class in &self.css_classes { + write!(f, ".{class}")?; + } + if let Some(hint) = &self.hint { + write!(f, " “{hint}”")?; + } + if let Some(owner) = &self.owner { + write!(f, " in {owner}")?; + } + write!(f, ": {}", self.missing) + } +} + +/// Whether GTK records the accessible properties the checks read, which +/// only its test backend does. +pub fn test_backend_active() -> bool { + let probe = gtk4::Button::new(); + probe.update_property(&[gtk4::accessible::Property::Label("probe")]); + gtk4::test_accessible_has_property(&probe, AccessibleProperty::Label) +} + +/// The controls at and under `root` that a screen reader reaches and +/// cannot name. A hidden widget, and all it holds, is out of reach. +pub fn audit(root: &impl IsA) -> Vec { + let mut findings = Vec::new(); + let mut pending = vec![(root.as_ref().clone(), true)]; + while let Some((widget, app_owned)) = pending.pop() { + if !widget.is_visible() || !widget.is_child_visible() { + continue; + } + if app_owned + && exposed(&widget) + && let Some(missing) = judge(&widget) + { + findings.push(finding(&widget, missing)); + } + if is_one_control(&widget) || is_libadwaitas_own(&widget) || hides_its_child(&widget) { + continue; + } + // The preferences dialog's own header is libadwaita's too, its + // search button named by a tooltip alone; the pages are the app's. + let app_owned = !widget.is::() && (app_owned || widget.is::()); + pending.extend(children(&widget).into_iter().map(|child| (child, app_owned))); + } + findings +} + +/// What libadwaita 1.9 builds inside these it names with a tooltip +/// alone, where the app can neither reach nor name it: a tab's close +/// button and a password row's peek button. +fn is_libadwaitas_own(widget: >k4::Widget) -> bool { + widget.is::() || widget.is::() +} + +fn finding(widget: >k4::Widget, missing: Missing) -> Finding { + let id = widget + .buildable_id() + .map(|id| id.to_string()) + .filter(|id| !id.starts_with("___object_")); + let mut owner = widget.parent(); + while let Some(parent) = owner + .as_ref() + .filter(|parent| !parent.type_().name().starts_with("TablePro")) + { + owner = parent.parent(); + } + Finding { + type_name: widget.type_().name().to_owned(), + css_classes: widget.css_classes().iter().map(ToString::to_string).collect(), + hint: id.or_else(|| widget.tooltip_text().map(|tooltip| tooltip.to_string())), + owner: owner.map(|owner| owner.type_().name().to_owned()), + missing, + } +} + +fn exposed(widget: >k4::Widget) -> bool { + !matches!( + widget.accessible_role(), + AccessibleRole::None | AccessibleRole::Presentation + ) +} + +fn judge(widget: >k4::Widget) -> Option { + if let Some(row) = widget.downcast_ref::() { + return judge_row(row); + } + let needs_name = match button_face(widget) { + Some(face) => face == ButtonFace::Bare, + None => is_field(widget), + }; + (needs_name && !named(widget)).then_some(Missing::Label) +} + +fn judge_row(row: &adw::PreferencesRow) -> Option { + // A row whose controls are its content names itself instead. + if row.title().is_empty() + && !gtk4::test_accessible_has_property(row.upcast_ref::(), AccessibleProperty::Label) + { + return Some(Missing::RowTitle); + } + if !row.is::() { + return None; + } + let labelled = descendants(row.upcast_ref()) + .into_iter() + .find(|widget| widget.is::()) + .is_some_and(|text| gtk4::test_accessible_has_relation(&text, AccessibleRelation::LabelledBy)); + (!labelled).then_some(Missing::EntryText) +} + +fn named(widget: >k4::Widget) -> bool { + gtk4::test_accessible_has_property(widget, AccessibleProperty::Label) + || gtk4::test_accessible_has_relation(widget, AccessibleRelation::LabelledBy) +} + +/// A field a user types in, picks from or switches, which shows no name +/// of its own. A text view that never takes the focus is text on the +/// page rather than a field. +fn is_field(widget: >k4::Widget) -> bool { + // A bare text field, such as a cell's editor; one inside an entry or + // a spin button is met as that control. + widget.is::() + || widget.is::() + || widget.is::() + || widget.is::() + || widget.is::() + || widget.is::() + || widget.is::() + || widget.is::() + || widget.is::() + || (widget.is::() && widget.is_focusable()) + || widget + .downcast_ref::() + .is_some_and(|check| check.label().is_none_or(|label| label.is_empty()) && check.child().is_none()) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ButtonFace { + /// A label, or content of its own, which is taken to carry text. + Text, + /// An icon, or nothing at all. + Bare, +} + +fn button_face(widget: >k4::Widget) -> Option { + let face = |label: Option, child: Option| { + let text = + label.is_some_and(|label| !label.is_empty()) || child.is_some_and(|child| !child.is::()); + if text { ButtonFace::Text } else { ButtonFace::Bare } + }; + if let Some(button) = widget.downcast_ref::() { + return Some(face(button.label(), button.child())); + } + if let Some(button) = widget.downcast_ref::() { + return Some(face(button.label(), button.child())); + } + if let Some(button) = widget.downcast_ref::() { + return Some(face(button.label(), button.child())); + } + widget.is::().then_some(ButtonFace::Bare) +} + +/// A control GTK or libadwaita builds from parts, which a screen reader +/// meets as one and names through it. +fn is_one_control(widget: >k4::Widget) -> bool { + widget.is::() + || widget.is::() + || widget.is::() + || widget.is::() + || widget.is::() + || widget.is::() + || widget.is::() + || widget.is::() +} + +fn hides_its_child(widget: >k4::Widget) -> bool { + widget + .downcast_ref::() + .is_some_and(|revealer| !revealer.reveals_child()) +} + +fn children(widget: >k4::Widget) -> Vec { + let mut found = Vec::new(); + let mut child = widget.first_child(); + while let Some(widget) = child { + child = widget.next_sibling(); + found.push(widget); + } + found +} + +pub fn descendants(root: >k4::Widget) -> Vec { + let mut found = Vec::new(); + let mut pending = children(root); + while let Some(widget) = pending.pop() { + pending.extend(children(&widget)); + found.push(widget); + } + found +} diff --git a/linux/crates/app/tests/accessibility/main.rs b/linux/crates/app/tests/accessibility/main.rs new file mode 100644 index 0000000000..d92b805e28 --- /dev/null +++ b/linux/crates/app/tests/accessibility/main.rs @@ -0,0 +1,515 @@ +//! Every page a session window shows, and the dialogs it opens, checked +//! for a control a screen reader reaches and cannot name. +//! +//! Each scenario starts a TablePro over fakes and walks it to the page, +//! on the main context, the way a user's clicks would. GTK records the +//! accessible properties only under GTK_A11Y=test. Pass scenario names +//! to run only those. + +#![expect( + clippy::print_stderr, + reason = "a harness-free test binary reports its own failures on stderr" +)] + +mod audit; +#[path = "../support/settings_schema.rs"] +mod settings_schema; +mod world; + +use std::process::ExitCode; +use std::sync::Arc; +use std::time::Duration; + +use gtk4::prelude::*; +use libadwaita as adw; +use libadwaita::prelude::*; +use tablepro_app::application::WindowLaunch; +use tablepro_core::column::{ColumnDefault, ColumnType}; +use tablepro_core::ddl::{ColumnAttributes, ColumnDefinition, TableDefinition}; +use tablepro_core::meta::{ObjectInfo, ObjectKind, RowCountEstimate, RowIdentity, SchemaInfo, TableEstimate, TableRef}; +use tablepro_core::{ColumnInfo, DatabaseDriver, DriverError, DriverRegistry, QueryResult, ServerInfo, Value}; +use tablepro_session::ConnectError; +use tablepro_session::fakes::{FakeConnector, FakeEstablish}; +use tablepro_test_support::{ConnectionScript, FakeDriver, FakeReply}; + +use audit::{Finding, audit, descendants}; +use world::{World, drain_main_context, wait_until}; + +type Scenario = fn() -> Result, String>; + +const SCENARIOS: [(&str, Scenario); 7] = [ + ("picker_page_controls_are_labelled", picker_page), + ("connecting_page_controls_are_labelled", connecting_page), + ("connect_failed_page_controls_are_labelled", connect_failed_page), + ("workspace_controls_are_labelled", workspace), + ("structure_forms_are_labelled", structure_forms), + ("connect_dialog_fields_are_labelled", connect_dialog), + ("preferences_dialog_fields_are_labelled", preferences_dialog), +]; +/// A run that has not finished by now never will. +const RUN_LIMIT: Duration = Duration::from_secs(600); +const TABLE: &str = "orders"; +const CELL: &str = "first order"; + +fn main() -> ExitCode { + let wanted: Vec = std::env::args().skip(1).filter(|arg| !arg.starts_with('-')).collect(); + if let Err(message) = init() { + eprintln!("{message}"); + return ExitCode::FAILURE; + } + std::thread::spawn(|| { + std::thread::sleep(RUN_LIMIT); + eprintln!("the scenarios did not finish within {RUN_LIMIT:?}"); + std::process::exit(2); + }); + let mut failed = false; + for (name, scenario) in SCENARIOS { + if !wanted.is_empty() && !wanted.iter().any(|filter| name.contains(filter.as_str())) { + continue; + } + match scenario() { + Ok(findings) if findings.is_empty() => eprintln!("{name}: ok"), + Ok(findings) => { + failed = true; + eprintln!("{name}: {} unlabelled", findings.len()); + for finding in findings { + eprintln!(" {finding}"); + } + } + Err(message) => { + failed = true; + eprintln!("{name} failed: {message}"); + } + } + } + if failed { ExitCode::FAILURE } else { ExitCode::SUCCESS } +} + +fn init() -> Result<(), String> { + gtk4::init().map_err(|error| format!("GTK could not start: {error}"))?; + adw::init().map_err(|error| format!("libadwaita could not start: {error}"))?; + if !audit::test_backend_active() { + return Err("the GTK test accessibility backend is not active; run with GTK_A11Y=test".to_owned()); + } + gio::resources_register_include!("tablepro.gresource") + .map_err(|error| format!("could not register the resources: {error}"))?; + // A page change or a dialog then finishes before the next step. + if let Some(settings) = gtk4::Settings::default() { + settings.set_gtk_enable_animations(false); + } + Ok(()) +} + +fn picker_page() -> Result, String> { + let world = World::start("Picker", fake_driver(), FakeConnector::new([]))?; + let window = world.open(WindowLaunch::Picker)?; + let name = world.saved.name.clone(); + wait_until("the saved connection in the picker", || { + shows(&window, "TableProPickerPage") && has_row(window.upcast_ref(), &name) + })?; + Ok(audit(&window)) +} + +fn connecting_page() -> Result, String> { + let world = World::start( + "Connecting", + fake_driver(), + FakeConnector::new([FakeEstablish::UntilCancelled]), + )?; + let window = world.open(WindowLaunch::Connect(Box::new(world.saved.clone())))?; + wait_until("the connecting page", || shows(&window, "TableProConnectingPage"))?; + Ok(audit(&window)) +} + +fn connect_failed_page() -> Result, String> { + let refused = ConnectError::Driver(DriverError::Auth { diagnostics: None }); + let world = World::start( + "ConnectFailed", + fake_driver(), + FakeConnector::new([FakeEstablish::Fail(refused)]), + )?; + let window = world.open(WindowLaunch::Connect(Box::new(world.saved.clone())))?; + wait_until("the connect failed page", || { + shows(&window, "TableProConnectFailedPage") + })?; + Ok(audit(&window)) +} + +/// A table tab, a structure tab and an editor tab, each audited while it +/// is the one in front, with the table's filter strip, the sidebar's +/// search and the editor's find and replace open, then the table tab +/// again with a cell's editor open. +fn workspace() -> Result, String> { + let (_world, window) = connected("Workspace")?; + open_table(&window)?; + open_filter_strip(&window)?; + open_structure(&window)?; + WidgetExt::activate_action(&window, "win.new-editor-tab", None) + .map_err(|error| format!("win.new-editor-tab: {error}"))?; + let mut editor = None; + wait_until("the editor tab", || { + editor = widgets_of(window.upcast_ref(), "TableProSqlEditor").pop(); + editor.is_some() + })?; + let editor = editor.ok_or("no editor")?; + editor + .activate_action("editor.find-replace", None) + .map_err(|error| format!("editor.find-replace: {error}"))?; + for search in descendants(window.upcast_ref()) + .into_iter() + .filter_map(|widget| widget.downcast::().ok()) + { + search.set_search_mode(true); + } + + let tabs = descendants(window.upcast_ref()) + .into_iter() + .find_map(|widget| widget.downcast::().ok()) + .ok_or("the workspace has no tab view")?; + if tabs.n_pages() != 3 { + return Err(format!("{} tabs open, not 3", tabs.n_pages())); + } + let mut findings = Vec::new(); + for position in 0..tabs.n_pages() { + tabs.set_selected_page(&tabs.nth_page(position)); + drain_main_context(); + merge(&mut findings, audit(&window)); + } + tabs.set_selected_page(&tabs.nth_page(0)); + drain_main_context(); + open_cell_editor(&window)?; + merge(&mut findings, audit(&window)); + Ok(findings) +} + +/// The Add Index and Add Foreign Key forms of the structure tab. +fn structure_forms() -> Result, String> { + let (_world, window) = connected("StructureForms")?; + open_structure(&window)?; + let mut findings = Vec::new(); + for title in ["Add Index…", "Add Foreign Key…"] { + let row = descendants(window.upcast_ref()) + .into_iter() + .filter_map(|widget| widget.downcast::().ok()) + .find(|row| row.title() == title) + .ok_or_else(|| format!("the structure tab has no {title} row"))?; + row.emit_by_name::<()>("activated", &[]); + let mut shown = None; + wait_until(title, || { + shown = window.visible_dialog(); + shown.is_some() + })?; + let dialog = shown.ok_or("no dialog")?; + drain_main_context(); + merge(&mut findings, audit(&dialog)); + dialog.close(); + wait_until("the form to close", || window.visible_dialog().is_none())?; + } + Ok(findings) +} + +/// A window connected to `one_table`, on its workspace. +fn connected(name: &str) -> Result<(World, adw::ApplicationWindow), String> { + let world = World::start( + name, + fake_driver(), + FakeConnector::new([]).always(FakeEstablish::Connect(one_table())), + )?; + let window = world.open(WindowLaunch::Connect(Box::new(world.saved.clone())))?; + wait_until("the table in the sidebar", || { + shows(&window, "TableProWorkspace") && shows_text(window.upcast_ref(), TABLE) + })?; + Ok((world, window)) +} + +fn sidebar_action(window: &adw::ApplicationWindow, action: &str) -> Result<(), String> { + let sidebar = descendants(window.upcast_ref()) + .into_iter() + .find(|widget| widget.is::()) + .ok_or("the sidebar has no list")?; + let table = (Some("public".to_owned()), TABLE.to_owned()).to_variant(); + sidebar + .activate_action(action, Some(&table)) + .map_err(|error| format!("{action}: {error}")) +} + +fn open_table(window: &adw::ApplicationWindow) -> Result<(), String> { + sidebar_action(window, "sidebar.open-in-new-tab")?; + wait_until("the table's rows", || shows_text(window.upcast_ref(), CELL)) +} + +fn open_structure(window: &adw::ApplicationWindow) -> Result<(), String> { + sidebar_action(window, "sidebar.edit-structure")?; + wait_until("the table's structure", || { + widgets_of(window.upcast_ref(), "TableProStructureTab") + .iter() + .flat_map(descendants) + .filter_map(|widget| widget.downcast::().ok()) + .any(|stack| stack.visible_child_name().as_deref() == Some("editor")) + }) +} + +/// The table tab's filter strip, open on its rule editor with one rule. +fn open_filter_strip(window: &adw::ApplicationWindow) -> Result<(), String> { + let tab = widgets_of(window.upcast_ref(), "TableProBrowseTab") + .pop() + .ok_or("no table tab")?; + tab.activate_action("browse.filter", None) + .map_err(|error| format!("browse.filter: {error}"))?; + let strip = widgets_of(&tab, "TableProFilterStrip").pop().ok_or("no filter strip")?; + strip + .activate_action("filter.add-rule", None) + .map_err(|error| format!("filter.add-rule: {error}"))?; + for expander in descendants(&strip) + .into_iter() + .filter_map(|widget| widget.downcast::().ok()) + { + expander.set_expanded(true); + } + wait_until("the filter rule", || { + !widgets_of(&strip, "TableProFilterRuleRow").is_empty() + }) +} + +/// The editor of the cell that shows `CELL`, opened from its menu the +/// way the Menu key opens it. +fn open_cell_editor(window: &adw::ApplicationWindow) -> Result<(), String> { + let cell = widgets_of(window.upcast_ref(), "TableProGridCell") + .into_iter() + .find(|cell| shows_text(cell, CELL)) + .ok_or("no cell shows the row")?; + let controllers = cell.observe_controllers(); + let menu = (0..controllers.n_items()) + .filter_map(|index| controllers.item(index).and_downcast::()) + .find(|gesture| gesture.button() == 3) + .ok_or("the cell has no menu")?; + menu.emit_by_name::<()>("pressed", &[&1i32, &1.0f64, &1.0f64]); + for popover in descendants(window.upcast_ref()) + .into_iter() + .filter_map(|widget| widget.downcast::().ok()) + { + popover.popdown(); + } + drain_main_context(); + cell.activate_action("cell.edit", None) + .map_err(|error| format!("cell.edit: {error}"))?; + wait_until("the cell's editor", || { + descendants(&cell) + .iter() + .any(|widget| widget.is::() && widget.is_visible()) + }) +} + +/// The dialog for a new connection, on a server engine and then on a +/// file engine, with every section expanded. +fn connect_dialog() -> Result, String> { + let mut drivers = DriverRegistry::new(); + let engines: [Arc; 2] = [ + Arc::new(tablepro_driver_postgres::PgDriver), + Arc::new(tablepro_driver_sqlite::SqliteDriver), + ]; + for engine in engines { + drivers.register(engine); + } + let world = World::start("ConnectDialog", drivers, FakeConnector::new([]))?; + let window = world.open(WindowLaunch::Picker)?; + wait_until("the picker", || shows(&window, "TableProPickerPage"))?; + WidgetExt::activate_action(&window, "win.new-connection", None) + .map_err(|error| format!("win.new-connection: {error}"))?; + let mut shown = None; + wait_until("the connect dialog", || { + shown = window.visible_dialog(); + shown.is_some() + })?; + let dialog = shown.ok_or("no dialog")?; + + let mut findings = Vec::new(); + for engine in ["PostgreSQL", "SQLite"] { + choose_driver(&dialog, engine)?; + expand_all(dialog.upcast_ref()); + drain_main_context(); + merge(&mut findings, audit(&dialog)); + } + Ok(findings) +} + +/// Every page of the dialog, each audited while it is the one shown. +fn preferences_dialog() -> Result, String> { + let world = World::start("Preferences", fake_driver(), FakeConnector::new([]))?; + let window = world.open(WindowLaunch::Picker)?; + wait_until("the picker", || shows(&window, "TableProPickerPage"))?; + world.app.activate_action("preferences", None); + let mut shown = None; + wait_until("the preferences dialog", || { + shown = window.visible_dialog().and_downcast::(); + shown.is_some() + })?; + let dialog = shown.ok_or("no dialog")?; + + // The pages the dialog switches between, which leaves out the one + // libadwaita keeps for search results. + let pages: Vec = descendants(dialog.upcast_ref()) + .into_iter() + .filter(|widget| widget.parent().is_some_and(|parent| parent.is::())) + .filter_map(|widget| widget.downcast::().ok()) + .collect(); + if pages.is_empty() { + return Err("the dialog has no pages".to_owned()); + } + let mut findings = Vec::new(); + for page in pages { + dialog.set_visible_page(&page); + drain_main_context(); + merge(&mut findings, audit(&dialog)); + } + Ok(findings) +} + +/// Add what `more` found that `findings` does not already hold, since a +/// control seen on two passes is one finding. +fn merge(findings: &mut Vec, more: Vec) { + for finding in more { + if !findings.contains(&finding) { + findings.push(finding); + } + } +} + +fn fake_driver() -> DriverRegistry { + let mut drivers = DriverRegistry::new(); + drivers.register(Arc::new(FakeDriver::connecting( + &FakeDriver::FILE_CAPABILITIES, + ConnectionScript::default(), + ))); + drivers +} + +/// A connection whose catalog lists one table, with its rows and its +/// definition, in an engine whose structure can be edited. +fn one_table() -> ConnectionScript { + let mut script = ConnectionScript { + dialect: Arc::new(tablepro_driver_postgres::PgDialect::new(&ServerInfo::new( + "PostgreSQL", + "18.0", + 18, + 0, + 0, + ))), + ..ConnectionScript::default() + }; + let columns = vec![column("id", true), column("note", false)]; + script.probe.always(FakeReply::ok(())); + script.list_schemas.always(FakeReply::ok(vec![SchemaInfo { + name: "public".to_owned(), + is_default: true, + is_system: false, + }])); + script.list_objects.always(FakeReply::ok(vec![ObjectInfo { + schema: Some("public".to_owned()), + name: TABLE.to_owned(), + kind: ObjectKind::Table, + }])); + script.fetch_columns.always(FakeReply::ok(columns.clone())); + script.fetch_indexes.always(FakeReply::ok(Vec::new())); + script.fetch_foreign_keys.always(FakeReply::ok(Vec::new())); + script.row_identity.always(FakeReply::ok(RowIdentity::UniqueKey { + columns: vec!["id".to_owned()], + })); + script.count_rows.always(FakeReply::ok(1)); + script.table_estimate.always(FakeReply::ok(TableEstimate { + rows: RowCountEstimate::Approximate(1), + heap_blocks: None, + })); + script.query_bound.always(FakeReply::ok(QueryResult::new( + columns.iter().map(ColumnInfo::result_column).collect::>(), + vec![vec![Value::Int(1), Value::Text(CELL.to_owned())]], + ))); + script.fetch_table_definition.always(FakeReply::ok(TableDefinition { + table: TableRef::new(Some("public".to_owned()), TABLE), + columns: columns + .into_iter() + .map(|column| ColumnDefinition::new(column, ColumnAttributes::default())) + .collect(), + primary_key: None, + indexes: Vec::new(), + foreign_keys: Vec::new(), + inbound_foreign_keys: Vec::new(), + source: None, + })); + script +} + +fn column(name: &str, primary_key: bool) -> ColumnInfo { + ColumnInfo { + name: name.to_owned(), + column_type: ColumnType::untyped_text("text"), + nullable: !primary_key, + primary_key, + is_auto_increment: false, + is_generated: false, + default: ColumnDefault::None, + comment: None, + } +} + +/// Whether the window's content is the page of class `name`. +fn shows(window: &adw::ApplicationWindow, name: &str) -> bool { + window.content().is_some_and(|page| page.type_().name() == name) +} + +fn widgets_of(root: >k4::Widget, name: &str) -> Vec { + descendants(root) + .into_iter() + .filter(|widget| widget.type_().name() == name) + .collect() +} + +/// Whether a label, or a grid cell's inscription, under `root` shows +/// `text`. +fn shows_text(root: >k4::Widget, text: &str) -> bool { + descendants(root).iter().any(|widget| { + widget + .downcast_ref::() + .is_some_and(|label| label.label() == text) + || widget + .downcast_ref::() + .is_some_and(|cell| cell.text().as_deref() == Some(text)) + }) +} + +fn has_row(root: >k4::Widget, title: &str) -> bool { + descendants(root) + .iter() + .filter_map(|widget| widget.downcast_ref::()) + .any(|row| row.title() == title) +} + +fn choose_driver(dialog: &adw::Dialog, engine: &str) -> Result<(), String> { + let row = descendants(dialog.upcast_ref()) + .into_iter() + .filter_map(|widget| widget.downcast::().ok()) + .find(|row| row.title() == "Driver") + .ok_or("the dialog has no Driver row")?; + let names = row + .model() + .and_downcast::() + .ok_or("the Driver row lists no names")?; + let position = (0..names.n_items()) + .find(|position| names.string(*position).as_deref() == Some(engine)) + .ok_or_else(|| format!("the Driver row does not offer {engine}"))?; + row.set_selected(position); + Ok(()) +} + +/// Open every expander row, as a user would to see what it holds. +fn expand_all(root: >k4::Widget) { + for row in descendants(root) + .into_iter() + .filter_map(|widget| widget.downcast::().ok()) + { + if row.shows_enable_switch() { + row.set_enable_expansion(true); + } + row.set_expanded(true); + } +} diff --git a/linux/crates/app/tests/accessibility/world.rs b/linux/crates/app/tests/accessibility/world.rs new file mode 100644 index 0000000000..4017a07c9d --- /dev/null +++ b/linux/crates/app/tests/accessibility/world.rs @@ -0,0 +1,144 @@ +//! A TablePro over fakes, driven on the main context the way a user's +//! clicks drive it. + +use std::path::Path; +use std::rc::Rc; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use gtk4::glib; +use gtk4::prelude::*; +use libadwaita as adw; +use tablepro_app::application::{AppServices, AppServicesParts, TableProApplication, WindowLaunch}; +use tablepro_app::runtime::AppRuntime; +use tablepro_app::storage::AppStorage; +use tablepro_core::{AuthMode, DriverRegistry, LivenessPolicy, TlsConfig}; +use tablepro_session::fakes::FakeConnector; +use tablepro_session::operation::DefaultDeadlines; +use tablepro_storage::{AppSettings, SavedConnection, StoragePaths}; +use tablepro_test_support::FakeSecretVault; + +/// Long enough for a page to settle on a loaded CI runner. +const WAIT: Duration = Duration::from_secs(20); + +/// One application with a saved connection in its list. Its id is its +/// own, so a TablePro the developer has open never answers for it on the +/// session bus. +pub struct World { + pub app: TableProApplication, + pub saved: SavedConnection, + runtime: Option, + _dir: tempfile::TempDir, +} + +impl World { + pub fn start(name: &str, drivers: DriverRegistry, connector: FakeConnector) -> Result { + let dir = tempfile::tempdir().map_err(|error| format!("no temporary directory: {error}"))?; + let runtime = AppRuntime::build().map_err(|error| format!("no runtime: {error}"))?; + let settings = Rc::new(settings(dir.path())?); + let paths = StoragePaths::under(dir.path(), "tablepro", tablepro_app::config::APP_ID); + let storage = AppStorage::new(paths, Arc::new(FakeSecretVault::new()), &runtime.tasks()); + let saved = saved_connection(&dir.path().join("fake.db")); + storage + .connections() + .upsert_blocking(saved.clone()) + .map_err(|error| format!("could not save the connection: {error}"))?; + let liveness = LivenessPolicy::DESKTOP; + let services = AppServices::new(AppServicesParts { + drivers, + connector: Arc::new(connector), + deadlines: Arc::new(DefaultDeadlines::new(liveness)), + liveness, + settings, + storage, + tasks: runtime.tasks(), + }); + let app = TableProApplication::new(services); + app.set_application_id(Some(&format!( + "{}.Test.Accessibility.{name}", + tablepro_app::config::APP_ID + ))); + app.register(gio::Cancellable::NONE) + .map_err(|error| format!("could not register the application: {error}"))?; + Ok(Self { + app, + saved, + runtime: Some(runtime), + _dir: dir, + }) + } + + pub fn open(&self, launch: WindowLaunch) -> Result { + self.app + .open_window(launch) + .ok_or_else(|| "the application opened no window".to_owned()) + } +} + +impl Drop for World { + fn drop(&mut self) { + for window in self.app.windows() { + window.destroy(); + } + drain_main_context(); + if let Some(runtime) = self.runtime.take() { + runtime.shutdown(); + } + } +} + +/// Run the main context until `ready` holds. +pub fn wait_until(what: &str, mut ready: impl FnMut() -> bool) -> Result<(), String> { + let context = glib::MainContext::default(); + let wake = glib::timeout_add_local(Duration::from_millis(10), || glib::ControlFlow::Continue); + let deadline = Instant::now() + WAIT; + let outcome = loop { + if ready() { + break Ok(()); + } + if Instant::now() >= deadline { + break Err(format!("timed out waiting for {what}")); + } + context.iteration(true); + }; + wake.remove(); + outcome +} + +pub fn drain_main_context() { + let context = glib::MainContext::default(); + while context.iteration(false) {} +} + +fn saved_connection(database: &Path) -> SavedConnection { + SavedConnection { + id: uuid::Uuid::new_v4(), + name: "Fake".to_owned(), + driver_id: "fake".to_owned(), + host: String::new(), + port: 0, + database: database.display().to_string(), + username: String::new(), + tls: TlsConfig::verify_full(), + read_only: false, + auth_mode: AuthMode::Password, + ssh: None, + last_opened_at: None, + color: None, + group: None, + } +} + +/// The schema compiled into the world's own directory behind a memory +/// backend, so a run never reads or writes the developer's settings. +fn settings(dir: &Path) -> Result { + let schemas = dir.join("schemas"); + std::fs::create_dir_all(&schemas).map_err(|error| format!("could not create the schema directory: {error}"))?; + crate::settings_schema::compile(&schemas, &[tablepro_app::config::APP_ID])?; + AppSettings::with_backend( + tablepro_app::config::APP_ID, + &schemas, + &gio::memory_settings_backend_new(), + ) + .map_err(|error| format!("could not open the settings: {error}")) +} diff --git a/linux/crates/app/tests/application_lifecycle.rs b/linux/crates/app/tests/application_lifecycle.rs new file mode 100644 index 0000000000..87b2a511bf --- /dev/null +++ b/linux/crates/app/tests/application_lifecycle.rs @@ -0,0 +1,493 @@ +//! Quitting a running TablePro, in process, over fake connections. +//! +//! Each case runs in a child process of its own: GTK allows one +//! application per process on the main thread, and the application id +//! can be registered on the bus only once at a time. + +#![expect( + clippy::print_stderr, + reason = "a harness-free test binary reports its own failures on stderr" +)] + +use std::cell::Cell; +use std::path::Path; +use std::process::{Command, ExitCode, Stdio}; +use std::rc::Rc; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use gtk4::prelude::*; +use libadwaita as adw; +use libadwaita::prelude::*; +use tablepro_app::application::{AppServices, AppServicesParts, TableProApplication, WindowLaunch}; +use tablepro_app::runtime::AppRuntime; +use tablepro_app::storage::AppStorage; +use tablepro_core::{AuthMode, DriverRegistry, EditorSession, LivenessPolicy, TlsConfig, TransactionState}; +use tablepro_session::fakes::{FakeConnector, FakeEstablish}; +use tablepro_session::operation::DefaultDeadlines; +use tablepro_storage::{AppSettings, SavedConnection, StoragePaths}; +use tablepro_test_support::{ + CloseBehaviour, ConnectionScript, FakeConnection, FakeDriver, FakeEditorSession, FakeReply, FakeRun, + FakeSecretVault, Replies, +}; + +#[path = "support/settings_schema.rs"] +mod settings_schema; + +const CASE: &str = "TABLEPRO_LIFECYCLE_CASE"; +const CASES: &[&str] = &[ + "quit_waits_for_300ms_close_within_timeout_budget", + "hanging_close_abandoned_at_close_timeout_and_run_returns", + "cancelled_close_guard_keeps_app_running", + "editor_session_close_keeps_app_alive_until_resolved", +]; +/// A case that has not finished by now never will. +const CASE_LIMIT: Duration = Duration::from_secs(90); +const STEP_LIMIT: Duration = Duration::from_secs(20); + +fn main() -> ExitCode { + if let Ok(case) = std::env::var(CASE) { + return child(&case); + } + parent() +} + +/// A private session bus, so a TablePro the developer has open never +/// answers for the application under test. +fn parent() -> ExitCode { + let display = Display::current(); + let bus = gio::TestDBus::new(gio::TestDBusFlags::NONE); + bus.up(); + let mut failed = false; + for case in CASES { + match run_case(case, &display) { + Ok(()) => eprintln!("{case}: ok"), + Err(message) => { + eprintln!("{case} failed: {message}"); + failed = true; + } + } + } + bus.down(); + if failed { ExitCode::FAILURE } else { ExitCode::SUCCESS } +} + +/// `g_test_dbus_up` unsets `DISPLAY` along with the session bus, so the +/// display is read before and handed to each case. +struct Display(Vec<(&'static str, std::ffi::OsString)>); + +impl Display { + fn current() -> Self { + Self( + ["DISPLAY", "WAYLAND_DISPLAY"] + .into_iter() + .filter_map(|name| Some((name, std::env::var_os(name)?))) + .collect(), + ) + } +} + +fn run_case(case: &str, display: &Display) -> Result<(), String> { + let exe = std::env::current_exe().map_err(|error| format!("could not find the test binary: {error}"))?; + // A file rather than a pipe: nobody reads a pipe while the case runs, + // and a full one would stop it mid-write. + let log = tempfile::NamedTempFile::new().map_err(|error| format!("no log file: {error}"))?; + let stderr = log + .reopen() + .map_err(|error| format!("could not open the log file: {error}"))?; + let mut child = Command::new(exe) + .envs(display.0.iter().cloned()) + .env(CASE, case) + .env("LC_ALL", "C.UTF-8") + .env("GSETTINGS_BACKEND", "memory") + .env_remove("LANGUAGE") + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(stderr) + .spawn() + .map_err(|error| format!("could not start the case: {error}"))?; + let deadline = Instant::now() + CASE_LIMIT + Duration::from_secs(10); + loop { + match child.try_wait() { + Ok(Some(_)) => break, + Ok(None) if Instant::now() < deadline => std::thread::sleep(Duration::from_millis(50)), + Ok(None) => { + let _ = child.kill(); + break; + } + Err(error) => return Err(format!("could not wait for the case: {error}")), + } + } + let status = child + .wait() + .map_err(|error| format!("could not wait for the case: {error}"))?; + if status.success() { + return Ok(()); + } + let stderr = std::fs::read_to_string(log.path()).unwrap_or_default(); + Err(format!("exited with {status}:\n{stderr}")) +} + +/// What a case scripts the fake connection and editor session to do. +struct Plan { + connection_close: CloseBehaviour, + close_timeout: Duration, + editor: Option, +} + +struct EditorPlan { + leaves: TransactionState, + close: CloseBehaviour, +} + +fn child(case: &str) -> ExitCode { + std::thread::spawn(|| { + std::thread::sleep(CASE_LIMIT); + eprintln!("the case did not finish within {CASE_LIMIT:?}"); + std::process::exit(2); + }); + match run_child(case) { + Ok(()) => ExitCode::SUCCESS, + Err(message) => { + eprintln!("{message}"); + ExitCode::FAILURE + } + } +} + +fn run_child(case: &str) -> Result<(), String> { + let plan = match case { + "quit_waits_for_300ms_close_within_timeout_budget" => Plan { + connection_close: CloseBehaviour::Delay(Duration::from_millis(300)), + close_timeout: Duration::from_secs(2), + editor: None, + }, + "hanging_close_abandoned_at_close_timeout_and_run_returns" => Plan { + connection_close: CloseBehaviour::Hang, + close_timeout: Duration::from_millis(200), + editor: None, + }, + "cancelled_close_guard_keeps_app_running" => Plan { + connection_close: CloseBehaviour::Immediate, + close_timeout: Duration::from_secs(2), + editor: Some(EditorPlan { + leaves: TransactionState::InTransaction, + close: CloseBehaviour::Immediate, + }), + }, + "editor_session_close_keeps_app_alive_until_resolved" => Plan { + connection_close: CloseBehaviour::Immediate, + close_timeout: Duration::from_secs(2), + editor: Some(EditorPlan { + leaves: TransactionState::Idle, + close: CloseBehaviour::Delay(Duration::from_millis(100)), + }), + }, + other => return Err(format!("no case named {other}")), + }; + let world = World::build(&plan)?; + let outcome = world.run(case); + world.runtime.shutdown(); + outcome +} + +/// A TablePro over fakes, with what the case reads after it exits. +struct World { + runtime: AppRuntime, + app: TableProApplication, + connector: FakeConnector, + editor: Option>, + saved: SavedConnection, + _dir: tempfile::TempDir, +} + +impl World { + fn build(plan: &Plan) -> Result { + gio::resources_register_include!("tablepro.gresource") + .map_err(|error| format!("could not register the resources: {error}"))?; + let dir = tempfile::tempdir().map_err(|error| format!("no temporary directory: {error}"))?; + let runtime = AppRuntime::build().map_err(|error| format!("no runtime: {error}"))?; + let settings = Rc::new(settings(dir.path())?); + let paths = StoragePaths::under(dir.path(), "tablepro", tablepro_app::config::APP_ID); + let storage = AppStorage::new(paths, Arc::new(FakeSecretVault::new()), &runtime.tasks()); + // Saved, as a picker's connection is: one the list does not hold + // opens as deleted if the window's read of the list lands first. + let saved = saved_connection(&dir.path().join("fake.db")); + storage + .connections() + .upsert_blocking(saved.clone()) + .map_err(|error| format!("could not save the connection: {error}"))?; + + let mut script = ConnectionScript { + close: plan.connection_close, + ..ConnectionScript::default() + }; + script.probe.always(FakeReply::ok(())); + script.list_schemas.always(FakeReply::ok(Vec::new())); + script.list_objects.always(FakeReply::ok(Vec::new())); + let editor = plan.editor.as_ref().map(|editor| { + let mut runs = Replies::default(); + runs.always(FakeRun::completed(Vec::new()).leaving(editor.leaves)); + Arc::new(FakeEditorSession::with_close(runs, editor.close)) + }); + if let Some(editor) = &editor { + let session: Arc = editor.clone(); + script.open_editor_session.always(FakeReply::ok(session)); + } + let mut drivers = DriverRegistry::new(); + drivers.register(Arc::new(FakeDriver::connecting( + &FakeDriver::FILE_CAPABILITIES, + script.clone(), + ))); + let connector = FakeConnector::new([]).always(FakeEstablish::Connect(script)); + let liveness = LivenessPolicy { + close_timeout: plan.close_timeout, + ..LivenessPolicy::DESKTOP + }; + let services = AppServices::new(AppServicesParts { + drivers, + connector: Arc::new(connector.clone()), + deadlines: Arc::new(DefaultDeadlines::new(liveness)), + liveness, + settings, + storage, + tasks: runtime.tasks(), + }); + Ok(Self { + app: TableProApplication::new(services), + runtime, + connector, + editor, + saved, + _dir: dir, + }) + } + + fn run(&self, case: &str) -> Result<(), String> { + let steps = Steps::default(); + let saved = self.saved.clone(); + let (connector, editor, recorded) = (self.connector.clone(), self.editor.clone(), steps.clone()); + let case_name = case.to_owned(); + self.app.connect_startup(move |app| { + let Some(window) = app.open_window(WindowLaunch::Connect(Box::new(saved.clone()))) else { + fail("the application opened no window"); + }; + let (app, connector, editor, recorded, case) = ( + app.clone(), + connector.clone(), + editor.clone(), + recorded.clone(), + case_name.clone(), + ); + glib::spawn_future_local(async move { + if let Err(message) = script(&case, &app, &window, &connector, editor.as_deref(), &recorded).await { + fail(&message); + } + }); + }); + + let code = self.app.run_with_args(&["tablepro"]); + let returned = Instant::now(); + if code != glib::ExitCode::SUCCESS { + return Err(format!("run() returned {code:?}")); + } + let quit_at = steps.quit_at.get().ok_or("the case never asked to quit")?; + let took = returned.duration_since(quit_at); + let connection = self + .connector + .connections() + .into_iter() + .next() + .ok_or("no connection was made")?; + check(case, &steps, &connection, self.editor.as_deref(), took) + } +} + +fn check( + case: &str, + steps: &Steps, + connection: &FakeConnection, + editor: Option<&FakeEditorSession>, + took: Duration, +) -> Result<(), String> { + match case { + "quit_waits_for_300ms_close_within_timeout_budget" => { + expect(connection.close_finished(), "run() returned before the close finished")?; + expect( + took >= Duration::from_millis(300), + &format!("run() returned after {took:?}"), + )?; + expect(took < Duration::from_secs(2), &format!("the quit took {took:?}")) + } + "hanging_close_abandoned_at_close_timeout_and_run_returns" => { + expect(connection.is_closed(), "the close never started")?; + expect(!connection.close_finished(), "a hanging close finished")?; + expect( + took >= Duration::from_millis(200), + &format!("run() returned after {took:?}"), + )?; + expect(took < Duration::from_secs(5), &format!("the quit took {took:?}")) + } + "cancelled_close_guard_keeps_app_running" => { + expect(steps.kept_running.get(), "the cancelled quit closed the window")?; + let editor = editor.ok_or("no editor session")?; + expect(editor.is_closed(), "the editor session was left open")?; + expect(connection.close_finished(), "the connection was left open") + } + "editor_session_close_keeps_app_alive_until_resolved" => { + let editor = editor.ok_or("no editor session")?; + expect(editor.is_closed(), "run() returned before the editor session closed") + } + other => Err(format!("no checks for {other}")), + } +} + +/// What the script running inside the application saw. +#[derive(Clone, Default)] +struct Steps { + quit_at: Rc>>, + kept_running: Rc>, +} + +async fn script( + case: &str, + app: &TableProApplication, + window: &adw::ApplicationWindow, + connector: &FakeConnector, + editor: Option<&FakeEditorSession>, + steps: &Steps, +) -> Result<(), String> { + let services = app.services().ok_or("the application has no services")?; + until("the session", || services.sessions.live_count() == 1).await?; + if let Some(editor) = editor { + run_in_a_new_editor_tab(window, editor).await?; + } + steps.quit_at.set(Some(Instant::now())); + app.activate_action("quit", None); + if case != "cancelled_close_guard_keeps_app_running" { + return Ok(()); + } + + answer(window, "Close With Open Transactions?", "cancel").await?; + glib::timeout_future(Duration::from_millis(500)).await; + let editor = editor.ok_or("no editor session")?; + let connection = connector.connections().into_iter().next().ok_or("no connection")?; + if app.windows().len() != 1 || editor.is_closed() || connection.is_closed() { + return Err("the quit went on after the close was cancelled".to_owned()); + } + steps.kept_running.set(true); + + steps.quit_at.set(Some(Instant::now())); + app.activate_action("quit", None); + answer(window, "Close With Open Transactions?", "close").await +} + +/// Open an editor tab through its window action and run what it holds, +/// which opens the tab's own editor session. +async fn run_in_a_new_editor_tab(window: &adw::ApplicationWindow, editor: &FakeEditorSession) -> Result<(), String> { + WidgetExt::activate_action(window, "win.new-editor-tab", None) + .map_err(|error| format!("win.new-editor-tab: {error}"))?; + let mut run = None; + until("the editor's Run button", || { + run = descendants(window.upcast_ref()) + .into_iter() + .filter(|widget| widget.type_().name() == "TableProSqlEditor") + .flat_map(|editor| descendants(&editor)) + .filter_map(|widget| widget.downcast::().ok()) + .find(|button| button.label().as_deref() == Some("Run")); + run.is_some() + }) + .await?; + let run = run.ok_or("no Run button")?; + run.emit_clicked(); + until("the run to reach the editor session", || !editor.texts().is_empty()).await?; + until("the run to end", || run.is_sensitive()).await +} + +/// Wait for the alert headed `heading` and click its `response`. +async fn answer(window: &adw::ApplicationWindow, heading: &str, response: &str) -> Result<(), String> { + let mut shown = None; + until(heading, || { + shown = window + .visible_dialog() + .and_downcast::() + .filter(|alert| alert.heading().as_deref() == Some(heading)); + shown.is_some() + }) + .await?; + let alert = shown.ok_or("no alert")?; + let label = alert.response_label(response); + let button = descendants(alert.upcast_ref()) + .into_iter() + .filter_map(|widget| widget.downcast::().ok()) + .find(|button| button.label().as_deref() == Some(label.as_str())) + .ok_or_else(|| format!("“{heading}” has no {response} button"))?; + button.emit_clicked(); + Ok(()) +} + +async fn until(what: &str, mut ready: impl FnMut() -> bool) -> Result<(), String> { + let deadline = Instant::now() + STEP_LIMIT; + while !ready() { + if Instant::now() >= deadline { + return Err(format!("timed out waiting for {what}")); + } + glib::timeout_future(Duration::from_millis(10)).await; + } + Ok(()) +} + +fn descendants(root: >k4::Widget) -> Vec { + let mut found = Vec::new(); + let mut pending: Vec = root.first_child().into_iter().collect(); + while let Some(widget) = pending.pop() { + pending.extend(widget.next_sibling()); + pending.extend(widget.first_child()); + found.push(widget); + } + found +} + +fn expect(holds: bool, otherwise: &str) -> Result<(), String> { + if holds { Ok(()) } else { Err(otherwise.to_owned()) } +} + +/// A step that cannot go on leaves the application holding windows and +/// connections, so the case ends here rather than waiting on them. +fn fail(message: &str) -> ! { + eprintln!("{message}"); + std::process::exit(1); +} + +fn saved_connection(database: &Path) -> SavedConnection { + SavedConnection { + id: uuid::Uuid::new_v4(), + name: "Fake".to_owned(), + driver_id: "fake".to_owned(), + host: String::new(), + port: 0, + database: database.display().to_string(), + username: String::new(), + tls: TlsConfig::verify_full(), + read_only: false, + auth_mode: AuthMode::Password, + ssh: None, + last_opened_at: None, + color: None, + group: None, + } +} + +/// The schema compiled into the case's own directory behind a memory +/// backend, so the case never reads or writes the developer's settings. +fn settings(dir: &Path) -> Result { + let schemas = dir.join("schemas"); + std::fs::create_dir_all(&schemas).map_err(|error| format!("could not create the schema directory: {error}"))?; + settings_schema::compile(&schemas, &[tablepro_app::config::APP_ID])?; + AppSettings::with_backend( + tablepro_app::config::APP_ID, + &schemas, + &gio::memory_settings_backend_new(), + ) + .map_err(|error| format!("could not open the settings: {error}")) +} diff --git a/linux/crates/app/tests/application_session_restore.rs b/linux/crates/app/tests/application_session_restore.rs new file mode 100644 index 0000000000..723e1b4122 --- /dev/null +++ b/linux/crates/app/tests/application_session_restore.rs @@ -0,0 +1,368 @@ +//! The windows open when TablePro quits open again at the next launch, +//! and a window the user closed first does not. + +#![expect( + clippy::print_stderr, + reason = "a harness-free test binary reports its own failures on stderr" +)] + +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::process::{Child, Command, ExitCode, ExitStatus, Stdio}; +use std::time::{Duration, Instant}; + +use gio::prelude::*; + +#[path = "support/settings_schema.rs"] +mod settings_schema; + +const APP_ID: &str = tablepro_app::config::APP_ID; +/// Long enough for a cold start under a loaded CI runner. +const WAIT: Duration = Duration::from_secs(30); +const CALL_TIMEOUT_MS: i32 = 5_000; + +fn main() -> ExitCode { + match run() { + Ok(()) => ExitCode::SUCCESS, + Err(message) => { + eprintln!("{message}"); + ExitCode::FAILURE + } + } +} + +fn run() -> Result<(), String> { + let mut sandbox = Sandbox::new()?; + // g_test_dbus_up unsets DISPLAY along with the session bus. + sandbox.display = ["DISPLAY", "WAYLAND_DISPLAY"] + .into_iter() + .filter_map(|name| Some((name, std::env::var_os(name)?))) + .collect(); + let bus = gio::TestDBus::new(gio::TestDBusFlags::NONE); + bus.up(); + let outcome = bus + .bus_address() + .ok_or_else(|| "the test bus has no address".to_owned()) + .and_then(|address| launches(&sandbox, &address)); + bus.down(); + outcome +} + +fn launches(sandbox: &Sandbox, address: &str) -> Result<(), String> { + let connection = gio::DBusConnection::for_address_sync( + address, + gio::DBusConnectionFlags::AUTHENTICATION_CLIENT | gio::DBusConnectionFlags::MESSAGE_BUS_CONNECTION, + None, + gio::Cancellable::NONE, + ) + .map_err(|error| format!("could not connect to the test bus: {error}"))?; + let first = sandbox.database("first.db")?; + let second = sandbox.database("second.db")?; + sandbox.record(&[first.as_str(), second.as_str()])?; + + sandbox.launch(address, "first", || { + wait_until("both recorded windows", || window_ids(&connection).len() == 2)?; + quit(&connection) + })?; + let recorded = sandbox.recorded()?; + if recorded != [first.clone(), second.clone()] { + return Err(format!("the quit left {recorded:?} recorded")); + } + + sandbox.launch(address, "second", || { + wait_until("both windows again", || window_ids(&connection).len() == 2)?; + close_one_window(&connection)?; + quit(&connection) + })?; + let recorded = sandbox.recorded()?; + if recorded.len() != 1 { + return Err(format!("closing a window before the quit left {recorded:?} recorded")); + } + + sandbox.launch(address, "third", || { + wait_until("the one window left", || window_ids(&connection).len() == 1)?; + // The other window would open with it, so it would be here by now. + std::thread::sleep(Duration::from_millis(500)); + let windows = window_ids(&connection).len(); + if windows != 1 { + return Err(format!("{windows} windows after one was closed before the quit")); + } + quit(&connection) + }) +} + +/// `win.close-current` closes the active tab, and the window once it +/// has none, as Ctrl+W does. +fn close_one_window(connection: &gio::DBusConnection) -> Result<(), String> { + let ids = window_ids(connection); + let id = ids.first().ok_or("no window to close")?; + let path = format!("{}/window/{id}", object_path()); + for _ in 0..5 { + activate(connection, &path, "close-current")?; + let closed = wait_until("the window to close", || window_ids(connection).len() < ids.len()); + if closed.is_ok() { + return Ok(()); + } + } + Err("close-current never closed the window".to_owned()) +} + +/// Ask the primary to quit the way the Quit menu item does. +fn quit(connection: &gio::DBusConnection) -> Result<(), String> { + activate(connection, &object_path(), "quit") +} + +fn activate(connection: &gio::DBusConnection, path: &str, action: &str) -> Result<(), String> { + let no_parameter: Vec = Vec::new(); + let platform_data: HashMap = HashMap::new(); + let arguments = (action, no_parameter, platform_data).to_variant(); + connection + .call_sync( + Some(APP_ID), + path, + "org.gtk.Actions", + "Activate", + Some(&arguments), + None, + gio::DBusCallFlags::NONE, + CALL_TIMEOUT_MS, + gio::Cancellable::NONE, + ) + .map(drop) + .map_err(|error| format!("{action} at {path} over D-Bus failed: {error}")) +} + +fn name_has_owner(connection: &gio::DBusConnection) -> bool { + connection + .call_sync( + Some("org.freedesktop.DBus"), + "/org/freedesktop/DBus", + "org.freedesktop.DBus", + "NameHasOwner", + Some(&(APP_ID,).to_variant()), + None, + gio::DBusCallFlags::NONE, + CALL_TIMEOUT_MS, + gio::Cancellable::NONE, + ) + .ok() + .and_then(|reply| reply.get::<(bool,)>()) + .is_some_and(|(owned,)| owned) +} + +/// GtkApplication exports each window's actions at +/// `/window/`, so the children of that node are the +/// open windows. +fn window_ids(connection: &gio::DBusConnection) -> Vec { + let Some((xml,)) = connection + .call_sync( + Some(APP_ID), + &format!("{}/window", object_path()), + "org.freedesktop.DBus.Introspectable", + "Introspect", + None, + None, + gio::DBusCallFlags::NONE, + CALL_TIMEOUT_MS, + gio::Cancellable::NONE, + ) + .ok() + .and_then(|reply| reply.get::<(String,)>()) + else { + return Vec::new(); + }; + xml.split(" String { + format!("/{}", APP_ID.replace('.', "/").replace('-', "_")) +} + +fn wait_for_exit(child: &mut Child) -> Result { + let deadline = Instant::now() + WAIT; + loop { + match child.try_wait() { + Ok(Some(status)) => return Ok(status), + Ok(None) if Instant::now() < deadline => std::thread::sleep(Duration::from_millis(20)), + Ok(None) => { + let _ = child.kill(); + let _ = child.wait(); + return Err(format!("tablepro did not exit within {WAIT:?}")); + } + Err(error) => return Err(format!("could not wait for tablepro: {error}")), + } + } +} + +fn wait_until(what: &str, mut ready: impl FnMut() -> bool) -> Result<(), String> { + let deadline = Instant::now() + WAIT; + while !ready() { + if Instant::now() >= deadline { + return Err(format!("timed out waiting for {what}")); + } + std::thread::sleep(Duration::from_millis(50)); + } + Ok(()) +} + +/// Directories of the test's own, so every launch reads and writes +/// nothing of the developer's. +struct Sandbox { + root: tempfile::TempDir, + schemas: PathBuf, + config: PathBuf, + data: PathBuf, + state: PathBuf, + cache: PathBuf, + runtime: PathBuf, + display: Vec<(&'static str, std::ffi::OsString)>, +} + +impl Sandbox { + fn new() -> Result { + let root = tempfile::tempdir().map_err(|error| format!("no temporary directory: {error}"))?; + let dir = |name: &str| -> Result { + let path = root.path().join(name); + std::fs::create_dir_all(&path).map_err(|error| format!("could not create {}: {error}", path.display()))?; + Ok(path) + }; + let sandbox = Self { + schemas: dir("schemas")?, + config: dir("config")?, + data: dir("data")?, + state: dir("state")?, + cache: dir("cache")?, + runtime: dir("runtime")?, + display: Vec::new(), + root, + }; + private(&sandbox.runtime)?; + settings_schema::compile(&sandbox.schemas, &[APP_ID])?; + Ok(sandbox) + } + + /// An empty SQLite database, by the URI the application keys it by. + fn database(&self, name: &str) -> Result { + let dir = std::fs::canonicalize(self.root.path()) + .map_err(|error| format!("could not resolve the sandbox: {error}"))?; + let path = dir.join(name); + std::fs::write(&path, b"").map_err(|error| format!("could not create {name}: {error}"))?; + glib::filename_to_uri(&path, None) + .map(|uri| uri.to_string()) + .map_err(|error| format!("no URI for {name}: {error}")) + } + + fn record_path(&self) -> PathBuf { + self.state + .join(tablepro_app::config::storage_dir_name()) + .join("workspaces") + .join("open-sessions.json") + } + + /// Write the files as the sessions open at the last quit. + fn record(&self, uris: &[&str]) -> Result<(), String> { + let sessions: Vec = uris + .iter() + .map(|uri| format!(r#"{{"kind":"file","uri":{}}}"#, serde_json::Value::from(*uri))) + .collect(); + let record = format!(r#"{{"version":1,"sessions":[{}]}}"#, sessions.join(",")); + let path = self.record_path(); + let parent = path.parent().ok_or("the record has no directory")?; + std::fs::create_dir_all(parent).map_err(|error| format!("could not create {}: {error}", parent.display()))?; + std::fs::write(&path, record).map_err(|error| format!("could not write the record: {error}")) + } + + /// The file URIs recorded, in order. + fn recorded(&self) -> Result, String> { + let bytes = std::fs::read(self.record_path()).map_err(|error| format!("no record after the quit: {error}"))?; + let record: serde_json::Value = + serde_json::from_slice(&bytes).map_err(|error| format!("the record is not JSON: {error}"))?; + let sessions = record["sessions"] + .as_array() + .ok_or_else(|| format!("the record lists no sessions: {record}"))?; + sessions + .iter() + .map(|session| { + session["uri"] + .as_str() + .map(str::to_owned) + .ok_or_else(|| format!("a recorded session is not a file: {session}")) + }) + .collect() + } + + /// Start TablePro, run `steps` against it, and wait for it to exit + /// with success. + fn launch(&self, address: &str, name: &str, steps: impl FnOnce() -> Result<(), String>) -> Result<(), String> { + // A file rather than a pipe: nobody reads a pipe while the app + // runs, and a full one would stop it mid-write. + let log_path = self.root.path().join(format!("{name}.log")); + let log = std::fs::File::create(&log_path).map_err(|error| format!("could not create the log: {error}"))?; + let mut child = self + .tablepro(address) + .stdout(Stdio::null()) + .stderr(log) + .spawn() + .map_err(|error| format!("could not start the {name} launch: {error}"))?; + let outcome = wait_until_owned(address) + .and_then(|()| steps()) + .and_then(|()| wait_for_exit(&mut child)) + .and_then(|status| { + if status.success() { + Ok(()) + } else { + Err(format!("the {name} launch exited with {status}")) + } + }); + if outcome.is_err() { + let _ = child.kill(); + let _ = child.wait(); + } + outcome.map_err(|message| { + let log = std::fs::read_to_string(&log_path).unwrap_or_default(); + format!("{message}\n{name} launch stderr:\n{log}") + }) + } + + fn tablepro(&self, address: &str) -> Command { + let mut command = Command::new(env!("CARGO_BIN_EXE_tablepro")); + command + .envs(self.display.iter().cloned()) + .env("DBUS_SESSION_BUS_ADDRESS", address) + .env("LC_ALL", "C.UTF-8") + .env("GSETTINGS_BACKEND", "memory") + .env("GSETTINGS_SCHEMA_DIR", &self.schemas) + .env("XDG_CONFIG_HOME", &self.config) + .env("XDG_DATA_HOME", &self.data) + .env("XDG_STATE_HOME", &self.state) + .env("XDG_CACHE_HOME", &self.cache) + .env("XDG_RUNTIME_DIR", &self.runtime) + .env_remove("LANGUAGE") + .stdin(Stdio::null()); + command + } +} + +fn wait_until_owned(address: &str) -> Result<(), String> { + let connection = gio::DBusConnection::for_address_sync( + address, + gio::DBusConnectionFlags::AUTHENTICATION_CLIENT | gio::DBusConnectionFlags::MESSAGE_BUS_CONNECTION, + None, + gio::Cancellable::NONE, + ) + .map_err(|error| format!("could not connect to the test bus: {error}"))?; + wait_until("the application to own its name", || name_has_owner(&connection)) +} + +/// GLib and the XDG base directory specification expect the runtime +/// directory to be private to the user. +fn private(path: &Path) -> Result<(), String> { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700)) + .map_err(|error| format!("could not make {} private: {error}", path.display())) +} diff --git a/linux/crates/app/tests/application_uniqueness.rs b/linux/crates/app/tests/application_uniqueness.rs new file mode 100644 index 0000000000..6cb2e6d0c9 --- /dev/null +++ b/linux/crates/app/tests/application_uniqueness.rs @@ -0,0 +1,277 @@ +//! One TablePro per session: a second launch forwards its activation to +//! the running one over D-Bus and exits. + +#![expect( + clippy::print_stderr, + reason = "a harness-free test binary reports its own failures on stderr" +)] + +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::process::{Child, Command, ExitCode, ExitStatus, Stdio}; +use std::time::{Duration, Instant}; + +use gio::prelude::*; + +#[path = "support/settings_schema.rs"] +mod settings_schema; + +const APP_ID: &str = tablepro_app::config::APP_ID; +/// Long enough for a cold start under a loaded CI runner. +const WAIT: Duration = Duration::from_secs(30); +const CALL_TIMEOUT_MS: i32 = 5_000; + +fn main() -> ExitCode { + match run() { + Ok(()) => ExitCode::SUCCESS, + Err(message) => { + eprintln!("{message}"); + ExitCode::FAILURE + } + } +} + +fn run() -> Result<(), String> { + let mut sandbox = Sandbox::new()?; + // g_test_dbus_up unsets DISPLAY along with the session bus. + sandbox.display = ["DISPLAY", "WAYLAND_DISPLAY"] + .into_iter() + .filter_map(|name| Some((name, std::env::var_os(name)?))) + .collect(); + let bus = gio::TestDBus::new(gio::TestDBusFlags::NONE); + bus.up(); + let outcome = bus + .bus_address() + .ok_or_else(|| "the test bus has no address".to_owned()) + .and_then(|address| launches(&sandbox, &address)); + bus.down(); + outcome +} + +fn launches(sandbox: &Sandbox, address: &str) -> Result<(), String> { + let connection = gio::DBusConnection::for_address_sync( + address, + gio::DBusConnectionFlags::AUTHENTICATION_CLIENT | gio::DBusConnectionFlags::MESSAGE_BUS_CONNECTION, + None, + gio::Cancellable::NONE, + ) + .map_err(|error| format!("could not connect to the test bus: {error}"))?; + // A file rather than a pipe: nobody reads a pipe while the primary + // runs, and a full one would stop it mid-write. + let log_path = sandbox.root().join("primary.log"); + let log = std::fs::File::create(&log_path).map_err(|error| format!("could not create the log: {error}"))?; + let mut primary = sandbox + .tablepro(address, &[]) + .stdout(Stdio::null()) + .stderr(log) + .spawn() + .map_err(|error| format!("could not start the primary: {error}"))?; + + let outcome = with_primary(sandbox, address, &connection); + if outcome.is_ok() { + quit(&connection)?; + let status = wait_for_exit(&mut primary)?; + if !status.success() { + return Err(format!("the primary exited with {status} after app.quit")); + } + return Ok(()); + } + let _ = primary.kill(); + let _ = primary.wait(); + let log = std::fs::read_to_string(&log_path).unwrap_or_default(); + outcome.map_err(|message| format!("{message}\nprimary stderr:\n{log}")) +} + +fn with_primary(sandbox: &Sandbox, address: &str, connection: &gio::DBusConnection) -> Result<(), String> { + wait_until("the primary to own its name", || name_has_owner(connection))?; + wait_until("the primary's first window", || window_count(connection) == 1)?; + + let plain = launch(sandbox, address, &[])?; + if !plain.success() { + return Err(format!("a second plain launch exited with {plain}")); + } + let new_window = launch(sandbox, address, &["--new-window"])?; + if !new_window.success() { + return Err(format!("a launch with --new-window exited with {new_window}")); + } + + wait_until("the second window", || window_count(connection) >= 2)?; + // The plain launch's activation reached the primary first, so a + // window it opened by mistake would be here by now. + std::thread::sleep(Duration::from_millis(500)); + let windows = window_count(connection); + if windows != 2 { + return Err(format!("{windows} windows after one plain launch and one --new-window")); + } + Ok(()) +} + +/// Ask the primary to quit the way the Quit menu item does. +fn quit(connection: &gio::DBusConnection) -> Result<(), String> { + let no_parameter: Vec = Vec::new(); + let platform_data: HashMap = HashMap::new(); + let arguments = ("quit", no_parameter, platform_data).to_variant(); + connection + .call_sync( + Some(APP_ID), + &object_path(), + "org.gtk.Actions", + "Activate", + Some(&arguments), + None, + gio::DBusCallFlags::NONE, + CALL_TIMEOUT_MS, + gio::Cancellable::NONE, + ) + .map(drop) + .map_err(|error| format!("app.quit over D-Bus failed: {error}")) +} + +fn name_has_owner(connection: &gio::DBusConnection) -> bool { + connection + .call_sync( + Some("org.freedesktop.DBus"), + "/org/freedesktop/DBus", + "org.freedesktop.DBus", + "NameHasOwner", + Some(&(APP_ID,).to_variant()), + None, + gio::DBusCallFlags::NONE, + CALL_TIMEOUT_MS, + gio::Cancellable::NONE, + ) + .ok() + .and_then(|reply| reply.get::<(bool,)>()) + .is_some_and(|(owned,)| owned) +} + +/// GtkApplication exports each window's actions at +/// `/window/`, so the children of that node are the +/// open windows. +fn window_count(connection: &gio::DBusConnection) -> usize { + connection + .call_sync( + Some(APP_ID), + &format!("{}/window", object_path()), + "org.freedesktop.DBus.Introspectable", + "Introspect", + None, + None, + gio::DBusCallFlags::NONE, + CALL_TIMEOUT_MS, + gio::Cancellable::NONE, + ) + .ok() + .and_then(|reply| reply.get::<(String,)>()) + .map_or(0, |(xml,)| xml.matches(" Result { + let mut child = sandbox + .tablepro(address, arguments) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .map_err(|error| format!("could not launch tablepro {arguments:?}: {error}"))?; + wait_for_exit(&mut child) +} + +fn wait_for_exit(child: &mut Child) -> Result { + let deadline = Instant::now() + WAIT; + loop { + match child.try_wait() { + Ok(Some(status)) => return Ok(status), + Ok(None) if Instant::now() < deadline => std::thread::sleep(Duration::from_millis(20)), + Ok(None) => { + let _ = child.kill(); + let _ = child.wait(); + return Err(format!("tablepro did not exit within {WAIT:?}")); + } + Err(error) => return Err(format!("could not wait for tablepro: {error}")), + } + } +} + +fn wait_until(what: &str, mut ready: impl FnMut() -> bool) -> Result<(), String> { + let deadline = Instant::now() + WAIT; + while !ready() { + if Instant::now() >= deadline { + return Err(format!("timed out waiting for {what}")); + } + std::thread::sleep(Duration::from_millis(50)); + } + Ok(()) +} + +/// Directories of the test's own, so every launch reads and writes +/// nothing of the developer's. +struct Sandbox { + root: tempfile::TempDir, + schemas: PathBuf, + config: PathBuf, + data: PathBuf, + state: PathBuf, + cache: PathBuf, + runtime: PathBuf, + display: Vec<(&'static str, std::ffi::OsString)>, +} + +impl Sandbox { + fn new() -> Result { + let root = tempfile::tempdir().map_err(|error| format!("no temporary directory: {error}"))?; + let dir = |name: &str| -> Result { + let path = root.path().join(name); + std::fs::create_dir_all(&path).map_err(|error| format!("could not create {}: {error}", path.display()))?; + Ok(path) + }; + let sandbox = Self { + schemas: dir("schemas")?, + config: dir("config")?, + data: dir("data")?, + state: dir("state")?, + cache: dir("cache")?, + runtime: dir("runtime")?, + display: Vec::new(), + root, + }; + private(&sandbox.runtime)?; + settings_schema::compile(&sandbox.schemas, &[APP_ID])?; + Ok(sandbox) + } + + fn root(&self) -> &Path { + self.root.path() + } + + fn tablepro(&self, address: &str, arguments: &[&str]) -> Command { + let mut command = Command::new(env!("CARGO_BIN_EXE_tablepro")); + command + .args(arguments) + .envs(self.display.iter().cloned()) + .env("DBUS_SESSION_BUS_ADDRESS", address) + .env("LC_ALL", "C.UTF-8") + .env("GSETTINGS_BACKEND", "memory") + .env("GSETTINGS_SCHEMA_DIR", &self.schemas) + .env("XDG_CONFIG_HOME", &self.config) + .env("XDG_DATA_HOME", &self.data) + .env("XDG_STATE_HOME", &self.state) + .env("XDG_CACHE_HOME", &self.cache) + .env("XDG_RUNTIME_DIR", &self.runtime) + .env_remove("LANGUAGE") + .stdin(Stdio::null()); + command + } +} + +/// GLib and the XDG base directory specification expect the runtime +/// directory to be private to the user. +fn private(path: &Path) -> Result<(), String> { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700)) + .map_err(|error| format!("could not make {} private: {error}", path.display())) +} diff --git a/linux/crates/app/tests/crash_report.rs b/linux/crates/app/tests/crash_report.rs new file mode 100644 index 0000000000..f2731e54a0 --- /dev/null +++ b/linux/crates/app/tests/crash_report.rs @@ -0,0 +1,144 @@ +//! A panic in a GTK callback cannot unwind through C, so it ends the +//! process. The hook has to leave a report behind that the next launch +//! finds, and the default output on stderr has to stay. +//! +//! A panic hook is process-wide and the crash ends the process, so each +//! case runs in a child of its own. + +#![expect( + clippy::print_stderr, + reason = "a harness-free test binary reports its own failures on stderr" +)] + +use std::os::unix::fs::PermissionsExt; +use std::path::{Path, PathBuf}; +use std::process::{Command, ExitCode, Output, Stdio}; + +const CHILD_DIR: &str = "TABLEPRO_CRASH_REPORT_DIR"; +const CHILD_OFF_THREAD: &str = "TABLEPRO_CRASH_REPORT_OFF_THREAD"; +const PROBE: &str = "the crash report probe"; + +fn main() -> ExitCode { + match std::env::var_os(CHILD_DIR) { + Some(dir) => child(PathBuf::from(dir)), + None => parent(), + } +} + +#[expect(clippy::panic, reason = "the panic is what the case is about")] +fn child(dir: PathBuf) -> ExitCode { + tablepro_app::crash_report::install(dir); + if std::env::var_os(CHILD_OFF_THREAD).is_some() { + // A runtime thread's panic is caught where it happens and ends + // a task, not the app. + let worker = std::thread::Builder::new() + .name("tokio-runtime-worker".to_owned()) + .spawn(|| panic!("{PROBE}")); + if let Ok(worker) = worker { + let _ = worker.join(); + } + return ExitCode::SUCCESS; + } + glib::idle_add_local_once(|| panic!("{PROBE}")); + glib::MainLoop::new(None, false).run(); + ExitCode::SUCCESS +} + +fn parent() -> ExitCode { + let Ok(root) = tempfile::tempdir() else { + eprintln!("no temporary directory"); + return ExitCode::FAILURE; + }; + let crashes = root.path().join("crashes"); + match crash_leaves_one_private_report(root.path(), &crashes) { + Ok(()) => ExitCode::SUCCESS, + Err(message) => { + eprintln!("{message}"); + ExitCode::FAILURE + } + } +} + +fn crash_leaves_one_private_report(root: &Path, crashes: &Path) -> Result<(), String> { + let caught = run_child(root, crashes, true)?; + if !caught.status.success() || crashes.exists() { + return Err(format!( + "a panic off the GTK thread was taken for a crash ({}):\n{}", + caught.status, + String::from_utf8_lossy(&caught.stderr) + )); + } + + let crashed = run_child(root, crashes, false)?; + let stderr = String::from_utf8_lossy(&crashed.stderr); + if crashed.status.success() { + return Err(format!("a panic in a GTK callback did not end the process:\n{stderr}")); + } + if !stderr.contains(PROBE) { + return Err(format!( + "the default hook no longer writes the panic to stderr:\n{stderr}" + )); + } + + let reports = reports_in(crashes)?; + let [report] = reports.as_slice() else { + return Err(format!("expected one report, found {reports:?}")); + }; + let mode = |path: &Path| std::fs::metadata(path).map(|metadata| metadata.permissions().mode() & 0o777); + if mode(crashes).ok() != Some(0o700) || mode(report).ok() != Some(0o600) { + return Err(format!( + "the report is not private: directory {:?}, file {:?}", + mode(crashes).ok(), + mode(report).ok() + )); + } + let text = std::fs::read_to_string(report).map_err(|error| format!("could not read the report: {error}"))?; + for needle in [ + format!("TablePro {}", tablepro_app::config::VERSION), + format!("Profile: {}", tablepro_app::config::PROFILE), + PROBE.to_owned(), + "Backtrace:\n".to_owned(), + ] { + if !text.contains(&needle) { + return Err(format!("the report is missing {needle:?}:\n{text}")); + } + } + + let announced = tablepro_app::crash_report::take_unannounced(crashes).map_err(|error| error.to_string())?; + if announced.as_ref() != Some(report) { + return Err(format!("the next launch found {announced:?}, not {}", report.display())); + } + let again = tablepro_app::crash_report::take_unannounced(crashes).map_err(|error| error.to_string())?; + if again.is_some() { + return Err("the same crash was announced twice".to_owned()); + } + Ok(()) +} + +fn run_child(root: &Path, crashes: &Path, off_thread: bool) -> Result { + let mut command = Command::new(std::env::args_os().next().unwrap_or_default()); + command + .env(CHILD_DIR, crashes) + .env_remove("RUST_BACKTRACE") + // An abort can leave a core file in the working directory. + .current_dir(root) + .stdin(Stdio::null()); + if off_thread { + command.env(CHILD_OFF_THREAD, "1"); + } + command + .output() + .map_err(|error| format!("could not run the child: {error}")) +} + +fn reports_in(dir: &Path) -> Result, String> { + let entries = std::fs::read_dir(dir).map_err(|error| format!("no crash directory: {error}"))?; + Ok(entries + .filter_map(Result::ok) + .map(|entry| entry.path()) + .filter(|path| { + path.file_name() + .is_some_and(|name| name.to_string_lossy().starts_with("crash-")) + }) + .collect()) +} diff --git a/linux/crates/app/tests/desktop_entry.rs b/linux/crates/app/tests/desktop_entry.rs new file mode 100644 index 0000000000..ca052de5be --- /dev/null +++ b/linux/crates/app/tests/desktop_entry.rs @@ -0,0 +1,72 @@ +//! The desktop entry offers TablePro for SQLite files and hands them +//! over the way the application expects. + +use std::path::Path; + +const MAIN: &str = "Desktop Entry"; +const NEW_WINDOW: &str = "Desktop Action new-window"; + +fn entry() -> Result { + let path = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../data/app.tablepro.TablePro.desktop.in.in"); + let entry = glib::KeyFile::new(); + entry.load_from_file(&path, glib::KeyFileFlags::NONE)?; + Ok(entry) +} + +fn list(entry: &glib::KeyFile, group: &str, key: &str) -> Result, glib::Error> { + Ok(entry + .string_list(group, key)? + .iter() + .map(|item| item.to_string()) + .collect()) +} + +#[test] +fn sqlite_files_are_offered_to_tablepro() { + let entry = entry().expect("the desktop entry parses"); + + let types = list(&entry, MAIN, "MimeType").expect("a MimeType list"); + + assert!( + types.contains(&"application/vnd.sqlite3".to_owned()), + "the entry does not take SQLite files: {types:?}" + ); +} + +#[test] +fn files_reach_the_running_application() { + let entry = entry().expect("the desktop entry parses"); + + let exec = entry.string(MAIN, "Exec").expect("an Exec line"); + + assert!(exec.ends_with(" %U"), "Exec passes no files: {exec}"); + assert!( + entry.boolean(MAIN, "DBusActivatable").expect("DBusActivatable"), + "files would start a second process instead of reaching the running one" + ); +} + +#[test] +fn new_window_is_a_desktop_action() { + let entry = entry().expect("the desktop entry parses"); + + let actions = list(&entry, MAIN, "Actions").expect("an Actions list"); + let name = entry.string(NEW_WINDOW, "Name").expect("the action's name"); + + assert!(actions.contains(&"new-window".to_owned()), "{actions:?}"); + assert!(!name.is_empty()); +} + +/// GNOME Settings lists an application under Notifications only when +/// its entry says it sends them. +#[test] +fn notifications_can_be_turned_off_in_settings() { + let entry = entry().expect("the desktop entry parses"); + + assert!( + entry + .boolean(MAIN, "X-GNOME-UsesNotifications") + .expect("X-GNOME-UsesNotifications"), + "TablePro is missing from the Notifications settings" + ); +} diff --git a/linux/crates/app/tests/fixtures/i18n/en_US.po b/linux/crates/app/tests/fixtures/i18n/en_US.po new file mode 100644 index 0000000000..c1089b3f8d --- /dev/null +++ b/linux/crates/app/tests/fixtures/i18n/en_US.po @@ -0,0 +1,28 @@ +msgid "" +msgstr "" +"Project-Id-Version: tablepro\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Language: en_US\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#. Reordered on purpose: the test proves a translator can move a named +#. placeholder without touching the code. +msgid "{table} in {schema}" +msgstr "{schema} owns {table}" + +msgid "{n} row" +msgid_plural "{n} rows" +msgstr[0] "exactly {n} row" +msgstr[1] "all {n} rows" + +msgctxt "filter operator" +msgid "all" +msgstr "every rule" + +msgctxt "filter operator" +msgid "{n} match" +msgid_plural "{n} matches" +msgstr[0] "{n} hit" +msgstr[1] "{n} hits" diff --git a/linux/crates/app/tests/fixtures/workspace-v1.json b/linux/crates/app/tests/fixtures/workspace-v1.json new file mode 100644 index 0000000000..5a8764fc66 --- /dev/null +++ b/linux/crates/app/tests/fixtures/workspace-v1.json @@ -0,0 +1,39 @@ +{ + "version": 1, + "active_tab": 2, + "tabs": [ + { + "kind": "table", + "schema": "public", + "table": "orders", + "offset": 5000, + "page_size": 5000, + "sort": { + "column": 2, + "ascending": false + } + }, + { + "kind": "table", + "schema": null, + "table": "events", + "offset": 0, + "page_size": 1000, + "sort": null + }, + { + "kind": "editor", + "draft": "7d0c5b1e-9a3f-4e2d-8b6c-1f0e9d8c7b6a" + }, + { + "kind": "structure", + "schema": "sales", + "table": "customers" + }, + { + "kind": "structure", + "schema": null, + "table": "users" + } + ] +} diff --git a/linux/crates/app/tests/i18n_catalog.rs b/linux/crates/app/tests/i18n_catalog.rs new file mode 100644 index 0000000000..01bd295eeb --- /dev/null +++ b/linux/crates/app/tests/i18n_catalog.rs @@ -0,0 +1,152 @@ +#![expect( + clippy::print_stderr, + reason = "a harness-free test binary reports its own failures on stderr" +)] + +use std::path::{Path, PathBuf}; +use std::process::{Command, ExitCode, Stdio}; + +const CHILD_MARKER: &str = "TABLEPRO_I18N_CATALOG_CHILD"; +const LOCALE: &str = "en_US.UTF-8"; + +fn main() -> ExitCode { + if std::env::var_os(CHILD_MARKER).is_some() { + return child(); + } + parent() +} + +/// glibc never loads a catalogue under the C locale, so the assertions +/// have to run in a process started with a real one. The parent compiles +/// the fixture and re-executes itself under `en_US.UTF-8`. +fn parent() -> ExitCode { + let Ok(locale_dir) = compile_catalog() else { + return ExitCode::FAILURE; + }; + let output = Command::new(std::env::args_os().next().unwrap_or_default()) + .env(CHILD_MARKER, "1") + .env("TABLEPRO_TEST_LOCALEDIR", &locale_dir) + .env("LC_ALL", LOCALE) + .env("LANG", LOCALE) + .env_remove("LANGUAGE") + .stdin(Stdio::null()) + .output(); + let output = match output { + Ok(output) => output, + Err(error) => { + eprintln!("could not re-execute the catalogue test: {error}"); + return ExitCode::FAILURE; + } + }; + if !output.status.success() { + eprintln!( + "the catalogue child exited with {}:\n{}{}", + output.status, + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + return ExitCode::FAILURE; + } + ExitCode::SUCCESS +} + +fn compile_catalog() -> Result { + let root = Path::new(env!("CARGO_TARGET_TMPDIR")).join("locale"); + let messages = root.join("en_US").join("LC_MESSAGES"); + std::fs::create_dir_all(&messages) + .map_err(|error| eprintln!("could not create {}: {error}", messages.display()))?; + let po = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("tests") + .join("fixtures") + .join("i18n") + .join("en_US.po"); + let output = Command::new("msgfmt") + .arg("--check") + .arg("-o") + .arg(messages.join("tablepro.mo")) + .arg(&po) + .output() + .map_err(|error| eprintln!("could not run msgfmt: {error}"))?; + if !output.status.success() { + eprintln!("msgfmt failed: {}", String::from_utf8_lossy(&output.stderr)); + return Err(()); + } + Ok(root) +} + +fn child() -> ExitCode { + let Some(locale_dir) = std::env::var_os("TABLEPRO_TEST_LOCALEDIR") else { + eprintln!("the child was started without TABLEPRO_TEST_LOCALEDIR"); + return ExitCode::FAILURE; + }; + + // SAFETY: this process has spawned no threads yet. + let selected = unsafe { gettextrs::setlocale(gettextrs::LocaleCategory::LcAll, LOCALE) }; + if selected.is_none() { + eprintln!("{LOCALE} is not generated on this system; install it and re-run"); + return ExitCode::FAILURE; + } + if let Err(error) = gettextrs::bindtextdomain("tablepro", &locale_dir) { + eprintln!("bindtextdomain failed: {error}"); + return ExitCode::FAILURE; + } + if let Err(error) = gettextrs::bind_textdomain_codeset("tablepro", "UTF-8") { + eprintln!("bind_textdomain_codeset failed: {error}"); + return ExitCode::FAILURE; + } + if let Err(error) = gettextrs::textdomain("tablepro") { + eprintln!("textdomain failed: {error}"); + return ExitCode::FAILURE; + } + + let mut failures = Vec::new(); + let mut check = |what: &str, got: String, want: &str| { + if got != want { + failures.push(format!("{what}: got {got:?}, want {want:?}")); + } + }; + + check( + "gettext_f reorders placeholders", + tablepro_app::i18n::gettext_f("{table} in {schema}", &[("table", "users"), ("schema", "public")]), + "public owns users", + ); + check( + "ngettext_f singular", + tablepro_app::i18n::ngettext_f("{n} row", "{n} rows", 1, &[("n", "1")]), + "exactly 1 row", + ); + check( + "ngettext_f plural", + tablepro_app::i18n::ngettext_f("{n} row", "{n} rows", 7, &[("n", "7")]), + "all 7 rows", + ); + check( + "pgettext context", + tablepro_app::i18n::pgettext("filter operator", "all"), + "every rule", + ); + check( + "pgettext_f context with placeholders", + tablepro_app::i18n::pgettext_f("filter operator", "{n} match", &[("n", "3")]), + "3 hit", + ); + check( + "npgettext plural with context", + tablepro_app::i18n::npgettext("filter operator", "{n} match", "{n} matches", 3), + "{n} hits", + ); + check( + "an untranslated id falls through unchanged", + tablepro_app::i18n::gettext("Not in the catalogue"), + "Not in the catalogue", + ); + + if failures.is_empty() { + return ExitCode::SUCCESS; + } + for failure in failures { + eprintln!("{failure}"); + } + ExitCode::FAILURE +} diff --git a/linux/crates/app/tests/logging.rs b/linux/crates/app/tests/logging.rs new file mode 100644 index 0000000000..0db07c3c56 --- /dev/null +++ b/linux/crates/app/tests/logging.rs @@ -0,0 +1,124 @@ +#![expect( + clippy::print_stderr, + reason = "a harness-free test binary reports its own failures on stderr" +)] + +use std::process::{Command, ExitCode, Stdio}; + +const CHILD_MARKER: &str = "TABLEPRO_LOGGING_CHILD"; +const TRACE_PROBE: &str = "probe-at-trace"; +const DEBUG_PROBE: &str = "probe-at-debug"; +const INFO_PROBE: &str = "probe-at-info"; + +fn main() -> ExitCode { + if std::env::var_os(CHILD_MARKER).is_some() { + return child(); + } + parent() +} + +/// A subscriber installs once per process, so each case runs in its own +/// child with its own `RUST_LOG`. +fn parent() -> ExitCode { + let cases = [ + ("trace", "trace", "", vec![TRACE_PROBE, DEBUG_PROBE, INFO_PROBE], vec![]), + ( + "an unparsable filter", + "this is not a filter=??", + "", + vec![INFO_PROBE, "RUST_LOG could not be parsed"], + vec![DEBUG_PROBE], + ), + ( + "a format naming nothing known", + "info", + "yaml", + vec![INFO_PROBE, "TABLEPRO_LOG_FORMAT names no known format"], + // The fallback is text, so the line is not an object. + vec!["\"fields\""], + ), + ]; + + for (what, filter, format, expected, forbidden) in cases { + let Ok(stderr) = run_child(filter, format, what) else { + return ExitCode::FAILURE; + }; + for needle in expected { + if !stderr.contains(needle) { + eprintln!("with RUST_LOG={what}, stderr is missing {needle:?}:\n{stderr}"); + return ExitCode::FAILURE; + } + } + for needle in forbidden { + if stderr.contains(needle) { + eprintln!("with RUST_LOG={what}, stderr should not contain {needle:?}:\n{stderr}"); + return ExitCode::FAILURE; + } + } + } + json_lines_are_objects() +} + +/// Every line a shipper reads has to parse on its own, so the case +/// checks the shape rather than a substring: one object per line, the +/// message in a field. +fn json_lines_are_objects() -> ExitCode { + let Ok(stderr) = run_child("info", "json", "json") else { + return ExitCode::FAILURE; + }; + let mut saw_probe = false; + for line in stderr.lines().filter(|line| !line.trim().is_empty()) { + if !(line.starts_with('{') && line.ends_with('}')) { + eprintln!("a json line is not an object:\n{line}"); + return ExitCode::FAILURE; + } + for key in ["\"timestamp\"", "\"level\"", "\"fields\""] { + if !line.contains(key) { + eprintln!("a json line is missing {key}:\n{line}"); + return ExitCode::FAILURE; + } + } + saw_probe |= line.contains(INFO_PROBE); + } + if !saw_probe { + eprintln!("the json output never carried {INFO_PROBE}:\n{stderr}"); + return ExitCode::FAILURE; + } + ExitCode::SUCCESS +} + +fn run_child(filter: &str, format: &str, what: &str) -> Result { + let output = Command::new(std::env::args_os().next().unwrap_or_default()) + .env(CHILD_MARKER, "1") + .env("RUST_LOG", filter) + .env("TABLEPRO_LOG_FORMAT", format) + .stdin(Stdio::null()) + .output() + .map_err(|error| eprintln!("could not run the {what} case: {error}"))?; + if !output.status.success() { + eprintln!( + "the {what} case exited with {}:\n{}", + output.status, + String::from_utf8_lossy(&output.stderr) + ); + return Err(()); + } + // Nothing may reach stdout: the journal reads stderr. + let stdout = String::from_utf8_lossy(&output.stdout); + if !stdout.trim().is_empty() { + eprintln!("the {what} case wrote to stdout:\n{stdout}"); + return Err(()); + } + Ok(String::from_utf8_lossy(&output.stderr).into_owned()) +} + +fn child() -> ExitCode { + if let Err(error) = tablepro_app::logging::init(tablepro_app::config::Profile::Default) { + eprintln!("logging::init failed: {error}"); + return ExitCode::FAILURE; + } + tracing::trace!("{TRACE_PROBE}"); + tracing::debug!("{DEBUG_PROBE}"); + tracing::info!("{INFO_PROBE}"); + ExitCode::SUCCESS +} diff --git a/linux/crates/app/tests/open_files.rs b/linux/crates/app/tests/open_files.rs new file mode 100644 index 0000000000..1c653f1293 --- /dev/null +++ b/linux/crates/app/tests/open_files.rs @@ -0,0 +1,297 @@ +//! A database file handed over D-Bus, the way Files' Open With does, +//! starts TablePro and opens a window on it. The same file again brings +//! that window forward; another file opens a second window. + +#![expect( + clippy::print_stderr, + reason = "a harness-free test binary reports its own failures on stderr" +)] + +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::process::ExitCode; +use std::time::{Duration, Instant}; + +use gio::prelude::*; + +#[path = "support/settings_schema.rs"] +mod settings_schema; + +const APP_ID: &str = tablepro_app::config::APP_ID; +/// Long enough for a cold start under a loaded CI runner. +const WAIT: Duration = Duration::from_secs(30); +/// The first Open waits for D-Bus to start the service. +const CALL_TIMEOUT_MS: i32 = 30_000; + +fn main() -> ExitCode { + match run() { + Ok(()) => ExitCode::SUCCESS, + Err(message) => { + eprintln!("{message}"); + ExitCode::FAILURE + } + } +} + +fn run() -> Result<(), String> { + let root = tempfile::Builder::new() + .prefix("open-files-") + .tempdir_in(env!("CARGO_TARGET_TMPDIR")) + .map_err(|error| format!("no temporary directory: {error}"))?; + let sandbox = Sandbox::new(root.path())?; + let bus = gio::TestDBus::new(gio::TestDBusFlags::NONE); + bus.add_service_dir(&sandbox.services.to_string_lossy()); + bus.up(); + let outcome = bus + .bus_address() + .ok_or_else(|| "the test bus has no address".to_owned()) + .and_then(|address| opens(&sandbox, &address)); + bus.down(); + outcome +} + +fn opens(sandbox: &Sandbox, address: &str) -> Result<(), String> { + let connection = gio::DBusConnection::for_address_sync( + address, + gio::DBusConnectionFlags::AUTHENTICATION_CLIENT | gio::DBusConnectionFlags::MESSAGE_BUS_CONNECTION, + None, + gio::Cancellable::NONE, + ) + .map_err(|error| format!("could not connect to the test bus: {error}"))?; + let first = sandbox.database("first.sqlite3")?; + let second = sandbox.database("second.sqlite3")?; + + let outcome = open_twice_then_another(&connection, &first, &second); + if name_has_owner(&connection) { + let quitting = quit(&connection) + .and_then(|()| wait_until("TablePro to exit after app.quit", || !name_has_owner(&connection))); + if let (Ok(()), Err(message)) = (&outcome, quitting) { + return Err(message); + } + } + outcome +} + +fn open_twice_then_another(connection: &gio::DBusConnection, first: &str, second: &str) -> Result<(), String> { + open(connection, first)?; + wait_until("a window on the first file", || window_ids(connection).len() == 1)?; + let window = window_ids(connection) + .first() + .copied() + .ok_or_else(|| "the first window went away".to_owned())?; + // Until the window shows the file's workspace it has not claimed the + // file, and a second Open could not find it. + wait_until("the first file's workspace", || shows_workspace(connection, window))?; + + open(connection, first)?; + std::thread::sleep(Duration::from_millis(500)); + let windows = window_ids(connection).len(); + if windows != 1 { + return Err(format!("{windows} windows after opening the same file twice")); + } + + open(connection, second)?; + wait_until("a window on the second file", || window_ids(connection).len() == 2) +} + +/// What Files sends for Open With on a D-Bus activatable application. +fn open(connection: &gio::DBusConnection, uri: &str) -> Result<(), String> { + let platform_data: HashMap = HashMap::new(); + let arguments = (vec![uri.to_owned()], platform_data).to_variant(); + connection + .call_sync( + Some(APP_ID), + &object_path(), + "org.freedesktop.Application", + "Open", + Some(&arguments), + None, + gio::DBusCallFlags::NONE, + CALL_TIMEOUT_MS, + gio::Cancellable::NONE, + ) + .map(drop) + .map_err(|error| format!("Open({uri}) over D-Bus failed: {error}")) +} + +/// Ask TablePro to quit the way the Quit menu item does. +fn quit(connection: &gio::DBusConnection) -> Result<(), String> { + let no_parameter: Vec = Vec::new(); + let platform_data: HashMap = HashMap::new(); + let arguments = ("quit", no_parameter, platform_data).to_variant(); + connection + .call_sync( + Some(APP_ID), + &object_path(), + "org.gtk.Actions", + "Activate", + Some(&arguments), + None, + gio::DBusCallFlags::NO_AUTO_START, + CALL_TIMEOUT_MS, + gio::Cancellable::NONE, + ) + .map(drop) + .map_err(|error| format!("app.quit over D-Bus failed: {error}")) +} + +fn name_has_owner(connection: &gio::DBusConnection) -> bool { + connection + .call_sync( + Some("org.freedesktop.DBus"), + "/org/freedesktop/DBus", + "org.freedesktop.DBus", + "NameHasOwner", + Some(&(APP_ID,).to_variant()), + None, + gio::DBusCallFlags::NONE, + CALL_TIMEOUT_MS, + gio::Cancellable::NONE, + ) + .ok() + .and_then(|reply| reply.get::<(bool,)>()) + .is_some_and(|(owned,)| owned) +} + +/// GtkApplication exports each window's actions at +/// `/window/`, so the children of that node are the +/// open windows. +fn window_ids(connection: &gio::DBusConnection) -> Vec { + let xml = connection + .call_sync( + Some(APP_ID), + &format!("{}/window", object_path()), + "org.freedesktop.DBus.Introspectable", + "Introspect", + None, + None, + gio::DBusCallFlags::NO_AUTO_START, + CALL_TIMEOUT_MS, + gio::Cancellable::NONE, + ) + .ok() + .and_then(|reply| reply.get::<(String,)>()) + .map(|(xml,)| xml) + .unwrap_or_default(); + xml.split(" bool { + connection + .call_sync( + Some(APP_ID), + &format!("{}/window/{window}", object_path()), + "org.gtk.Actions", + "Describe", + Some(&("disconnect",).to_variant()), + None, + gio::DBusCallFlags::NO_AUTO_START, + CALL_TIMEOUT_MS, + gio::Cancellable::NONE, + ) + .ok() + .and_then(|reply| reply.child_value(0).child_value(0).get::()) + .unwrap_or(false) +} + +/// GApplication's object path: the id with dots as slashes, as GTK's +/// window paths `/window/` build on. +fn object_path() -> String { + format!("/{}", APP_ID.replace('.', "/").replace('-', "_")) +} + +fn wait_until(what: &str, mut ready: impl FnMut() -> bool) -> Result<(), String> { + let deadline = Instant::now() + WAIT; + while !ready() { + if Instant::now() >= deadline { + return Err(format!("timed out waiting for {what}")); + } + std::thread::sleep(Duration::from_millis(50)); + } + Ok(()) +} + +/// Directories of the test's own, so the started TablePro reads and +/// writes nothing of the developer's. +struct Sandbox { + root: PathBuf, + services: PathBuf, +} + +impl Sandbox { + fn new(root: &Path) -> Result { + let dir = |name: &str| -> Result { + let path = root.join(name); + std::fs::create_dir_all(&path).map_err(|error| format!("could not create {}: {error}", path.display()))?; + Ok(path) + }; + let schemas = dir("schemas")?; + let runtime = dir("runtime")?; + private(&runtime)?; + settings_schema::compile(&schemas, &[APP_ID])?; + let mut environment = vec![ + ("LC_ALL".to_owned(), PathBuf::from("C.UTF-8")), + ("GSETTINGS_BACKEND".to_owned(), PathBuf::from("memory")), + ("GSETTINGS_SCHEMA_DIR".to_owned(), schemas), + ("XDG_CONFIG_HOME".to_owned(), dir("config")?), + ("XDG_DATA_HOME".to_owned(), dir("data")?), + ("XDG_STATE_HOME".to_owned(), dir("state")?), + ("XDG_CACHE_HOME".to_owned(), dir("cache")?), + ("XDG_RUNTIME_DIR".to_owned(), runtime), + ]; + // GTestDBus clears DISPLAY once it is up, so the display goes + // into the service's own environment. + for name in ["DISPLAY", "WAYLAND_DISPLAY"] { + if let Some(value) = std::env::var_os(name) { + environment.push((name.to_owned(), PathBuf::from(value))); + } + } + let services = dir("services")?; + write_service(&services, &environment)?; + Ok(Self { + root: root.to_path_buf(), + services, + }) + } + + /// An empty file, which SQLite reads as an empty database, by URI. + fn database(&self, name: &str) -> Result { + let path = self.root.join(name); + std::fs::write(&path, b"").map_err(|error| format!("could not create {}: {error}", path.display()))?; + Ok(gio::File::for_path(&path).uri().to_string()) + } +} + +/// The D-Bus service file that starts this build's binary, with the +/// sandbox set in its Exec line rather than in this process. +fn write_service(dir: &Path, environment: &[(String, PathBuf)]) -> Result<(), String> { + let mut exec = vec!["/usr/bin/env".to_owned()]; + for (name, value) in environment { + exec.push(quoted(&format!("{name}={}", value.display()))?); + } + exec.push(quoted(env!("CARGO_BIN_EXE_tablepro"))?); + exec.push("--gapplication-service".to_owned()); + let service = format!("[D-BUS Service]\nName={APP_ID}\nExec={}\n", exec.join(" ")); + let path = dir.join(format!("{APP_ID}.service")); + std::fs::write(&path, service).map_err(|error| format!("could not write {}: {error}", path.display())) +} + +/// dbus-daemon splits Exec the way a shell does. +fn quoted(word: &str) -> Result { + if word.contains('\'') { + return Err(format!("cannot quote {word} for a D-Bus service file")); + } + Ok(format!("'{word}'")) +} + +/// GLib and the XDG base directory specification expect the runtime +/// directory to be private to the user. +fn private(path: &Path) -> Result<(), String> { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700)) + .map_err(|error| format!("could not make {} private: {error}", path.display())) +} diff --git a/linux/crates/app/tests/secret_save_flow.rs b/linux/crates/app/tests/secret_save_flow.rs new file mode 100644 index 0000000000..5cd714aeea --- /dev/null +++ b/linux/crates/app/tests/secret_save_flow.rs @@ -0,0 +1,274 @@ +//! How a connection's secrets reach the keyring and come back, proved +//! against a fake vault and the real connection list on disk. +//! +//! Saving writes secrets first, then the entry once. A secret the +//! keyring refuses does not stop the save: the entry is kept without it +//! and the user is asked for it at the next connect, which is also what +//! happens to a connection whose password was never stored. + +use std::sync::Arc; + +use secrecy::{ExposeSecret, SecretString}; +use tablepro_core::credentials::{ + CredentialInteraction, PromptReply, SecretError, SecretKind, SecretSave, SecretVault, +}; +use tablepro_core::{AuthMode, Credentials, DriverRegistry, TlsConfig}; +use tablepro_session::connect::StoreConnectionWrites; +use tablepro_session::runtime::Tasks; +use tablepro_session::{ConnectError, PendingSave, RequestTarget, persist_pending_save, resolve_saved}; +use tablepro_storage::{ + ConnectionStore, CredentialDemand, KeyringCredentials, KeyringError, SavedConnection, SavedSshAuth, SavedSshConfig, + StorageError, StoragePaths, +}; +use tablepro_test_support::{Call, ConnectionScript, FakeDriver, FakePrompter, FakeSecretVault}; +use uuid::Uuid; + +fn paths(root: &tempfile::TempDir) -> StoragePaths { + StoragePaths::under(root.path(), "tablepro-test", "app.tablepro.TablePro.Devel") +} + +#[expect( + clippy::disallowed_methods, + reason = "a test is already inside its own runtime; the app takes the handle from AppRuntime" +)] +fn tasks() -> Tasks { + Tasks::new(tokio::runtime::Handle::current()) +} + +fn connection(id: Uuid, ssh: Option) -> SavedConnection { + SavedConnection { + id, + name: "Prod".into(), + driver_id: "fake".into(), + host: "db.example.com".into(), + port: 5432, + database: "shop".into(), + username: "app".into(), + tls: TlsConfig::verify_full(), + read_only: false, + auth_mode: AuthMode::Password, + ssh, + last_opened_at: None, + color: None, + group: None, + } +} + +fn keyed_ssh() -> SavedSshConfig { + SavedSshConfig { + host: "bastion.example.com".into(), + port: None, + username: Some("deploy".into()), + jump_hosts: Vec::new(), + auth: SavedSshAuth::PrivateKey { + path: Some("/home/u/.ssh/id_ed25519".into()), + // The save records whether the passphrase actually landed. + has_passphrase: true, + }, + } +} + +fn passphrase() -> SecretSave { + SecretSave { + kind: SecretKind::SshPassphrase, + secret: SecretString::from("pass"), + label: "TablePro SSH key passphrase".into(), + } +} + +async fn save( + vault: &FakeSecretVault, + store: &ConnectionStore, + saved: SavedConnection, + is_new: bool, +) -> Result { + let writes = StoreConnectionWrites::new(store.clone(), tasks()); + let pending = PendingSave { + connection: saved, + secrets: vec![passphrase()], + forget: Vec::new(), + is_new, + }; + persist_pending_save(vault, &writes, pending).await +} + +fn has_passphrase(saved: &SavedConnection) -> bool { + saved.has_stored_passphrase() +} + +#[tokio::test] +async fn secrets_stored_before_single_upsert() { + let root = tempfile::tempdir().expect("tempdir"); + let vault = FakeSecretVault::new(); + let store = ConnectionStore::new(&paths(&root)); + let id = Uuid::new_v4(); + + save(&vault, &store, connection(id, Some(keyed_ssh())), true) + .await + .expect("save"); + + assert_eq!(vault.calls(), vec![Call::Store(id, SecretKind::SshPassphrase)]); + let listed = store.load_blocking().expect("load"); + assert_eq!(listed.len(), 1); + assert!(has_passphrase(&listed[0])); +} + +#[tokio::test] +async fn passphrase_store_failure_saves_has_passphrase_false_in_one_write() { + let root = tempfile::tempdir().expect("tempdir"); + let vault = FakeSecretVault::new(); + vault.fail_store(SecretKind::SshPassphrase, SecretError::Locked); + let store = ConnectionStore::new(&paths(&root)); + let id = Uuid::new_v4(); + let revision_before = store.snapshot().revision; + + let report = save(&vault, &store, connection(id, Some(keyed_ssh())), true) + .await + .expect("the entry is saved without the secret"); + + assert!( + !has_passphrase(&report.saved), + "the record claims a passphrase the keyring refused" + ); + assert_eq!( + report.secret_failures, + [(SecretKind::SshPassphrase, SecretError::Locked)] + ); + // One load plus one write, not a write followed by a correction. + assert_eq!(store.snapshot().revision, revision_before + 2); +} + +#[tokio::test] +async fn new_connection_upsert_failure_deletes_stored_secrets() { + let root = tempfile::tempdir().expect("tempdir"); + let vault = FakeSecretVault::new(); + // A corrupt list makes every write fail, which is what the rollback + // has to cope with. + let paths = paths(&root); + std::fs::create_dir_all(paths.connections_file().parent().expect("parent")).expect("create"); + std::fs::write(paths.connections_file(), "not json").expect("seed"); + let store = ConnectionStore::new(&paths); + let id = Uuid::new_v4(); + + let refused = save(&vault, &store, connection(id, Some(keyed_ssh())), true).await; + + assert!(refused.is_err()); + assert!(vault.is_empty(), "secrets outlived the connection that never saved"); + assert!(vault.calls().contains(&Call::DeleteConnection(id))); +} + +#[tokio::test] +async fn an_existing_connection_keeps_its_secrets_when_the_write_fails() { + let root = tempfile::tempdir().expect("tempdir"); + let vault = FakeSecretVault::new(); + let paths = paths(&root); + std::fs::create_dir_all(paths.connections_file().parent().expect("parent")).expect("create"); + std::fs::write(paths.connections_file(), "not json").expect("seed"); + let store = ConnectionStore::new(&paths); + let id = Uuid::new_v4(); + + let refused = save(&vault, &store, connection(id, Some(keyed_ssh())), false).await; + + assert!(refused.is_err()); + // The entry already existed, so its secrets still belong to it. + assert!(!vault.is_empty()); + assert!(!vault.calls().contains(&Call::DeleteConnection(id))); +} + +fn drivers() -> DriverRegistry { + let mut drivers = DriverRegistry::new(); + drivers.register(Arc::new(FakeDriver::connecting( + &FakeDriver::NETWORK_CAPABILITIES, + ConnectionScript::default(), + ))); + drivers +} + +fn database_password(target: &RequestTarget) -> Option { + match target { + RequestTarget::Network { + credentials: Credentials::Password { password, .. }, + .. + } => Some(password.expose_secret().to_owned()), + _ => None, + } +} + +#[tokio::test] +async fn keyring_credentials_not_stored_is_error_not_empty_password() { + let vault: Arc = Arc::new(FakeSecretVault::new()); + let saved = connection(Uuid::new_v4(), None); + let credentials = KeyringCredentials::for_saved(vault, &saved); + + let resolved = resolve_saved( + &saved, + &drivers(), + &credentials, + CredentialInteraction::Unattended, + CredentialDemand::default(), + ) + .await; + + // The old code turned this into an empty password, which reached the + // driver and came back as a confusing authentication failure. + assert_eq!( + resolved.err(), + Some(ConnectError::Keyring(KeyringError::SecretNotStored { + kind: SecretKind::DatabasePassword + })) + ); +} + +#[tokio::test] +async fn a_saved_connection_without_a_password_asks_for_one() { + let vault: Arc = Arc::new(FakeSecretVault::new()); + let saved = connection(Uuid::new_v4(), None); + let credentials = KeyringCredentials::for_saved(vault, &saved); + let prompter = Arc::new(FakePrompter::new([PromptReply::Submitted { + values: vec![SecretString::from("typed")], + remember: true, + }])); + + let refreshed = resolve_saved( + &saved, + &drivers(), + &credentials, + CredentialInteraction::Attended(prompter), + CredentialDemand::default(), + ) + .await + .expect("the prompt supplies the password"); + + assert_eq!(database_password(&refreshed.request.target).as_deref(), Some("typed")); + assert_eq!(refreshed.remember.len(), 1, "remember was asked for and dropped"); + assert_eq!(refreshed.remember[0].kind, SecretKind::DatabasePassword); +} + +#[tokio::test] +async fn keyring_credentials_found_injects_secret() { + let fake = Arc::new(FakeSecretVault::new()); + let saved = connection(Uuid::new_v4(), None); + fake.store( + saved.id, + SecretKind::DatabasePassword, + &SecretString::from("stored"), + "label", + ) + .await + .expect("seed the keyring"); + let vault: Arc = fake; + let credentials = KeyringCredentials::for_saved(vault, &saved); + + let refreshed = resolve_saved( + &saved, + &drivers(), + &credentials, + CredentialInteraction::Unattended, + CredentialDemand::default(), + ) + .await + .expect("the stored password is used"); + + assert_eq!(database_password(&refreshed.request.target).as_deref(), Some("stored")); + assert!(refreshed.remember.is_empty()); +} diff --git a/linux/crates/app/tests/startup.rs b/linux/crates/app/tests/startup.rs new file mode 100644 index 0000000000..97fc0dd72c --- /dev/null +++ b/linux/crates/app/tests/startup.rs @@ -0,0 +1,218 @@ +#![expect( + clippy::print_stderr, + reason = "a harness-free test binary reports its own failures on stderr" +)] + +use std::path::{Path, PathBuf}; +use std::process::{Child, Command, ExitCode, Output, Stdio}; +use std::time::{Duration, Instant}; + +#[path = "support/settings_schema.rs"] +mod settings_schema; + +/// A run that has not finished by now is a run that never will: every +/// case here exits on its own within milliseconds. Without this, a +/// case that wrongly opens a window waits for a user who is not +/// there, and the suite hangs instead of failing. +const RUN_LIMIT: Duration = Duration::from_secs(30); + +fn main() -> ExitCode { + let (Ok(schemas), Ok(empty), Ok(runtime)) = (compiled_schema_dir(), empty_dir(), runtime_dir()) else { + return ExitCode::FAILURE; + }; + let sandbox = Sandbox { + empty: &empty, + runtime: &runtime, + }; + if help_lists_the_gapplication_options(&schemas, &sandbox).is_err() { + return ExitCode::FAILURE; + } + if version_prints_the_version(&schemas, &sandbox).is_err() { + return ExitCode::FAILURE; + } + if a_missing_schema_is_reported(&sandbox).is_err() { + return ExitCode::FAILURE; + } + ExitCode::SUCCESS +} + +/// The directories a run is confined to. +struct Sandbox<'a> { + empty: &'a Path, + runtime: &'a Path, +} + +fn target_tmp(name: &str) -> PathBuf { + Path::new(env!("CARGO_TARGET_TMPDIR")).join(name) +} + +/// The binary opens its settings during startup, so every case needs the +/// schema somewhere GIO can find it. Meson installs it; here the test +/// compiles it next to the test binary. +fn compiled_schema_dir() -> Result { + let dir = target_tmp("schemas"); + std::fs::create_dir_all(&dir).map_err(|error| eprintln!("could not create {}: {error}", dir.display()))?; + settings_schema::compile(&dir, &[tablepro_app::config::APP_ID]).map_err(|error| eprintln!("{error}"))?; + Ok(dir) +} + +/// The binary under an environment that carries nothing in from the +/// machine running the test. +/// +/// GLib looks for a schema in `GSETTINGS_SCHEMA_DIR`, then in +/// `XDG_DATA_HOME`, then across `XDG_DATA_DIRS`. A developer who +/// installed the schema into their own data directory, which is how +/// the app runs without meson, would otherwise have it found through +/// the second of those, and the case that asks what happens when no +/// schema is installed would be answered by their machine instead of +/// by the code. +/// +/// `XDG_RUNTIME_DIR` is the test's own, so nothing the binary leaves +/// there, such as SSH control sockets, lands beside a TablePro the +/// developer has open. +fn tablepro(sandbox: &Sandbox<'_>) -> Command { + let mut command = Command::new(env!("CARGO_BIN_EXE_tablepro")); + command + .env("LC_ALL", "C.UTF-8") + .env("GSETTINGS_BACKEND", "memory") + .env("XDG_DATA_HOME", sandbox.empty) + .env("XDG_DATA_DIRS", sandbox.empty) + .env("XDG_RUNTIME_DIR", sandbox.runtime) + .env_remove("LANGUAGE") + .stdin(Stdio::null()); + command +} + +/// A directory that exists and stays empty, so a lookup through it +/// finds nothing rather than failing for want of the directory. +fn empty_dir() -> Result { + let dir = target_tmp("no-schemas"); + std::fs::create_dir_all(&dir).map_err(|error| eprintln!("could not create {}: {error}", dir.display()))?; + Ok(dir) +} + +/// A runtime directory of the test's own. GLib and the XDG base directory +/// specification expect it to be private to the user, so it is created +/// with mode 0700. +fn runtime_dir() -> Result { + use std::os::unix::fs::{DirBuilderExt, PermissionsExt}; + + let dir = target_tmp("runtime"); + std::fs::DirBuilder::new() + .recursive(true) + .mode(0o700) + .create(&dir) + .and_then(|()| std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700))) + .map_err(|error| eprintln!("could not create {}: {error}", dir.display()))?; + Ok(dir) +} + +fn run(command: &mut Command, what: &str) -> Result { + let child = command + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|error| eprintln!("could not run tablepro for {what}: {error}"))?; + wait_with_limit(child, what) +} + +fn wait_with_limit(mut child: Child, what: &str) -> Result { + let deadline = Instant::now() + RUN_LIMIT; + loop { + match child.try_wait() { + Ok(Some(_)) => break, + Ok(None) => {} + Err(error) => { + eprintln!("could not wait for tablepro during {what}: {error}"); + return Err(()); + } + } + if Instant::now() >= deadline { + let _ = child.kill(); + let _ = child.wait(); + eprintln!("tablepro did not exit within {RUN_LIMIT:?} during {what}"); + return Err(()); + } + std::thread::sleep(Duration::from_millis(20)); + } + child + .wait_with_output() + .map_err(|error| eprintln!("could not read tablepro output for {what}: {error}")) +} + +fn combined(output: &Output) -> String { + format!( + "{}{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ) +} + +/// `--help-gapplication` appears only once GApplication has parsed the +/// command line, which happens after `run()` registered the embedded +/// GResource. A missing or malformed gresource aborts before that. +fn help_lists_the_gapplication_options(schemas: &Path, sandbox: &Sandbox<'_>) -> Result<(), ()> { + let output = run( + tablepro(sandbox).arg("--help").env("GSETTINGS_SCHEMA_DIR", schemas), + "--help", + )?; + + if !output.status.success() { + eprintln!("tablepro --help exited with {}:\n{}", output.status, combined(&output)); + return Err(()); + } + let stdout = String::from_utf8_lossy(&output.stdout); + if !stdout.contains("--help-gapplication") { + eprintln!("tablepro --help did not list the GApplication options:\n{stdout}"); + return Err(()); + } + Ok(()) +} + +/// `--version` prints the version and exits rather than opening a +/// window. +fn version_prints_the_version(schemas: &Path, sandbox: &Sandbox<'_>) -> Result<(), ()> { + let output = run( + tablepro(sandbox).arg("--version").env("GSETTINGS_SCHEMA_DIR", schemas), + "--version", + )?; + + if !output.status.success() { + eprintln!( + "tablepro --version exited with {}:\n{}", + output.status, + combined(&output) + ); + return Err(()); + } + let stdout = String::from_utf8_lossy(&output.stdout); + if !stdout.contains(tablepro_app::config::VERSION) { + eprintln!( + "tablepro --version did not print {}:\n{}", + tablepro_app::config::VERSION, + combined(&output) + ); + return Err(()); + } + Ok(()) +} + +/// Without the schema installed the app must name what is missing and +/// exit, not abort inside GIO with nothing to go on. +fn a_missing_schema_is_reported(sandbox: &Sandbox<'_>) -> Result<(), ()> { + let output = run( + tablepro(sandbox).env("GSETTINGS_SCHEMA_DIR", sandbox.empty), + "a missing schema", + )?; + + if output.status.success() { + eprintln!("tablepro started with no settings schema installed"); + return Err(()); + } + let reported = combined(&output); + if !reported.contains(tablepro_app::config::APP_ID) { + eprintln!("the startup failure did not name the schema:\n{reported}"); + return Err(()); + } + Ok(()) +} diff --git a/linux/crates/app/tests/support/settings_schema.rs b/linux/crates/app/tests/support/settings_schema.rs new file mode 100644 index 0000000000..cb1e6f54b9 --- /dev/null +++ b/linux/crates/app/tests/support/settings_schema.rs @@ -0,0 +1,30 @@ +use std::path::Path; +use std::process::Command; + +const TEMPLATE: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../data/app.tablepro.TablePro.gschema.xml.in" +)); + +/// Renders the schema for each ID the way data/meson.build does and +/// compiles them together into `dir`. +pub fn compile(dir: &Path, app_ids: &[&str]) -> Result<(), String> { + for app_id in app_ids { + let path = format!("/{}/", app_id.replace('.', "/")); + let schema = TEMPLATE.replace("@app-id@", app_id).replace("@schema-path@", &path); + let file = dir.join(format!("{app_id}.gschema.xml")); + std::fs::write(&file, schema).map_err(|error| format!("could not write {}: {error}", file.display()))?; + } + let output = Command::new("glib-compile-schemas") + .arg("--strict") + .arg(dir) + .output() + .map_err(|error| format!("could not run glib-compile-schemas: {error}"))?; + if output.status.success() { + return Ok(()); + } + Err(format!( + "glib-compile-schemas failed: {}", + String::from_utf8_lossy(&output.stderr) + )) +} diff --git a/linux/crates/app/tests/tracker_save_postgres.rs b/linux/crates/app/tests/tracker_save_postgres.rs new file mode 100644 index 0000000000..f4e01342f9 --- /dev/null +++ b/linux/crates/app/tests/tracker_save_postgres.rs @@ -0,0 +1,140 @@ +//! A browse tab's pending edits saved to a real PostgreSQL, planned the +//! way the app plans them. + +#![expect( + clippy::expect_used, + reason = "the setup helpers fail a test the way the test functions themselves do" +)] + +use std::sync::Arc; +use std::time::Duration; + +use secrecy::SecretString; +use tablepro_app::change_tracker::{RowKey, pk_identity}; +use tablepro_app::tab_tracker_handle::TabTrackerHandle; +use tablepro_app::tracker_edit::TrackerEdit; +use tablepro_core::edit::CellInput; +use tablepro_core::meta::TableRef; +use tablepro_core::save::plan_save; +use tablepro_core::statement::{GuardedStatement, Statement, StatementBuilder}; +use tablepro_core::{ + CallOptions, ConnectEnvironment, ConnectOptions, ConnectTarget, Connection, Credentials, DatabaseDriver, + ForeignKeyMode, NetworkEndpoint, NetworkTarget, RowLimit, TlsConfig, Value, WorkspaceSettings, WriteBatch, + WriteKind, +}; +use tablepro_driver_postgres::PgDriver; +use tablepro_net::DirectTcpTransport; +use tablepro_test_fixtures::{HbaMode, PostgresFixture}; +use tablepro_test_support::conformance::test_liveness; +use tokio::time::Instant; +use tokio_util::sync::CancellationToken; + +const CALL: Duration = Duration::from_secs(30); + +fn within() -> CallOptions { + CallOptions::new(Some(Instant::now() + CALL), CancellationToken::new()) +} + +fn statement(sql: &str) -> Statement { + let mut builder = StatementBuilder::new(); + builder.push_sql(sql); + builder.finish() +} + +async fn connect(fixture: &PostgresFixture) -> Arc { + let liveness = test_liveness(); + let endpoint = NetworkEndpoint::new(fixture.host(), fixture.port()).expect("a valid endpoint"); + let credentials = fixture.password_credentials(); + let options = ConnectOptions { + target: ConnectTarget::Network(NetworkTarget { + transport: Arc::new(DirectTcpTransport::new(endpoint, &liveness)), + credentials: Credentials::Password { + username: credentials.username.clone(), + password: SecretString::from(credentials.password.clone()), + }, + database: Some(fixture.database().to_owned()), + tls: TlsConfig::disabled(), + }), + workspace_settings: WorkspaceSettings::workspace(false), + liveness, + environment: ConnectEnvironment::new("test"), + }; + PgDriver + .connect(options, CancellationToken::new()) + .await + .expect("a connection") +} + +async fn setup(connection: &dyn Connection, statements: &[&str]) { + let steps = statements + .iter() + .map(|sql| GuardedStatement::unguarded(statement(sql))) + .collect(); + let batch = WriteBatch::new( + WriteKind::SchemaChanges, + TableRef::unqualified("users"), + steps, + ForeignKeyMode::Enforced, + ); + connection + .write_batch(&batch, within()) + .await + .expect("the setup statements"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[ignore = "requires docker"] +async fn replace_by_unique_key_saves() { + let fixture = PostgresFixture::start(HbaMode::Password) + .await + .expect("a PostgreSQL container"); + let connection = connect(&fixture).await; + setup( + connection.as_ref(), + &[ + "CREATE TABLE users (id integer PRIMARY KEY, email text NOT NULL UNIQUE)", + "INSERT INTO users VALUES (1, 'a')", + ], + ) + .await; + let table = TableRef::new(Some("public".to_owned()), "users"); + let columns = connection.fetch_columns(&table, within()).await.expect("the columns"); + let tracker = TabTrackerHandle::default(); + let _ = tracker.apply(TrackerEdit::DeleteRows { + persisted: vec![( + RowKey::from_pk_values(&[Value::Int(1)]).expect("a key"), + vec![Value::Int(1), Value::Text("a".to_owned())].into(), + )], + drafts: Vec::new(), + }); + let _ = tracker.apply(TrackerEdit::InsertDrafts { + rows: vec![ + vec![Value::Int(2), Value::Text("a".to_owned())] + .into_iter() + .map(CellInput::Value) + .collect(), + ], + }); + + let changes = tracker.change_set(&columns).expect("a change set"); + let plan = plan_save( + connection.dialect().as_ref(), + &table, + &columns, + &pk_identity(&columns), + &changes.changes, + ) + .expect("a save plan"); + connection.write_batch(&plan.batch, within()).await.expect("the save"); + + let saved = connection + .query_bound( + &statement("SELECT id FROM users WHERE email = 'a'"), + RowLimit::EDITOR_DEFAULT, + within(), + ) + .await + .expect("a read") + .rows; + assert_eq!(saved, vec![vec![Value::Int(2)]]); +} diff --git a/linux/crates/core/Cargo.toml b/linux/crates/core/Cargo.toml new file mode 100644 index 0000000000..abdb754d84 --- /dev/null +++ b/linux/crates/core/Cargo.toml @@ -0,0 +1,31 @@ +[package] +name = "tablepro-core" +edition.workspace = true +rust-version.workspace = true +license.workspace = true +publish.workspace = true + +[dependencies] +async-trait.workspace = true +chrono.workspace = true +csv.workspace = true +futures.workspace = true +num-bigint.workspace = true +rust_xlsxwriter.workspace = true +rustls-pki-types.workspace = true +secrecy.workspace = true +serde.workspace = true +serde_json.workspace = true +sqlparser.workspace = true +thiserror.workspace = true +tokio = { workspace = true, features = ["io-util", "macros", "rt", "sync", "time"] } +tokio-util = { workspace = true, features = ["rt"] } +uuid.workspace = true + +[dev-dependencies] +proptest.workspace = true +zip.workspace = true +tokio = { workspace = true, features = ["macros", "rt", "test-util"] } + +[lints] +workspace = true diff --git a/linux/crates/core/src/auth_mode.rs b/linux/crates/core/src/auth_mode.rs new file mode 100644 index 0000000000..2e60423a6a --- /dev/null +++ b/linux/crates/core/src/auth_mode.rs @@ -0,0 +1,33 @@ +use serde::{Deserialize, Serialize}; + +/// How a saved connection logs in. +/// +/// It is persisted with the connection, so a variant is never renamed: +/// the stored spelling is the serde name. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AuthMode { + #[default] + Password, + /// SQL Server integrated login over Kerberos, with the ticket the + /// user already holds from `kinit`. No username or password is + /// sent. + Kerberos, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_stored_spelling_is_snake_case() { + assert_eq!( + serde_json::to_string(&AuthMode::Kerberos).ok().as_deref(), + Some("\"kerberos\"") + ); + assert_eq!( + serde_json::from_str::("\"password\"").ok(), + Some(AuthMode::Password) + ); + } +} diff --git a/linux/crates/core/src/browse/browse_page.rs b/linux/crates/core/src/browse/browse_page.rs new file mode 100644 index 0000000000..f36f37343c --- /dev/null +++ b/linux/crates/core/src/browse/browse_page.rs @@ -0,0 +1,35 @@ +use std::sync::Arc; + +use crate::column::ResultColumn; +use crate::value::Value; + +/// A page of rows, with the key that names each one. +/// +/// The columns and rows hold only what the user asked for: any hidden +/// row-id column the query projected has already been taken out, so it +/// cannot reach the grid, an export, a copy or an insert preview. +#[derive(Debug, Clone)] +pub struct BrowsePage { + pub columns: Arc<[ResultColumn]>, + pub rows: Vec>, + /// One per row, in the same order. `None` means the row cannot be + /// edited: the table has no key, or part of this row's key could + /// not be read. + pub keys: Vec>>, + pub truncated: bool, +} + +impl BrowsePage { + pub fn row_count(&self) -> usize { + self.rows.len() + } + + pub fn key(&self, row: usize) -> Option<&Vec> { + self.keys.get(row)?.as_ref() + } + + /// Whether any row on this page can be changed. + pub fn has_editable_rows(&self) -> bool { + self.keys.iter().any(Option::is_some) + } +} diff --git a/linux/crates/core/src/browse/browse_sql_error.rs b/linux/crates/core/src/browse/browse_sql_error.rs new file mode 100644 index 0000000000..96de0c9449 --- /dev/null +++ b/linux/crates/core/src/browse/browse_sql_error.rs @@ -0,0 +1,30 @@ +use thiserror::Error; + +use crate::dialect::BindError; +use crate::dml::BuildSqlError; +use crate::filter::BuildFilterError; + +use super::PagingMode; + +/// Why a page could not be built or read back. +#[derive(Debug, Clone, PartialEq, Eq, Error)] +pub enum BrowseSqlError { + #[error("the table has no columns to select")] + NoColumns, + #[error("the sort names column {0}, which the table does not have")] + UnknownSortColumn(usize), + #[error("this page position cannot be read when paging by {mode:?}")] + UnsupportedPosition { mode: PagingMode }, + #[error("the row to page from has a NULL key, which no row compares past")] + NullAnchor, + #[error("the row to page from has no row address that can be read")] + UnreadableAnchor, + #[error("the page returned {found} columns but the query asked for {expected}")] + ShapeMismatch { expected: usize, found: usize }, + #[error(transparent)] + Filter(#[from] BuildFilterError), + #[error(transparent)] + Build(#[from] BuildSqlError), + #[error(transparent)] + Bind(#[from] BindError), +} diff --git a/linux/crates/core/src/browse/build_count.rs b/linux/crates/core/src/browse/build_count.rs new file mode 100644 index 0000000000..8583c7ac74 --- /dev/null +++ b/linux/crates/core/src/browse/build_count.rs @@ -0,0 +1,32 @@ +use crate::column::ColumnInfo; +use crate::dialect::SqlDialect; +use crate::filter::{FilterSet, build_filter}; +use crate::meta::TableRef; +use crate::statement::{Statement, StatementBuilder}; + +use super::BrowseSqlError; +use super::select_parts::where_clause; + +/// The count the paginator shows, under the same filter as the page. +/// +/// The count expression is the dialect's, because COUNT(*) is a 32-bit +/// int on SQL Server and fails past two billion rows. +pub fn build_count( + dialect: &dyn SqlDialect, + table: &TableRef, + columns: &[ColumnInfo], + filter: &FilterSet, +) -> Result { + let mut builder = StatementBuilder::new(); + let mut conditions = Vec::new(); + if build_filter(&mut builder, dialect, columns, filter)? { + conditions.push(builder.take_fragment()); + } + builder.push_sql(&format!( + "SELECT {} FROM {}{}", + dialect.count_all(), + dialect.qualify(table), + where_clause(&conditions) + )); + Ok(builder.finish()) +} diff --git a/linux/crates/core/src/browse/build_export.rs b/linux/crates/core/src/browse/build_export.rs new file mode 100644 index 0000000000..ff1ceca59b --- /dev/null +++ b/linux/crates/core/src/browse/build_export.rs @@ -0,0 +1,45 @@ +use crate::dialect::SqlDialect; +use crate::filter::build_filter; +use crate::meta::RowIdentity; +use crate::statement::{Statement, StatementBuilder}; + +use super::select_parts::{ + identity_components, identity_order, projection_list, user_order, where_clause, with_tiebreak, +}; +use super::{BrowseSqlError, ExportRequest}; + +/// Every row the grid's filter and sort select, for an export. +/// +/// The same projections and filter as a page, so an export holds what +/// the grid showed. No engine row id is selected: it is never data the +/// user asked for. A key still orders the rows, so two exports of an +/// unchanged table come out the same. A heap address orders them only +/// behind the user's sort: on its own it would sort the whole table +/// before the first row could be written. +pub fn build_export(dialect: &dyn SqlDialect, request: &ExportRequest<'_>) -> Result { + let projection = projection_list(dialect, request.columns)?; + let components = identity_components(request.identity, request.columns)?; + let user = user_order(dialect, request.table, request.columns, request.sort)?; + let heap_order_only = + user.is_empty() && matches!(request.identity, RowIdentity::EngineRowId(id) if id.is_heap_address()); + let order = match heap_order_only { + true => Vec::new(), + false => with_tiebreak(user, &identity_order(dialect, request.table, &components)?), + }; + + let mut builder = StatementBuilder::new(); + let mut conditions = Vec::new(); + if build_filter(&mut builder, dialect, request.columns, request.filter)? { + conditions.push(builder.take_fragment()); + } + let order_by = match order.is_empty() { + true => String::new(), + false => format!(" ORDER BY {}", order.join(", ")), + }; + builder.push_sql(&format!( + "SELECT {projection} FROM {}{}{order_by}", + dialect.qualify(request.table), + where_clause(&conditions) + )); + Ok(builder.finish()) +} diff --git a/linux/crates/core/src/browse/build_page.rs b/linux/crates/core/src/browse/build_page.rs new file mode 100644 index 0000000000..9184bf6131 --- /dev/null +++ b/linux/crates/core/src/browse/build_page.rs @@ -0,0 +1,271 @@ +use crate::column::ColumnInfo; +use crate::dialect::{BindTarget, KeysetDirection, SqlDialect}; +use crate::dml::{BuildSqlError, KeyComponent}; +use crate::filter::build_filter; +use crate::meta::{CtidWindow, EngineRowIdPart, PageCost, PagePosition, PageStability, RowIdentity}; +use crate::statement::StatementBuilder; +use crate::value::Value; + +use super::select_parts::{ + direction, identity_components, identity_order, projection_list, user_order, where_clause, with_tiebreak, +}; +use super::{BrowseSqlError, PageLayout, PageRequest, PageSpec, PagingMode, RowKeyLayout, paging_mode}; + +/// The query for one page of a table. +/// +/// The visible columns come first, through the dialect's projections, +/// and any engine row id parts follow as hidden columns that +/// `split_page` strips. The position decides how the page is found: +/// past a key, inside a block window, or at an offset, under the one +/// mode `paging_mode` picks for the table and sort. +pub fn build_page(dialect: &dyn SqlDialect, request: &PageRequest<'_>) -> Result { + let mode = paging_mode(request.identity, request.sort); + let components = identity_components(request.identity, request.columns)?; + let (row_key, hidden) = row_key_layout(dialect, request.identity, request.columns, &components)?; + let layout = PageLayout::new(request.columns.len(), hidden.as_ref().map_or(0, |(count, _)| *count)); + + let mut projection = projection_list(dialect, request.columns)?; + if let Some((_, hidden_sql)) = hidden { + projection = format!("{projection}, {hidden_sql}"); + } + + let mut builder = StatementBuilder::new(); + let mut conditions = Vec::new(); + if build_filter(&mut builder, dialect, request.columns, request.filter)? { + conditions.push(builder.take_fragment()); + } + + let identity_sql = identity_order(dialect, request.table, &components)?; + let user = user_order(dialect, request.table, request.columns, request.sort)?; + let walk = match mode { + PagingMode::Keyset => keyset_walk(&mut builder, dialect, request, &components, &identity_sql)?, + PagingMode::CtidWindow => ctid_walk(&mut builder, dialect, request.position)?, + PagingMode::Offset => offset_walk(request.position, request.identity, with_tiebreak(user, &identity_sql))?, + }; + conditions.extend(walk.condition); + + let order_by = (!walk.order.is_empty()).then(|| walk.order.join(", ")); + builder.push_sql(&format!( + "SELECT {projection} FROM {}{}{}", + dialect.qualify(request.table), + where_clause(&conditions), + dialect.row_window(order_by.as_deref(), request.limit, walk.offset) + )); + + Ok(PageSpec { + statement: builder.finish(), + layout, + row_key, + stability: walk.stability, + cost: walk.cost, + }) +} + +/// How the page is located within the table. +struct Walk { + condition: Option, + order: Vec, + offset: u64, + stability: PageStability, + cost: PageCost, +} + +/// Where each row's key is read from, and the hidden projection with +/// its column count when the key is the engine's own row address. +fn row_key_layout( + dialect: &dyn SqlDialect, + identity: &RowIdentity, + columns: &[ColumnInfo], + components: &[KeyComponent<'_>], +) -> Result<(RowKeyLayout, Option<(usize, String)>), BrowseSqlError> { + match identity { + RowIdentity::Unordered => Ok((RowKeyLayout::ReadOnly, None)), + RowIdentity::EngineRowId(id) => { + let hidden = dialect.engine_row_id(*id).ok_or(BuildSqlError::NoRowIdentity)?; + let parts = id.parts(); + Ok(( + RowKeyLayout::EngineRowId { + first_hidden: columns.len(), + parts, + }, + Some((parts.len(), hidden)), + )) + } + RowIdentity::UniqueKey { .. } | RowIdentity::SortingKey { .. } => { + let positions = components + .iter() + .filter_map(|component| match component { + KeyComponent::Column(key) => columns.iter().position(|column| column.name == key.name), + KeyComponent::EngineRowId(_) => None, + }) + .collect(); + Ok((RowKeyLayout::Columns(positions), None)) + } + } +} + +fn keyset_walk( + builder: &mut StatementBuilder, + dialect: &dyn SqlDialect, + request: &PageRequest<'_>, + components: &[KeyComponent<'_>], + identity_sql: &[String], +) -> Result { + let key_order = |keyset_direction: KeysetDirection| { + let ascending = !keyset_direction.reverses_order(); + identity_sql + .iter() + .map(|expression| format!("{expression} {}", direction(ascending))) + .collect::>() + }; + let (anchor, keyset_direction) = match request.position { + PagePosition::First => (None, KeysetDirection::After), + PagePosition::Last => (None, KeysetDirection::Before), + PagePosition::Offset(offset) => { + return Ok(Walk { + condition: None, + order: key_order(KeysetDirection::After), + offset: *offset, + stability: PageStability::Stable, + cost: PageCost::SortPerPage, + }); + } + PagePosition::AfterKey(key) => (Some(key), KeysetDirection::After), + PagePosition::BeforeKey(key) => (Some(key), KeysetDirection::Before), + PagePosition::CtidWindow(_) => { + return Err(BrowseSqlError::UnsupportedPosition { + mode: PagingMode::Keyset, + }); + } + }; + let condition = match anchor { + Some(key) => { + let markers = bind_anchor(builder, dialect, components, key)?; + Some(dialect.row_value_after(identity_sql, &markers, keyset_direction)) + } + None => None, + }; + Ok(Walk { + condition, + order: key_order(keyset_direction), + offset: 0, + stability: PageStability::Stable, + cost: PageCost::KeySeek, + }) +} + +/// Bind the edge row's key, taken from `BrowsePage::keys`, one marker +/// per key part in key order. +fn bind_anchor( + builder: &mut StatementBuilder, + dialect: &dyn SqlDialect, + components: &[KeyComponent<'_>], + key: &[Value], +) -> Result, BrowseSqlError> { + if key.len() != components.len() { + return Err(BuildSqlError::LengthMismatch { + expected: components.len(), + found: key.len(), + } + .into()); + } + let mut markers = Vec::with_capacity(key.len()); + for (component, value) in components.iter().zip(key) { + match value { + Value::Undecodable(_) => return Err(BuildSqlError::UndecodableKey.into()), + Value::Null => return Err(BrowseSqlError::NullAnchor), + _ => {} + } + let target = match component { + KeyComponent::Column(column) => BindTarget::Column(column), + KeyComponent::EngineRowId(part) => BindTarget::EngineRowId(*part), + }; + markers.push(builder.bind(dialect, value.clone(), target)?); + } + Ok(markers) +} + +/// A range of heap addresses in ctid order. The bounds are bound as +/// text the dialect casts back to a row address. +fn ctid_walk( + builder: &mut StatementBuilder, + dialect: &dyn SqlDialect, + position: &PagePosition, +) -> Result { + let ctid = dialect + .engine_row_id_column(EngineRowIdPart::PostgresCtid) + .ok_or(BuildSqlError::NoRowIdentity)?; + let whole_table = |backward| CtidWindow { + after: None, + before: None, + backward, + }; + let window = match position { + PagePosition::First => whole_table(false), + PagePosition::Last => whole_table(true), + PagePosition::Offset(offset) => { + return Ok(Walk { + condition: None, + order: vec![format!("{ctid} {}", direction(true))], + offset: *offset, + stability: PageStability::Stable, + cost: PageCost::SortPerPage, + }); + } + PagePosition::CtidWindow(window) => *window, + PagePosition::AfterKey(_) | PagePosition::BeforeKey(_) => { + return Err(BrowseSqlError::UnsupportedPosition { + mode: PagingMode::CtidWindow, + }); + } + }; + + let target = BindTarget::EngineRowId(EngineRowIdPart::PostgresCtid); + let mut bounds = Vec::new(); + for (bound, comparison) in [(window.after, ">"), (window.before, "<")] { + if let Some((block, offset)) = bound { + let marker = builder.bind(dialect, Value::Text(format!("({block},{offset})")), target)?; + bounds.push(format!("{ctid} {comparison} {marker}")); + } + } + // Only the bound the read walks toward keeps the server to a range + // of blocks; without it the sort takes in the rest of the table. + let bounded = match window.backward { + true => window.after.is_some(), + false => window.before.is_some(), + }; + Ok(Walk { + condition: (!bounds.is_empty()).then(|| bounds.join(" AND ")), + order: vec![format!("{ctid} {}", direction(!window.backward))], + offset: 0, + stability: PageStability::Stable, + cost: match bounded { + true => PageCost::BlockWindow, + false => PageCost::SortPerPage, + }, + }) +} + +fn offset_walk(position: &PagePosition, identity: &RowIdentity, order: Vec) -> Result { + let offset = match position { + PagePosition::First => 0, + PagePosition::Offset(offset) => *offset, + PagePosition::Last | PagePosition::AfterKey(_) | PagePosition::BeforeKey(_) | PagePosition::CtidWindow(_) => { + return Err(BrowseSqlError::UnsupportedPosition { + mode: PagingMode::Offset, + }); + } + }; + let stability = match identity { + RowIdentity::UniqueKey { .. } | RowIdentity::EngineRowId(_) => PageStability::Stable, + RowIdentity::SortingKey { .. } => PageStability::NonUniqueOrder, + RowIdentity::Unordered => PageStability::Unordered, + }; + Ok(Walk { + condition: None, + order, + offset, + stability, + cost: PageCost::SortPerPage, + }) +} diff --git a/linux/crates/core/src/browse/export_request.rs b/linux/crates/core/src/browse/export_request.rs new file mode 100644 index 0000000000..fe050e3214 --- /dev/null +++ b/linux/crates/core/src/browse/export_request.rs @@ -0,0 +1,16 @@ +use crate::column::ColumnInfo; +use crate::filter::FilterSet; +use crate::meta::{RowIdentity, TableRef}; + +use super::SortKey; + +/// The rows an export reads: the table as the grid filters and sorts +/// it, every page at once. +#[derive(Debug, Clone, Copy)] +pub struct ExportRequest<'a> { + pub table: &'a TableRef, + pub columns: &'a [ColumnInfo], + pub filter: &'a FilterSet, + pub sort: &'a [SortKey], + pub identity: &'a RowIdentity, +} diff --git a/linux/crates/core/src/browse/mod.rs b/linux/crates/core/src/browse/mod.rs new file mode 100644 index 0000000000..f0d84166cf --- /dev/null +++ b/linux/crates/core/src/browse/mod.rs @@ -0,0 +1,35 @@ +//! Reading a table a page at a time, and every row at once for an +//! export. + +mod browse_page; +mod browse_sql_error; +mod build_count; +mod build_export; +mod build_page; +mod export_request; +mod page_layout; +mod page_request; +mod page_spec; +mod paging_mode; +mod read_page; +mod row_key_layout; +mod select_parts; +mod sort_key; +mod split_page; +#[cfg(test)] +mod tests; + +pub use browse_page::BrowsePage; +pub use browse_sql_error::BrowseSqlError; +pub use build_count::build_count; +pub use build_export::build_export; +pub use build_page::build_page; +pub use export_request::ExportRequest; +pub use page_layout::PageLayout; +pub use page_request::PageRequest; +pub use page_spec::PageSpec; +pub use paging_mode::{PagingMode, paging_mode}; +pub use read_page::{PageRead, read_page}; +pub use row_key_layout::RowKeyLayout; +pub use sort_key::SortKey; +pub use split_page::split_page; diff --git a/linux/crates/core/src/browse/page_layout.rs b/linux/crates/core/src/browse/page_layout.rs new file mode 100644 index 0000000000..dec53a8595 --- /dev/null +++ b/linux/crates/core/src/browse/page_layout.rs @@ -0,0 +1,27 @@ +use std::ops::Range; + +/// Where the visible columns and the row's identity sit in what a page +/// query returns. +/// +/// The identity columns come straight after the visible ones. Its range +/// is empty when the key is made of visible columns, and holds the +/// hidden engine row id parts otherwise. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PageLayout { + pub visible: Range, + pub identity: Range, +} + +impl PageLayout { + pub(super) fn new(visible: usize, hidden: usize) -> Self { + Self { + visible: 0..visible, + identity: visible..visible + hidden, + } + } + + /// How many columns the query returns. + pub fn width(&self) -> usize { + self.identity.end + } +} diff --git a/linux/crates/core/src/browse/page_request.rs b/linux/crates/core/src/browse/page_request.rs new file mode 100644 index 0000000000..5206333ebd --- /dev/null +++ b/linux/crates/core/src/browse/page_request.rs @@ -0,0 +1,19 @@ +use crate::column::ColumnInfo; +use crate::filter::FilterSet; +use crate::meta::{PagePosition, RowIdentity, TableRef}; + +use super::SortKey; + +/// Everything a page query depends on. +#[derive(Debug, Clone, Copy)] +pub struct PageRequest<'a> { + pub table: &'a TableRef, + /// The table's columns from the catalogue, in the order the grid + /// shows them. + pub columns: &'a [ColumnInfo], + pub filter: &'a FilterSet, + pub sort: &'a [SortKey], + pub identity: &'a RowIdentity, + pub position: &'a PagePosition, + pub limit: u64, +} diff --git a/linux/crates/core/src/browse/page_spec.rs b/linux/crates/core/src/browse/page_spec.rs new file mode 100644 index 0000000000..1028774722 --- /dev/null +++ b/linux/crates/core/src/browse/page_spec.rs @@ -0,0 +1,22 @@ +use crate::meta::{PageCost, PageStability}; +use crate::statement::Statement; + +use super::{PageLayout, RowKeyLayout}; + +/// A page query and what the caller needs to read its result back. +#[derive(Debug, Clone, PartialEq)] +pub struct PageSpec { + pub statement: Statement, + pub layout: PageLayout, + /// Where each row's key is read from, in `key_components` order. + pub row_key: RowKeyLayout, + pub stability: PageStability, + pub cost: PageCost, +} + +impl PageSpec { + /// How many of the returned columns the user sees. + pub fn visible_columns(&self) -> usize { + self.layout.visible.len() + } +} diff --git a/linux/crates/core/src/browse/paging_mode.rs b/linux/crates/core/src/browse/paging_mode.rs new file mode 100644 index 0000000000..f36ceed66b --- /dev/null +++ b/linux/crates/core/src/browse/paging_mode.rs @@ -0,0 +1,85 @@ +use crate::meta::{EngineRowId, RowIdentity}; + +use super::SortKey; + +/// How the next page is found. +/// +/// One rule for the page builder and the pager, so the SQL and the +/// position the pager asks for cannot disagree. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PagingMode { + /// Seek past the edge row's key. Constant cost whatever the page. + Keyset, + /// A range of PostgreSQL heap blocks, for a table with no key. + CtidWindow, + /// Skip rows. Any order the user asks for, at a cost that grows + /// with the offset. + Offset, +} + +/// The paging mode for a table and the sort the user chose. +/// +/// A user sort orders by columns that are not unique, which a seek +/// cannot resume from, so it always pages by offset. A partitioned +/// table's ctid is unique only within one partition, so it has no +/// block window to walk. +pub fn paging_mode(identity: &RowIdentity, sort: &[SortKey]) -> PagingMode { + if !sort.is_empty() { + return PagingMode::Offset; + } + match identity { + RowIdentity::UniqueKey { .. } | RowIdentity::EngineRowId(EngineRowId::SqliteRowid) => PagingMode::Keyset, + RowIdentity::EngineRowId(EngineRowId::PostgresCtid) => PagingMode::CtidWindow, + RowIdentity::EngineRowId(EngineRowId::PostgresTableoidCtid) + | RowIdentity::SortingKey { .. } + | RowIdentity::Unordered => PagingMode::Offset, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn by_id() -> RowIdentity { + RowIdentity::UniqueKey { + columns: vec!["id".to_owned()], + } + } + + #[test] + fn a_key_with_no_user_sort_seeks() { + assert_eq!(paging_mode(&by_id(), &[]), PagingMode::Keyset); + assert_eq!( + paging_mode(&RowIdentity::EngineRowId(EngineRowId::SqliteRowid), &[]), + PagingMode::Keyset + ); + } + + #[test] + fn a_heap_with_no_key_walks_block_windows() { + assert_eq!( + paging_mode(&RowIdentity::EngineRowId(EngineRowId::PostgresCtid), &[]), + PagingMode::CtidWindow + ); + assert_eq!( + paging_mode(&RowIdentity::EngineRowId(EngineRowId::PostgresTableoidCtid), &[]), + PagingMode::Offset, + "a ctid repeats across partitions" + ); + } + + #[test] + fn a_user_sort_always_pages_by_offset() { + let sort = [SortKey { + column: 0, + ascending: false, + }]; + + assert_eq!(paging_mode(&by_id(), &sort), PagingMode::Offset); + assert_eq!( + paging_mode(&RowIdentity::EngineRowId(EngineRowId::PostgresCtid), &sort), + PagingMode::Offset + ); + assert_eq!(paging_mode(&RowIdentity::Unordered, &[]), PagingMode::Offset); + } +} diff --git a/linux/crates/core/src/browse/read_page.rs b/linux/crates/core/src/browse/read_page.rs new file mode 100644 index 0000000000..ab3e652219 --- /dev/null +++ b/linux/crates/core/src/browse/read_page.rs @@ -0,0 +1,416 @@ +use crate::call_options::CallOptions; +use crate::connection::Connection; +use crate::error::DriverError; +use crate::meta::{CtidWindow, EngineRowIdPart, HeapAddress, PagePosition, RowIdentity, TableEstimate}; +use crate::query_result::QueryResult; +use crate::row_limit::RowLimit; +use crate::value::Value; + +use super::split_page::key_of; +use super::{BrowseSqlError, PageLayout, PageRequest, PageSpec, PagingMode, RowKeyLayout, build_page, paging_mode}; + +/// Rows per heap block assumed for a table the server never analysed. +/// Low on purpose: a window too wide costs a little sorting, one too +/// narrow costs another round trip. +const ASSUMED_ROWS_PER_BLOCK: u64 = 16; + +/// A page as the server returned it, its rows in the table's order. +#[derive(Debug, Clone)] +pub struct PageRead { + /// The visible columns, then the row identity's. + pub result: QueryResult, + pub layout: PageLayout, + /// What the rows are. A read backward that reached the start of the + /// table before it filled the page is the first page. + pub position: PagePosition, + pub mode: PagingMode, + /// The page before this one by its first row's key, and the page + /// after it by its last. `None` where only an offset reaches it: the + /// table pages by offset, or the edge row's key could not be read. + pub previous: Option, + pub next: Option, +} + +/// Read the page `request` names, by the mode `paging_mode` picks. +/// +/// A page of a keyless PostgreSQL table is read through block windows, +/// so no read sorts more of the table than a page's worth of blocks. +pub async fn read_page( + connection: &dyn Connection, + request: &PageRequest<'_>, + options: &CallOptions, +) -> Result +where + E: From + From, +{ + let mode = paging_mode(request.identity, request.sort); + let (mut result, mut spec) = read_at::(connection, request, request.position, options).await?; + let mut position = request.position.clone(); + let reached_start = reads_backward(request.position) && (result.rows.len() as u64) < request.limit; + if reached_start { + position = PagePosition::First; + // Rows past the anchor fill the rest of the first page. + if matches!(request.position, PagePosition::BeforeKey(_)) { + (result, spec) = read_at::(connection, request, &PagePosition::First, options).await?; + } + } + let (previous, next) = match mode { + PagingMode::Offset => (None, None), + PagingMode::Keyset | PagingMode::CtidWindow => ( + edge_key(result.rows.first(), &spec.row_key).map(PagePosition::BeforeKey), + edge_key(result.rows.last(), &spec.row_key).map(PagePosition::AfterKey), + ), + }; + Ok(PageRead { + result, + layout: spec.layout, + position, + mode, + previous, + next, + }) +} + +fn reads_backward(position: &PagePosition) -> bool { + match position { + PagePosition::Last | PagePosition::BeforeKey(_) => true, + PagePosition::CtidWindow(window) => window.backward, + PagePosition::First | PagePosition::Offset(_) | PagePosition::AfterKey(_) => false, + } +} + +/// A key a later page can be read past: whole, readable and not null. +fn edge_key(row: Option<&Vec>, layout: &RowKeyLayout) -> Option> { + key_of(row?, layout).filter(|key| key.iter().all(|value| !value.is_null())) +} + +async fn read_at( + connection: &dyn Connection, + request: &PageRequest<'_>, + position: &PagePosition, + options: &CallOptions, +) -> Result<(QueryResult, PageSpec), E> +where + E: From + From, +{ + let start = match paging_mode(request.identity, request.sort) { + PagingMode::CtidWindow => ctid_start(position, request.identity)?, + PagingMode::Keyset | PagingMode::Offset => None, + }; + let (mut result, spec) = match start { + Some(start) => walk_heap::(connection, request, start, options).await?, + None => query::(connection, request, position, request.limit, options).await?, + }; + if reads_backward(position) { + result.rows.reverse(); + } + Ok((result, spec)) +} + +/// Where a walk of the heap starts, for a position a single window +/// cannot answer. +fn ctid_start(position: &PagePosition, identity: &RowIdentity) -> Result, BrowseSqlError> { + Ok(match position { + PagePosition::First => Some(CtidStart::Forward(None)), + PagePosition::Last => Some(CtidStart::Backward(None)), + PagePosition::AfterKey(key) => Some(CtidStart::Forward(Some(heap_address(key, identity)?))), + PagePosition::BeforeKey(key) => Some(CtidStart::Backward(Some(heap_address(key, identity)?))), + PagePosition::Offset(_) | PagePosition::CtidWindow(_) => None, + }) +} + +/// The ctid in a row's key, which a page projects as text: `(block,line)`. +fn heap_address(key: &[Value], identity: &RowIdentity) -> Result { + let RowIdentity::EngineRowId(id) = identity else { + return Err(BrowseSqlError::UnreadableAnchor); + }; + let text = id + .parts() + .iter() + .position(|part| *part == EngineRowIdPart::PostgresCtid) + .and_then(|index| key.get(index)); + match text { + Some(Value::Text(text)) => parse_heap_address(text).ok_or(BrowseSqlError::UnreadableAnchor), + _ => Err(BrowseSqlError::UnreadableAnchor), + } +} + +fn parse_heap_address(text: &str) -> Option { + let (block, line) = text.strip_prefix('(')?.strip_suffix(')')?.split_once(',')?; + Some((block.trim().parse().ok()?, line.trim().parse().ok()?)) +} + +async fn query( + connection: &dyn Connection, + request: &PageRequest<'_>, + position: &PagePosition, + limit: u64, + options: &CallOptions, +) -> Result<(QueryResult, PageSpec), E> +where + E: From + From, +{ + let dialect = connection.dialect(); + let spec = build_page( + &*dialect, + &PageRequest { + position, + limit, + ..*request + }, + )?; + let row_limit = RowLimit::new(limit).unwrap_or(RowLimit::EDITOR_DEFAULT); + let result = connection + .query_bound(&spec.statement, row_limit, options.clone()) + .await?; + check_shape(&result, &spec.layout)?; + Ok((result, spec)) +} + +/// Fill a page from block windows, each read in ctid order, until it +/// is full or the walk passes the end of the table. Rows come back in +/// the order the walk read them. +async fn walk_heap( + connection: &dyn Connection, + request: &PageRequest<'_>, + start: CtidStart, + options: &CallOptions, +) -> Result<(QueryResult, PageSpec), E> +where + E: From + From, +{ + let estimate = connection.table_estimate(request.table, options.clone()).await?; + let mut walk = CtidWalk::new(start, &estimate, request.limit); + let mut rows = Vec::new(); + loop { + let wanted = request.limit.saturating_sub(rows.len() as u64).max(1); + let position = PagePosition::CtidWindow(walk.window()); + let (result, spec) = query::(connection, request, &position, wanted, options).await?; + let filled = result.rows.len() as u64 >= wanted; + rows.extend(result.rows); + if filled || !walk.advance() { + return Ok((QueryResult::new(result.columns, rows), spec)); + } + } +} + +/// Every row has the columns the page asked for, visible then identity, +/// so the split on the GTK thread takes each row apart at the same place. +fn check_shape(result: &QueryResult, layout: &PageLayout) -> Result<(), BrowseSqlError> { + let expected = layout.width(); + let found = std::iter::once(result.columns.len()) + .chain(result.rows.iter().map(Vec::len)) + .find(|width| *width != expected); + match found { + Some(found) => Err(BrowseSqlError::ShapeMismatch { expected, found }), + None => Ok(()), + } +} + +/// Which way a walk of the heap reads, and the row it reads past. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum CtidStart { + Forward(Option), + Backward(Option), +} + +/// The block windows a page of a keyless PostgreSQL table is read +/// through. A window that comes back short is followed by one twice as +/// wide, so a sparse table or a narrow filter still takes few reads. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) struct CtidWalk { + backward: bool, + /// The row the next window reads past, or the start of a block. + edge: Option, + /// The next window's first block, or going backward the block just + /// past its last. + block: u64, + span: u64, + heap_blocks: u64, +} + +impl CtidWalk { + pub(super) fn new(start: CtidStart, estimate: &TableEstimate, limit: u64) -> Self { + let heap_blocks = estimate.heap_blocks.unwrap_or(0); + let rows_per_block = match estimate.rows.value() { + Some(rows) if heap_blocks > 0 => (rows / heap_blocks).max(1), + _ => ASSUMED_ROWS_PER_BLOCK, + }; + let span = limit.div_ceil(rows_per_block).max(1); + let (backward, edge, block) = match start { + CtidStart::Forward(edge) => (false, edge, edge.map_or(0, |(block, _)| u64::from(block))), + CtidStart::Backward(edge) => (true, edge, edge.map_or(heap_blocks, |(block, _)| u64::from(block) + 1)), + }; + Self { + backward, + edge, + block, + span, + heap_blocks, + } + } + + pub(super) fn window(&self) -> CtidWindow { + match self.backward { + false => CtidWindow { + after: self.edge, + before: self.forward_end().map(block_start), + backward: false, + }, + true => CtidWindow { + after: self.backward_start().map(block_start), + before: self.edge, + backward: true, + }, + } + } + + /// Move on past a window that came back short. False when that + /// window already ran to the end of the table. + pub(super) fn advance(&mut self) -> bool { + let next = match self.backward { + false => self.forward_end(), + true => self.backward_start(), + }; + let Some(block) = next else { + return false; + }; + self.edge = Some(block_start(block)); + self.block = u64::from(block); + self.span = self.span.saturating_mul(2); + true + } + + /// The block a forward window stops before. `None` lets the last + /// window run on past the size read at the start, for rows added + /// since. + fn forward_end(&self) -> Option { + let end = self.block.saturating_add(self.span); + (end < self.heap_blocks).then(|| u32::try_from(end).ok()).flatten() + } + + /// The first block of a backward window, `None` once it reaches the + /// start of the table. + fn backward_start(&self) -> Option { + let start = self.block.saturating_sub(self.span); + (start > 0).then(|| u32::try_from(start).ok()).flatten() + } +} + +fn block_start(block: u32) -> HeapAddress { + (block, 0) +} + +#[cfg(test)] +mod tests { + use crate::meta::RowCountEstimate; + + use super::*; + + fn estimate(rows: u64, heap_blocks: u64) -> TableEstimate { + TableEstimate { + rows: RowCountEstimate::Approximate(rows), + heap_blocks: Some(heap_blocks), + } + } + + fn windows(mut walk: CtidWalk) -> Vec { + let mut windows = vec![walk.window()]; + while walk.advance() { + windows.push(walk.window()); + } + windows + } + + fn forward(after: Option, before: Option) -> CtidWindow { + CtidWindow { + after, + before, + backward: false, + } + } + + fn backward(after: Option, before: Option) -> CtidWindow { + CtidWindow { + after, + before, + backward: true, + } + } + + #[test] + fn a_window_holds_about_a_page_by_the_tables_own_density() { + // 100 rows a block, so a page of 1,000 needs 10 blocks. + let walk = CtidWalk::new(CtidStart::Forward(None), &estimate(100_000, 1_000), 1_000); + + assert_eq!(walk.window(), forward(None, Some((10, 0)))); + } + + #[test] + fn a_short_window_is_followed_by_one_twice_as_wide_up_to_the_end() { + let walk = CtidWalk::new(CtidStart::Forward(Some((5, 3))), &estimate(10_000, 100), 1_000); + + assert_eq!( + windows(walk), + [ + forward(Some((5, 3)), Some((15, 0))), + forward(Some((15, 0)), Some((35, 0))), + forward(Some((35, 0)), Some((75, 0))), + forward(Some((75, 0)), None), + ], + "the last window must run past the size read, for rows added since" + ); + } + + #[test] + fn a_backward_walk_ends_at_the_first_block() { + let walk = CtidWalk::new(CtidStart::Backward(Some((40, 2))), &estimate(10_000, 100), 1_000); + + assert_eq!( + windows(walk), + [ + backward(Some((31, 0)), Some((40, 2))), + backward(Some((11, 0)), Some((31, 0))), + backward(None, Some((11, 0))), + ] + ); + } + + #[test] + fn the_last_page_starts_from_the_tables_end_with_no_upper_bound() { + let walk = CtidWalk::new(CtidStart::Backward(None), &estimate(10_000, 100), 1_000); + + assert_eq!(walk.window(), backward(Some((90, 0)), None)); + } + + #[test] + fn a_table_never_analysed_or_empty_still_walks() { + let unknown = TableEstimate { + rows: RowCountEstimate::Unknown, + heap_blocks: Some(10_000), + }; + let walk = CtidWalk::new(CtidStart::Forward(None), &unknown, 1_000); + assert_eq!(walk.window(), forward(None, Some((63, 0)))); + + let empty = CtidWalk::new(CtidStart::Forward(None), &estimate(0, 0), 1_000); + assert_eq!(windows(empty), [forward(None, None)]); + } + + #[test] + fn a_heap_address_is_read_from_the_ctid_part_of_the_key() { + let ctid = RowIdentity::EngineRowId(crate::meta::EngineRowId::PostgresCtid); + let partitioned = RowIdentity::EngineRowId(crate::meta::EngineRowId::PostgresTableoidCtid); + let key = |parts: &[&str]| -> Vec { parts.iter().map(|part| Value::Text((*part).to_owned())).collect() }; + + assert_eq!(heap_address(&key(&["(4294967295,7)", "812"]), &ctid), Ok((u32::MAX, 7))); + assert_eq!(heap_address(&key(&["16384", "(0,1)", "812"]), &partitioned), Ok((0, 1))); + assert_eq!( + heap_address(&key(&["(4,x)", "812"]), &ctid), + Err(BrowseSqlError::UnreadableAnchor) + ); + assert_eq!( + heap_address(&[Value::Int(4)], &ctid), + Err(BrowseSqlError::UnreadableAnchor) + ); + } +} diff --git a/linux/crates/core/src/browse/row_key_layout.rs b/linux/crates/core/src/browse/row_key_layout.rs new file mode 100644 index 0000000000..31cc7eb5a0 --- /dev/null +++ b/linux/crates/core/src/browse/row_key_layout.rs @@ -0,0 +1,32 @@ +use crate::meta::EngineRowIdPart; + +/// Where a page's row key lives in the columns it selected. +/// +/// A key made of real columns is read from the visible ones. An engine +/// row id is projected after them, so the grid never sees it. +#[derive(Debug, Clone, PartialEq)] +pub enum RowKeyLayout { + /// Positions in the visible columns, in key order. + Columns(Vec), + EngineRowId { + /// Where the hidden columns start, which is the visible count. + first_hidden: usize, + parts: &'static [EngineRowIdPart], + }, + /// Nothing names a row, so the grid is read-only. + ReadOnly, +} + +impl RowKeyLayout { + /// How many columns the query selected beyond the visible ones. + pub fn hidden_count(&self) -> usize { + match self { + Self::EngineRowId { parts, .. } => parts.len(), + Self::Columns(_) | Self::ReadOnly => 0, + } + } + + pub fn is_read_only(&self) -> bool { + matches!(self, Self::ReadOnly) + } +} diff --git a/linux/crates/core/src/browse/select_parts.rs b/linux/crates/core/src/browse/select_parts.rs new file mode 100644 index 0000000000..0c45117b07 --- /dev/null +++ b/linux/crates/core/src/browse/select_parts.rs @@ -0,0 +1,109 @@ +use crate::column::ColumnInfo; +use crate::dialect::SqlDialect; +use crate::dml::{BuildSqlError, KeyComponent, key_components}; +use crate::meta::{RowIdentity, TableRef}; + +use super::{BrowseSqlError, SortKey}; + +/// The visible select list: one projection per column, so the read +/// form each column needs is honoured. +pub(super) fn projection_list(dialect: &dyn SqlDialect, columns: &[ColumnInfo]) -> Result { + if columns.is_empty() { + return Err(BrowseSqlError::NoColumns); + } + Ok(columns + .iter() + .map(|column| dialect.projection(column)) + .collect::>() + .join(", ")) +} + +/// The identity's parts, or none for a table where nothing names a +/// row. Browsing such a table is fine; only editing it is not. +pub(super) fn identity_components<'a>( + identity: &RowIdentity, + columns: &'a [ColumnInfo], +) -> Result>, BrowseSqlError> { + match identity { + RowIdentity::Unordered => Ok(Vec::new()), + _ => Ok(key_components(identity, columns)?), + } +} + +/// The ORDER BY terms the user asked for. +pub(super) fn user_order( + dialect: &dyn SqlDialect, + table: &TableRef, + columns: &[ColumnInfo], + sort: &[SortKey], +) -> Result, BrowseSqlError> { + sort.iter() + .map(|key| { + let column = columns + .get(key.column) + .ok_or(BrowseSqlError::UnknownSortColumn(key.column))?; + Ok(format!( + "{} {}", + dialect.sort_expression(table, column), + direction(key.ascending) + )) + }) + .collect() +} + +/// The identity as sort expressions, in key order, leaving out a part +/// that has no ordering of its own. +pub(super) fn identity_order( + dialect: &dyn SqlDialect, + table: &TableRef, + components: &[KeyComponent<'_>], +) -> Result, BrowseSqlError> { + components + .iter() + .filter_map(|component| match component { + KeyComponent::Column(column) => Some(Ok(dialect.sort_expression(table, column))), + KeyComponent::EngineRowId(part) if part.orders_rows() => { + Some(dialect.engine_row_id_column(*part).ok_or(BuildSqlError::NoRowIdentity)) + } + KeyComponent::EngineRowId(_) => None, + }) + .collect::, _>>() + .map_err(BrowseSqlError::from) +} + +/// The user's order with the identity appended as a tiebreak, so equal +/// sort values still come back in one order every time. An expression +/// the user already sorts by is not repeated: SQL Server refuses a +/// column named twice in ORDER BY. +pub(super) fn with_tiebreak(user: Vec, identity: &[String]) -> Vec { + let mut terms = user; + for expression in identity { + let already = terms + .iter() + .any(|term| term.rsplit_once(' ').is_some_and(|(sorted, _)| sorted == expression)); + if !already { + terms.push(format!("{expression} {}", direction(true))); + } + } + terms +} + +pub(super) fn direction(ascending: bool) -> &'static str { + if ascending { "ASC" } else { "DESC" } +} + +/// `WHERE` with each condition in parentheses, or nothing when there +/// are none. +pub(super) fn where_clause(conditions: &[String]) -> String { + match conditions { + [] => String::new(), + [only] => format!(" WHERE {only}"), + many => format!( + " WHERE {}", + many.iter() + .map(|condition| format!("({condition})")) + .collect::>() + .join(" AND ") + ), + } +} diff --git a/linux/crates/core/src/browse/sort_key.rs b/linux/crates/core/src/browse/sort_key.rs new file mode 100644 index 0000000000..e2f056e06f --- /dev/null +++ b/linux/crates/core/src/browse/sort_key.rs @@ -0,0 +1,7 @@ +/// One column the user sorted the grid by. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct SortKey { + /// Position in the table's columns. + pub column: usize, + pub ascending: bool, +} diff --git a/linux/crates/core/src/browse/split_page.rs b/linux/crates/core/src/browse/split_page.rs new file mode 100644 index 0000000000..83fa9d7489 --- /dev/null +++ b/linux/crates/core/src/browse/split_page.rs @@ -0,0 +1,224 @@ +use crate::query_result::QueryResult; +use crate::value::Value; + +use super::{BrowsePage, BrowseSqlError, PageSpec, RowKeyLayout}; + +/// Take a page apart into what the user sees and what names each row. +/// +/// The hidden row-id columns stop here. Everything downstream, the +/// grid, export, copy and the insert preview, only ever gets the +/// visible ones. +pub fn split_page(result: QueryResult, spec: &PageSpec) -> Result { + let visible_columns = spec.visible_columns(); + let layout = &spec.row_key; + let expected = spec.layout.width(); + if result.columns.len() != expected { + return Err(BrowseSqlError::ShapeMismatch { + expected, + found: result.columns.len(), + }); + } + + let columns = result.columns[..visible_columns].to_vec().into(); + let mut rows = Vec::with_capacity(result.rows.len()); + let mut keys = Vec::with_capacity(result.rows.len()); + for row in result.rows { + if row.len() != expected { + return Err(BrowseSqlError::ShapeMismatch { + expected, + found: row.len(), + }); + } + keys.push(key_of(&row, layout)); + let mut visible = row; + visible.truncate(visible_columns); + rows.push(visible); + } + + Ok(BrowsePage { + columns, + rows, + keys, + truncated: result.truncated, + }) +} + +/// The key for one row, or `None` when it cannot be used to name the +/// row again. +pub(super) fn key_of(row: &[Value], layout: &RowKeyLayout) -> Option> { + match layout { + RowKeyLayout::ReadOnly => None, + RowKeyLayout::Columns(positions) => positions + .iter() + .map(|position| row.get(*position).filter(|value| is_readable(value)).cloned()) + .collect(), + RowKeyLayout::EngineRowId { first_hidden, parts } => (0..parts.len()) + .map(|part| { + row.get(first_hidden + part) + // A null row address names nothing, and an + // unreadable one cannot go back to the server. + .filter(|value| is_readable(value) && !value.is_null()) + .cloned() + }) + .collect(), + } +} + +fn is_readable(value: &Value) -> bool { + !matches!(value, Value::Undecodable(_)) +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use crate::column::{ColumnKind, ColumnType, IntegerKind, IntegerWidth, ReadForm, ResultColumn}; + use crate::meta::{EngineRowIdPart, PageCost, PageStability}; + use crate::statement::StatementBuilder; + use crate::value::{UndecodableReason, UndecodedValue}; + + use super::super::PageLayout; + use super::*; + + fn spec(visible: usize, row_key: RowKeyLayout) -> PageSpec { + PageSpec { + statement: StatementBuilder::new().finish(), + layout: PageLayout::new(visible, row_key.hidden_count()), + row_key, + stability: PageStability::Stable, + cost: PageCost::KeySeek, + } + } + + fn split(page: QueryResult, visible: usize, row_key: RowKeyLayout) -> Result { + split_page(page, &spec(visible, row_key)) + } + + fn column(name: &str) -> ResultColumn { + ResultColumn::new( + name, + ColumnType { + name: "integer".to_owned(), + kind: ColumnKind::Integer(IntegerKind { + width: IntegerWidth::W32, + signed: true, + }), + catalog: None, + dynamic_storage: false, + read_form: ReadForm::Native, + }, + ) + } + + fn result(names: &[&str], rows: Vec>) -> QueryResult { + QueryResult { + columns: names.iter().map(|name| column(name)).collect::>().into(), + rows, + truncated: false, + } + } + + fn undecodable() -> Value { + Value::Undecodable(Box::new(UndecodedValue { + type_name: "geography".to_owned(), + reason: UndecodableReason::UnsupportedType, + })) + } + + #[test] + fn split_page_strips_hidden_row_id_columns() { + let page = result( + &["name", "__tablepro_row_id"], + vec![vec![Value::Text("a".to_owned()), Value::Int(11)]], + ); + let layout = RowKeyLayout::EngineRowId { + first_hidden: 1, + parts: &[EngineRowIdPart::SqliteRowid], + }; + + let split = split(page, 1, layout).expect("the page"); + + assert_eq!(split.columns.len(), 1); + assert_eq!(split.columns[0].name, "name"); + assert_eq!(split.rows, vec![vec![Value::Text("a".to_owned())]]); + assert_eq!(split.key(0), Some(&vec![Value::Int(11)])); + } + + #[test] + fn split_page_keys_follow_unique_key_positions() { + let page = result( + &["tenant", "name", "id"], + vec![vec![Value::Int(1), Value::Text("a".to_owned()), Value::Int(7)]], + ); + + // The key is (id, tenant), which is not the column order. + let split = split(page, 3, RowKeyLayout::Columns(vec![2, 0])).expect("the page"); + + assert_eq!(split.key(0), Some(&vec![Value::Int(7), Value::Int(1)])); + assert_eq!(split.columns.len(), 3, "a column-keyed page hid a column"); + } + + #[test] + fn split_page_undecodable_key_is_not_editable() { + let page = result(&["id", "name"], vec![vec![undecodable(), Value::Text("a".to_owned())]]); + + let split = split(page, 2, RowKeyLayout::Columns(vec![0])).expect("the page"); + + assert_eq!(split.key(0), None, "a row whose key could not be read looked editable"); + assert!(!split.has_editable_rows()); + assert_eq!(split.rows[0][0], undecodable(), "the cell itself was dropped"); + } + + #[test] + fn a_null_engine_row_id_is_not_editable() { + let page = result( + &["name", "__tablepro_row_id"], + vec![vec![Value::Text("a".to_owned()), Value::Null]], + ); + let layout = RowKeyLayout::EngineRowId { + first_hidden: 1, + parts: &[EngineRowIdPart::SqliteRowid], + }; + + let split = split(page, 1, layout).expect("the page"); + + assert_eq!(split.key(0), None); + } + + #[test] + fn split_page_shape_mismatch_is_error() { + let page = result(&["name"], vec![vec![Value::Text("a".to_owned())]]); + let layout = RowKeyLayout::EngineRowId { + first_hidden: 1, + parts: &[EngineRowIdPart::SqliteRowid], + }; + + let error = split(page, 1, layout).expect_err("a page missing its hidden column"); + + assert_eq!(error, BrowseSqlError::ShapeMismatch { expected: 2, found: 1 }); + } + + #[test] + fn a_read_only_page_keeps_every_column_and_no_keys() { + let page = result(&["a", "b"], vec![vec![Value::Int(1), Value::Int(2)]]); + + let split = split(page, 2, RowKeyLayout::ReadOnly).expect("the page"); + + assert_eq!(split.columns.len(), 2); + assert_eq!(split.row_count(), 1); + assert!(!split.has_editable_rows()); + } + + #[test] + fn the_shared_column_slice_is_not_the_one_the_query_returned() { + let page = result(&["a", "hidden"], vec![]); + let layout = RowKeyLayout::EngineRowId { + first_hidden: 1, + parts: &[EngineRowIdPart::SqliteRowid], + }; + + let split = split(page, 1, layout).expect("the page"); + + assert_eq!(Arc::strong_count(&split.columns), 1); + } +} diff --git a/linux/crates/core/src/browse/tests.rs b/linux/crates/core/src/browse/tests.rs new file mode 100644 index 0000000000..44b5bf3604 --- /dev/null +++ b/linux/crates/core/src/browse/tests.rs @@ -0,0 +1,584 @@ +use crate::column::{ + ColumnDefault, ColumnInfo, ColumnKind, ColumnType, IntegerKind, IntegerWidth, ReadForm, TextLength, +}; +use crate::dialect::{RecordedTarget, TestDialect, TestMarkers}; +use crate::filter::{Combinator, FilterOp, FilterRule, FilterSet, FilterValue}; +use crate::meta::{ + CtidWindow, EngineRowId, EngineRowIdPart, PageCost, PagePosition, PageStability, RowIdentity, TableRef, +}; +use crate::query_result::QueryResult; +use crate::value::Value; + +use super::*; + +fn column(name: &str, kind: ColumnKind) -> ColumnInfo { + ColumnInfo { + name: name.to_owned(), + column_type: ColumnType { + name: "t".to_owned(), + kind, + catalog: None, + dynamic_storage: false, + read_form: ReadForm::Native, + }, + nullable: true, + primary_key: false, + is_auto_increment: false, + is_generated: false, + default: ColumnDefault::None, + comment: None, + } +} + +fn bigint() -> ColumnKind { + ColumnKind::Integer(IntegerKind { + width: IntegerWidth::W64, + signed: true, + }) +} + +fn columns() -> Vec { + vec![ + column("id", bigint()), + column( + "name", + ColumnKind::Text { + length: TextLength::Unbounded, + }, + ), + ] +} + +fn table() -> TableRef { + TableRef::new(Some("public".to_owned()), "users") +} + +fn by_id() -> RowIdentity { + RowIdentity::UniqueKey { + columns: vec!["id".to_owned()], + } +} + +fn adults() -> FilterSet { + FilterSet { + combinator: Combinator::Or, + rules: vec![ + FilterRule { + column: "id".to_owned(), + op: FilterOp::Gt, + value: Some(FilterValue::Single("18".to_owned())), + }, + FilterRule { + column: "name".to_owned(), + op: FilterOp::IsNull, + value: None, + }, + ], + extra_sql: None, + } +} + +struct Page { + identity: RowIdentity, + filter: FilterSet, + sort: Vec, + position: PagePosition, +} + +impl Page { + fn of(identity: RowIdentity) -> Self { + Self { + identity, + filter: FilterSet::default(), + sort: Vec::new(), + position: PagePosition::First, + } + } + + fn at(self, position: PagePosition) -> Self { + Self { position, ..self } + } + + fn filtered(self, filter: FilterSet) -> Self { + Self { filter, ..self } + } + + fn sorted(self, sort: Vec) -> Self { + Self { sort, ..self } + } + + fn build(&self, dialect: &TestDialect) -> Result { + let columns = columns(); + let table = table(); + build_page( + dialect, + &PageRequest { + table: &table, + columns: &columns, + filter: &self.filter, + sort: &self.sort, + identity: &self.identity, + position: &self.position, + limit: 50, + }, + ) + } +} + +fn built(page: &Page, dialect: &TestDialect) -> PageSpec { + match page.build(dialect) { + Ok(spec) => spec, + Err(error) => panic!("the page did not build: {error}"), + } +} + +#[test] +fn build_page_keyset_after_uses_row_value_after() { + let dialect = TestDialect::exact(); + let page = Page::of(by_id()).at(PagePosition::AfterKey(vec![Value::Int(40)])); + + let spec = built(&page, &dialect); + + assert_eq!( + spec.statement.sql(), + r#"SELECT "id", "name" FROM "public"."users" WHERE ("users"."id") > ($1) ORDER BY "users"."id" ASC LIMIT 50"# + ); + assert_eq!(spec.statement.params()[0].value(), &Value::Int(40)); + assert_eq!(dialect.targets(), vec![RecordedTarget::Column("id".to_owned())]); + assert_eq!(spec.stability, PageStability::Stable); + assert_eq!(spec.cost, PageCost::KeySeek); +} + +#[test] +fn a_keyset_page_backward_reverses_the_order_after_the_filter() { + let dialect = TestDialect::exact().with_markers(TestMarkers::Positional); + let page = Page::of(by_id()) + .filtered(adults()) + .at(PagePosition::BeforeKey(vec![Value::Int(40)])); + + let spec = built(&page, &dialect); + + assert_eq!( + spec.statement.sql(), + r#"SELECT "id", "name" FROM "public"."users" WHERE ("id" > ? OR "name" IS NULL) AND (("users"."id") < (?)) ORDER BY "users"."id" DESC LIMIT 50"# + ); + let values: Vec<_> = spec + .statement + .params() + .iter() + .map(|param| param.value().clone()) + .collect(); + assert_eq!( + values, + vec![Value::Int(18), Value::Int(40)], + "a positional marker took the wrong value" + ); +} + +#[test] +fn build_page_user_sort_appends_identity_tiebreak() { + let dialect = TestDialect::exact(); + let page = Page::of(by_id()) + .sorted(vec![SortKey { + column: 1, + ascending: false, + }]) + .at(PagePosition::Offset(100)); + + let spec = built(&page, &dialect); + + assert_eq!( + spec.statement.sql(), + r#"SELECT "id", "name" FROM "public"."users" ORDER BY "users"."name" DESC, "users"."id" ASC LIMIT 50 OFFSET 100"# + ); + assert_eq!(spec.stability, PageStability::Stable); + assert_eq!(spec.cost, PageCost::SortPerPage); +} + +#[test] +fn a_sort_on_the_key_itself_is_not_repeated_as_its_tiebreak() { + let page = Page::of(by_id()).sorted(vec![SortKey { + column: 0, + ascending: false, + }]); + + let spec = built(&page, &TestDialect::exact()); + + assert!( + spec.statement.sql().ends_with(r#"ORDER BY "users"."id" DESC LIMIT 50"#), + "{}", + spec.statement.sql() + ); +} + +#[test] +fn build_page_ctid_window_bounds() { + let dialect = TestDialect::exact(); + let page = Page::of(RowIdentity::EngineRowId(EngineRowId::PostgresCtid)).at(PagePosition::CtidWindow(CtidWindow { + after: Some((3, 7)), + before: Some((10, 0)), + backward: false, + })); + + let spec = built(&page, &dialect); + + assert_eq!( + spec.statement.sql(), + r#"SELECT "id", "name", ctid AS "__tablepro_row_id", xmin AS "__tablepro_row_xmin" FROM "public"."users" WHERE ctid > $1 AND ctid < $2 ORDER BY ctid ASC LIMIT 50"# + ); + let bounds: Vec<_> = spec + .statement + .params() + .iter() + .map(|param| param.value().clone()) + .collect(); + assert_eq!( + bounds, + vec![Value::Text("(3,7)".to_owned()), Value::Text("(10,0)".to_owned())] + ); + assert_eq!( + dialect.targets(), + vec![ + RecordedTarget::EngineRowId(EngineRowIdPart::PostgresCtid), + RecordedTarget::EngineRowId(EngineRowIdPart::PostgresCtid), + ] + ); + assert_eq!(spec.cost, PageCost::BlockWindow); +} + +#[test] +fn the_first_ctid_window_has_only_its_upper_bound() { + let page = Page::of(RowIdentity::EngineRowId(EngineRowId::PostgresCtid)).at(PagePosition::CtidWindow(CtidWindow { + after: None, + before: Some((4, 0)), + backward: false, + })); + + let spec = built(&page, &TestDialect::exact()); + + assert!( + spec.statement.sql().contains("WHERE ctid < $1 ORDER BY"), + "{}", + spec.statement.sql() + ); + assert_eq!(spec.statement.params().len(), 1); +} + +#[test] +fn a_backward_ctid_window_reads_down_from_its_upper_bound() { + let page = Page::of(RowIdentity::EngineRowId(EngineRowId::PostgresCtid)).at(PagePosition::CtidWindow(CtidWindow { + after: Some((31, 0)), + before: Some((40, 2)), + backward: true, + })); + + let spec = built(&page, &TestDialect::exact()); + + assert!( + spec.statement + .sql() + .ends_with("WHERE ctid > $1 AND ctid < $2 ORDER BY ctid DESC LIMIT 50"), + "{}", + spec.statement.sql() + ); + assert_eq!(spec.cost, PageCost::BlockWindow); +} + +#[test] +fn a_ctid_window_open_on_the_side_it_reads_toward_sorts_the_rest_of_the_table() { + let open_end = + Page::of(RowIdentity::EngineRowId(EngineRowId::PostgresCtid)).at(PagePosition::CtidWindow(CtidWindow { + after: Some((75, 0)), + before: None, + backward: false, + })); + + assert_eq!(built(&open_end, &TestDialect::exact()).cost, PageCost::SortPerPage); +} + +#[test] +fn the_last_keyset_page_reads_the_key_backward_from_the_end() { + let spec = built(&Page::of(by_id()).at(PagePosition::Last), &TestDialect::exact()); + + assert_eq!( + spec.statement.sql(), + r#"SELECT "id", "name" FROM "public"."users" ORDER BY "users"."id" DESC LIMIT 50"# + ); + assert_eq!(spec.cost, PageCost::KeySeek); +} + +#[test] +fn build_count_reuses_filter_predicate() { + let dialect = TestDialect::exact(); + let filter = adults(); + + let count = build_count(&dialect, &table(), &columns(), &filter).expect("the count"); + let page = built(&Page::of(by_id()).filtered(filter), &TestDialect::exact()); + + assert_eq!( + count.sql(), + r#"SELECT COUNT(*) FROM "public"."users" WHERE "id" > $1 OR "name" IS NULL"# + ); + assert!( + page.statement.sql().contains(r#"WHERE "id" > $1 OR "name" IS NULL "#), + "the page and the count filter differently: {}", + page.statement.sql() + ); + assert_eq!(count.params(), page.statement.params()); +} + +#[test] +fn build_count_counts_with_the_dialects_expression() { + let dialect = TestDialect::exact().counting_with("COUNT_BIG(*)"); + + let count = build_count(&dialect, &table(), &columns(), &FilterSet::default()).expect("the count"); + + assert_eq!(count.sql(), r#"SELECT COUNT_BIG(*) FROM "public"."users""#); +} + +#[test] +fn build_page_layout_identity_follows_visible() { + let cases = [ + (by_id(), 0, RowKeyLayout::Columns(vec![0])), + ( + RowIdentity::EngineRowId(EngineRowId::SqliteRowid), + 1, + RowKeyLayout::EngineRowId { + first_hidden: 2, + parts: EngineRowId::SqliteRowid.parts(), + }, + ), + ( + RowIdentity::EngineRowId(EngineRowId::PostgresCtid), + 2, + RowKeyLayout::EngineRowId { + first_hidden: 2, + parts: EngineRowId::PostgresCtid.parts(), + }, + ), + ( + RowIdentity::EngineRowId(EngineRowId::PostgresTableoidCtid), + 3, + RowKeyLayout::EngineRowId { + first_hidden: 2, + parts: EngineRowId::PostgresTableoidCtid.parts(), + }, + ), + (RowIdentity::Unordered, 0, RowKeyLayout::ReadOnly), + ]; + + for (identity, hidden, row_key) in cases { + let spec = built(&Page::of(identity.clone()), &TestDialect::exact()); + + assert_eq!(spec.layout.visible, 0..2, "{identity:?}"); + assert_eq!(spec.layout.identity, 2..2 + hidden, "{identity:?}"); + assert_eq!(spec.row_key, row_key, "{identity:?}"); + assert_eq!(spec.visible_columns(), 2); + } +} + +#[test] +fn a_page_of_an_unkeyed_table_is_read_only_and_says_its_order_is_not_stable() { + let spec = built(&Page::of(RowIdentity::Unordered), &TestDialect::exact()); + + assert_eq!( + spec.statement.sql(), + r#"SELECT "id", "name" FROM "public"."users" LIMIT 50"# + ); + assert_eq!(spec.stability, PageStability::Unordered); + assert!(spec.row_key.is_read_only()); +} + +#[test] +fn a_sorting_key_orders_but_is_not_unique() { + let identity = RowIdentity::SortingKey { + expressions: vec!["name".to_owned()], + }; + + let spec = built(&Page::of(identity), &TestDialect::estimated()); + + assert!( + spec.statement + .sql() + .ends_with(r#"ORDER BY "users"."name" ASC LIMIT 50"#), + "{}", + spec.statement.sql() + ); + assert_eq!(spec.stability, PageStability::NonUniqueOrder); + assert_eq!(spec.row_key, RowKeyLayout::Columns(vec![1])); +} + +#[test] +fn a_position_the_mode_cannot_read_is_an_error() { + let ctid_after_key = Page::of(RowIdentity::EngineRowId(EngineRowId::PostgresCtid)) + .at(PagePosition::AfterKey(vec![Value::Text("(0,1)".to_owned())])); + let sorted_after_key = Page::of(by_id()) + .sorted(vec![SortKey { + column: 1, + ascending: true, + }]) + .at(PagePosition::AfterKey(vec![Value::Int(1)])); + + assert_eq!( + ctid_after_key.build(&TestDialect::exact()), + Err(BrowseSqlError::UnsupportedPosition { + mode: PagingMode::CtidWindow + }) + ); + assert_eq!( + sorted_after_key.build(&TestDialect::exact()), + Err(BrowseSqlError::UnsupportedPosition { + mode: PagingMode::Offset + }) + ); + let sorted_last = Page::of(by_id()) + .sorted(vec![SortKey { + column: 1, + ascending: true, + }]) + .at(PagePosition::Last); + assert_eq!( + sorted_last.build(&TestDialect::exact()), + Err(BrowseSqlError::UnsupportedPosition { + mode: PagingMode::Offset + }), + "a user sort with ties has no exact reverse" + ); +} + +#[test] +fn a_keyset_anchor_must_be_a_whole_readable_key() { + let null = Page::of(by_id()).at(PagePosition::AfterKey(vec![Value::Null])); + let short = Page::of(by_id()).at(PagePosition::AfterKey(Vec::new())); + + assert_eq!(null.build(&TestDialect::exact()), Err(BrowseSqlError::NullAnchor)); + assert!(matches!( + short.build(&TestDialect::exact()), + Err(BrowseSqlError::Build(crate::dml::BuildSqlError::LengthMismatch { .. })) + )); +} + +#[test] +fn a_raw_predicate_saved_before_validation_is_refused_on_replay() { + let page = Page::of(by_id()).filtered(FilterSet { + extra_sql: Some("1=1) OR (1=1".to_owned()), + ..FilterSet::default() + }); + + assert!(matches!( + page.build(&TestDialect::exact()), + Err(BrowseSqlError::Filter(crate::filter::BuildFilterError::RawPredicate(_))) + )); +} + +#[test] +fn an_unknown_sort_column_is_an_error() { + let page = Page::of(by_id()).sorted(vec![SortKey { + column: 9, + ascending: true, + }]); + + assert_eq!( + page.build(&TestDialect::exact()), + Err(BrowseSqlError::UnknownSortColumn(9)) + ); +} + +#[test] +fn build_export_projects_only_visible_columns() { + let dialect = TestDialect::exact(); + let columns = columns(); + let identity = RowIdentity::EngineRowId(EngineRowId::PostgresTableoidCtid); + let filter = adults(); + let sort = [SortKey { + column: 1, + ascending: true, + }]; + + let statement = build_export( + &dialect, + &ExportRequest { + table: &table(), + columns: &columns, + filter: &filter, + sort: &sort, + identity: &identity, + }, + ) + .expect("the export"); + + let sql = statement.sql(); + let projection = sql + .strip_prefix("SELECT ") + .and_then(|rest| rest.split_once(" FROM ")) + .map(|(projection, _)| projection); + assert_eq!( + projection, + Some(r#""id", "name""#), + "an engine row id reached the export" + ); + assert_eq!( + sql, + r#"SELECT "id", "name" FROM "public"."users" WHERE "id" > $1 OR "name" IS NULL ORDER BY "users"."name" ASC, tableoid ASC, ctid ASC"# + ); +} + +fn export_sql(identity: RowIdentity, sort: &[SortKey]) -> String { + let columns = columns(); + let statement = build_export( + &TestDialect::exact(), + &ExportRequest { + table: &table(), + columns: &columns, + filter: &FilterSet::default(), + sort, + identity: &identity, + }, + ) + .expect("the export"); + statement.sql().to_owned() +} + +#[test] +fn an_unsorted_export_of_a_heap_is_not_sorted_by_row_address() { + for id in [EngineRowId::PostgresCtid, EngineRowId::PostgresTableoidCtid] { + assert_eq!( + export_sql(RowIdentity::EngineRowId(id), &[]), + r#"SELECT "id", "name" FROM "public"."users""#, + "{id:?}" + ); + } + assert_eq!( + export_sql(RowIdentity::EngineRowId(EngineRowId::SqliteRowid), &[]), + r#"SELECT "id", "name" FROM "public"."users" ORDER BY rowid ASC"#, + "a rowid is the table's own key, so its order costs nothing" + ); + assert_eq!( + export_sql(by_id(), &[]), + r#"SELECT "id", "name" FROM "public"."users" ORDER BY "users"."id" ASC"# + ); +} + +#[test] +fn a_page_splits_into_keys_the_dml_can_bind_back() { + let dialect = TestDialect::exact(); + let identity = RowIdentity::EngineRowId(EngineRowId::SqliteRowid); + let spec = built(&Page::of(identity.clone()), &dialect); + let result = QueryResult { + columns: ["id", "name", "__tablepro_row_id"] + .iter() + .map(|name| crate::column::ResultColumn::new(*name, ColumnType::untyped_text("t"))) + .collect::>() + .into(), + rows: vec![vec![Value::Int(1), Value::Text("a".to_owned()), Value::Int(77)]], + truncated: false, + }; + + let page = split_page(result, &spec).expect("the page"); + let key = page.key(0).cloned().expect("an editable row"); + let delete = crate::dml::build_delete(&dialect, &table(), &columns(), &identity, &key).expect("the delete"); + + assert_eq!(page.columns.len(), 2, "the row id reached the grid"); + assert_eq!(delete.sql(), r#"DELETE FROM "public"."users" WHERE rowid = $1"#); + assert_eq!(delete.params()[0].value(), &Value::Int(77)); +} diff --git a/linux/crates/core/src/call_options.rs b/linux/crates/core/src/call_options.rs new file mode 100644 index 0000000000..e6c83f345b --- /dev/null +++ b/linux/crates/core/src/call_options.rs @@ -0,0 +1,28 @@ +use tokio::time::Instant; +use tokio_util::sync::CancellationToken; + +/// When a call must be done by, and how to stop it early. +/// +/// Both travel together because a driver that has one without the +/// other cannot finish the job: a deadline with no cancel leaves the +/// server working after the app gave up, and a cancel with no deadline +/// hangs on a server that never answers. +#[derive(Debug, Clone)] +pub struct CallOptions { + deadline: Option, + cancel: CancellationToken, +} + +impl CallOptions { + pub fn new(deadline: Option, cancel: CancellationToken) -> Self { + Self { deadline, cancel } + } + + pub fn deadline(&self) -> Option { + self.deadline + } + + pub fn cancel(&self) -> &CancellationToken { + &self.cancel + } +} diff --git a/linux/crates/core/src/capabilities.rs b/linux/crates/core/src/capabilities.rs new file mode 100644 index 0000000000..6a7c6fc125 --- /dev/null +++ b/linux/crates/core/src/capabilities.rs @@ -0,0 +1,130 @@ +use crate::auth_mode::AuthMode; +use crate::meta::ObjectKind; +use crate::tls_capabilities::TlsCapabilities; +use crate::write_ledger::WriteAtomicity; + +/// Where a driver connects to. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum EndpointKind { + Network { default_port: u16 }, + File, +} + +/// How a read-only connection is held to it. +/// +/// The three differ in what the user can do about a refused write: a +/// setting on this connection, a session default the server can +/// override, or a login that simply cannot write. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum ReadOnlyEnforcement { + EngineEnforced, + SessionDefault, + LoginPrivileges, +} + +/// Whether the engine runs a script as one unit. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum ScriptExecution { + NativeBatch, + /// One statement per round trip, which is what the editor splits + /// for. + SingleStatement, +} + +/// How a commit whose answer never arrived can be resolved afterwards. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum CommitResolutionSupport { + /// The server can be asked about the transaction directly. + ServerTransactionStatus, + /// The queries that ran are recorded and can be looked up. + QueryLog, + Unavailable, +} + +/// How good a row-count estimate the engine can give without counting. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum EstimateQuality { + /// From the planner's statistics, so "about N". + Statistics, + /// The engine keeps an exact count in its metadata. + ExactMetadata, + Unavailable, +} + +/// What a driver can do, before any connection exists. +/// +/// The UI reads this to decide what to offer: whether to show a +/// Kerberos row, whether a save is one transaction or a sequence of +/// statements, whether a row count can be shown as exact. +#[derive(Debug, Clone, PartialEq)] +pub struct DriverCapabilities { + pub endpoint: EndpointKind, + pub auth_modes: &'static [AuthMode], + pub tls: Option, + pub read_only: ReadOnlyEnforcement, + pub dml_writes: WriteAtomicity, + pub ddl_writes: WriteAtomicity, + pub script_execution: ScriptExecution, + pub commit_resolution: CommitResolutionSupport, + pub row_count_estimate: EstimateQuality, + pub object_kinds: &'static [ObjectKind], + /// Whether a connection needs a database name before it can open. + pub database_required: bool, +} + +impl DriverCapabilities { + pub fn default_port(&self) -> Option { + match self.endpoint { + EndpointKind::Network { default_port } => Some(default_port), + EndpointKind::File => None, + } + } + + pub fn is_file_based(&self) -> bool { + matches!(self.endpoint, EndpointKind::File) + } + + pub fn supports(&self, mode: AuthMode) -> bool { + self.auth_modes.contains(&mode) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const NETWORK: DriverCapabilities = DriverCapabilities { + endpoint: EndpointKind::Network { default_port: 5432 }, + auth_modes: &[AuthMode::Password], + tls: None, + read_only: ReadOnlyEnforcement::SessionDefault, + dml_writes: WriteAtomicity::Transactional, + ddl_writes: WriteAtomicity::Transactional, + script_execution: ScriptExecution::NativeBatch, + commit_resolution: CommitResolutionSupport::ServerTransactionStatus, + row_count_estimate: EstimateQuality::Statistics, + object_kinds: &[ObjectKind::Table, ObjectKind::View], + database_required: false, + }; + + #[test] + fn a_file_engine_has_no_port_to_offer() { + let file = DriverCapabilities { + endpoint: EndpointKind::File, + auth_modes: &[], + ..NETWORK + }; + + assert_eq!(file.default_port(), None); + assert!(file.is_file_based()); + assert!(!file.supports(AuthMode::Password), "a file has no login"); + } + + #[test] + fn a_network_engine_offers_its_port_and_its_logins() { + assert_eq!(NETWORK.default_port(), Some(5432)); + assert!(!NETWORK.is_file_based()); + assert!(NETWORK.supports(AuthMode::Password)); + assert!(!NETWORK.supports(AuthMode::Kerberos)); + } +} diff --git a/linux/crates/core/src/certificate_file_problem.rs b/linux/crates/core/src/certificate_file_problem.rs new file mode 100644 index 0000000000..417968ee8d --- /dev/null +++ b/linux/crates/core/src/certificate_file_problem.rs @@ -0,0 +1,28 @@ +use thiserror::Error; + +#[derive(Debug, Clone, PartialEq, Eq, Error)] +pub enum CertificateFileProblem { + #[error("the file could not be read: {detail}")] + Unreadable { detail: String }, + + #[error("it is not a regular file")] + NotRegularFile, + + #[error("the file is larger than {} KiB", limit_bytes / 1024)] + TooLarge { limit_bytes: usize }, + + #[error("the file contains no certificates")] + NoCertificates, + + #[error("a certificate is invalid: {detail}")] + InvalidCertificate { detail: String }, + + #[error("the file contains {count} certificates, but only one is allowed")] + MultipleCertificates { count: usize }, + + #[error("the file contains no private key")] + NoPrivateKey, + + #[error("the private key is invalid: {detail}")] + InvalidPrivateKey { detail: String }, +} diff --git a/linux/crates/core/src/client_identity.rs b/linux/crates/core/src/client_identity.rs new file mode 100644 index 0000000000..72e0322ae8 --- /dev/null +++ b/linux/crates/core/src/client_identity.rs @@ -0,0 +1,9 @@ +use std::path::PathBuf; + +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct ClientIdentity { + pub certificate: PathBuf, + pub key: PathBuf, +} diff --git a/linux/crates/core/src/column/catalog_type.rs b/linux/crates/core/src/column/catalog_type.rs new file mode 100644 index 0000000000..7eb7c2b8ef --- /dev/null +++ b/linux/crates/core/src/column/catalog_type.rs @@ -0,0 +1,12 @@ +use super::SqlTypeExpr; + +/// The server's own spelling of a column's type, from its catalogue. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CatalogType { + /// The type as declared, modifiers included, for DDL: PostgreSQL + /// `format_type(atttypid, atttypmod)`, MySQL COLUMN_TYPE. + pub declaration: SqlTypeExpr, + /// The type a bound text parameter is cast to. It drops the + /// modifiers, because a cast to `varchar(10)` truncates silently. + pub cast_target: SqlTypeExpr, +} diff --git a/linux/crates/core/src/column/classify.rs b/linux/crates/core/src/column/classify.rs new file mode 100644 index 0000000000..66d31332f0 --- /dev/null +++ b/linux/crates/core/src/column/classify.rs @@ -0,0 +1,540 @@ +use super::{ + BoolStorage, ColumnKind, FloatWidth, IntegerKind, IntegerWidth, NetworkFamily, TemporalZone, TextLength, TimeRange, +}; + +/// Deeper than any type a catalogue reports, and shallow enough that a +/// hostile name cannot exhaust the stack. +const MAX_NESTING: usize = 32; + +/// What a type name means, for a driver that has nothing better. +/// +/// Each engine knows its own catalogue and should say so directly. This +/// is the shared fallback for the spellings every SQL engine agrees on, +/// so a driver only carries the branches where it actually differs. An +/// unrecognised name is `Other`, which round-trips as the server's own +/// text rather than guessing. +pub fn classify_type_name(name: &str) -> ColumnKind { + classify_nested(name.trim(), 0) +} + +/// Whether the server decides the stored width per row, so a declared +/// length is a cap rather than a promise. +pub fn has_dynamic_storage(kind: &ColumnKind) -> bool { + !matches!( + kind, + ColumnKind::Bool(_) + | ColumnKind::Integer(_) + | ColumnKind::Float(_) + | ColumnKind::Money { .. } + | ColumnKind::Date + | ColumnKind::Time { .. } + | ColumnKind::Timestamp { .. } + | ColumnKind::Uuid + ) +} + +fn classify_nested(text: &str, depth: usize) -> ColumnKind { + if depth > MAX_NESTING { + return ColumnKind::Other; + } + if let Some(element) = text.strip_suffix("[]") { + return array_of(classify_nested(element.trim_end(), depth + 1)); + } + let spelling = TypeSpelling::split(text); + let inner = || { + spelling + .arguments + .map_or(ColumnKind::Other, |inner| classify_nested(inner.trim(), depth + 1)) + }; + match spelling.base() { + // PostgreSQL's information_schema says only `ARRAY`. + "array" => array_of(inner()), + // ClickHouse wrappers that change nullability or storage, not + // what the column holds. + "nullable" | "lowcardinality" => inner(), + base => classify_base(base, &spelling), + } +} + +fn array_of(element: ColumnKind) -> ColumnKind { + ColumnKind::Array { + element: Box::new(element), + } +} + +fn classify_base(base: &str, spelling: &TypeSpelling<'_>) -> ColumnKind { + if let Some(kind) = integer_kind(base, spelling.is_unsigned()) { + return ColumnKind::Integer(kind); + } + let length = spelling.number(0); + match base { + "bool" | "boolean" => ColumnKind::Bool(BoolStorage::Native), + "decimal" | "numeric" | "dec" | "fixed" => ColumnKind::Decimal { + precision: length, + // `numeric(10)` is `numeric(10,0)`. + scale: spelling.number(1).or(length.map(|_| 0)), + }, + "decimal32" => clickhouse_decimal(9, length), + "decimal64" => clickhouse_decimal(18, length), + "decimal128" => clickhouse_decimal(38, length), + "decimal256" => clickhouse_decimal(76, length), + "money" | "smallmoney" => ColumnKind::Money { scale: None }, + "real" | "float4" | "float32" => ColumnKind::Float(FloatWidth::F32), + "double" | "float" | "float8" | "float64" => ColumnKind::Float(FloatWidth::F64), + "char" | "character" | "nchar" | "national" | "bpchar" | "fixedstring" => character(base, spelling), + "varchar" | "nvarchar" | "varchar2" | "nvarchar2" | "string" | "name" => ColumnKind::Text { + length: varying(length), + }, + "text" | "ntext" | "longtext" | "mediumtext" | "tinytext" | "clob" => ColumnKind::Text { + length: TextLength::Unbounded, + }, + "bytea" | "blob" | "longblob" | "mediumblob" | "tinyblob" | "image" => ColumnKind::Bytes { + length: TextLength::Unbounded, + }, + "binary" => ColumnKind::Bytes { + length: TextLength::Fixed(length.unwrap_or(1)), + }, + "varbinary" => ColumnKind::Bytes { + length: varying(length), + }, + "date" | "date32" => ColumnKind::Date, + "time" | "timetz" => ColumnKind::Time { + fractional_digits: spelling.fractional_digits(), + with_offset: base == "timetz" || spelling.has_time_zone(), + range: TimeRange::TimeOfDay, + }, + "timestamp" | "timestamptz" | "datetimeoffset" => ColumnKind::Timestamp { + fractional_digits: spelling.fractional_digits(), + zone: if base != "timestamp" || spelling.has_time_zone() { + TemporalZone::Offset + } else { + TemporalZone::Naive + }, + }, + "datetime" | "datetime2" | "datetime64" | "smalldatetime" => ColumnKind::Timestamp { + fractional_digits: spelling.fractional_digits(), + zone: spelling + .quoted_arguments() + .into_iter() + .next() + .map_or(TemporalZone::Naive, TemporalZone::Named), + }, + "interval" => ColumnKind::Interval, + "uuid" | "uniqueidentifier" => ColumnKind::Uuid, + "json" => ColumnKind::Json { binary: false }, + "jsonb" => ColumnKind::Json { binary: true }, + "enum" | "enum8" | "enum16" | "set" => ColumnKind::Enum { + labels: spelling.quoted_arguments(), + multiple: base == "set", + }, + "bit" | "varbit" => ColumnKind::BitString { + length, + varying: base == "varbit" || spelling.has_word("varying"), + }, + "inet" => ColumnKind::Network(NetworkFamily::Inet), + "cidr" => ColumnKind::Network(NetworkFamily::Cidr), + "macaddr" => ColumnKind::Network(NetworkFamily::MacAddr), + "macaddr8" => ColumnKind::Network(NetworkFamily::MacAddr8), + "ipv4" => ColumnKind::Network(NetworkFamily::Ipv4), + "ipv6" => ColumnKind::Network(NetworkFamily::Ipv6), + "xml" => ColumnKind::Xml, + "geometry" | "geography" | "point" | "linestring" | "polygon" | "multipoint" | "multilinestring" + | "multipolygon" | "geometrycollection" => ColumnKind::Spatial, + "map" | "tuple" | "nested" | "dynamic" | "aggregatefunction" => ColumnKind::Composite, + "variant" | "sql_variant" => ColumnKind::Variant, + _ => ColumnKind::Other, + } +} + +fn integer_kind(base: &str, unsigned: bool) -> Option { + let (width, signed) = match base { + "tinyint" | "int1" => (IntegerWidth::W8, !unsigned), + "smallint" | "int2" | "smallserial" | "int16" => (IntegerWidth::W16, !unsigned), + "mediumint" => (IntegerWidth::W24, !unsigned), + "int" | "integer" | "int4" | "serial" | "int32" => (IntegerWidth::W32, !unsigned), + "bigint" | "int8" | "bigserial" | "int64" => (IntegerWidth::W64, !unsigned), + "int128" => (IntegerWidth::W128, !unsigned), + "int256" => (IntegerWidth::W256, !unsigned), + "uint8" => (IntegerWidth::W8, false), + "uint16" => (IntegerWidth::W16, false), + "uint32" => (IntegerWidth::W32, false), + "uint64" => (IntegerWidth::W64, false), + "uint128" => (IntegerWidth::W128, false), + "uint256" => (IntegerWidth::W256, false), + _ => return None, + }; + Some(IntegerKind { width, signed }) +} + +fn clickhouse_decimal(precision: u32, scale: Option) -> ColumnKind { + ColumnKind::Decimal { + precision: Some(precision), + scale, + } +} + +fn character(base: &str, spelling: &TypeSpelling<'_>) -> ColumnKind { + let length = spelling.number(0); + let length = match (spelling.has_word("varying"), length) { + (true, length) => varying(length), + (false, Some(length)) => TextLength::Fixed(length), + // PostgreSQL `bpchar` with no length takes any length. + (false, None) if base == "bpchar" => TextLength::Unbounded, + // SQL gives `char` with no length a length of one. + (false, None) => TextLength::Fixed(1), + }; + ColumnKind::Text { length } +} + +fn varying(length: Option) -> TextLength { + length.map_or(TextLength::Unbounded, TextLength::Varying) +} + +/// A type name cut at its first parentheses: `timestamp(6) with time +/// zone` is the words `timestamp with time zone` and the argument `6`. +struct TypeSpelling<'a> { + /// Lowercased, from before and after the parentheses. + words: Vec, + /// Inside the parentheses, in the original case, because an enum + /// label or a zone name is case-sensitive. + arguments: Option<&'a str>, +} + +impl<'a> TypeSpelling<'a> { + fn split(text: &'a str) -> Self { + let (head, arguments, tail) = match text.find('(') { + None => (text, None, ""), + Some(open) => match matching_paren(text, open) { + Some(close) => (&text[..open], Some(&text[open + 1..close]), &text[close + 1..]), + None => (&text[..open], None, ""), + }, + }; + let words = head + .split_whitespace() + .chain(tail.split_whitespace()) + .map(str::to_ascii_lowercase) + .collect(); + Self { words, arguments } + } + + fn base(&self) -> &str { + self.words.first().map_or("", String::as_str) + } + + fn has_word(&self, word: &str) -> bool { + self.words.iter().skip(1).any(|candidate| candidate == word) + } + + fn has_time_zone(&self) -> bool { + self.words + .windows(3) + .any(|window| window[0] == "with" && window[1] == "time" && window[2] == "zone") + } + + /// MySQL spells the unsigned integers as a suffix, and ZEROFILL + /// implies UNSIGNED: `bigint(20) unsigned zerofill`. + fn is_unsigned(&self) -> bool { + self.has_word("unsigned") || self.has_word("zerofill") + } + + /// The argument at `index` as a number, or `None` where it is + /// missing or not one, as in `varchar(max)`. + fn number(&self, index: usize) -> Option { + self.arguments?.split(',').nth(index)?.trim().parse().ok() + } + + fn fractional_digits(&self) -> Option { + self.number(0).and_then(|digits| u8::try_from(digits).ok()) + } + + /// Every quoted string among the arguments, unescaped: the labels + /// of `enum('a','it''s')`, the zone of `DateTime64(3, 'UTC')`. + fn quoted_arguments(&self) -> Vec { + self.arguments.map(quoted_strings).unwrap_or_default() + } +} + +/// The byte index of the `)` that closes the `(` at `open`. Quoted text +/// is skipped, so an enum label holding a parenthesis does not end the +/// list early. +fn matching_paren(text: &str, open: usize) -> Option { + let mut depth = 0usize; + let mut quoted = false; + for (index, byte) in text.bytes().enumerate().skip(open) { + match byte { + b'\'' => quoted = !quoted, + b'(' if !quoted => depth += 1, + b')' if !quoted => { + depth = depth.saturating_sub(1); + if depth == 0 { + return Some(index); + } + } + _ => {} + } + } + None +} + +fn quoted_strings(text: &str) -> Vec { + let mut found = Vec::new(); + let mut chars = text.chars().peekable(); + while let Some(next) = chars.next() { + if next != '\'' { + continue; + } + let mut value = String::new(); + loop { + match chars.next() { + Some('\'') if chars.peek() == Some(&'\'') => { + chars.next(); + value.push('\''); + } + Some('\'') | None => break, + Some(other) => value.push(other), + } + } + found.push(value); + } + found +} + +#[cfg(test)] +mod tests { + use super::*; + + fn int(width: IntegerWidth, signed: bool) -> ColumnKind { + ColumnKind::Integer(IntegerKind { width, signed }) + } + + fn text(length: TextLength) -> ColumnKind { + ColumnKind::Text { length } + } + + fn stamp(fractional_digits: Option, zone: TemporalZone) -> ColumnKind { + ColumnKind::Timestamp { + fractional_digits, + zone, + } + } + + fn labels(labels: &[&str], multiple: bool) -> ColumnKind { + ColumnKind::Enum { + labels: labels.iter().map(|label| (*label).to_owned()).collect(), + multiple, + } + } + + fn assert_classified(cases: Vec<(&str, ColumnKind)>) { + for (name, expected) in cases { + assert_eq!(classify_type_name(name), expected, "{name:?}"); + } + } + + #[test] + fn classifies_integers_booleans_and_floats() { + assert_classified(vec![ + ("bigint", int(IntegerWidth::W64, true)), + ("int8", int(IntegerWidth::W64, true)), + ("BIGSERIAL", int(IntegerWidth::W64, true)), + ("int", int(IntegerWidth::W32, true)), + ("int unsigned", int(IntegerWidth::W32, false)), + ("bigint(20) unsigned zerofill", int(IntegerWidth::W64, false)), + ("mediumint unsigned", int(IntegerWidth::W24, false)), + ("UInt256", int(IntegerWidth::W256, false)), + ("boolean", ColumnKind::Bool(BoolStorage::Native)), + ("real", ColumnKind::Float(FloatWidth::F32)), + ("double precision", ColumnKind::Float(FloatWidth::F64)), + ]); + } + + #[test] + fn classifies_decimals_and_money() { + assert_classified(vec![ + ( + "numeric(10,2)", + ColumnKind::Decimal { + precision: Some(10), + scale: Some(2), + }, + ), + ( + "DECIMAL(38, 10)", + ColumnKind::Decimal { + precision: Some(38), + scale: Some(10), + }, + ), + ( + "numeric(10)", + ColumnKind::Decimal { + precision: Some(10), + scale: Some(0), + }, + ), + ( + "numeric", + ColumnKind::Decimal { + precision: None, + scale: None, + }, + ), + ( + "Decimal64(4)", + ColumnKind::Decimal { + precision: Some(18), + scale: Some(4), + }, + ), + ("money", ColumnKind::Money { scale: None }), + ]); + } + + #[test] + fn classifies_text_and_bytes() { + assert_classified(vec![ + ("varchar(255)", text(TextLength::Varying(255))), + ("character varying(255)", text(TextLength::Varying(255))), + ("nvarchar(max)", text(TextLength::Unbounded)), + ("char(10)", text(TextLength::Fixed(10))), + ("character", text(TextLength::Fixed(1))), + ("bpchar", text(TextLength::Unbounded)), + ("text", text(TextLength::Unbounded)), + ("LowCardinality(String)", text(TextLength::Unbounded)), + ( + "bytea", + ColumnKind::Bytes { + length: TextLength::Unbounded, + }, + ), + ( + "varbinary(max)", + ColumnKind::Bytes { + length: TextLength::Unbounded, + }, + ), + ( + "binary(16)", + ColumnKind::Bytes { + length: TextLength::Fixed(16), + }, + ), + ]); + } + + #[test] + fn classifies_dates_times_and_timestamps() { + assert_classified(vec![ + ("date", ColumnKind::Date), + ( + "time(3)", + ColumnKind::Time { + fractional_digits: Some(3), + with_offset: false, + range: TimeRange::TimeOfDay, + }, + ), + ( + "time with time zone", + ColumnKind::Time { + fractional_digits: None, + with_offset: true, + range: TimeRange::TimeOfDay, + }, + ), + ("timestamp", stamp(None, TemporalZone::Naive)), + ("timestamp(6) with time zone", stamp(Some(6), TemporalZone::Offset)), + ("timestamp without time zone", stamp(None, TemporalZone::Naive)), + ("timestamptz", stamp(None, TemporalZone::Offset)), + ("datetime(6)", stamp(Some(6), TemporalZone::Naive)), + ("datetimeoffset(7)", stamp(Some(7), TemporalZone::Offset)), + ("DateTime64(3)", stamp(Some(3), TemporalZone::Naive)), + ( + "DateTime64(3, 'Europe/Paris')", + stamp(Some(3), TemporalZone::Named("Europe/Paris".to_owned())), + ), + ("DateTime('UTC')", stamp(None, TemporalZone::Named("UTC".to_owned()))), + ]); + } + + #[test] + fn classifies_intervals_uuids_documents_and_labels() { + assert_classified(vec![ + ("interval", ColumnKind::Interval), + ("uniqueidentifier", ColumnKind::Uuid), + ("json", ColumnKind::Json { binary: false }), + ("jsonb", ColumnKind::Json { binary: true }), + ("enum('Draft','It''s')", labels(&["Draft", "It's"], false)), + ("Enum8('a' = 1, 'b(2)' = 2)", labels(&["a", "b(2)"], false)), + ("set('x','y')", labels(&["x", "y"], true)), + ]); + } + + #[test] + fn classifies_bits_networks_and_opaque_kinds() { + assert_classified(vec![ + ( + "bit", + ColumnKind::BitString { + length: None, + varying: false, + }, + ), + ( + "bit varying(5)", + ColumnKind::BitString { + length: Some(5), + varying: true, + }, + ), + ("cidr", ColumnKind::Network(NetworkFamily::Cidr)), + ("macaddr8", ColumnKind::Network(NetworkFamily::MacAddr8)), + ("IPv6", ColumnKind::Network(NetworkFamily::Ipv6)), + ("xml", ColumnKind::Xml), + ("geography", ColumnKind::Spatial), + ("Map(String, UInt64)", ColumnKind::Composite), + ("sql_variant", ColumnKind::Variant), + ]); + } + + #[test] + fn classifies_wrappers_arrays_and_unknown_names() { + assert_classified(vec![ + ("Nullable(Int64)", int(IntegerWidth::W64, true)), + ("integer[]", array_of(int(IntegerWidth::W32, true))), + ("text[][]", array_of(array_of(text(TextLength::Unbounded)))), + ("Array(Nullable(UInt8))", array_of(int(IntegerWidth::W8, false))), + ("ARRAY", array_of(ColumnKind::Other)), + ("hstore", ColumnKind::Other), + ("tsvector", ColumnKind::Other), + ("", ColumnKind::Other), + ]); + } + + #[test] + fn a_bit_column_is_bits_not_a_boolean() { + assert!(matches!(classify_type_name("bit(8)"), ColumnKind::BitString { .. })); + assert!(matches!(classify_type_name("boolean"), ColumnKind::Bool(_))); + } + + #[test] + fn deep_nesting_stops_at_other_rather_than_exhausting_the_stack() { + let mut kind = classify_type_name(&format!("int{}", "[]".repeat(10_000))); + + let mut depth = 0; + while let ColumnKind::Array { element } = kind { + kind = *element; + depth += 1; + } + assert_eq!(kind, ColumnKind::Other); + assert_eq!(depth, MAX_NESTING + 1); + } + + #[test] + fn a_fixed_width_type_has_no_dynamic_storage() { + assert!(!has_dynamic_storage(&int(IntegerWidth::W32, true))); + assert!(!has_dynamic_storage(&stamp(None, TemporalZone::Naive))); + assert!(has_dynamic_storage(&text(TextLength::Varying(10)))); + assert!(has_dynamic_storage(&ColumnKind::Other)); + } +} diff --git a/linux/crates/core/src/column/column_info.rs b/linux/crates/core/src/column/column_info.rs new file mode 100644 index 0000000000..fa66f03702 --- /dev/null +++ b/linux/crates/core/src/column/column_info.rs @@ -0,0 +1,110 @@ +use super::{ColumnDefault, ColumnType, ResultColumn}; + +/// Everything the catalogue says about a table column. +/// +/// This is the authority for browse: what can be edited, what the +/// server fills in, and what a value means. A result column carries +/// only the part an arbitrary query can report. +#[derive(Debug, Clone, PartialEq)] +pub struct ColumnInfo { + pub name: String, + pub column_type: ColumnType, + pub nullable: bool, + pub primary_key: bool, + /// The server assigns the value, so an insert leaves the column + /// out of the user-facing form. + pub is_auto_increment: bool, + /// `GENERATED ALWAYS AS`. Read-only, and never written. + pub is_generated: bool, + pub default: ColumnDefault, + /// What the schema says the column is for, where the engine stores + /// such a thing. `None` on an engine that has no column comments, + /// which is not the same as one left empty. + pub comment: Option, +} + +impl ColumnInfo { + pub fn result_column(&self) -> ResultColumn { + ResultColumn::new(self.name.clone(), self.column_type.clone()) + } + + /// Whether the user may put a value in this column at all. A + /// generated column is computed by the server. + pub fn is_writable(&self) -> bool { + !self.is_generated + } + + /// Whether an insert may leave the column out and still get a row: + /// the server has something to put there. + pub fn is_optional_on_insert(&self) -> bool { + self.nullable || self.is_auto_increment || !self.default.is_none() + } +} + +#[cfg(test)] +mod tests { + use super::super::{CatalogType, ColumnKind, IntegerKind, IntegerWidth, ReadForm, SqlExpression, SqlTypeExpr}; + use super::*; + + fn column(name: &str) -> ColumnInfo { + ColumnInfo { + name: name.to_owned(), + column_type: ColumnType { + name: "integer".to_owned(), + kind: ColumnKind::Integer(IntegerKind { + width: IntegerWidth::W32, + signed: true, + }), + catalog: Some(CatalogType { + declaration: SqlTypeExpr::from_catalog_text("integer"), + cast_target: SqlTypeExpr::from_catalog_text("integer"), + }), + dynamic_storage: false, + read_form: ReadForm::Native, + }, + nullable: false, + primary_key: false, + is_auto_increment: false, + is_generated: false, + default: ColumnDefault::None, + comment: None, + } + } + + #[test] + fn a_result_column_keeps_the_name_and_type() { + let info = column("id"); + + let result = info.result_column(); + + assert_eq!(result.name, "id"); + assert_eq!(result.column_type, info.column_type); + } + + #[test] + fn a_generated_column_is_not_writable() { + let mut info = column("total"); + info.is_generated = true; + + assert!(!info.is_writable()); + assert!(column("id").is_writable()); + } + + #[test] + fn insert_may_omit_a_column_the_server_can_fill() { + let required = column("name"); + assert!(!required.is_optional_on_insert()); + + let mut nullable = column("note"); + nullable.nullable = true; + assert!(nullable.is_optional_on_insert()); + + let mut serial = column("id"); + serial.is_auto_increment = true; + assert!(serial.is_optional_on_insert()); + + let mut defaulted = column("created_at"); + defaulted.default = ColumnDefault::Expression(SqlExpression::from_catalog_text("now()")); + assert!(defaulted.is_optional_on_insert()); + } +} diff --git a/linux/crates/core/src/column/column_type.rs b/linux/crates/core/src/column/column_type.rs new file mode 100644 index 0000000000..061903be0c --- /dev/null +++ b/linux/crates/core/src/column/column_type.rs @@ -0,0 +1,67 @@ +use super::{CatalogType, ColumnKind, ReadForm}; + +/// A column's type: what the app needs to render and edit it, plus the +/// server's own spelling for splicing it into DDL and casts. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ColumnType { + /// What the user sees. Never spliced into SQL: that is what + /// `catalog` is for. + pub name: String, + pub kind: ColumnKind, + /// `None` where the driver has no server spelling to quote, as for + /// most result-set columns. A dialect then refuses to guess a cast. + pub catalog: Option, + /// Whether the server decides the stored width per row, so a + /// declared length is a cap rather than a promise. + pub dynamic_storage: bool, + pub read_form: ReadForm, +} + +impl ColumnType { + /// A stand-in for a column whose type is not known yet, used + /// while the catalogue is still loading. + /// + /// It has no catalogue entry, so nothing can splice it into DDL or + /// a cast: a caller that needs the server's own spelling has to + /// wait for the catalogue rather than invent one. + pub fn unknown() -> Self { + Self::untyped_text(String::new()) + } + + /// The fallback for a type no driver placed: the value is the + /// server's text and the grid treats it as such. + pub fn untyped_text(name: impl Into) -> Self { + Self { + name: name.into(), + kind: ColumnKind::Other, + catalog: None, + dynamic_storage: true, + read_form: ReadForm::ServerText, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn an_unknown_type_names_nothing_the_server_would_recognise() { + let unknown = ColumnType::unknown(); + + assert_eq!(unknown.name, ""); + assert_eq!(unknown.kind, ColumnKind::Other); + assert_eq!(unknown.catalog, None); + } + + #[test] + fn untyped_text_reads_as_server_text() { + let column = ColumnType::untyped_text("geography"); + + assert_eq!(column.kind, ColumnKind::Other); + assert_eq!(column.read_form, ReadForm::ServerText); + assert_eq!(column.catalog, None); + assert!(column.dynamic_storage); + assert_eq!(column.name, "geography"); + } +} diff --git a/linux/crates/core/src/column/default.rs b/linux/crates/core/src/column/default.rs new file mode 100644 index 0000000000..37b7146b57 --- /dev/null +++ b/linux/crates/core/src/column/default.rs @@ -0,0 +1,34 @@ +use crate::value::Value; + +use super::SqlExpression; + +/// A column's DEFAULT clause. +/// +/// A literal is kept as a `Value` so the insert path can bind it, while +/// an expression such as `now()` stays as text because only the server +/// can evaluate it. +#[derive(Debug, Clone, PartialEq)] +pub enum ColumnDefault { + None, + Literal(Value), + Expression(SqlExpression), +} + +impl ColumnDefault { + pub fn is_none(&self) -> bool { + matches!(self, Self::None) + } + + /// Whether the server has to evaluate the default, which means an + /// insert must omit the column rather than bind a value. + pub fn needs_server_evaluation(&self) -> bool { + matches!(self, Self::Expression(_)) + } + + pub fn literal(&self) -> Option<&Value> { + match self { + Self::Literal(value) => Some(value), + _ => None, + } + } +} diff --git a/linux/crates/core/src/column/integer_kind.rs b/linux/crates/core/src/column/integer_kind.rs new file mode 100644 index 0000000000..55f5a4045c --- /dev/null +++ b/linux/crates/core/src/column/integer_kind.rs @@ -0,0 +1,67 @@ +use std::fmt; + +/// An integer column's storage width and sign. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct IntegerKind { + pub width: IntegerWidth, + pub signed: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum IntegerWidth { + W8, + W16, + /// MySQL MEDIUMINT. + W24, + W32, + W64, + W128, + /// ClickHouse Int256 and UInt256. + W256, +} + +impl IntegerWidth { + pub const ALL: [IntegerWidth; 7] = [ + IntegerWidth::W8, + IntegerWidth::W16, + IntegerWidth::W24, + IntegerWidth::W32, + IntegerWidth::W64, + IntegerWidth::W128, + IntegerWidth::W256, + ]; + + pub fn bits(self) -> u32 { + match self { + Self::W8 => 8, + Self::W16 => 16, + Self::W24 => 24, + Self::W32 => 32, + Self::W64 => 64, + Self::W128 => 128, + Self::W256 => 256, + } + } +} + +impl fmt::Display for IntegerKind { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let sign = if self.signed { "signed" } else { "unsigned" }; + write!(f, "{sign} {}-bit", self.width.bits()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn an_integer_kind_names_its_sign_and_width() { + let kind = IntegerKind { + width: IntegerWidth::W24, + signed: false, + }; + + assert_eq!(kind.to_string(), "unsigned 24-bit"); + } +} diff --git a/linux/crates/core/src/column/kind.rs b/linux/crates/core/src/column/kind.rs new file mode 100644 index 0000000000..50227f3580 --- /dev/null +++ b/linux/crates/core/src/column/kind.rs @@ -0,0 +1,263 @@ +use super::{BoolStorage, FloatWidth, IntegerKind, NetworkFamily, TemporalZone, TextLength, TimeRange}; + +/// What a column holds, independent of the engine's spelling for it. +/// +/// The UI picks an editor, a default alignment and an empty-string rule +/// from this rather than from the type name, so a new engine spelling +/// never needs a new branch in the grid. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ColumnKind { + Bool(BoolStorage), + Integer(IntegerKind), + Float(FloatWidth), + Decimal { + precision: Option, + scale: Option, + }, + Money { + scale: Option, + }, + Text { + length: TextLength, + }, + Enum { + labels: Vec, + /// MySQL SET: the value is any combination of the labels. + multiple: bool, + }, + Bytes { + length: TextLength, + }, + Uuid, + Json { + binary: bool, + }, + Date, + Time { + fractional_digits: Option, + with_offset: bool, + range: TimeRange, + }, + Timestamp { + fractional_digits: Option, + zone: TemporalZone, + }, + Interval, + BitString { + length: Option, + varying: bool, + }, + Network(NetworkFamily), + Xml, + Spatial, + Array { + element: Box, + }, + /// A value whose type is chosen per row: ClickHouse Variant, SQL + /// Server sql_variant. + Variant, + /// ClickHouse Map, Tuple, Nested, Dynamic, JSON and + /// AggregateFunction, and PostgreSQL composite types. + Composite, + /// The driver could not place the type. The value round-trips as the + /// server's own text. + Other, +} + +impl ColumnKind { + pub fn is_textual(&self) -> bool { + matches!(self, Self::Text { .. } | Self::Enum { .. } | Self::Xml | Self::Other) + } + + pub fn is_numeric(&self) -> bool { + matches!( + self, + Self::Integer(_) | Self::Float(_) | Self::Decimal { .. } | Self::Money { .. } + ) + } + + /// Whether an empty cell means the empty string rather than NULL. + /// Anywhere else the empty string is not a value the column can + /// hold, so clearing the cell can only mean NULL. + pub fn accepts_empty_string(&self) -> bool { + match self { + Self::Text { .. } | Self::Other => true, + Self::Enum { labels, .. } => labels.iter().any(String::is_empty), + _ => false, + } + } + + /// Whether a line break belongs in the value. The grid keeps a + /// single-line editor for everything else, so Enter commits. + pub fn accepts_line_breaks(&self) -> bool { + matches!(self, Self::Text { .. } | Self::Json { .. } | Self::Xml | Self::Other) + } + + /// The fractional-second digits a time or timestamp column declares, + /// as in `timestamp(6)`. + /// + /// `None` where the type names none, which is not the same as zero: + /// a value's own fraction is then all anyone knows about it. + pub fn fractional_digits(&self) -> Option { + match self { + Self::Time { fractional_digits, .. } | Self::Timestamp { fractional_digits, .. } => *fractional_digits, + _ => None, + } + } + + /// The digits after the decimal point a decimal or money column + /// declares, as in `numeric(38,10)`. + pub fn decimal_scale(&self) -> Option { + match self { + Self::Decimal { scale, .. } => *scale, + Self::Money { scale } => scale.map(u32::from), + _ => None, + } + } +} + +#[cfg(test)] +mod tests { + use super::super::IntegerWidth; + use super::*; + + fn enumeration(labels: &[&str]) -> ColumnKind { + ColumnKind::Enum { + labels: labels.iter().map(|label| (*label).to_owned()).collect(), + multiple: false, + } + } + + #[test] + fn column_kind_helpers_table() { + let int = ColumnKind::Integer(IntegerKind { + width: IntegerWidth::W32, + signed: true, + }); + let time = ColumnKind::Time { + fractional_digits: None, + with_offset: false, + range: TimeRange::TimeOfDay, + }; + let stamp = ColumnKind::Timestamp { + fractional_digits: Some(6), + zone: TemporalZone::Offset, + }; + // (kind, is_textual, is_numeric, accepts_empty_string, accepts_line_breaks) + let cases = [ + (ColumnKind::Bool(BoolStorage::Native), false, false, false, false), + (ColumnKind::Bool(BoolStorage::Integer), false, false, false, false), + (int, false, true, false, false), + (ColumnKind::Float(FloatWidth::F64), false, true, false, false), + ( + ColumnKind::Decimal { + precision: Some(10), + scale: Some(2), + }, + false, + true, + false, + false, + ), + (ColumnKind::Money { scale: Some(4) }, false, true, false, false), + ( + ColumnKind::Text { + length: TextLength::Unbounded, + }, + true, + false, + true, + true, + ), + ( + ColumnKind::Text { + length: TextLength::Fixed(4), + }, + true, + false, + true, + true, + ), + (enumeration(&["a", "b"]), true, false, false, false), + (enumeration(&["a", ""]), true, false, true, false), + ( + ColumnKind::Bytes { + length: TextLength::Unbounded, + }, + false, + false, + false, + false, + ), + (ColumnKind::Uuid, false, false, false, false), + (ColumnKind::Json { binary: true }, false, false, false, true), + (ColumnKind::Date, false, false, false, false), + (time, false, false, false, false), + (stamp, false, false, false, false), + (ColumnKind::Interval, false, false, false, false), + ( + ColumnKind::BitString { + length: Some(8), + varying: false, + }, + false, + false, + false, + false, + ), + (ColumnKind::Network(NetworkFamily::Inet), false, false, false, false), + (ColumnKind::Xml, true, false, false, true), + (ColumnKind::Spatial, false, false, false, false), + ( + ColumnKind::Array { + element: Box::new(ColumnKind::Uuid), + }, + false, + false, + false, + false, + ), + (ColumnKind::Variant, false, false, false, false), + (ColumnKind::Composite, false, false, false, false), + (ColumnKind::Other, true, false, true, true), + ]; + + for (kind, textual, numeric, empty, breaks) in cases { + assert_eq!(kind.is_textual(), textual, "is_textual {kind:?}"); + assert_eq!(kind.is_numeric(), numeric, "is_numeric {kind:?}"); + assert_eq!(kind.accepts_empty_string(), empty, "accepts_empty_string {kind:?}"); + assert_eq!(kind.accepts_line_breaks(), breaks, "accepts_line_breaks {kind:?}"); + } + } + + #[test] + fn declared_precision_comes_from_the_kind() { + let stamp = ColumnKind::Timestamp { + fractional_digits: Some(6), + zone: TemporalZone::Offset, + }; + let undeclared = ColumnKind::Time { + fractional_digits: None, + with_offset: false, + range: TimeRange::TimeOfDay, + }; + let decimal = ColumnKind::Decimal { + precision: Some(38), + scale: Some(10), + }; + + assert_eq!(stamp.fractional_digits(), Some(6)); + assert_eq!(stamp.decimal_scale(), None, "a timestamp has no decimal scale"); + assert_eq!(undeclared.fractional_digits(), None); + assert_eq!(decimal.decimal_scale(), Some(10)); + assert_eq!(ColumnKind::Money { scale: Some(4) }.decimal_scale(), Some(4)); + assert_eq!( + ColumnKind::Text { + length: TextLength::Varying(255) + } + .fractional_digits(), + None, + "a length was read as a precision" + ); + } +} diff --git a/linux/crates/core/src/column/kind_parts.rs b/linux/crates/core/src/column/kind_parts.rs new file mode 100644 index 0000000000..e66c42b125 --- /dev/null +++ b/linux/crates/core/src/column/kind_parts.rs @@ -0,0 +1,69 @@ +use crate::value::SqlTime; + +/// How a boolean column stores its value. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BoolStorage { + Native, + /// MySQL `tinyint(1)` and SQLite BOOLEAN: the server keeps any + /// integer, so the value stays a `Value::Int` and 2 is not true. + Integer, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FloatWidth { + F32, + F64, +} + +/// The length a text or byte column declares. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TextLength { + Unbounded, + Varying(u32), + /// Padded to this length by the server, so a trailing-space diff is + /// the server's doing and not the user's. + Fixed(u32), +} + +/// What a time column measures. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TimeRange { + TimeOfDay, + /// MySQL TIME, which runs from -838:59:59 to 838:59:59. + Duration, + /// ClickHouse Time and Time64, which run to 999:59:59 either side of + /// zero. + LongDuration, +} + +impl TimeRange { + /// Whether a column of this range can hold `time`. + pub fn holds(self, time: &SqlTime) -> bool { + match self { + Self::TimeOfDay => time.to_time_of_day().is_some(), + Self::Duration => time.within_hours(838), + // A `SqlTime` reaches no further than ClickHouse's Time64. + Self::LongDuration => true, + } + } +} + +/// How a timestamp column relates to a time zone. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum TemporalZone { + Naive, + Offset, + /// ClickHouse `DateTime('Europe/Paris')`: wall-clock time in a zone + /// the column names, with no offset in the value. + Named(String), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum NetworkFamily { + Inet, + Cidr, + MacAddr, + MacAddr8, + Ipv4, + Ipv6, +} diff --git a/linux/crates/core/src/column/mod.rs b/linux/crates/core/src/column/mod.rs new file mode 100644 index 0000000000..682da48187 --- /dev/null +++ b/linux/crates/core/src/column/mod.rs @@ -0,0 +1,23 @@ +mod catalog_type; +mod classify; +mod column_info; +mod column_type; +mod default; +mod integer_kind; +mod kind; +mod kind_parts; +mod read_form; +mod result_column; +mod sql_type_expr; + +pub use catalog_type::CatalogType; +pub use classify::{classify_type_name, has_dynamic_storage}; +pub use column_info::ColumnInfo; +pub use column_type::ColumnType; +pub use default::ColumnDefault; +pub use integer_kind::{IntegerKind, IntegerWidth}; +pub use kind::ColumnKind; +pub use kind_parts::{BoolStorage, FloatWidth, NetworkFamily, TemporalZone, TextLength, TimeRange}; +pub use read_form::ReadForm; +pub use result_column::ResultColumn; +pub use sql_type_expr::{SqlExpression, SqlTypeExpr}; diff --git a/linux/crates/core/src/column/read_form.rs b/linux/crates/core/src/column/read_form.rs new file mode 100644 index 0000000000..66b97ed56e --- /dev/null +++ b/linux/crates/core/src/column/read_form.rs @@ -0,0 +1,21 @@ +/// How a driver got the value off the wire, which decides whether a +/// round-trip is lossless. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum ReadForm { + /// Decoded into a typed Rust value with no precision lost. + Native, + /// Read as the server's own text. Exact, but the app cannot compute + /// with it beyond what the text says. + ServerText, + /// Read as a numeric string the server formatted. Exact to the + /// column's declared precision. + ServerNumeric, +} + +impl ReadForm { + /// Whether the value is the server's text rather than a decoded + /// value. Export writes those through unchanged. + pub fn is_server_text(self) -> bool { + matches!(self, Self::ServerText | Self::ServerNumeric) + } +} diff --git a/linux/crates/core/src/column/result_column.rs b/linux/crates/core/src/column/result_column.rs new file mode 100644 index 0000000000..9f1e7d010f --- /dev/null +++ b/linux/crates/core/src/column/result_column.rs @@ -0,0 +1,22 @@ +use super::ColumnType; + +/// A column of a result set, which is all a driver can say about an +/// arbitrary query's output. +/// +/// Browse uses `ColumnInfo` from `fetch_columns`, which knows about +/// keys and defaults. A result column knows only its name and type, +/// because that is all a `SELECT` over an expression has. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ResultColumn { + pub name: String, + pub column_type: ColumnType, +} + +impl ResultColumn { + pub fn new(name: impl Into, column_type: ColumnType) -> Self { + Self { + name: name.into(), + column_type, + } + } +} diff --git a/linux/crates/core/src/column/sql_type_expr.rs b/linux/crates/core/src/column/sql_type_expr.rs new file mode 100644 index 0000000000..cad5f32f45 --- /dev/null +++ b/linux/crates/core/src/column/sql_type_expr.rs @@ -0,0 +1,47 @@ +/// A SQL type as the server spells it. +/// +/// The inner text is pasted into DDL verbatim, so it may only come from +/// the server's own catalogue or from text a `SqlDialect` has already +/// parsed. `from_catalog_text` carries that provenance in its name and is +/// banned outside the driver crates by `disallowed-methods`. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct SqlTypeExpr(Box); + +impl SqlTypeExpr { + /// Only for text the server itself produced, such as + /// `format_type()` output or an `information_schema` column. + pub fn from_catalog_text(text: impl Into>) -> Self { + Self(text.into()) + } + + pub fn as_sql(&self) -> &str { + &self.0 + } +} + +impl std::fmt::Display for SqlTypeExpr { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.0) + } +} + +/// A SQL expression as the server spells it, with the same provenance +/// rule as `SqlTypeExpr`. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct SqlExpression(Box); + +impl SqlExpression { + pub fn from_catalog_text(text: impl Into>) -> Self { + Self(text.into()) + } + + pub fn as_sql(&self) -> &str { + &self.0 + } +} + +impl std::fmt::Display for SqlExpression { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.0) + } +} diff --git a/linux/crates/core/src/commit.rs b/linux/crates/core/src/commit.rs new file mode 100644 index 0000000000..b3b09c3dd8 --- /dev/null +++ b/linux/crates/core/src/commit.rs @@ -0,0 +1,39 @@ +use crate::meta::TableRef; + +/// What the app needs to ask the server whether a commit landed. +/// +/// A connection lost between COMMIT and its acknowledgement leaves the +/// outcome unknown. Every engine that can answer the question later +/// needs something from before the loss, so it is taken during the +/// write and kept until the answer arrives. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum CommitToken { + /// The transaction id PostgreSQL gives out inside the transaction, + /// which `pg_xact_status` reads back afterwards. `legacy_txid` says + /// the id came from `txid_current` on a server before 13. + PostgresXid { xid: u64, legacy_txid: bool }, + /// ClickHouse has no transactions, so the token is the query ids + /// that were sent, and how many of them are known to have finished. + /// `table` is the table they write, qualified: a mutation is looked + /// up by table, and the log of an ALTER that failed while waiting on + /// its mutation names none. + ClickHouseQuery { + query_ids: Vec, + first_unconfirmed: usize, + table: TableRef, + }, +} + +/// What the server said when asked about a commit whose outcome was +/// unknown. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum CommitResolution { + Applied, + NotApplied, + PartiallyApplied, + /// The work is still going, so the answer is not final yet. + StillRunning, + /// The server cannot answer: the record aged out, or the engine + /// never had one. + Unresolvable, +} diff --git a/linux/crates/core/src/config_error.rs b/linux/crates/core/src/config_error.rs new file mode 100644 index 0000000000..9cbb390338 --- /dev/null +++ b/linux/crates/core/src/config_error.rs @@ -0,0 +1,56 @@ +use std::path::PathBuf; + +use thiserror::Error; + +use crate::{CertificateFileProblem, TransportClass}; + +#[derive(Debug, Clone, PartialEq, Eq, Error)] +pub enum ConfigError { + #[error("the host is empty")] + EmptyHost, + + #[error("the host contains spaces or control characters")] + InvalidHost, + + #[error("the port must be between 1 and 65535")] + InvalidPort, + + #[error("the path must be absolute")] + RelativePath, + + #[error("this TLS mode is not supported")] + UnsupportedTlsMode, + + #[error("a CA file needs Verify full and a driver that accepts one")] + CaFileUnsupported, + + #[error("a client certificate needs TLS turned on and a driver that accepts one")] + ClientIdentityUnsupported, + + #[error("a client certificate needs both a certificate file and a key file")] + ClientIdentityIncomplete, + + #[error("CA file {}: {problem}", .path.display())] + CaFile { + path: PathBuf, + problem: CertificateFileProblem, + }, + + #[error("client certificate file {}: {problem}", .path.display())] + ClientIdentityFile { + path: PathBuf, + problem: CertificateFileProblem, + }, + + #[error("the TLS server name is not a valid DNS name or IP address")] + InvalidServerName, + + #[error("a TLS server name override is not supported over {route}")] + ServerNameOverrideUnsupported { route: TransportClass }, + + #[error("this authentication method is not supported")] + AuthModeUnsupported, + + #[error("a database name is required")] + DatabaseRequired, +} diff --git a/linux/crates/core/src/connect_environment.rs b/linux/crates/core/src/connect_environment.rs new file mode 100644 index 0000000000..736c68ca56 --- /dev/null +++ b/linux/crates/core/src/connect_environment.rs @@ -0,0 +1,21 @@ +/// The name the app gives itself to a server, which shows up in the +/// server's own session lists (`pg_stat_activity`, `sys.dm_exec_sessions`) +/// so an administrator can tell which sessions are ours. +pub const APPLICATION_NAME: &str = "TablePro"; + +/// What a driver needs to know about the app itself, rather than about +/// the connection. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ConnectEnvironment { + /// The app's version, reported next to `APPLICATION_NAME` where the + /// protocol has room for one. + pub client_version: String, +} + +impl ConnectEnvironment { + pub fn new(client_version: impl Into) -> Self { + Self { + client_version: client_version.into(), + } + } +} diff --git a/linux/crates/core/src/connect_options.rs b/linux/crates/core/src/connect_options.rs new file mode 100644 index 0000000000..40d1a1b055 --- /dev/null +++ b/linux/crates/core/src/connect_options.rs @@ -0,0 +1,17 @@ +use crate::connect_environment::ConnectEnvironment; +use crate::connect_target::ConnectTarget; +use crate::liveness_policy::LivenessPolicy; +use crate::workspace_settings::WorkspaceSettings; + +/// Everything a driver needs to open a connection. +/// +/// There is no default: a connection opened with a made-up host, an +/// empty password or a missing timeout is a bug that should not +/// compile. +#[derive(Debug, Clone)] +pub struct ConnectOptions { + pub target: ConnectTarget, + pub workspace_settings: WorkspaceSettings, + pub liveness: LivenessPolicy, + pub environment: ConnectEnvironment, +} diff --git a/linux/crates/core/src/connect_target.rs b/linux/crates/core/src/connect_target.rs new file mode 100644 index 0000000000..7a2515bd90 --- /dev/null +++ b/linux/crates/core/src/connect_target.rs @@ -0,0 +1,9 @@ +use crate::file_endpoint::FileEndpoint; +use crate::network_target::NetworkTarget; + +/// Where a connection goes. +#[derive(Debug, Clone)] +pub enum ConnectTarget { + Network(NetworkTarget), + File(FileEndpoint), +} diff --git a/linux/crates/core/src/connection.rs b/linux/crates/core/src/connection.rs new file mode 100644 index 0000000000..b04d34e2c2 --- /dev/null +++ b/linux/crates/core/src/connection.rs @@ -0,0 +1,127 @@ +use std::sync::Arc; + +use async_trait::async_trait; +use tokio_util::sync::{CancellationToken, WaitForCancellationFutureOwned}; + +use crate::call_options::CallOptions; +use crate::column::ColumnInfo; +use crate::commit::{CommitResolution, CommitToken}; +use crate::ddl::TableDefinition; +use crate::dialect::SqlDialect; +use crate::editor_session::{EditorSession, EditorSessionOptions}; +use crate::error::DriverError; +use crate::foreign_key_info::ForeignKeyInfo; +use crate::link_watch::LinkState; +use crate::meta::{IndexInfo, ObjectInfo, ObjectScope, RowIdentity, SchemaInfo, TableEstimate, TableRef}; +use crate::query_result::QueryResult; +use crate::read_statement::ReadStatement; +use crate::result_sink::ResultSink; +use crate::row_limit::RowLimit; +use crate::server_info::ServerInfo; +use crate::statement::Statement; +use crate::stream_summary::StreamSummary; +use crate::write_batch::WriteBatch; +use crate::write_report::WriteReport; + +/// A workspace's link to one server: the pool behind the sidebar, the +/// grid and every save. +/// +/// Each call runs on a task the connection owns, not on the caller's +/// future, so dropping the caller cancels the call instead of leaving +/// the server working for nobody (`OwnedCalls`). A call returns within +/// its deadline plus the liveness policy's cancel grace. +#[async_trait] +pub trait Connection: Send + Sync { + fn server_info(&self) -> &ServerInfo; + + /// Built from the server version at connect, so generated SQL + /// matches what this server accepts. + fn dialect(&self) -> Arc; + + fn read_only(&self) -> bool; + + fn link_state(&self) -> LinkState; + + /// Resolves once the link is lost, for a monitor that waits on it + /// without holding the connection busy. + fn lost(&self) -> WaitForCancellationFutureOwned; + + /// Whether the server answers. Never waits for a pool slot: a busy + /// pool is not a dead server, so with every slot taken the probe + /// opens a connection of its own. + async fn probe(&self, options: CallOptions) -> Result<(), DriverError>; + + async fn list_schemas(&self, options: CallOptions) -> Result, DriverError>; + + async fn list_objects(&self, scope: &ObjectScope, options: CallOptions) -> Result, DriverError>; + + async fn fetch_columns(&self, table: &TableRef, options: CallOptions) -> Result, DriverError>; + + async fn fetch_indexes(&self, table: &TableRef, options: CallOptions) -> Result, DriverError>; + + async fn fetch_foreign_keys( + &self, + table: &TableRef, + options: CallOptions, + ) -> Result, DriverError>; + + /// The table as structure changes plan against it, read in one call + /// so its columns, keys and dependents come from the same moment. + async fn fetch_table_definition( + &self, + table: &TableRef, + options: CallOptions, + ) -> Result; + + async fn row_identity(&self, table: &TableRef, options: CallOptions) -> Result; + + async fn table_estimate(&self, table: &TableRef, options: CallOptions) -> Result; + + /// Run a generated read and keep at most `limit` rows, marking the + /// result truncated when there were more. + async fn query_bound( + &self, + statement: &Statement, + limit: RowLimit, + options: CallOptions, + ) -> Result; + + async fn count_rows(&self, statement: &Statement, options: CallOptions) -> Result; + + /// Stream every row of a read into `sink`, with no row limit. + /// + /// It runs on a connection of its own, never a pool slot, because an + /// export can take minutes. The read is held read-only by the engine + /// for its whole length, so user SQL that tries to write fails with + /// a `ReadOnly` error. Each result set arrives as `ResultSetStarted`, + /// then `Rows`, then `ResultSetFinished`, and each event is accepted + /// before the next rows are read. + async fn stream_read( + &self, + read: &ReadStatement, + sink: Box, + options: CallOptions, + ) -> Result; + + /// Apply a save. On an engine that cannot suspend foreign keys, a + /// batch that asks for it fails before anything is sent. + async fn write_batch(&self, batch: &WriteBatch, options: CallOptions) -> Result; + + /// Ask the server what became of a write whose commit was sent and + /// never answered. A commit the engine keeps no record of is + /// `Unresolvable`, and an engine whose `commit_resolution` is + /// `Unavailable` keeps a record of none: neither is an error. + async fn resolve_commit(&self, token: &CommitToken, options: CallOptions) -> Result; + + /// Open an editor tab's own connection. It outlives this one: the + /// tab owns it, and closing the workspace does not close it. + async fn open_editor_session( + &self, + options: EditorSessionOptions, + cancel: CancellationToken, + ) -> Result, DriverError>; + + /// Stop every call and release the pool. Safe to call more than + /// once, and it never closes an editor session. + async fn close(&self) -> Result<(), DriverError>; +} diff --git a/linux/crates/core/src/credentials/connect_credentials.rs b/linux/crates/core/src/credentials/connect_credentials.rs new file mode 100644 index 0000000000..0dace13b3a --- /dev/null +++ b/linux/crates/core/src/credentials/connect_credentials.rs @@ -0,0 +1,55 @@ +use secrecy::SecretString; + +use crate::auth_mode::AuthMode; + +/// What a network connection logs in with. +/// +/// The password is a `SecretString`, so a `Debug` print of a connect +/// request never shows it. +#[derive(Debug, Clone)] +pub enum Credentials { + Password { + username: String, + password: SecretString, + }, + /// The operating system's own login, so nothing is sent from here. + Integrated, +} + +impl Credentials { + pub fn mode(&self) -> AuthMode { + match self { + Self::Password { .. } => AuthMode::Password, + Self::Integrated => AuthMode::Kerberos, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn integrated_credentials_log_in_with_kerberos() { + let password = Credentials::Password { + username: "deploy".to_owned(), + password: SecretString::from("hunter2"), + }; + + assert_eq!(password.mode(), AuthMode::Password); + assert_eq!(Credentials::Integrated.mode(), AuthMode::Kerberos); + } + + #[test] + fn a_debug_print_never_shows_the_password() { + let credentials = Credentials::Password { + username: "deploy".to_owned(), + password: SecretString::from("hunter2"), + }; + + let printed = format!("{credentials:?}"); + + assert!(printed.contains("deploy")); + assert!(!printed.contains("hunter2"), "the password leaked: {printed}"); + } +} diff --git a/linux/crates/core/src/credentials/credential_interaction.rs b/linux/crates/core/src/credentials/credential_interaction.rs new file mode 100644 index 0000000000..0e0c80b786 --- /dev/null +++ b/linux/crates/core/src/credentials/credential_interaction.rs @@ -0,0 +1,19 @@ +use std::fmt; +use std::sync::Arc; + +use crate::credentials::CredentialPrompter; + +#[derive(Clone)] +pub enum CredentialInteraction { + Unattended, + Attended(Arc), +} + +impl fmt::Debug for CredentialInteraction { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(match self { + Self::Unattended => "Unattended", + Self::Attended(_) => "Attended", + }) + } +} diff --git a/linux/crates/core/src/credentials/credential_prompt.rs b/linux/crates/core/src/credentials/credential_prompt.rs new file mode 100644 index 0000000000..052c11de37 --- /dev/null +++ b/linux/crates/core/src/credentials/credential_prompt.rs @@ -0,0 +1,9 @@ +use crate::credentials::{PromptField, PromptPurpose}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CredentialPrompt { + pub purpose: PromptPurpose, + pub target: String, + pub fields: Vec, + pub offer_remember: bool, +} diff --git a/linux/crates/core/src/credentials/credential_prompter.rs b/linux/crates/core/src/credentials/credential_prompter.rs new file mode 100644 index 0000000000..10b113f676 --- /dev/null +++ b/linux/crates/core/src/credentials/credential_prompter.rs @@ -0,0 +1,7 @@ +use futures::future::BoxFuture; + +use crate::credentials::{CredentialPrompt, PromptReply}; + +pub trait CredentialPrompter: Send + Sync { + fn prompt(&self, request: CredentialPrompt) -> BoxFuture<'static, PromptReply>; +} diff --git a/linux/crates/core/src/credentials/mod.rs b/linux/crates/core/src/credentials/mod.rs new file mode 100644 index 0000000000..502f42a462 --- /dev/null +++ b/linux/crates/core/src/credentials/mod.rs @@ -0,0 +1,90 @@ +mod connect_credentials; +mod credential_interaction; +mod credential_prompt; +mod credential_prompter; +mod prompt_field; +mod prompt_purpose; +mod prompt_reason; +mod prompt_reply; +mod secret_error; +mod secret_kind; +mod secret_lookup; +mod secret_save; +mod secret_vault; + +pub use connect_credentials::Credentials; +pub use credential_interaction::CredentialInteraction; +pub use credential_prompt::CredentialPrompt; +pub use credential_prompter::CredentialPrompter; +pub use prompt_field::PromptField; +pub use prompt_purpose::PromptPurpose; +pub use prompt_reason::PromptReason; +pub use prompt_reply::PromptReply; +pub use secret_error::SecretError; +pub use secret_kind::SecretKind; +pub use secret_lookup::SecretLookup; +pub use secret_save::SecretSave; +pub use secret_vault::SecretVault; + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use futures::future::BoxFuture; + use secrecy::{ExposeSecret, SecretString}; + + use super::*; + + struct AnsweringPrompter; + + impl CredentialPrompter for AnsweringPrompter { + fn prompt(&self, request: CredentialPrompt) -> BoxFuture<'static, PromptReply> { + let values = request + .fields + .iter() + .map(|field| SecretString::from(format!("answer for {}", field.label))) + .collect(); + Box::pin(async move { + PromptReply::Submitted { + values, + remember: request.offer_remember, + } + }) + } + } + + fn assert_send_sync() {} + + #[test] + fn prompter_trait_object_is_send_sync() { + assert_send_sync::(); + assert_send_sync::(); + } + + #[tokio::test] + async fn attended_interaction_prompts_through_its_prompter() { + let interaction = CredentialInteraction::Attended(Arc::new(AnsweringPrompter)); + let CredentialInteraction::Attended(prompter) = interaction.clone() else { + panic!("expected an attended interaction"); + }; + let request = CredentialPrompt { + purpose: PromptPurpose::DatabasePassword { + reason: PromptReason::NotStored, + }, + target: "deploy@db.example.com".to_owned(), + fields: vec![PromptField { + label: "Password".to_owned(), + secret: true, + }], + offer_remember: true, + }; + + let PromptReply::Submitted { values, remember } = prompter.prompt(request).await else { + panic!("expected a submitted reply"); + }; + assert!(remember); + assert_eq!(values.len(), 1); + assert_eq!(values[0].expose_secret(), "answer for Password"); + assert_eq!(format!("{interaction:?}"), "Attended"); + } +} diff --git a/linux/crates/core/src/credentials/prompt_field.rs b/linux/crates/core/src/credentials/prompt_field.rs new file mode 100644 index 0000000000..cdf1941caf --- /dev/null +++ b/linux/crates/core/src/credentials/prompt_field.rs @@ -0,0 +1,5 @@ +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PromptField { + pub label: String, + pub secret: bool, +} diff --git a/linux/crates/core/src/credentials/prompt_purpose.rs b/linux/crates/core/src/credentials/prompt_purpose.rs new file mode 100644 index 0000000000..a86b07c8b9 --- /dev/null +++ b/linux/crates/core/src/credentials/prompt_purpose.rs @@ -0,0 +1,29 @@ +use std::path::PathBuf; + +use crate::credentials::PromptReason; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PromptPurpose { + DatabasePassword { + reason: PromptReason, + }, + SshPassword { + reason: PromptReason, + }, + SshPassphrase { + path: PathBuf, + reason: PromptReason, + }, + SshKeyboardInteractive { + name: String, + instructions: String, + }, + SshHostKeyConfirmation { + host: String, + algorithm: String, + fingerprint: String, + }, + SshConfirmation { + text: String, + }, +} diff --git a/linux/crates/core/src/credentials/prompt_reason.rs b/linux/crates/core/src/credentials/prompt_reason.rs new file mode 100644 index 0000000000..a1a4600e87 --- /dev/null +++ b/linux/crates/core/src/credentials/prompt_reason.rs @@ -0,0 +1,7 @@ +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PromptReason { + NotStored, + KeyringLocked, + KeyringUnavailable { detail: String }, + Rejected, +} diff --git a/linux/crates/core/src/credentials/prompt_reply.rs b/linux/crates/core/src/credentials/prompt_reply.rs new file mode 100644 index 0000000000..aa8ac9f6aa --- /dev/null +++ b/linux/crates/core/src/credentials/prompt_reply.rs @@ -0,0 +1,7 @@ +use secrecy::SecretString; + +#[derive(Debug)] +pub enum PromptReply { + Submitted { values: Vec, remember: bool }, + Cancelled, +} diff --git a/linux/crates/core/src/credentials/secret_error.rs b/linux/crates/core/src/credentials/secret_error.rs new file mode 100644 index 0000000000..f3eb8f3d9e --- /dev/null +++ b/linux/crates/core/src/credentials/secret_error.rs @@ -0,0 +1,31 @@ +use thiserror::Error; + +/// Why a keyring operation failed. +/// +/// These are distinguished because the app acts on each differently: +/// a dismissed unlock is the user's choice, a locked keyring can be +/// retried, and a missing service means secrets cannot be saved at all. +#[derive(Debug, Clone, PartialEq, Eq, Error)] +pub enum SecretError { + #[error("the secret service is unavailable: {detail}")] + ServiceUnavailable { detail: String }, + #[error("the secret portal is unavailable: {detail}")] + PortalUnavailable { detail: String }, + #[error("the keyring is locked")] + Locked, + #[error("the unlock prompt was dismissed")] + UnlockDismissed, + #[error("the stored secret is not valid UTF-8")] + InvalidEncoding, + #[error("the secret service reported: {detail}")] + Backend { detail: String }, +} + +impl SecretError { + /// Whether trying again could work. A dismissed prompt and a locked + /// keyring both clear once the user unlocks; a missing service does + /// not. + pub fn is_retryable(&self) -> bool { + matches!(self, Self::Locked | Self::UnlockDismissed) + } +} diff --git a/linux/crates/core/src/credentials/secret_kind.rs b/linux/crates/core/src/credentials/secret_kind.rs new file mode 100644 index 0000000000..4caed05c9d --- /dev/null +++ b/linux/crates/core/src/credentials/secret_kind.rs @@ -0,0 +1,29 @@ +/// Which secret of a connection this is. +/// +/// The keyring stores one item per (connection, kind), so deleting a +/// connection removes all three and a password change never disturbs an +/// SSH passphrase. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum SecretKind { + DatabasePassword, + SshPassword, + SshPassphrase, +} + +impl SecretKind { + pub const ALL: [SecretKind; 3] = [ + SecretKind::DatabasePassword, + SecretKind::SshPassword, + SecretKind::SshPassphrase, + ]; + + /// The `kind` attribute the keyring item carries. Stable: changing + /// one of these strings orphans every secret already stored. + pub fn attribute(self) -> &'static str { + match self { + Self::DatabasePassword => "db_password", + Self::SshPassword => "ssh_password", + Self::SshPassphrase => "ssh_passphrase", + } + } +} diff --git a/linux/crates/core/src/credentials/secret_lookup.rs b/linux/crates/core/src/credentials/secret_lookup.rs new file mode 100644 index 0000000000..5d50e08d34 --- /dev/null +++ b/linux/crates/core/src/credentials/secret_lookup.rs @@ -0,0 +1,24 @@ +use secrecy::SecretString; + +/// The result of asking the keyring for a secret. +/// +/// `NotStored` is not an error: a connection may legitimately have no +/// saved password, and the app prompts for one instead of failing. +#[derive(Debug, Clone)] +pub enum SecretLookup { + Found(SecretString), + NotStored, +} + +impl SecretLookup { + pub fn found(self) -> Option { + match self { + Self::Found(secret) => Some(secret), + Self::NotStored => None, + } + } + + pub fn is_stored(&self) -> bool { + matches!(self, Self::Found(_)) + } +} diff --git a/linux/crates/core/src/credentials/secret_save.rs b/linux/crates/core/src/credentials/secret_save.rs new file mode 100644 index 0000000000..d49dc8786e --- /dev/null +++ b/linux/crates/core/src/credentials/secret_save.rs @@ -0,0 +1,11 @@ +use secrecy::SecretString; + +use super::SecretKind; + +/// One secret to store, with the label the keyring shows the user. +#[derive(Debug, Clone)] +pub struct SecretSave { + pub kind: SecretKind, + pub secret: SecretString, + pub label: String, +} diff --git a/linux/crates/core/src/credentials/secret_vault.rs b/linux/crates/core/src/credentials/secret_vault.rs new file mode 100644 index 0000000000..2277be0bf7 --- /dev/null +++ b/linux/crates/core/src/credentials/secret_vault.rs @@ -0,0 +1,26 @@ +use async_trait::async_trait; +use secrecy::SecretString; +use uuid::Uuid; + +use super::{SecretError, SecretKind, SecretLookup}; + +/// Where connection secrets live. +/// +/// A trait so the app can be tested without a Secret Service, and so the +/// backend can change without touching the call sites. +#[async_trait] +pub trait SecretVault: Send + Sync { + async fn store(&self, id: Uuid, kind: SecretKind, secret: &SecretString, label: &str) -> Result<(), SecretError>; + + async fn load(&self, id: Uuid, kind: SecretKind) -> Result; + + /// Change the label without touching the secret, for when a + /// connection is renamed or its host changes. + async fn relabel(&self, id: Uuid, kind: SecretKind, label: &str) -> Result<(), SecretError>; + + async fn delete_kind(&self, id: Uuid, kind: SecretKind) -> Result<(), SecretError>; + + /// Remove every secret of a connection in one call, so a delete + /// cannot leave one kind orphaned in the keyring. + async fn delete_connection(&self, id: Uuid) -> Result<(), SecretError>; +} diff --git a/linux/crates/core/src/ddl/build_ddl_error.rs b/linux/crates/core/src/ddl/build_ddl_error.rs new file mode 100644 index 0000000000..39a2b1c3a8 --- /dev/null +++ b/linux/crates/core/src/ddl/build_ddl_error.rs @@ -0,0 +1,42 @@ +use thiserror::Error; + +use super::{DdlOperation, DraftProblem, ReferentialAction, RefusalReason}; +use crate::dialect::LiteralError; +use crate::sql_syntax::TypeSyntaxError; + +/// Why a structure change could not be written as SQL. +#[derive(Debug, Clone, PartialEq, Eq, Error)] +pub enum BuildDdlError { + #[error(transparent)] + Draft(#[from] DraftProblem), + #[error("{0} is not supported by this engine")] + Unsupported(DdlOperation), + #[error("column {column}: {operation} is refused because {reason}")] + Refused { + column: String, + operation: DdlOperation, + reason: RefusalReason, + }, + #[error("column {column} has an invalid type: {error}")] + InvalidType { column: String, error: TypeSyntaxError }, + #[error("column {column} has a default with no SQL form: {error}")] + Literal { column: String, error: LiteralError }, + /// An existing column whose type the catalogue did not spell, so no + /// statement can restate it. + #[error("the catalogue gave no type declaration for column {0}")] + MissingCatalogDeclaration(String), + #[error("{} is not a referential action this engine accepts", .0.as_sql())] + ReferentialAction(ReferentialAction), + /// A table this engine keeps no CREATE statement for, where only the + /// server's own text would recreate it faithfully. + #[error("the server gave no CREATE statement for this table")] + NoTableSource, + /// A view, trigger or index a table rebuild makes again, whose stored + /// statement cannot run as it is. + #[error("the stored statement of {0}, which the rebuild makes again, cannot run as it is")] + UnreplayableDependent(String), + /// The statements a save would run are not the ones the user + /// reviewed, because the draft or the connection changed meanwhile. + #[error("the statements to run are not the ones reviewed")] + NotAsReviewed, +} diff --git a/linux/crates/core/src/ddl/change_review.rs b/linux/crates/core/src/ddl/change_review.rs new file mode 100644 index 0000000000..57e1e7252e --- /dev/null +++ b/linux/crates/core/src/ddl/change_review.rs @@ -0,0 +1,111 @@ +use super::{DdlPlan, DestructiveChange, DraftType, TableChanges}; +use crate::write_ledger::WriteAtomicity; + +/// What a structure save runs and what it loses, for the user to read +/// before it runs. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ChangeReview { + /// The edits the user made, counted as the action bar counts them. + pub operations: usize, + pub statements: Vec, + /// The rebuild first, then the drops, type changes and new NOT NULLs. + pub destructive: Vec, +} + +impl ChangeReview { + /// `plan` is the one the engine wrote for `changes`, which may drop + /// more than the changes name: a rebuild drops the whole table. + pub fn review(changes: &TableChanges, plan: &DdlPlan) -> Self { + let mut destructive: Vec = plan + .rebuild + .iter() + .cloned() + .map(DestructiveChange::RebuildTable) + .collect(); + destructive.extend( + changes + .dropped_columns + .iter() + .map(|column| DestructiveChange::DropColumn { + column: column.name().to_owned(), + }), + ); + destructive.extend( + changes + .altered_columns + .iter() + .filter(|alteration| alteration.changed.column_type) + .map(|alteration| { + let before = &alteration.original.info.column_type; + let from = match DraftType::Catalog(before.clone()).text() { + "" => before.name.clone(), + declaration => declaration.to_owned(), + }; + DestructiveChange::ChangeColumnType { + column: alteration.original.name().to_owned(), + from, + to: alteration.draft.column_type.text().to_owned(), + } + }), + ); + destructive.extend( + changes + .altered_columns + .iter() + .filter(|alteration| alteration.changed.nullability && !alteration.draft.nullable) + .map(|alteration| DestructiveChange::SetNotNull { + column: alteration.original.name().to_owned(), + }), + ); + destructive.extend( + changes + .dropped_indexes + .iter() + .map(|index| DestructiveChange::DropIndex { + index: index.name.clone(), + }), + ); + destructive.extend( + changes + .dropped_foreign_keys + .iter() + .map(|key| DestructiveChange::DropForeignKey { key: key.name.clone() }), + ); + Self { + operations: changes.change_count(), + statements: statements(plan), + destructive, + } + } + + /// A new table: one edit, and nothing there yet to lose. + pub fn create(plan: &DdlPlan) -> Self { + Self { + operations: 1, + statements: statements(plan), + destructive: Vec::new(), + } + } + + /// Whether the user has to agree first: when something is lost, or + /// when a statement that fails leaves the ones before it applied. + pub fn requires_confirmation(&self, ddl_writes: WriteAtomicity) -> bool { + !self.destructive.is_empty() + || (ddl_writes == WriteAtomicity::StatementByStatement && self.statements.len() > 1) + } + + /// Whether `plan` runs exactly the statements reviewed. + pub fn matches(&self, plan: &DdlPlan) -> bool { + plan.sql().into_iter().eq(self.statements.iter().map(String::as_str)) + } + + /// The statements as the review shows them, laid out as the + /// structure tab's preview. + pub fn sql(&self) -> String { + super::ddl_plan::script(self.statements.iter().map(String::as_str)) + } +} + +fn statements(plan: &DdlPlan) -> Vec { + plan.sql().into_iter().map(str::to_owned).collect() +} diff --git a/linux/crates/core/src/ddl/changed_attributes.rs b/linux/crates/core/src/ddl/changed_attributes.rs new file mode 100644 index 0000000000..7b9265cc9e --- /dev/null +++ b/linux/crates/core/src/ddl/changed_attributes.rs @@ -0,0 +1,44 @@ +use super::{ColumnDefinition, DraftColumn, DraftType}; + +/// What an edit changed about a column it kept. The name is not here: +/// a rename is its own change, so a rename alone alters nothing. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] +pub struct ChangedAttributes { + pub column_type: bool, + pub nullability: bool, + pub default: bool, + pub primary_key: bool, + pub auto_increment: bool, + pub comment: bool, +} + +impl ChangedAttributes { + pub fn between(original: &ColumnDefinition, draft: &DraftColumn) -> Self { + let info = &original.info; + let column_type = match &draft.column_type { + DraftType::Catalog(column_type) => *column_type != info.column_type, + // Typed text equal to the declaration is the same type: the + // user retyped what was there. + DraftType::Typed(text) => { + info.column_type + .catalog + .as_ref() + .map(|catalog| catalog.declaration.as_sql()) + != Some(text.trim()) + } + }; + let comment = info.comment.as_deref().filter(|text| !text.is_empty()) != draft.comment_text(); + Self { + column_type, + nullability: info.nullable != draft.nullable, + default: info.default != draft.default, + primary_key: info.primary_key != draft.primary_key, + auto_increment: info.is_auto_increment != draft.auto_increment, + comment, + } + } + + pub fn any(&self) -> bool { + self.column_type || self.nullability || self.default || self.primary_key || self.auto_increment || self.comment + } +} diff --git a/linux/crates/core/src/ddl/collation_name.rs b/linux/crates/core/src/ddl/collation_name.rs new file mode 100644 index 0000000000..aafeb81bd9 --- /dev/null +++ b/linux/crates/core/src/ddl/collation_name.rs @@ -0,0 +1,19 @@ +/// A collation as the catalogue names it. +/// +/// PostgreSQL collations are schema objects, so `"de_DE"` in one schema +/// is not the one in `pg_catalog`. Engines whose collations are global +/// leave `schema` as `None`. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct CollationName { + pub schema: Option, + pub name: String, +} + +impl CollationName { + pub fn new(schema: Option, name: impl Into) -> Self { + Self { + schema, + name: name.into(), + } + } +} diff --git a/linux/crates/core/src/ddl/column_alteration.rs b/linux/crates/core/src/ddl/column_alteration.rs new file mode 100644 index 0000000000..dcc9da3b53 --- /dev/null +++ b/linux/crates/core/src/ddl/column_alteration.rs @@ -0,0 +1,10 @@ +use super::{ChangedAttributes, ColumnDefinition, DraftColumn}; + +/// A kept column whose definition changed. The draft carries the name +/// the column has once its rename, if any, has run. +#[derive(Debug, Clone, PartialEq)] +pub struct ColumnAlteration { + pub original: ColumnDefinition, + pub draft: DraftColumn, + pub changed: ChangedAttributes, +} diff --git a/linux/crates/core/src/ddl/column_attributes.rs b/linux/crates/core/src/ddl/column_attributes.rs new file mode 100644 index 0000000000..0add42112a --- /dev/null +++ b/linux/crates/core/src/ddl/column_attributes.rs @@ -0,0 +1,50 @@ +use super::{ + CollationName, ColumnCompression, ColumnKeyRoles, ColumnStorage, ColumnVisibility, GeneratedColumn, IdentityStyle, + UnmodeledAttribute, +}; +use crate::column::SqlExpression; + +/// What a column definition says beyond its name, type, nullability and +/// default. +/// +/// MySQL and SQL Server redefine a column as a whole, so anything left +/// out of the new definition is lost. Each attribute here is one a +/// redefinition has to restate to keep the column as it was. +/// +/// The comment is not here: `ColumnInfo` carries it, because browse and +/// the structure tab read it from `fetch_columns` too. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct ColumnAttributes { + /// Set only when it differs from the type's or table's default, so a + /// restated definition does not pin a collation nobody chose. + pub collation: Option, + pub character_set: Option, + /// MySQL `ON UPDATE`, which sets the column on every row update. + pub on_update: Option, + pub generated: Option, + pub visibility: ColumnVisibility, + /// MySQL `SRID` on a spatial column. + pub spatial_reference: Option, + /// SQL Server `SPARSE`. + pub sparse: bool, + /// The function a SQL Server dynamic data mask applies, as in + /// `email()`. ALTER COLUMN drops the mask, which shows the data to + /// every user without UNMASK. + pub masking_function: Option, + /// A CHECK constraint MariaDB keeps on the column itself. MODIFY + /// drops it unless the definition restates it; every other engine + /// keeps a CHECK on the table, where a redefinition leaves it alone. + pub check: Option, + /// PostgreSQL `SET STORAGE`, set only when it differs from the + /// type's own. + pub storage: Option, + /// PostgreSQL `SET COMPRESSION`, set only when the column names a + /// method rather than following the server's default. + pub compression: Option, + pub identity: IdentityStyle, + /// The SQL Server constraint that holds the default. A type change + /// has to drop it by name first and add it back afterwards. + pub default_constraint_name: Option, + pub key_roles: ColumnKeyRoles, + pub unmodeled: Vec, +} diff --git a/linux/crates/core/src/ddl/column_capabilities.rs b/linux/crates/core/src/ddl/column_capabilities.rs new file mode 100644 index 0000000000..5b63eac46f --- /dev/null +++ b/linux/crates/core/src/ddl/column_capabilities.rs @@ -0,0 +1,116 @@ +use super::{ColumnDefinition, DdlCapabilities, IdentityStyle, Permission, RefusalReason, TableDefinition}; + +/// Which changes one existing column takes, each with the reason when +/// it does not. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct ColumnCapabilities { + pub rename: Permission, + pub drop: Permission, + pub alter_type: Permission, + pub alter_nullability: Permission, + pub alter_default: Permission, + pub alter_primary_key: Permission, + pub alter_auto_increment: Permission, + pub alter_comment: Permission, +} + +impl ColumnCapabilities { + /// What every engine refuses on top of its own capabilities: a + /// restatement that would drop a generated expression or an + /// attribute this build cannot restate, a default on a numbered + /// column, and anything foreign keys elsewhere depend on. + /// + /// A comment is set apart from the definition on most engines, so + /// only an engine that restates the column for it refuses it here. + pub fn standard(capabilities: &DdlCapabilities, table: &TableDefinition, column: &ColumnDefinition) -> Self { + let engine = |allowed: bool| Permission::unless(allowed, RefusalReason::EngineUnsupported); + let restatement = || Self::restatement(column); + let not_numbered = || { + Permission::unless( + column.attributes.identity == IdentityStyle::None && !column.info.is_auto_increment, + RefusalReason::IdentityColumn, + ) + }; + let referencing: Vec = table + .inbound_foreign_keys + .iter() + .filter(|key| key.referenced_columns.iter().any(|name| name == column.name())) + .map(|key| key.name.clone()) + .collect(); + Self { + rename: engine(capabilities.rename_column), + drop: engine(capabilities.drop_column).and(|| { + Permission::unless( + referencing.is_empty(), + RefusalReason::ReferencedByForeignKeys(referencing), + ) + }), + alter_type: engine(capabilities.alter_column_type).and(restatement), + alter_nullability: engine(capabilities.alter_column_nullability).and(restatement), + alter_default: engine(capabilities.alter_column_default) + .and(|| not_generated(column)) + .and(not_numbered) + .and(|| Self::modeled(column)), + alter_primary_key: primary_key_permission(capabilities, table), + alter_auto_increment: engine(capabilities.alter_auto_increment).and(restatement), + alter_comment: engine(capabilities.column_comments), + } + } + + /// Whether the column can be written out again whole: a generated + /// one would lose its expression, and one with an attribute this + /// build does not model would lose that. + pub fn restatement(column: &ColumnDefinition) -> Permission { + not_generated(column).and(|| Self::modeled(column)) + } + + /// Whether the catalogue reported only attributes this build can + /// write again. An engine that restates a generated column's + /// expression checks this rather than `restatement`. + pub fn modeled(column: &ColumnDefinition) -> Permission { + match column.attributes.unmodeled.first() { + Some(attribute) => Permission::Refused(RefusalReason::UnmodeledAttribute(attribute.name.clone())), + None => Permission::Allowed, + } + } +} + +fn not_generated(column: &ColumnDefinition) -> Permission { + Permission::unless( + !column.info.is_generated && column.attributes.generated.is_none(), + RefusalReason::GeneratedColumn, + ) +} + +/// Whether the table's primary key can move. A foreign key elsewhere +/// that points at the key holds its constraint in place: PostgreSQL +/// needs CASCADE to drop it, and MySQL, SQL Server and SQLite refuse +/// the change or leave the reference dangling. +pub fn primary_key_permission(capabilities: &DdlCapabilities, table: &TableDefinition) -> Permission { + let key_columns: Vec<&str> = match &table.primary_key { + Some(key) => key.columns.iter().map(String::as_str).collect(), + None => table + .columns + .iter() + .filter(|column| column.info.primary_key) + .map(ColumnDefinition::name) + .collect(), + }; + let referencing: Vec = table + .inbound_foreign_keys + .iter() + .filter(|key| { + key.referenced_columns + .iter() + .any(|name| key_columns.contains(&name.as_str())) + }) + .map(|key| key.name.clone()) + .collect(); + // The references come first: they hold the key on every engine, and + // naming them tells the user what to change. + Permission::unless( + referencing.is_empty(), + RefusalReason::ReferencedByForeignKeys(referencing), + ) + .and(|| Permission::unless(capabilities.alter_primary_key, RefusalReason::EngineUnsupported)) +} diff --git a/linux/crates/core/src/ddl/column_definition.rs b/linux/crates/core/src/ddl/column_definition.rs new file mode 100644 index 0000000000..9332357cc8 --- /dev/null +++ b/linux/crates/core/src/ddl/column_definition.rs @@ -0,0 +1,19 @@ +use super::ColumnAttributes; +use crate::column::ColumnInfo; + +/// A column as its table's DDL defines it. +#[derive(Debug, Clone, PartialEq)] +pub struct ColumnDefinition { + pub info: ColumnInfo, + pub attributes: ColumnAttributes, +} + +impl ColumnDefinition { + pub fn new(info: ColumnInfo, attributes: ColumnAttributes) -> Self { + Self { info, attributes } + } + + pub fn name(&self) -> &str { + &self.info.name + } +} diff --git a/linux/crates/core/src/ddl/column_key_roles.rs b/linux/crates/core/src/ddl/column_key_roles.rs new file mode 100644 index 0000000000..6257e5a34b --- /dev/null +++ b/linux/crates/core/src/ddl/column_key_roles.rs @@ -0,0 +1,10 @@ +/// The ClickHouse table keys a column is part of. +/// +/// A column in the sorting or partition key cannot be renamed, retyped +/// or dropped without rebuilding the table, so the structure tab has to +/// know before it offers any of that. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] +pub struct ColumnKeyRoles { + pub sorting_key: bool, + pub partition_key: bool, +} diff --git a/linux/crates/core/src/ddl/column_rename.rs b/linux/crates/core/src/ddl/column_rename.rs new file mode 100644 index 0000000000..b72d3d4e7f --- /dev/null +++ b/linux/crates/core/src/ddl/column_rename.rs @@ -0,0 +1,6 @@ +/// A kept column that changed its name. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct ColumnRename { + pub from: String, + pub to: String, +} diff --git a/linux/crates/core/src/ddl/column_storage.rs b/linux/crates/core/src/ddl/column_storage.rs new file mode 100644 index 0000000000..9b2cb9805e --- /dev/null +++ b/linux/crates/core/src/ddl/column_storage.rs @@ -0,0 +1,23 @@ +/// How PostgreSQL keeps a column's long values: inline or out of line, +/// compressed or not, as `SET STORAGE` names it. +/// +/// A type change resets it to the new type's own, so a column that had +/// one set keeps it only when the change restates it. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum ColumnStorage { + Plain, + Main, + External, + Extended, +} + +/// The method PostgreSQL compresses a column's values with, as +/// `SET COMPRESSION` names it. +/// +/// A type change resets it to `default_toast_compression`, so it is +/// restated the same way as the storage. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum ColumnCompression { + Pglz, + Lz4, +} diff --git a/linux/crates/core/src/ddl/column_visibility.rs b/linux/crates/core/src/ddl/column_visibility.rs new file mode 100644 index 0000000000..a48c281d90 --- /dev/null +++ b/linux/crates/core/src/ddl/column_visibility.rs @@ -0,0 +1,11 @@ +/// Whether `SELECT *` includes the column. +/// +/// MySQL and MariaDB can hide a column from `*`. A redefinition that +/// does not restate `INVISIBLE` makes it visible again, which changes +/// every `SELECT *` and `INSERT` without a column list. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] +pub enum ColumnVisibility { + #[default] + Visible, + Invisible, +} diff --git a/linux/crates/core/src/ddl/ddl_capabilities.rs b/linux/crates/core/src/ddl/ddl_capabilities.rs new file mode 100644 index 0000000000..2cb535f410 --- /dev/null +++ b/linux/crates/core/src/ddl/ddl_capabilities.rs @@ -0,0 +1,29 @@ +/// Which structure changes an engine can make, so the structure tab +/// offers only those instead of failing at Save. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct DdlCapabilities { + pub create_table: bool, + pub rename_table: bool, + pub add_column: bool, + pub drop_column: bool, + pub rename_column: bool, + pub alter_column_type: bool, + pub alter_column_nullability: bool, + pub alter_column_default: bool, + pub alter_primary_key: bool, + pub alter_auto_increment: bool, + /// Whether a column numbers itself outside the primary key, as a + /// PostgreSQL identity or a SQL Server IDENTITY does. Where it cannot, + /// numbering goes off as the column leaves the key. + pub auto_increment_outside_primary_key: bool, + pub create_index: bool, + pub drop_index: bool, + pub expression_index_keys: bool, + pub partial_indexes: bool, + pub add_foreign_key: bool, + pub drop_foreign_key: bool, + /// Whether a column can carry a comment. SQLite drops one from the + /// CREATE TABLE text, so it has nowhere to keep it. + pub column_comments: bool, + pub drop_table_cascade: bool, +} diff --git a/linux/crates/core/src/ddl/ddl_dialect.rs b/linux/crates/core/src/ddl/ddl_dialect.rs new file mode 100644 index 0000000000..d64d05aaa5 --- /dev/null +++ b/linux/crates/core/src/ddl/ddl_dialect.rs @@ -0,0 +1,77 @@ +use super::{ + BuildDdlError, ColumnCapabilities, ColumnDefinition, DdlCapabilities, DdlOperation, DdlPlan, DropTableOptions, + ReferentialAction, TableChanges, TableDefinition, TableDraft, +}; +use crate::dialect::SqlDialect; +use crate::meta::TableRef; + +/// How one engine plans structure changes. +/// +/// Reached through `SqlDialect::ddl`, which is `None` for an engine +/// whose structure the app does not edit. A plan is built against the +/// table's definition as one read returned it, so a statement that +/// restates a column, renames a key or drops a default by name has +/// everything it needs from the same moment. +/// +/// Every plan refuses what `capabilities` and `column_capabilities` +/// refuse, with the same reasons the structure tab shows. +pub trait DdlDialect: Send + Sync + std::fmt::Debug { + fn capabilities(&self) -> &DdlCapabilities; + + /// The dialect that quotes, binds and parses for this engine. + fn sql_dialect(&self) -> &dyn SqlDialect; + + /// What the engine takes for this table, where that depends on more + /// than the engine: the structure tab offers a table-wide control, + /// such as adding a foreign key, only where this allows it. + fn table_capabilities(&self, _table: &TableDefinition) -> DdlCapabilities { + *self.capabilities() + } + + fn column_capabilities(&self, table: &TableDefinition, column: &ColumnDefinition) -> ColumnCapabilities { + ColumnCapabilities::standard(self.capabilities(), table, column) + } + + /// Types offered in the structure tab, most common first. Each one + /// parses through `SqlDialect::parse_type`, and any other type can + /// still be typed. + fn type_suggestions(&self) -> &'static [&'static str]; + + /// The type a column added in the structure tab starts with. + fn default_column_type(&self) -> &'static str; + + /// The actions a foreign key may take, in the order the UI offers + /// them. The first is the SQL default. + fn referential_actions(&self) -> &'static [ReferentialAction] { + &ReferentialAction::ALL + } + + /// Whether a foreign key can reference a table in another schema. + /// Where it cannot, a key is offered only the tables of its own. + fn references_other_schemas(&self) -> bool { + true + } + + fn plan_create_table(&self, draft: &TableDraft) -> Result; + + fn plan_alter_table(&self, original: &TableDefinition, changes: &TableChanges) -> Result; + + fn plan_drop_table(&self, table: &TableRef, options: DropTableOptions) -> Result { + if options.cascade && !self.capabilities().drop_table_cascade { + return Err(BuildDdlError::Unsupported(DdlOperation::DropTableCascade)); + } + let if_exists = if options.if_exists { "IF EXISTS " } else { "" }; + let cascade = if options.cascade { " CASCADE" } else { "" }; + Ok(DdlPlan::from_sql([format!( + "DROP TABLE {if_exists}{}{cascade}", + self.sql_dialect().qualify(table) + )])) + } + + /// Statements that create the table as it is defined now: the + /// server's own text where the engine keeps one, otherwise written + /// from the definition. + fn create_table_script(&self, definition: &TableDefinition) -> Result { + self.plan_create_table(&TableDraft::from_definition(definition)) + } +} diff --git a/linux/crates/core/src/ddl/ddl_operation.rs b/linux/crates/core/src/ddl/ddl_operation.rs new file mode 100644 index 0000000000..7fa9561b58 --- /dev/null +++ b/linux/crates/core/src/ddl/ddl_operation.rs @@ -0,0 +1,57 @@ +use std::fmt; + +/// One kind of structure change, named in an error that refuses it. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum DdlOperation { + CreateTable, + AlterTable, + DropTable, + DropTableCascade, + RenameTable, + AddColumn, + /// SQLite refuses `ADD COLUMN ... NOT NULL` without a default, + /// because the rows already there would have no value. + AddRequiredColumnWithoutDefault, + DropColumn, + RenameColumn, + AlterColumnType, + AlterColumnNullability, + AlterColumnDefault, + AlterPrimaryKey, + AlterAutoIncrement, + SetColumnComment, + CreateIndex, + IndexExpressionKey, + PartialIndex, + DropIndex, + AddForeignKey, + DropForeignKey, +} + +impl fmt::Display for DdlOperation { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(match self { + Self::CreateTable => "creating a table", + Self::AlterTable => "changing a table", + Self::DropTable => "dropping a table", + Self::DropTableCascade => "dropping a table with its dependents", + Self::RenameTable => "renaming a table", + Self::AddColumn => "adding a column", + Self::AddRequiredColumnWithoutDefault => "adding a NOT NULL column without a default", + Self::DropColumn => "dropping a column", + Self::RenameColumn => "renaming a column", + Self::AlterColumnType => "changing a column's type", + Self::AlterColumnNullability => "changing whether a column takes NULL", + Self::AlterColumnDefault => "changing a column's default", + Self::AlterPrimaryKey => "changing the primary key", + Self::AlterAutoIncrement => "changing whether a column numbers itself", + Self::SetColumnComment => "setting a column comment", + Self::CreateIndex => "creating an index", + Self::IndexExpressionKey => "an index on an expression", + Self::PartialIndex => "a partial index", + Self::DropIndex => "dropping an index", + Self::AddForeignKey => "adding a foreign key", + Self::DropForeignKey => "dropping a foreign key", + }) + } +} diff --git a/linux/crates/core/src/ddl/ddl_plan.rs b/linux/crates/core/src/ddl/ddl_plan.rs new file mode 100644 index 0000000000..8b3b39d142 --- /dev/null +++ b/linux/crates/core/src/ddl/ddl_plan.rs @@ -0,0 +1,152 @@ +use super::{BuildDdlError, DdlOperation, DdlPrecondition, TableChanges, TableRebuild}; +use crate::foreign_key_mode::ForeignKeyMode; +use crate::meta::TableRef; +use crate::statement::{GuardedStatement, RowGuard, Statement, StatementBuilder}; +use crate::write_batch::{WriteBatch, WriteKind}; + +/// The statements a structure change runs, in order, whether foreign +/// keys stay on while they do, and what the schema has to hold first. +#[derive(Debug, Clone, PartialEq)] +pub struct DdlPlan { + pub statements: Vec, + pub foreign_keys: ForeignKeyMode, + pub precondition: Option, + /// Set where the plan copies the table into a new one, which the + /// review names before it runs. + pub rebuild: Option, +} + +impl DdlPlan { + /// SQL the engine's DDL dialect wrote, with every name quoted by it + /// and nothing to bind. + pub fn from_sql(statements: impl IntoIterator) -> Self { + Self { + statements: statements + .into_iter() + .map(|sql| { + let mut builder = StatementBuilder::new(); + builder.push_sql(&sql); + builder.finish() + }) + .collect(), + foreign_keys: ForeignKeyMode::Enforced, + precondition: None, + rebuild: None, + } + } + + /// The statements an engine wrote for a change to an existing table. + /// A change that came to no statement would save as done while the + /// table stayed as it was, so it is refused instead. + pub fn for_changes(changes: &TableChanges, statements: Vec) -> Result { + match statements.is_empty() && !changes.is_empty() { + true => Err(BuildDdlError::Unsupported(DdlOperation::AlterTable)), + false => Ok(Self::from_sql(statements)), + } + } + + pub fn is_empty(&self) -> bool { + self.statements.is_empty() + } + + pub fn len(&self) -> usize { + self.statements.len() + } + + pub fn sql(&self) -> Vec<&str> { + self.statements.iter().map(Statement::sql).collect() + } + + /// The plan as the user reads it before Save. + pub fn preview(&self) -> String { + script(self.sql()) + } + + /// Schema statements have no row count to prove. The precondition + /// guards the first one, so the write counts it inside its + /// transaction before anything runs, and a schema guard that fails + /// always means the table changed after the plan read it. + pub fn into_steps(self) -> Vec { + let mut steps: Vec = self.statements.into_iter().map(GuardedStatement::unguarded).collect(); + if let (Some(first), Some(precondition)) = (steps.first_mut(), self.precondition) { + first.guard = RowGuard::ProbeExactly { + probe: precondition.probe, + rows: precondition.rows, + }; + } + steps + } + + /// `table` is the table as it is named before the plan runs. + pub fn into_write_batch(self, table: TableRef) -> WriteBatch { + let foreign_keys = self.foreign_keys.clone(); + WriteBatch::new(WriteKind::SchemaChanges, table, self.into_steps(), foreign_keys) + } +} + +/// Statements as the user reads them: each ended and set apart. +pub(super) fn script<'a>(statements: impl IntoIterator) -> String { + statements + .into_iter() + .map(|statement| format!("{statement};")) + .collect::>() + .join("\n\n") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_plan_becomes_an_unguarded_schema_write() { + let plan = DdlPlan::from_sql(["ALTER TABLE t ADD COLUMN a text".to_owned(), "DROP INDEX i".to_owned()]); + + assert_eq!(plan.preview(), "ALTER TABLE t ADD COLUMN a text;\n\nDROP INDEX i;"); + let batch = plan.into_write_batch(TableRef::unqualified("t")); + + assert_eq!(batch.kind, WriteKind::SchemaChanges); + assert!(batch.foreign_keys.is_enforced()); + assert!(batch.steps.iter().all(|step| step.statement.params().is_empty())); + assert_eq!(batch.steps[1].statement.sql(), "DROP INDEX i"); + } + + #[test] + fn the_precondition_guards_the_first_statement_only() { + let mut builder = StatementBuilder::new(); + builder.push_sql("SELECT count(*) FROM sqlite_schema WHERE sql <> 'x'"); + let mut plan = DdlPlan::from_sql(["DROP TABLE t".to_owned(), "ALTER TABLE u RENAME TO t".to_owned()]); + plan.precondition = Some(DdlPrecondition { + probe: builder.finish(), + rows: 0, + }); + + let steps = plan.into_steps(); + + let Some((probe, rows)) = steps[0].guard.probe() else { + panic!("the first step is unguarded: {:?}", steps[0].guard); + }; + assert_eq!( + (probe.sql(), rows), + ("SELECT count(*) FROM sqlite_schema WHERE sql <> 'x'", 0) + ); + assert_eq!(steps[1].guard, RowGuard::None); + } + + #[test] + fn a_change_that_wrote_nothing_is_refused() { + let renamed = TableChanges { + renamed_table: Some("u".to_owned()), + ..TableChanges::default() + }; + + assert_eq!( + DdlPlan::for_changes(&renamed, Vec::new()), + Err(BuildDdlError::Unsupported(DdlOperation::AlterTable)) + ); + assert!(DdlPlan::for_changes(&TableChanges::default(), Vec::new()).is_ok_and(|plan| plan.is_empty())); + assert_eq!( + DdlPlan::for_changes(&renamed, vec!["ALTER TABLE t RENAME TO u".to_owned()]).map(|plan| plan.len()), + Ok(1) + ); + } +} diff --git a/linux/crates/core/src/ddl/ddl_precondition.rs b/linux/crates/core/src/ddl/ddl_precondition.rs new file mode 100644 index 0000000000..a451babc21 --- /dev/null +++ b/linux/crates/core/src/ddl/ddl_precondition.rs @@ -0,0 +1,12 @@ +use crate::statement::Statement; + +/// What the schema has to hold for a plan to apply, counted in the +/// write's transaction before its first statement. A plan that writes +/// a table again from the definition it was built from would otherwise +/// undo whatever changed the table after that read. +#[derive(Debug, Clone, PartialEq)] +pub struct DdlPrecondition { + pub probe: Statement, + /// The count the probe has to return. + pub rows: u64, +} diff --git a/linux/crates/core/src/ddl/ddl_sql.rs b/linux/crates/core/src/ddl/ddl_sql.rs new file mode 100644 index 0000000000..2d9b4acbbd --- /dev/null +++ b/linux/crates/core/src/ddl/ddl_sql.rs @@ -0,0 +1,143 @@ +//! The pieces of DDL most engines spell alike, each written through the +//! engine's own dialect. + +use super::BuildDdlError; +use crate::column::{ColumnDefault, ColumnType}; +use crate::dialect::SqlDialect; +use crate::foreign_key_info::ForeignKeyInfo; +use crate::meta::{IndexInfo, IndexKey, TableRef}; + +/// A literal default through the dialect's literal, and an expression as +/// the catalogue or the engine's parser gave it. `None` for no default. +pub fn default_sql( + dialect: &dyn SqlDialect, + column: &str, + default: &ColumnDefault, + column_type: &ColumnType, +) -> Result, BuildDdlError> { + match default { + ColumnDefault::None => Ok(None), + ColumnDefault::Literal(value) => { + dialect + .literal(value, column_type) + .map(Some) + .map_err(|error| BuildDdlError::Literal { + column: column.to_owned(), + error, + }) + } + ColumnDefault::Expression(expression) => Ok(Some(expression.as_sql().to_owned())), + } +} + +/// `expression` inside one pair of parentheses. The catalogue hands back +/// SQL Server and MySQL defaults already wrapped, and wrapping them again +/// would grow the text on every round trip. +pub fn parenthesized(expression: &str) -> String { + let text = expression.trim(); + match wrapped_once(text) { + true => text.to_owned(), + false => format!("({text})"), + } +} + +fn wrapped_once(text: &str) -> bool { + if !text.starts_with('(') || !text.ends_with(')') { + return false; + } + let mut depth = 0usize; + let mut quote: Option = None; + for (index, character) in text.char_indices() { + match (quote, character) { + (Some(open), _) if character == open => quote = None, + (Some(_), _) => {} + (None, '\'' | '"' | '`' | '[') => quote = Some(if character == '[' { ']' } else { character }), + (None, '(') => depth += 1, + (None, ')') => { + depth = depth.saturating_sub(1); + if depth == 0 && index + 1 < text.len() { + return false; + } + } + (None, _) => {} + } + } + depth == 0 +} + +pub fn column_list(dialect: &dyn SqlDialect, columns: &[String]) -> String { + columns + .iter() + .map(|column| dialect.quote_identifier(column)) + .collect::>() + .join(", ") +} + +/// An index's keys inside its parentheses. An expression key came from +/// the catalogue, and goes in parentheses of its own, which every engine +/// with expression keys reads as one key. +pub fn index_keys_sql(dialect: &dyn SqlDialect, index: &IndexInfo) -> String { + index + .keys + .iter() + .map(|key| { + let direction = if key.descending() { " DESC" } else { "" }; + match key { + IndexKey::Column { name, .. } => format!("{}{direction}", dialect.quote_identifier(name)), + IndexKey::Expression { sql, .. } => format!("{}{direction}", parenthesized(sql)), + } + }) + .collect::>() + .join(", ") +} + +/// `CREATE [UNIQUE] INDEX name ON table (keys) [WHERE predicate]`, with +/// the table qualified and the index name bare, which is how PostgreSQL, +/// MySQL and SQL Server read it. +pub fn create_index_sql(dialect: &dyn SqlDialect, table: &TableRef, index: &IndexInfo) -> String { + let unique = if index.unique { "UNIQUE " } else { "" }; + let mut sql = format!( + "CREATE {unique}INDEX {} ON {} ({})", + dialect.quote_identifier(&index.name), + dialect.qualify(table), + index_keys_sql(dialect, index) + ); + if let Some(predicate) = &index.predicate { + sql.push_str(&format!(" WHERE {predicate}")); + } + sql +} + +/// `CONSTRAINT name FOREIGN KEY (...) REFERENCES table (...)` and its +/// actions. A key with no schema of its own references the table's. +pub fn foreign_key_sql(dialect: &dyn SqlDialect, table: &TableRef, key: &ForeignKeyInfo) -> String { + let schema = key.ref_schema.clone().or_else(|| table.schema.clone()); + let referenced = TableRef::new(schema, key.ref_table.clone()); + let mut sql = format!( + "CONSTRAINT {} FOREIGN KEY ({}) REFERENCES {} ({})", + dialect.quote_identifier(&key.name), + column_list(dialect, &key.columns), + dialect.qualify(&referenced), + column_list(dialect, &key.ref_columns) + ); + for (clause, action) in [("ON DELETE", key.on_delete), ("ON UPDATE", key.on_update)] { + if let Some(action) = action.filter(|action| !action.is_default()) { + sql.push_str(&format!(" {clause} {}", action.as_sql())); + } + } + sql +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn an_expression_is_wrapped_once() { + assert_eq!(parenthesized("getdate()"), "(getdate())"); + assert_eq!(parenthesized("(getdate())"), "(getdate())"); + assert_eq!(parenthesized("(a) + (b)"), "((a) + (b))"); + assert_eq!(parenthesized("(N')(')"), "(N')(')"); + assert_eq!(parenthesized(" now() "), "(now())"); + } +} diff --git a/linux/crates/core/src/ddl/default_text.rs b/linux/crates/core/src/ddl/default_text.rs new file mode 100644 index 0000000000..3a2a83696a --- /dev/null +++ b/linux/crates/core/src/ddl/default_text.rs @@ -0,0 +1,49 @@ +use crate::column::{ColumnDefault, ColumnType}; +use crate::dialect::SqlDialect; +use crate::edit::parse_literal_text; +use crate::sql_syntax::{ExpressionSyntaxError, LiteralToken, MAX_EXPRESSION_LEN, parse_expression}; +use crate::value::Value; + +/// A default as the structure tab shows it: SQL the user could type +/// back in. +pub fn default_text(dialect: &dyn SqlDialect, default: &ColumnDefault, column_type: &ColumnType) -> String { + match default { + ColumnDefault::None => String::new(), + ColumnDefault::Literal(value) => dialect + .literal(value, column_type) + .ok() + .or_else(|| crate::export::value_to_text(value)) + .unwrap_or_default(), + ColumnDefault::Expression(expression) => expression.as_sql().to_owned(), + } +} + +/// SQL the user typed as a default, accepted only when the engine's +/// parser reads it as one expression. A plain literal becomes a value, +/// so the plan writes it back in the dialect's own literal form, as it +/// does a literal the catalogue reported. +pub fn parse_default( + dialect: &dyn SqlDialect, + text: &str, + column_type: &ColumnType, +) -> Result { + let text = text.trim(); + if text.is_empty() { + return Ok(ColumnDefault::None); + } + let expression = dialect.parse_expression(text)?; + let literal = parse_expression(dialect.grammar(), text, MAX_EXPRESSION_LEN) + .ok() + .and_then(|parsed| parsed.literal); + let literal_text = match literal { + None => return Ok(ColumnDefault::Expression(expression)), + Some(LiteralToken::Null) => return Ok(ColumnDefault::None), + Some(LiteralToken::Text(text)) => text, + Some(LiteralToken::Number(number)) => number.to_string(), + Some(LiteralToken::Boolean(flag)) => flag.to_string(), + }; + match parse_literal_text(&literal_text, column_type) { + Ok(Value::Null) | Err(_) => Ok(ColumnDefault::Expression(expression)), + Ok(value) => Ok(ColumnDefault::Literal(value)), + } +} diff --git a/linux/crates/core/src/ddl/destructive_change.rs b/linux/crates/core/src/ddl/destructive_change.rs new file mode 100644 index 0000000000..8a9d4ad6d5 --- /dev/null +++ b/linux/crates/core/src/ddl/destructive_change.rs @@ -0,0 +1,30 @@ +use super::TableRebuild; + +/// A part of a structure save that loses data, or drops something the +/// save has to make again, named in the review before it runs. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum DestructiveChange { + /// The rows are copied into a new table and the old one dropped. + RebuildTable(TableRebuild), + DropColumn { + column: String, + }, + /// The values the column holds are converted, and a value the new + /// type cannot take is changed or refused. + ChangeColumnType { + column: String, + from: String, + to: String, + }, + /// A NULL already in the column has to go: the server refuses the + /// change, or on some engines puts a default value in its place. + SetNotNull { + column: String, + }, + DropIndex { + index: String, + }, + DropForeignKey { + key: String, + }, +} diff --git a/linux/crates/core/src/ddl/diff_table.rs b/linux/crates/core/src/ddl/diff_table.rs new file mode 100644 index 0000000000..eb68f63f4a --- /dev/null +++ b/linux/crates/core/src/ddl/diff_table.rs @@ -0,0 +1,112 @@ +use std::collections::{BTreeSet, HashMap, HashSet}; + +use super::{ + ChangedAttributes, ColumnAlteration, ColumnRename, PrimaryKeyChange, PrimaryKeyConstraint, TableChanges, + TableDefinition, TableDraft, +}; + +/// What the draft changed about the table it started from. +/// +/// Columns are matched by the name they had when the table was read, so +/// a rename is a rename and not a drop and an add. Indexes and foreign +/// keys are matched whole: one that changed is dropped and added again, +/// since no engine alters either in place. +pub fn diff_table(original: &TableDefinition, draft: &TableDraft) -> TableChanges { + let name = draft.table.name.trim(); + let renamed_table = (!name.is_empty() && name != original.table.name).then(|| name.to_owned()); + + let kept: HashSet<&str> = draft + .columns + .iter() + .filter_map(|column| column.original_name()) + .collect(); + let dropped_columns = original + .columns + .iter() + .filter(|column| !kept.contains(column.name())) + .cloned() + .collect(); + let mut renamed_columns = Vec::new(); + let mut altered_columns = Vec::new(); + let mut added_columns = Vec::new(); + for column in &draft.columns { + let Some(before) = &column.original else { + added_columns.push(column.clone()); + continue; + }; + if before.name() != column.name { + renamed_columns.push(ColumnRename { + from: before.name().to_owned(), + to: column.name.clone(), + }); + } + let changed = ChangedAttributes::between(before, column); + if changed.any() { + altered_columns.push(ColumnAlteration { + original: before.clone(), + draft: column.clone(), + changed, + }); + } + } + + let secondary = |indexes: &[crate::meta::IndexInfo], other: &[crate::meta::IndexInfo]| { + indexes + .iter() + .filter(|index| !index.primary && !other.contains(index)) + .cloned() + .collect::>() + }; + let missing = |keys: &[crate::ForeignKeyInfo], other: &[crate::ForeignKeyInfo]| { + keys.iter() + .filter(|key| !other.contains(key)) + .cloned() + .collect::>() + }; + + TableChanges { + renamed_table, + dropped_foreign_keys: missing(&original.foreign_keys, &draft.foreign_keys), + dropped_indexes: secondary(&original.indexes, &draft.indexes), + dropped_columns, + primary_key: primary_key_change(original, draft, &renamed_columns), + renamed_columns, + altered_columns, + added_columns, + added_indexes: secondary(&draft.indexes, &original.indexes), + added_foreign_keys: missing(&draft.foreign_keys, &original.foreign_keys), + } +} + +/// The key compared as a set of columns under their new names: listing +/// the same columns in another order is not a change the user made. +fn primary_key_change( + original: &TableDefinition, + draft: &TableDraft, + renames: &[ColumnRename], +) -> Option { + let renamed: HashMap<&str, &str> = renames + .iter() + .map(|rename| (rename.from.as_str(), rename.to.as_str())) + .collect(); + let from = original.primary_key.clone().or_else(|| { + let columns: Vec = original + .columns + .iter() + .filter(|column| column.info.primary_key) + .map(|column| column.name().to_owned()) + .collect(); + (!columns.is_empty()).then_some(PrimaryKeyConstraint { name: None, columns }) + }); + let before: BTreeSet<&str> = from + .iter() + .flat_map(|key| key.columns.iter()) + .map(|name| renamed.get(name.as_str()).copied().unwrap_or(name)) + .collect(); + let to = draft.primary_key(); + let after: BTreeSet<&str> = to.iter().copied().collect(); + (before != after).then(|| PrimaryKeyChange { + from, + to: to.into_iter().map(str::to_owned).collect(), + }) +} diff --git a/linux/crates/core/src/ddl/draft_column.rs b/linux/crates/core/src/ddl/draft_column.rs new file mode 100644 index 0000000000..f76d1baaed --- /dev/null +++ b/linux/crates/core/src/ddl/draft_column.rs @@ -0,0 +1,55 @@ +use super::{ColumnDefinition, DraftType}; +use crate::column::ColumnDefault; + +/// A column as the user is editing it. +#[derive(Debug, Clone, PartialEq)] +pub struct DraftColumn { + /// The column this draft started from, `None` for one the user added. + pub original: Option, + pub name: String, + pub column_type: DraftType, + pub nullable: bool, + pub primary_key: bool, + pub auto_increment: bool, + pub default: ColumnDefault, + pub comment: Option, +} + +impl DraftColumn { + pub fn new(name: impl Into, column_type: DraftType) -> Self { + Self { + original: None, + name: name.into(), + column_type, + nullable: true, + primary_key: false, + auto_increment: false, + default: ColumnDefault::None, + comment: None, + } + } + + pub fn from_definition(definition: ColumnDefinition) -> Self { + let info = &definition.info; + Self { + name: info.name.clone(), + column_type: DraftType::Catalog(info.column_type.clone()), + nullable: info.nullable, + primary_key: info.primary_key, + auto_increment: info.is_auto_increment, + default: info.default.clone(), + comment: info.comment.clone(), + original: Some(definition), + } + } + + pub fn original_name(&self) -> Option<&str> { + self.original.as_ref().map(ColumnDefinition::name) + } + + /// An empty entry is the user saying "no comment", not a comment that + /// happens to be empty. + pub fn comment_text(&self) -> Option<&str> { + self.comment.as_deref().filter(|text| !text.is_empty()) + } +} diff --git a/linux/crates/core/src/ddl/draft_problem.rs b/linux/crates/core/src/ddl/draft_problem.rs new file mode 100644 index 0000000000..a005cf5c67 --- /dev/null +++ b/linux/crates/core/src/ddl/draft_problem.rs @@ -0,0 +1,31 @@ +use thiserror::Error; + +/// Why a table draft cannot be saved as it stands, found before any SQL +/// is written. +#[derive(Debug, Clone, PartialEq, Eq, Hash, Error)] +pub enum DraftProblem { + #[error("the table name is empty")] + EmptyTableName, + #[error("the table has no columns")] + NoColumns, + #[error("a column has no name")] + EmptyColumnName, + #[error("two columns are named {0}")] + DuplicateColumnName(String), + #[error("column {0} has no type")] + EmptyColumnType(String), + #[error("primary key column {0} takes NULL")] + NullablePrimaryKey(String), + /// An index or foreign key the draft keeps names a column it no + /// longer has. + #[error("column {column} is gone but {} still use it", used_by.join(", "))] + ColumnInUse { column: String, used_by: Vec }, + #[error("an index has no name")] + EmptyIndexName, + #[error("index {0} has no keys")] + IndexWithoutKeys(String), + #[error("a foreign key has no name")] + EmptyForeignKeyName, + #[error("foreign key {0} pairs a different number of columns on each side")] + ForeignKeyColumnMismatch(String), +} diff --git a/linux/crates/core/src/ddl/draft_type.rs b/linux/crates/core/src/ddl/draft_type.rs new file mode 100644 index 0000000000..2755c4effd --- /dev/null +++ b/linux/crates/core/src/ddl/draft_type.rs @@ -0,0 +1,69 @@ +use super::BuildDdlError; +use crate::column::ColumnType; +use crate::dialect::SqlDialect; + +/// A draft column's type: the catalogue's own, or text the user typed. +/// +/// The catalogue's declaration is spliced as the server gave it and is +/// never parsed again. Typed text goes through the engine's parser when +/// it is planned, and is spliced exactly as typed once it passes. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum DraftType { + Catalog(ColumnType), + Typed(String), +} + +/// A draft type ready to splice, with what the engine says it holds. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ResolvedType { + pub sql: String, + pub column_type: ColumnType, +} + +impl DraftType { + /// The text the user sees and edits. Empty for a catalogue type the + /// server did not spell. + pub fn text(&self) -> &str { + match self { + Self::Catalog(column_type) => column_type + .catalog + .as_ref() + .map_or("", |catalog| catalog.declaration.as_sql()), + Self::Typed(text) => text.trim(), + } + } + + /// What the column holds, for writing a default in its literal form. + /// A catalogue type needs no declaration for that. + pub fn column_type(&self, dialect: &dyn SqlDialect, column: &str) -> Result { + match self { + Self::Catalog(column_type) => Ok(column_type.clone()), + Self::Typed(_) => self.resolve(dialect, column).map(|resolved| resolved.column_type), + } + } + + pub fn resolve(&self, dialect: &dyn SqlDialect, column: &str) -> Result { + match self { + Self::Catalog(column_type) => { + let catalog = column_type + .catalog + .as_ref() + .ok_or_else(|| BuildDdlError::MissingCatalogDeclaration(column.to_owned()))?; + Ok(ResolvedType { + sql: catalog.declaration.as_sql().to_owned(), + column_type: column_type.clone(), + }) + } + Self::Typed(text) => { + let column_type = dialect.parse_type(text).map_err(|error| BuildDdlError::InvalidType { + column: column.to_owned(), + error, + })?; + Ok(ResolvedType { + sql: text.trim().to_owned(), + column_type, + }) + } + } + } +} diff --git a/linux/crates/core/src/ddl/drop_table_options.rs b/linux/crates/core/src/ddl/drop_table_options.rs new file mode 100644 index 0000000000..6727c6ad5e --- /dev/null +++ b/linux/crates/core/src/ddl/drop_table_options.rs @@ -0,0 +1,8 @@ +/// How a table is dropped. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] +pub struct DropTableOptions { + pub if_exists: bool, + /// Drop the objects that depend on the table too. Only an engine + /// with `drop_table_cascade` takes it. + pub cascade: bool, +} diff --git a/linux/crates/core/src/ddl/generated_column.rs b/linux/crates/core/src/ddl/generated_column.rs new file mode 100644 index 0000000000..df609797d2 --- /dev/null +++ b/linux/crates/core/src/ddl/generated_column.rs @@ -0,0 +1,19 @@ +use crate::column::SqlExpression; + +/// Whether a generated column's value is kept on disk. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum GeneratedStorage { + Virtual, + /// Computed on write and stored. MariaDB's `PERSISTENT` is this. + Stored, +} + +/// A column the server computes from an expression. +/// +/// The expression is kept because a redefinition that drops it turns +/// the column into a plain one and loses every computed value. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct GeneratedColumn { + pub expression: SqlExpression, + pub storage: GeneratedStorage, +} diff --git a/linux/crates/core/src/ddl/identity_style.rs b/linux/crates/core/src/ddl/identity_style.rs new file mode 100644 index 0000000000..50f8d72ae3 --- /dev/null +++ b/linux/crates/core/src/ddl/identity_style.rs @@ -0,0 +1,53 @@ +/// How the server fills a column with the next number. +/// +/// The styles are kept apart because each is restated with different +/// DDL, and a redefinition that restates the wrong one either fails or +/// silently stops the numbering. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] +pub enum IdentityStyle { + #[default] + None, + /// `GENERATED ALWAYS AS IDENTITY`: an explicit value is refused. + GeneratedAlways, + /// `GENERATED BY DEFAULT AS IDENTITY`: an explicit value wins. + GeneratedByDefault, + /// A default of `nextval(...)` on an owned sequence, which is what + /// PostgreSQL's `serial` types expand to. + SequenceDefault, + /// MySQL and MariaDB `AUTO_INCREMENT`. + AutoIncrementKeyword, + /// SQLite `INTEGER PRIMARY KEY`, which names the rowid rather than + /// adding a column. + SqliteRowidAlias, + /// SQL Server `IDENTITY(seed, increment)`. + MssqlIdentity, +} + +impl IdentityStyle { + /// Whether a plain INSERT may give the column a value of its own. + /// GENERATED ALWAYS needs `OVERRIDING SYSTEM VALUE` and SQL Server + /// IDENTITY needs `IDENTITY_INSERT`, so both refuse one. + pub fn accepts_explicit_value(self) -> bool { + !matches!(self, Self::GeneratedAlways | Self::MssqlIdentity) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn only_the_identities_that_need_an_override_refuse_a_value() { + assert!(!IdentityStyle::GeneratedAlways.accepts_explicit_value()); + assert!(!IdentityStyle::MssqlIdentity.accepts_explicit_value()); + for style in [ + IdentityStyle::None, + IdentityStyle::GeneratedByDefault, + IdentityStyle::SequenceDefault, + IdentityStyle::AutoIncrementKeyword, + IdentityStyle::SqliteRowidAlias, + ] { + assert!(style.accepts_explicit_value(), "{style:?}"); + } + } +} diff --git a/linux/crates/core/src/ddl/inbound_foreign_key.rs b/linux/crates/core/src/ddl/inbound_foreign_key.rs new file mode 100644 index 0000000000..52c1851516 --- /dev/null +++ b/linux/crates/core/src/ddl/inbound_foreign_key.rs @@ -0,0 +1,16 @@ +use crate::meta::TableRef; + +/// A foreign key on another table that points at this one. +/// +/// A rename, a retype or a drop of a referenced column breaks these, so +/// the structure tab lists them before it lets the change through. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct InboundForeignKey { + pub name: String, + /// The referencing table. + pub table: TableRef, + /// The referencing columns, in key order. + pub columns: Vec, + /// The columns of this table they point at, in the same order. + pub referenced_columns: Vec, +} diff --git a/linux/crates/core/src/ddl/mod.rs b/linux/crates/core/src/ddl/mod.rs new file mode 100644 index 0000000000..120918d60d --- /dev/null +++ b/linux/crates/core/src/ddl/mod.rs @@ -0,0 +1,89 @@ +//! Structure changes: the definition of a table, a draft of it the user +//! edits, the changes between the two, and the per-engine plans that +//! turn them into DDL. + +mod build_ddl_error; +mod change_review; +mod changed_attributes; +mod collation_name; +mod column_alteration; +mod column_attributes; +mod column_capabilities; +mod column_definition; +mod column_key_roles; +mod column_rename; +mod column_storage; +mod column_visibility; +mod ddl_capabilities; +mod ddl_dialect; +mod ddl_operation; +mod ddl_plan; +mod ddl_precondition; +pub mod ddl_sql; +mod default_text; +mod destructive_change; +mod diff_table; +mod draft_column; +mod draft_problem; +mod draft_type; +mod drop_table_options; +mod generated_column; +mod identity_style; +mod inbound_foreign_key; +mod order_renames; +mod permission; +mod primary_key_change; +mod primary_key_constraint; +mod referential_action; +mod refusal_reason; +mod schema_object_source; +mod table_changes; +mod table_definition; +mod table_draft; +mod table_rebuild; +mod table_source; +mod unmodeled_attribute; + +#[cfg(test)] +mod tests; + +pub use build_ddl_error::BuildDdlError; +pub use change_review::ChangeReview; +pub use changed_attributes::ChangedAttributes; +pub use collation_name::CollationName; +pub use column_alteration::ColumnAlteration; +pub use column_attributes::ColumnAttributes; +pub use column_capabilities::{ColumnCapabilities, primary_key_permission}; +pub use column_definition::ColumnDefinition; +pub use column_key_roles::ColumnKeyRoles; +pub use column_rename::ColumnRename; +pub use column_storage::{ColumnCompression, ColumnStorage}; +pub use column_visibility::ColumnVisibility; +pub use ddl_capabilities::DdlCapabilities; +pub use ddl_dialect::DdlDialect; +pub use ddl_operation::DdlOperation; +pub use ddl_plan::DdlPlan; +pub use ddl_precondition::DdlPrecondition; +pub use default_text::{default_text, parse_default}; +pub use destructive_change::DestructiveChange; +pub use diff_table::diff_table; +pub use draft_column::DraftColumn; +pub use draft_problem::DraftProblem; +pub use draft_type::{DraftType, ResolvedType}; +pub use drop_table_options::DropTableOptions; +pub use generated_column::{GeneratedColumn, GeneratedStorage}; +pub use identity_style::IdentityStyle; +pub use inbound_foreign_key::InboundForeignKey; +pub use order_renames::order_renames; +pub use permission::Permission; +pub use primary_key_change::PrimaryKeyChange; +pub use primary_key_constraint::PrimaryKeyConstraint; +pub use referential_action::ReferentialAction; +pub use refusal_reason::RefusalReason; +pub use schema_object_source::{SchemaObjectKind, SchemaObjectSource}; +pub use table_changes::TableChanges; +pub use table_definition::TableDefinition; +pub use table_draft::TableDraft; +pub use table_rebuild::{RecreatedObject, TableRebuild}; +pub use table_source::TableSource; +pub use unmodeled_attribute::UnmodeledAttribute; diff --git a/linux/crates/core/src/ddl/order_renames.rs b/linux/crates/core/src/ddl/order_renames.rs new file mode 100644 index 0000000000..c6a53a015d --- /dev/null +++ b/linux/crates/core/src/ddl/order_renames.rs @@ -0,0 +1,260 @@ +use std::collections::{HashMap, HashSet}; + +use super::ColumnRename; + +const TEMPORARY_PREFIX: &str = "__tablepro_rename_"; + +/// The renames in an order that runs them one at a time, each into a +/// name that is free when it runs. A swap, or any longer cycle, has no +/// such order, so one column of it steps aside to a temporary +/// `__tablepro_rename_` name first and takes its new name last. +/// +/// `occupied` holds the columns the table has when the renames start, +/// the renamed ones included. `added` holds the columns added after +/// them, which a temporary name keeps clear of, as it does every name +/// the renames start from or end with. +/// +/// A rename into a name a column keeps until after the renames cannot +/// be ordered, and is left for the server to refuse. +/// +/// Names match without regard to case: MySQL, SQL Server and SQLite +/// compare column names that way, and the extra step this can cost a +/// case-sensitive engine changes nothing it ends with. A rename that +/// changes only case waits while another column holds the name in some +/// case, which only a case-sensitive engine allows. +pub fn order_renames<'a>( + renames: &[ColumnRename], + occupied: impl IntoIterator, + added: impl IntoIterator, +) -> Vec { + let mut pending: Vec = renames + .iter() + .filter(|rename| rename.from != rename.to) + .cloned() + .collect(); + let mut held = Held::default(); + for name in occupied { + held.insert(name); + } + for rename in &pending { + if !held.contains(&rename.from) { + held.insert(&rename.from); + } + } + let mut reserved: HashSet = held.counts.keys().cloned().collect(); + reserved.extend(added.into_iter().map(fold)); + reserved.extend(pending.iter().map(|rename| fold(&rename.to))); + + let mut ordered = Vec::with_capacity(pending.len()); + let mut next_temporary = 1usize; + while !pending.is_empty() { + if let Some(index) = pending.iter().position(|rename| is_free(&held, rename)) { + let rename = pending.remove(index); + held.remove(&rename.from); + held.insert(&rename.to); + ordered.push(rename); + continue; + } + let Some(index) = cycle_member(&pending) else { + ordered.append(&mut pending); + break; + }; + let temporary = loop { + let name = format!("{TEMPORARY_PREFIX}{next_temporary}"); + next_temporary += 1; + if reserved.insert(fold(&name)) { + break name; + } + }; + let rename = &mut pending[index]; + held.remove(&rename.from); + held.insert(&temporary); + ordered.push(ColumnRename { + from: std::mem::replace(&mut rename.from, temporary.clone()), + to: temporary, + }); + } + ordered +} + +/// A rename into its own name in other case frees what it takes, unless +/// another column holds that name too. +fn is_free(held: &Held, rename: &ColumnRename) -> bool { + !held.contains(&rename.to) || (same(&rename.from, &rename.to) && held.count(&rename.to) == 1) +} + +/// A rename whose new name another pending rename starts from, and so on +/// back to it. Each name starts at most one rename, so following the +/// names either comes back or runs out. +fn cycle_member(pending: &[ColumnRename]) -> Option { + let blocker = |index: usize| pending.iter().position(|other| same(&other.from, &pending[index].to)); + (0..pending.len()).find(|&start| { + let mut current = start; + for _ in 0..pending.len() { + match blocker(current) { + Some(next) if next == start => return true, + Some(next) => current = next, + None => return false, + } + } + false + }) +} + +fn fold(name: &str) -> String { + name.to_lowercase() +} + +fn same(left: &str, right: &str) -> bool { + fold(left) == fold(right) +} + +/// Column names in use, counted, since a case-sensitive engine can hold +/// two that fold to one. +#[derive(Default)] +struct Held { + counts: HashMap, +} + +impl Held { + fn insert(&mut self, name: &str) { + *self.counts.entry(fold(name)).or_default() += 1; + } + + fn remove(&mut self, name: &str) { + let key = fold(name); + if let Some(count) = self.counts.get_mut(&key) { + *count -= 1; + if *count == 0 { + self.counts.remove(&key); + } + } + } + + fn contains(&self, name: &str) -> bool { + self.counts.contains_key(&fold(name)) + } + + fn count(&self, name: &str) -> usize { + self.counts.get(&fold(name)).copied().unwrap_or_default() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn rename(from: &str, to: &str) -> ColumnRename { + ColumnRename { + from: from.to_owned(), + to: to.to_owned(), + } + } + + fn steps(ordered: &[ColumnRename]) -> Vec<(&str, &str)> { + ordered + .iter() + .map(|rename| (rename.from.as_str(), rename.to.as_str())) + .collect() + } + + #[test] + fn order_renames_simple() { + let ordered = order_renames( + &[rename("email", "mail"), rename("name", "full_name")], + ["id", "email", "name"], + [], + ); + + assert_eq!(steps(&ordered), [("email", "mail"), ("name", "full_name")]); + } + + #[test] + fn order_renames_runs_a_chain_from_its_free_end() { + let ordered = order_renames(&[rename("a", "b"), rename("b", "c")], ["a", "b"], []); + + assert_eq!(steps(&ordered), [("b", "c"), ("a", "b")]); + } + + #[test] + fn order_renames_swap_uses_temporary_name() { + let ordered = order_renames(&[rename("a", "b"), rename("b", "a")], ["id", "a", "b"], []); + + assert_eq!( + steps(&ordered), + [("a", "__tablepro_rename_1"), ("b", "a"), ("__tablepro_rename_1", "b")] + ); + } + + #[test] + fn order_renames_breaks_a_three_way_cycle_once() { + let ordered = order_renames( + &[rename("a", "b"), rename("b", "c"), rename("c", "a")], + ["a", "b", "c"], + [], + ); + + assert_eq!( + steps(&ordered), + [ + ("a", "__tablepro_rename_1"), + ("c", "a"), + ("b", "c"), + ("__tablepro_rename_1", "b"), + ] + ); + } + + #[test] + fn order_renames_avoids_added_column_name() { + let ordered = order_renames( + &[rename("a", "b"), rename("b", "a")], + ["a", "b", "__tablepro_rename_1"], + ["__TablePro_Rename_2"], + ); + + assert_eq!( + steps(&ordered), + [("a", "__tablepro_rename_3"), ("b", "a"), ("__tablepro_rename_3", "b")] + ); + } + + #[test] + fn order_renames_matches_names_without_case() { + let case_only = order_renames(&[rename("Email", "email")], ["id", "Email"], []); + let swapped = order_renames(&[rename("a", "B"), rename("b", "A")], ["a", "b"], []); + + assert_eq!(steps(&case_only), [("Email", "email")]); + assert_eq!( + steps(&swapped), + [("a", "__tablepro_rename_1"), ("b", "A"), ("__tablepro_rename_1", "B")] + ); + } + + #[test] + fn order_renames_keeps_names_that_differ_only_in_case_apart() { + let freed_first = order_renames( + &[rename("Name", "name"), rename("name", "old")], + ["id", "Name", "name"], + [], + ); + let swapped = order_renames(&[rename("Name", "name"), rename("name", "Name")], ["Name", "name"], []); + + assert_eq!(steps(&freed_first), [("name", "old"), ("Name", "name")]); + assert_eq!( + steps(&swapped), + [ + ("Name", "__tablepro_rename_1"), + ("name", "Name"), + ("__tablepro_rename_1", "name"), + ] + ); + } + + #[test] + fn order_renames_leaves_a_name_still_held_to_the_server() { + let ordered = order_renames(&[rename("a", "b"), rename("c", "d")], ["a", "b", "c"], []); + + assert_eq!(steps(&ordered), [("c", "d"), ("a", "b")]); + } +} diff --git a/linux/crates/core/src/ddl/permission.rs b/linux/crates/core/src/ddl/permission.rs new file mode 100644 index 0000000000..f3a5f08fb9 --- /dev/null +++ b/linux/crates/core/src/ddl/permission.rs @@ -0,0 +1,38 @@ +use super::RefusalReason; + +/// Whether one change to one column can be made. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum Permission { + Allowed, + Refused(RefusalReason), +} + +impl Permission { + pub fn is_allowed(&self) -> bool { + matches!(self, Self::Allowed) + } + + pub fn refusal(&self) -> Option<&RefusalReason> { + match self { + Self::Allowed => None, + Self::Refused(reason) => Some(reason), + } + } + + /// `Allowed` when `allowed` holds, otherwise refused for `reason`. + pub fn unless(allowed: bool, reason: RefusalReason) -> Self { + match allowed { + true => Self::Allowed, + false => Self::Refused(reason), + } + } + + /// The first refusal of the two. A change needs every check to pass, + /// and the user is told the first one that did not. + pub fn and(self, then: impl FnOnce() -> Permission) -> Self { + match self { + Self::Allowed => then(), + refused => refused, + } + } +} diff --git a/linux/crates/core/src/ddl/primary_key_change.rs b/linux/crates/core/src/ddl/primary_key_change.rs new file mode 100644 index 0000000000..8eb82143ca --- /dev/null +++ b/linux/crates/core/src/ddl/primary_key_change.rs @@ -0,0 +1,11 @@ +use super::PrimaryKeyConstraint; + +/// The primary key moving to other columns, or away. +/// +/// `to` names the columns as they are after every rename, and is empty +/// when the table is left without a key. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct PrimaryKeyChange { + pub from: Option, + pub to: Vec, +} diff --git a/linux/crates/core/src/ddl/primary_key_constraint.rs b/linux/crates/core/src/ddl/primary_key_constraint.rs new file mode 100644 index 0000000000..242a5964fb --- /dev/null +++ b/linux/crates/core/src/ddl/primary_key_constraint.rs @@ -0,0 +1,9 @@ +/// A table's primary key as a constraint, with its name. +/// +/// The name matters for DDL that drops or restates the key. It is +/// `None` on engines that do not name it, such as SQLite. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct PrimaryKeyConstraint { + pub name: Option, + pub columns: Vec, +} diff --git a/linux/crates/core/src/ddl/referential_action.rs b/linux/crates/core/src/ddl/referential_action.rs new file mode 100644 index 0000000000..fe5a1a0a2b --- /dev/null +++ b/linux/crates/core/src/ddl/referential_action.rs @@ -0,0 +1,51 @@ +/// What a foreign key does to the referencing rows. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] +pub enum ReferentialAction { + /// The SQL default. Generated DDL omits the clause entirely. + #[default] + NoAction, + Restrict, + Cascade, + SetNull, + SetDefault, +} + +impl ReferentialAction { + pub const ALL: [ReferentialAction; 5] = [ + ReferentialAction::NoAction, + ReferentialAction::Restrict, + ReferentialAction::Cascade, + ReferentialAction::SetNull, + ReferentialAction::SetDefault, + ]; + + pub fn as_sql(self) -> &'static str { + match self { + Self::NoAction => "NO ACTION", + Self::Restrict => "RESTRICT", + Self::Cascade => "CASCADE", + Self::SetNull => "SET NULL", + Self::SetDefault => "SET DEFAULT", + } + } + + /// `NO ACTION` is the default, so generated DDL leaves it out. + pub fn is_default(self) -> bool { + matches!(self, Self::NoAction) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn as_sql_is_the_standard_spelling() { + assert_eq!(ReferentialAction::NoAction.as_sql(), "NO ACTION"); + assert_eq!(ReferentialAction::SetNull.as_sql(), "SET NULL"); + assert_eq!(ReferentialAction::SetDefault.as_sql(), "SET DEFAULT"); + assert_eq!(ReferentialAction::default(), ReferentialAction::NoAction); + assert!(ReferentialAction::NoAction.is_default()); + assert!(ReferentialAction::ALL.iter().filter(|a| a.is_default()).count() == 1); + } +} diff --git a/linux/crates/core/src/ddl/refusal_reason.rs b/linux/crates/core/src/ddl/refusal_reason.rs new file mode 100644 index 0000000000..cf566e738f --- /dev/null +++ b/linux/crates/core/src/ddl/refusal_reason.rs @@ -0,0 +1,66 @@ +use std::fmt; + +/// Why one change to one column cannot be made. +/// +/// The structure tab shows this beside the control it turns off, so the +/// user learns why before Save rather than from the server afterwards. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum RefusalReason { + /// The engine has no statement for it. + EngineUnsupported, + /// A ClickHouse sorting or partition key column, which only a table + /// rebuild can change. + KeyColumn, + /// The server computes the column, and restating it would drop the + /// expression. + GeneratedColumn, + /// The server numbers the column, which rules out a default. + IdentityColumn, + /// Only an integer column can number itself. + NotAnInteger, + /// The engine numbers the column for as long as it is the table's + /// integer key, as SQLite does its rowid column. + AlwaysNumbered, + /// The column carries an attribute this build does not model, which a + /// restated definition would drop. + UnmodeledAttribute(String), + /// Indexes or foreign keys still use the column. + ColumnInUse(Vec), + /// Foreign keys on other tables point at the column or at the primary + /// key it belongs to. + ReferencedByForeignKeys(Vec), + /// The catalogue gave no definition the change could restate. + UnreadableDefinition, + /// The column has an attribute the change would break, such as + /// SPARSE on a SQL Server column made NOT NULL. + ConflictingAttribute(String), + /// The restated definition names a column the same change renames. + /// The server reads it against the new names, and the catalogue's + /// text still has the old one. + NamesRenamedColumn(String), + /// Triggers insert into the table without naming its columns, so they + /// take one value per column and break when one is added or dropped. + InsertedByPosition(Vec), +} + +impl fmt::Display for RefusalReason { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::EngineUnsupported => f.write_str("the engine cannot make this change"), + Self::KeyColumn => f.write_str("the column is part of the table's sorting or partition key"), + Self::GeneratedColumn => f.write_str("the server computes the column"), + Self::IdentityColumn => f.write_str("the server numbers the column"), + Self::NotAnInteger => f.write_str("only an integer column can number itself"), + Self::AlwaysNumbered => f.write_str("the engine numbers the column while it is the table's integer key"), + Self::UnmodeledAttribute(name) => write!(f, "the column has an attribute this app cannot restate: {name}"), + Self::ColumnInUse(users) => write!(f, "still used by {}", users.join(", ")), + Self::ReferencedByForeignKeys(keys) => write!(f, "referenced by foreign keys {}", keys.join(", ")), + Self::UnreadableDefinition => f.write_str("the catalogue gave no definition to restate"), + Self::ConflictingAttribute(name) => write!(f, "the column's {name} does not allow it"), + Self::NamesRenamedColumn(name) => write!(f, "its definition names {name}, which the same change renames"), + Self::InsertedByPosition(triggers) => { + write!(f, "triggers {} insert into the table by position", triggers.join(", ")) + } + } + } +} diff --git a/linux/crates/core/src/ddl/schema_object_source.rs b/linux/crates/core/src/ddl/schema_object_source.rs new file mode 100644 index 0000000000..bb4b9d2338 --- /dev/null +++ b/linux/crates/core/src/ddl/schema_object_source.rs @@ -0,0 +1,22 @@ +/// What kind of object a dependent is. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum SchemaObjectKind { + Index, + Trigger, + View, +} + +/// An object that has to be recreated when its table is rebuilt. +/// +/// SQLite rebuilds a table to change it, and dropping the old table +/// drops its indexes and triggers with it, so the rebuild replays each +/// one from the SQL the schema stored. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct SchemaObjectSource { + pub kind: SchemaObjectKind, + pub name: String, + /// The table the object belongs to, which for a view or a trigger on + /// another table is not the one being rebuilt. + pub table: String, + pub sql: String, +} diff --git a/linux/crates/core/src/ddl/table_changes.rs b/linux/crates/core/src/ddl/table_changes.rs new file mode 100644 index 0000000000..a876498a5f --- /dev/null +++ b/linux/crates/core/src/ddl/table_changes.rs @@ -0,0 +1,207 @@ +use super::{ + BuildDdlError, ColumnAlteration, ColumnDefinition, ColumnRename, DdlDialect, DdlOperation, DraftColumn, Permission, + PrimaryKeyChange, RefusalReason, TableDefinition, order_renames, primary_key_permission, +}; +use crate::foreign_key_info::ForeignKeyInfo; +use crate::meta::{IndexInfo, IndexKey, TableRef}; + +/// Everything an edit changed about an existing table, which a DDL +/// dialect turns into its own statements in its own order. +#[derive(Debug, Clone, PartialEq, Default)] +pub struct TableChanges { + pub renamed_table: Option, + pub dropped_foreign_keys: Vec, + pub dropped_indexes: Vec, + pub dropped_columns: Vec, + pub renamed_columns: Vec, + pub altered_columns: Vec, + pub added_columns: Vec, + /// Follows from the columns' primary-key flags, so it is not a change + /// of its own in `change_count`. + pub primary_key: Option, + pub added_indexes: Vec, + pub added_foreign_keys: Vec, +} + +impl TableChanges { + pub fn is_empty(&self) -> bool { + self.change_count() == 0 && self.primary_key.is_none() + } + + /// One per edit the user made: a column renamed and retyped is two. + pub fn change_count(&self) -> usize { + usize::from(self.renamed_table.is_some()) + + self.dropped_foreign_keys.len() + + self.dropped_indexes.len() + + self.dropped_columns.len() + + self.renamed_columns.len() + + self.altered_columns.len() + + self.added_columns.len() + + self.added_indexes.len() + + self.added_foreign_keys.len() + } + + /// The table as it is named once the changes have run. + pub fn target(&self, original: &TableRef) -> TableRef { + match &self.renamed_table { + Some(name) => TableRef::new(original.schema.clone(), name.clone()), + None => original.clone(), + } + } + + /// The column renames as single statements run them, for an engine + /// that drops columns before it renames and adds columns after. + pub fn ordered_renames(&self, original: &TableDefinition) -> Vec { + let occupied = original + .columns + .iter() + .map(ColumnDefinition::name) + .filter(|name| !self.dropped_columns.iter().any(|column| column.name() == *name)); + let added = self.added_columns.iter().map(|column| column.name.as_str()); + order_renames(&self.renamed_columns, occupied, added) + } + + /// The name of the primary key this change drops, for an engine that + /// drops it by name. A key the catalogue gave no name cannot be + /// dropped that way, and adding the new one beside it would fail. + pub fn dropped_primary_key_name(&self) -> Result, BuildDdlError> { + let Some(key) = self.primary_key.as_ref().and_then(|change| change.from.as_ref()) else { + return Ok(None); + }; + match &key.name { + Some(name) => Ok(Some(name)), + None => Err(BuildDdlError::Refused { + column: key.columns.first().cloned().unwrap_or_default(), + operation: DdlOperation::AlterPrimaryKey, + reason: RefusalReason::UnreadableDefinition, + }), + } + } + + /// Refuse a change the dialect says it cannot make, before any SQL + /// is written. The structure tab gates its controls on the same + /// answers, so this only fires for a caller that skipped them. + pub fn check(&self, ddl: &dyn DdlDialect, original: &TableDefinition) -> Result<(), BuildDdlError> { + let capabilities = ddl.capabilities(); + if self.renamed_table.is_some() { + require(capabilities.rename_table, DdlOperation::RenameTable)?; + } + if !self.dropped_foreign_keys.is_empty() { + require(capabilities.drop_foreign_key, DdlOperation::DropForeignKey)?; + } + if !self.dropped_indexes.is_empty() { + require(capabilities.drop_index, DdlOperation::DropIndex)?; + } + for column in &self.dropped_columns { + let permission = ddl.column_capabilities(original, column).drop; + permit(permission, column.name(), DdlOperation::DropColumn)?; + } + for rename in &self.renamed_columns { + if let Some(column) = original.column(&rename.from) { + let permission = ddl.column_capabilities(original, column).rename; + permit(permission, &rename.from, DdlOperation::RenameColumn)?; + } + } + for alteration in &self.altered_columns { + let permissions = ddl.column_capabilities(original, &alteration.original); + let name = alteration.original.name(); + let changed = alteration.changed; + let checks = [ + ( + changed.column_type, + permissions.alter_type, + DdlOperation::AlterColumnType, + ), + ( + changed.nullability, + permissions.alter_nullability, + DdlOperation::AlterColumnNullability, + ), + ( + changed.default, + permissions.alter_default, + DdlOperation::AlterColumnDefault, + ), + ( + changed.auto_increment, + permissions.alter_auto_increment, + DdlOperation::AlterAutoIncrement, + ), + ( + changed.comment, + permissions.alter_comment, + DdlOperation::SetColumnComment, + ), + ]; + for (changed, permission, operation) in checks { + if changed { + permit(permission, name, operation)?; + } + } + } + for column in &self.added_columns { + require(capabilities.add_column, DdlOperation::AddColumn)?; + if column.comment_text().is_some() { + require(capabilities.column_comments, DdlOperation::SetColumnComment)?; + } + } + if let Some(change) = &self.primary_key { + let column = change + .to + .first() + .or_else(|| change.from.as_ref().and_then(|key| key.columns.first())) + .map_or("", String::as_str); + permit( + primary_key_permission(capabilities, original), + column, + DdlOperation::AlterPrimaryKey, + )?; + } + check_new_indexes(ddl, &self.added_indexes)?; + if !self.added_foreign_keys.is_empty() { + require(capabilities.add_foreign_key, DdlOperation::AddForeignKey)?; + } + check_actions(ddl, &self.added_foreign_keys) + } +} + +pub(super) fn require(allowed: bool, operation: DdlOperation) -> Result<(), BuildDdlError> { + match allowed { + true => Ok(()), + false => Err(BuildDdlError::Unsupported(operation)), + } +} + +fn permit(permission: Permission, column: &str, operation: DdlOperation) -> Result<(), BuildDdlError> { + match permission { + Permission::Allowed => Ok(()), + Permission::Refused(reason) => Err(BuildDdlError::Refused { + column: column.to_owned(), + operation, + reason, + }), + } +} + +pub(super) fn check_new_indexes(ddl: &dyn DdlDialect, indexes: &[IndexInfo]) -> Result<(), BuildDdlError> { + let capabilities = ddl.capabilities(); + for index in indexes.iter().filter(|index| !index.primary) { + require(capabilities.create_index, DdlOperation::CreateIndex)?; + if index.keys.iter().any(|key| matches!(key, IndexKey::Expression { .. })) { + require(capabilities.expression_index_keys, DdlOperation::IndexExpressionKey)?; + } + if index.predicate.is_some() { + require(capabilities.partial_indexes, DdlOperation::PartialIndex)?; + } + } + Ok(()) +} + +pub(super) fn check_actions(ddl: &dyn DdlDialect, keys: &[ForeignKeyInfo]) -> Result<(), BuildDdlError> { + let accepted = ddl.referential_actions(); + keys.iter() + .flat_map(|key| [key.on_delete, key.on_update]) + .flatten() + .find(|action| !accepted.contains(action)) + .map_or(Ok(()), |action| Err(BuildDdlError::ReferentialAction(action))) +} diff --git a/linux/crates/core/src/ddl/table_definition.rs b/linux/crates/core/src/ddl/table_definition.rs new file mode 100644 index 0000000000..06d6b6a036 --- /dev/null +++ b/linux/crates/core/src/ddl/table_definition.rs @@ -0,0 +1,88 @@ +use super::{ColumnDefinition, InboundForeignKey, PrimaryKeyConstraint, TableSource}; +use crate::foreign_key_info::ForeignKeyInfo; +use crate::meta::{IndexInfo, TableRef}; + +/// Everything a structure change needs to know about a table, read in +/// one call. +/// +/// The structure tab plans DDL against this rather than against the +/// separate column, index and key lists, because a plan that restates +/// a column, rebuilds a table or renames a key needs all of them from +/// the same moment. +#[derive(Debug, Clone, PartialEq)] +pub struct TableDefinition { + pub table: TableRef, + pub columns: Vec, + pub primary_key: Option, + pub indexes: Vec, + pub foreign_keys: Vec, + pub inbound_foreign_keys: Vec, + /// `None` where the engine cannot produce its own DDL for the table. + pub source: Option, +} + +impl TableDefinition { + pub fn column(&self, name: &str) -> Option<&ColumnDefinition> { + self.columns.iter().find(|column| column.name() == name) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::column::{CatalogType, ColumnDefault, ColumnInfo, ColumnKind, ColumnType, ReadForm, SqlTypeExpr}; + use crate::ddl::{ColumnAttributes, IdentityStyle}; + + fn column(name: &str) -> ColumnDefinition { + ColumnDefinition::new( + ColumnInfo { + name: name.to_owned(), + column_type: ColumnType { + name: "text".to_owned(), + kind: ColumnKind::Text { + length: crate::column::TextLength::Unbounded, + }, + catalog: Some(CatalogType { + declaration: SqlTypeExpr::from_catalog_text("text"), + cast_target: SqlTypeExpr::from_catalog_text("text"), + }), + dynamic_storage: false, + read_form: ReadForm::Native, + }, + nullable: true, + primary_key: false, + is_auto_increment: false, + is_generated: false, + default: ColumnDefault::None, + comment: None, + }, + ColumnAttributes::default(), + ) + } + + #[test] + fn a_column_is_found_by_its_exact_name() { + let definition = TableDefinition { + table: TableRef::unqualified("users"), + columns: vec![column("email"), column("Email")], + primary_key: None, + indexes: Vec::new(), + foreign_keys: Vec::new(), + inbound_foreign_keys: Vec::new(), + source: None, + }; + + assert_eq!(definition.column("Email").map(ColumnDefinition::name), Some("Email")); + assert!(definition.column("missing").is_none()); + } + + #[test] + fn default_attributes_describe_a_plain_column() { + let attributes = ColumnAttributes::default(); + + assert_eq!(attributes.identity, IdentityStyle::None); + assert!(attributes.generated.is_none()); + assert!(attributes.unmodeled.is_empty()); + assert!(!attributes.key_roles.sorting_key && !attributes.key_roles.partition_key); + } +} diff --git a/linux/crates/core/src/ddl/table_draft.rs b/linux/crates/core/src/ddl/table_draft.rs new file mode 100644 index 0000000000..4fea7675c3 --- /dev/null +++ b/linux/crates/core/src/ddl/table_draft.rs @@ -0,0 +1,157 @@ +use std::collections::{BTreeMap, HashSet}; + +use super::table_changes::{check_actions, check_new_indexes, require}; +use super::{BuildDdlError, DdlDialect, DdlOperation, DraftColumn, DraftProblem, TableDefinition}; +use crate::foreign_key_info::ForeignKeyInfo; +use crate::meta::{IndexInfo, TableRef}; + +/// A table as the user is editing it: a new one, or an existing one +/// started from its definition. +#[derive(Debug, Clone, PartialEq)] +pub struct TableDraft { + pub table: TableRef, + pub columns: Vec, + /// The primary key's own index included: it follows the columns' + /// primary-key flags, and no plan restates it. + pub indexes: Vec, + pub foreign_keys: Vec, +} + +impl TableDraft { + pub fn new(table: TableRef) -> Self { + Self { + table, + columns: Vec::new(), + indexes: Vec::new(), + foreign_keys: Vec::new(), + } + } + + pub fn from_definition(definition: &TableDefinition) -> Self { + Self { + table: definition.table.clone(), + columns: definition + .columns + .iter() + .cloned() + .map(DraftColumn::from_definition) + .collect(), + indexes: definition.indexes.clone(), + foreign_keys: definition.foreign_keys.clone(), + } + } + + pub fn column(&self, name: &str) -> Option<&DraftColumn> { + self.columns.iter().find(|column| column.name == name) + } + + /// The primary key's columns, in column order. + pub fn primary_key(&self) -> Vec<&str> { + self.columns + .iter() + .filter(|column| column.primary_key) + .map(|column| column.name.as_str()) + .collect() + } + + /// The first reason the draft cannot be saved. + pub fn validate(&self) -> Result<(), DraftProblem> { + if self.table.name.trim().is_empty() { + return Err(DraftProblem::EmptyTableName); + } + if self.columns.is_empty() { + return Err(DraftProblem::NoColumns); + } + let mut seen: HashSet<&str> = HashSet::new(); + for column in &self.columns { + if column.name.trim().is_empty() { + return Err(DraftProblem::EmptyColumnName); + } + if !seen.insert(column.name.as_str()) { + return Err(DraftProblem::DuplicateColumnName(column.name.clone())); + } + if column.column_type.text().is_empty() { + return Err(DraftProblem::EmptyColumnType(column.name.clone())); + } + if column.primary_key && column.nullable && key_or_nullability_edited(column) { + return Err(DraftProblem::NullablePrimaryKey(column.name.clone())); + } + } + for index in self.indexes.iter().filter(|index| !index.primary) { + if index.name.trim().is_empty() { + return Err(DraftProblem::EmptyIndexName); + } + if index.keys.is_empty() { + return Err(DraftProblem::IndexWithoutKeys(index.name.clone())); + } + } + for key in &self.foreign_keys { + if key.name.trim().is_empty() { + return Err(DraftProblem::EmptyForeignKeyName); + } + if key.columns.is_empty() || key.columns.len() != key.ref_columns.len() { + return Err(DraftProblem::ForeignKeyColumnMismatch(key.name.clone())); + } + } + self.columns_in_use() + } + + /// Refuse a draft the dialect cannot create, before any SQL is + /// written. Foreign keys are declared inside CREATE TABLE, so an + /// engine that cannot add one to an existing table still takes them. + pub fn check_create(&self, ddl: &dyn DdlDialect) -> Result<(), BuildDdlError> { + let capabilities = ddl.capabilities(); + require(capabilities.create_table, DdlOperation::CreateTable)?; + self.validate()?; + if self.columns.iter().any(|column| column.comment_text().is_some()) { + require(capabilities.column_comments, DdlOperation::SetColumnComment)?; + } + check_new_indexes(ddl, &self.indexes)?; + check_actions(ddl, &self.foreign_keys) + } + + /// A kept index or foreign key may name a column by the name it had + /// when it was read, since a rename carries it along. + fn columns_in_use(&self) -> Result<(), DraftProblem> { + let known: HashSet<&str> = self + .columns + .iter() + .flat_map(|column| std::iter::once(column.name.as_str()).chain(column.original_name())) + .collect(); + let mut missing: BTreeMap<&str, Vec> = BTreeMap::new(); + let index_columns = self.indexes.iter().filter(|index| !index.primary).flat_map(|index| { + index + .keys + .iter() + .filter_map(|key| key.column_name()) + .map(|c| (c, &index.name)) + }); + let key_columns = self + .foreign_keys + .iter() + .flat_map(|key| key.columns.iter().map(|c| (c.as_str(), &key.name))); + for (column, user) in index_columns.chain(key_columns) { + if !known.contains(column) { + let users = missing.entry(column).or_default(); + if !users.contains(user) { + users.push(user.clone()); + } + } + } + match missing.into_iter().next() { + Some((column, used_by)) => Err(DraftProblem::ColumnInUse { + column: column.to_owned(), + used_by, + }), + None => Ok(()), + } + } +} + +/// A key read back as it is stays the server's business: SQLite reports +/// an `INTEGER PRIMARY KEY` as taking NULL. +fn key_or_nullability_edited(column: &DraftColumn) -> bool { + column.original.as_ref().is_none_or(|original| { + original.info.primary_key != column.primary_key || original.info.nullable != column.nullable + }) +} diff --git a/linux/crates/core/src/ddl/table_rebuild.rs b/linux/crates/core/src/ddl/table_rebuild.rs new file mode 100644 index 0000000000..02d8c1501d --- /dev/null +++ b/linux/crates/core/src/ddl/table_rebuild.rs @@ -0,0 +1,23 @@ +use super::SchemaObjectKind; + +/// A plan that copies the table's rows into a new table and drops the +/// old one, where the engine's ALTER TABLE cannot make the change. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct TableRebuild { + /// The indexes, triggers and views the plan drops, with the table or + /// on their own, and makes again from the SQL the schema stored. + pub recreated: Vec, + /// The copied rows get new rowids, so anything outside the schema + /// that kept one no longer finds its row. + pub renumbers_rowids: bool, + /// A column that becomes the rowid, by its final name. SQLite gives a + /// NULL copied into it a new number, so the plan stops instead when + /// the column holds one. + pub numbered_column: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RecreatedObject { + pub kind: SchemaObjectKind, + pub name: String, +} diff --git a/linux/crates/core/src/ddl/table_source.rs b/linux/crates/core/src/ddl/table_source.rs new file mode 100644 index 0000000000..d1c1c35709 --- /dev/null +++ b/linux/crates/core/src/ddl/table_source.rs @@ -0,0 +1,10 @@ +use super::SchemaObjectSource; + +/// A table as the server would recreate it. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct TableSource { + pub create_statement: String, + /// Indexes, triggers and views a rebuild has to replay, in the order + /// the schema created them. + pub dependents: Vec, +} diff --git a/linux/crates/core/src/ddl/tests.rs b/linux/crates/core/src/ddl/tests.rs new file mode 100644 index 0000000000..2f9c487c6a --- /dev/null +++ b/linux/crates/core/src/ddl/tests.rs @@ -0,0 +1,893 @@ +use super::*; +use crate::column::{ + CatalogType, ColumnDefault, ColumnInfo, ColumnKind, ColumnType, IntegerKind, IntegerWidth, ReadForm, SqlExpression, + SqlTypeExpr, TextLength, +}; +use crate::dialect::{SqlDialect, TestDialect}; +use crate::foreign_key_info::ForeignKeyInfo; +use crate::meta::{IndexInfo, IndexKey, TableRef}; +use crate::value::Value; + +fn catalog_type(declaration: &str, kind: ColumnKind) -> ColumnType { + ColumnType { + name: declaration.to_owned(), + kind, + catalog: Some(CatalogType { + declaration: SqlTypeExpr::from_catalog_text(declaration), + cast_target: SqlTypeExpr::from_catalog_text(declaration), + }), + dynamic_storage: false, + read_form: ReadForm::Native, + } +} + +fn integer() -> ColumnType { + catalog_type( + "integer", + ColumnKind::Integer(IntegerKind { + width: IntegerWidth::W32, + signed: true, + }), + ) +} + +fn text() -> ColumnType { + catalog_type( + "text", + ColumnKind::Text { + length: TextLength::Unbounded, + }, + ) +} + +fn column(name: &str, column_type: ColumnType, primary_key: bool) -> ColumnDefinition { + ColumnDefinition::new( + ColumnInfo { + name: name.to_owned(), + column_type, + nullable: !primary_key, + primary_key, + is_auto_increment: false, + is_generated: false, + default: ColumnDefault::None, + comment: None, + }, + ColumnAttributes::default(), + ) +} + +fn index(name: &str, columns: &[&str]) -> IndexInfo { + IndexInfo { + name: name.to_owned(), + keys: columns + .iter() + .map(|column| IndexKey::Column { + name: (*column).to_owned(), + descending: false, + }) + .collect(), + unique: false, + primary: false, + predicate: None, + } +} + +fn foreign_key(name: &str, column: &str) -> ForeignKeyInfo { + ForeignKeyInfo { + name: name.to_owned(), + columns: vec![column.to_owned()], + ref_schema: None, + ref_table: "accounts".to_owned(), + ref_columns: vec!["id".to_owned()], + on_delete: Some(ReferentialAction::Cascade), + on_update: None, + } +} + +fn users() -> TableDefinition { + TableDefinition { + table: TableRef::new(Some("app".to_owned()), "users"), + columns: vec![ + column("id", integer(), true), + column("email", text(), false), + column("account_id", integer(), false), + column("legacy", text(), false), + ], + primary_key: Some(PrimaryKeyConstraint { + name: Some("users_pkey".to_owned()), + columns: vec!["id".to_owned()], + }), + indexes: vec![ + IndexInfo { + primary: true, + unique: true, + ..index("users_pkey", &["id"]) + }, + index("by_email", &["email"]), + index("by_legacy", &["legacy"]), + ], + foreign_keys: vec![foreign_key("users_account_fk", "account_id")], + inbound_foreign_keys: Vec::new(), + source: None, + } +} + +fn draft_column<'a>(draft: &'a mut TableDraft, name: &str) -> &'a mut DraftColumn { + draft + .columns + .iter_mut() + .find(|column| column.name == name) + .expect("the column is in the draft") +} + +#[test] +fn diff_table_detects_each_change_kind() { + let original = users(); + let mut draft = TableDraft::from_definition(&original); + draft.table.name = "members".to_owned(); + draft.columns.retain(|column| column.name != "legacy"); + draft.indexes.retain(|index| index.name != "by_legacy"); + draft.foreign_keys.clear(); + draft_column(&mut draft, "email").name = "mail".to_owned(); + draft_column(&mut draft, "account_id").nullable = false; + let mut added = DraftColumn::new("nickname", DraftType::Typed("varchar(40)".to_owned())); + added.default = ColumnDefault::Literal(Value::Text("it's".to_owned())); + draft.columns.push(added); + draft.indexes.push(index("by_nickname", &["nickname"])); + draft.foreign_keys.push(foreign_key("users_account_fk2", "account_id")); + + let changes = diff_table(&original, &draft); + + assert_eq!(changes.renamed_table.as_deref(), Some("members")); + assert_eq!( + changes + .dropped_columns + .iter() + .map(ColumnDefinition::name) + .collect::>(), + ["legacy"] + ); + assert_eq!( + changes.renamed_columns, + [ColumnRename { + from: "email".to_owned(), + to: "mail".to_owned() + }] + ); + assert_eq!(changes.altered_columns.len(), 1); + let alteration = &changes.altered_columns[0]; + assert_eq!(alteration.original.name(), "account_id"); + assert!(alteration.changed.nullability && !alteration.changed.column_type && !alteration.changed.default); + assert_eq!( + changes + .added_columns + .iter() + .map(|c| c.name.as_str()) + .collect::>(), + ["nickname"] + ); + assert_eq!( + changes + .dropped_indexes + .iter() + .map(|i| i.name.as_str()) + .collect::>(), + ["by_legacy"] + ); + assert_eq!( + changes + .added_indexes + .iter() + .map(|i| i.name.as_str()) + .collect::>(), + ["by_nickname"] + ); + assert_eq!(changes.dropped_foreign_keys[0].name, "users_account_fk"); + assert_eq!(changes.added_foreign_keys[0].name, "users_account_fk2"); + assert_eq!(changes.primary_key, None, "the key did not move"); + assert_eq!( + changes.target(&original.table), + TableRef::new(Some("app".to_owned()), "members") + ); +} + +#[test] +fn an_unedited_draft_changes_nothing() { + let original = users(); + let mut draft = TableDraft::from_definition(&original); + // Retyping the declaration is the same type. + draft_column(&mut draft, "email").column_type = DraftType::Typed(" text ".to_owned()); + // An empty comment is no comment. + draft_column(&mut draft, "legacy").comment = Some(String::new()); + + let changes = diff_table(&original, &draft); + + assert!(changes.is_empty(), "{changes:?}"); + assert_eq!(changes.change_count(), 0); +} + +#[test] +fn diff_rename_only_is_one_change() { + let original = users(); + let mut draft = TableDraft::from_definition(&original); + draft_column(&mut draft, "email").name = "mail".to_owned(); + + let changes = diff_table(&original, &draft); + + assert_eq!(changes.change_count(), 1); + assert!(changes.altered_columns.is_empty()); +} + +#[test] +fn change_count_matches_user_visible_edits() { + let original = users(); + let mut draft = TableDraft::from_definition(&original); + // A column renamed and retyped is two edits. + let email = draft_column(&mut draft, "email"); + email.name = "mail".to_owned(); + email.column_type = DraftType::Typed("varchar(320)".to_owned()); + // Three attributes of one column are one edit. + let legacy = draft_column(&mut draft, "legacy"); + legacy.nullable = false; + legacy.default = ColumnDefault::Expression(SqlExpression::from_catalog_text("now()")); + legacy.comment = Some("kept for the import".to_owned()); + draft.indexes.retain(|index| index.name != "by_email"); + draft + .columns + .push(DraftColumn::new("nickname", DraftType::Typed("text".to_owned()))); + + let changes = diff_table(&original, &draft); + + assert_eq!(changes.change_count(), 5, "{changes:#?}"); +} + +#[test] +fn diff_primary_key_change_detected() { + let original = users(); + let mut draft = TableDraft::from_definition(&original); + let id = draft_column(&mut draft, "id"); + id.name = "user_id".to_owned(); + let unchanged = diff_table(&original, &draft); + assert_eq!(unchanged.primary_key, None, "a renamed key column is the same key"); + + let account = draft_column(&mut draft, "account_id"); + account.primary_key = true; + account.nullable = false; + let changes = diff_table(&original, &draft); + + let change = changes.primary_key.expect("the key moved"); + assert_eq!(change.to, ["user_id", "account_id"]); + assert_eq!(change.from.and_then(|key| key.name).as_deref(), Some("users_pkey")); +} + +#[test] +fn renames_into_a_dropped_name_and_a_swap_run_one_at_a_time() { + let original = users(); + let mut draft = TableDraft::from_definition(&original); + draft.columns.retain(|column| column.name != "legacy"); + draft.indexes.retain(|index| index.name != "by_legacy"); + draft_column(&mut draft, "email").name = "legacy".to_owned(); + draft_column(&mut draft, "account_id").name = "id".to_owned(); + draft_column(&mut draft, "id").name = "account_id".to_owned(); + draft.columns.push(DraftColumn::new( + "__tablepro_rename_1", + DraftType::Typed("text".to_owned()), + )); + + let changes = diff_table(&original, &draft); + let steps: Vec<(String, String)> = changes + .ordered_renames(&original) + .into_iter() + .map(|rename| (rename.from, rename.to)) + .collect(); + + let step = |from: &str, to: &str| (from.to_owned(), to.to_owned()); + assert_eq!( + steps, + [ + step("email", "legacy"), + step("id", "__tablepro_rename_2"), + step("account_id", "id"), + step("__tablepro_rename_2", "account_id"), + ] + ); +} + +#[test] +fn a_moved_key_is_dropped_by_its_name() { + let mut table = users(); + let mut draft = TableDraft::from_definition(&table); + draft_column(&mut draft, "account_id").primary_key = true; + draft_column(&mut draft, "account_id").nullable = false; + + assert_eq!( + diff_table(&table, &draft).dropped_primary_key_name(), + Ok(Some("users_pkey")) + ); + table.primary_key = Some(PrimaryKeyConstraint { + name: None, + columns: vec!["id".to_owned()], + }); + assert_eq!( + diff_table(&table, &draft).dropped_primary_key_name(), + Err(BuildDdlError::Refused { + column: "id".to_owned(), + operation: DdlOperation::AlterPrimaryKey, + reason: RefusalReason::UnreadableDefinition, + }) + ); + assert_eq!( + diff_table(&table, &TableDraft::from_definition(&table)).dropped_primary_key_name(), + Ok(None) + ); +} + +#[test] +fn table_draft_validate_reports_column_in_use() { + let original = users(); + let mut draft = TableDraft::from_definition(&original); + draft + .columns + .retain(|column| column.name != "email" && column.name != "account_id"); + + assert_eq!( + draft.validate(), + Err(DraftProblem::ColumnInUse { + column: "account_id".to_owned(), + used_by: vec!["users_account_fk".to_owned()], + }) + ); + + draft.foreign_keys.clear(); + assert_eq!( + draft.validate(), + Err(DraftProblem::ColumnInUse { + column: "email".to_owned(), + used_by: vec!["by_email".to_owned()], + }) + ); + + // A rename carries the index along, so the old name still counts. + let mut renamed = TableDraft::from_definition(&original); + draft_column(&mut renamed, "email").name = "mail".to_owned(); + assert_eq!(renamed.validate(), Ok(())); +} + +#[test] +fn table_draft_validate_reports_the_first_problem() { + let mut draft = TableDraft::new(TableRef::unqualified(" ")); + assert_eq!(draft.validate(), Err(DraftProblem::EmptyTableName)); + draft.table.name = "t".to_owned(); + assert_eq!(draft.validate(), Err(DraftProblem::NoColumns)); + + draft.columns = vec![ + DraftColumn::new("a", DraftType::Typed("int".to_owned())), + DraftColumn::new("a", DraftType::Typed("int".to_owned())), + ]; + assert_eq!(draft.validate(), Err(DraftProblem::DuplicateColumnName("a".to_owned()))); + + draft.columns[1].name = String::new(); + assert_eq!(draft.validate(), Err(DraftProblem::EmptyColumnName)); + + draft.columns[1].name = "b".to_owned(); + draft.columns[1].column_type = DraftType::Typed(" ".to_owned()); + assert_eq!(draft.validate(), Err(DraftProblem::EmptyColumnType("b".to_owned()))); + + draft.columns[1].column_type = DraftType::Typed("int".to_owned()); + draft.columns[0].primary_key = true; + assert_eq!(draft.validate(), Err(DraftProblem::NullablePrimaryKey("a".to_owned()))); + + draft.columns[0].nullable = false; + assert_eq!(draft.validate(), Ok(())); +} + +#[test] +fn a_nullable_key_read_back_as_it_is_saves() { + let mut table = users(); + // How SQLite reports an INTEGER PRIMARY KEY. + table.columns[0].info.nullable = true; + let mut draft = TableDraft::from_definition(&table); + + assert_eq!(draft.validate(), Ok(())); + + draft_column(&mut draft, "email").primary_key = true; + assert_eq!( + draft.validate(), + Err(DraftProblem::NullablePrimaryKey("email".to_owned())) + ); +} + +fn inbound(name: &str, referenced: &str) -> InboundForeignKey { + InboundForeignKey { + name: name.to_owned(), + table: TableRef::new(Some("app".to_owned()), "orders"), + columns: vec!["user_id".to_owned()], + referenced_columns: vec![referenced.to_owned()], + } +} + +fn every_change() -> DdlCapabilities { + DdlCapabilities { + create_table: true, + rename_table: true, + add_column: true, + drop_column: true, + rename_column: true, + alter_column_type: true, + alter_column_nullability: true, + alter_column_default: true, + alter_primary_key: true, + alter_auto_increment: true, + auto_increment_outside_primary_key: true, + create_index: true, + drop_index: true, + expression_index_keys: true, + partial_indexes: true, + add_foreign_key: true, + drop_foreign_key: true, + column_comments: true, + drop_table_cascade: true, + } +} + +#[test] +fn a_referenced_primary_key_cannot_move_from_any_column() { + let mut table = users(); + table.inbound_foreign_keys = vec![inbound("orders_user_fk", "id")]; + + for column in &table.columns { + let permissions = ColumnCapabilities::standard(&every_change(), &table, column); + assert_eq!( + permissions.alter_primary_key, + Permission::Refused(RefusalReason::ReferencedByForeignKeys(vec![ + "orders_user_fk".to_owned() + ])), + "{}", + column.name() + ); + } + let free = ColumnCapabilities::standard(&every_change(), &users(), &users().columns[0]); + assert_eq!(free.alter_primary_key, Permission::Allowed); +} + +#[test] +fn a_referenced_column_cannot_be_dropped() { + let mut table = users(); + table.inbound_foreign_keys = vec![inbound("orders_mail_fk", "email"), inbound("audit_mail_fk", "email")]; + + let email = ColumnCapabilities::standard(&every_change(), &table, &table.columns[1]); + let legacy = ColumnCapabilities::standard(&every_change(), &table, &table.columns[3]); + + assert_eq!( + email.drop, + Permission::Refused(RefusalReason::ReferencedByForeignKeys(vec![ + "orders_mail_fk".to_owned(), + "audit_mail_fk".to_owned() + ])) + ); + assert_eq!(email.rename, Permission::Allowed); + assert_eq!(legacy.drop, Permission::Allowed); +} + +#[test] +fn generated_numbered_and_unmodeled_columns_refuse_a_restatement() { + let table = users(); + let mut generated = column("total", integer(), false); + generated.attributes.generated = Some(GeneratedColumn { + expression: SqlExpression::from_catalog_text("a + b"), + storage: GeneratedStorage::Stored, + }); + let mut numbered = column("seq", integer(), false); + numbered.attributes.identity = IdentityStyle::GeneratedByDefault; + let mut versioned = column("note", text(), false); + versioned.attributes.unmodeled = vec![UnmodeledAttribute::new("WITHOUT SYSTEM VERSIONING")]; + let mut capabilities = every_change(); + capabilities.alter_column_type = false; + + let generated = ColumnCapabilities::standard(&every_change(), &table, &generated); + let numbered = ColumnCapabilities::standard(&every_change(), &table, &numbered); + let versioned = ColumnCapabilities::standard(&every_change(), &table, &versioned); + let unsupported = ColumnCapabilities::standard(&capabilities, &table, &table.columns[1]); + + assert_eq!( + generated.alter_type, + Permission::Refused(RefusalReason::GeneratedColumn) + ); + assert_eq!(generated.rename, Permission::Allowed); + assert_eq!(generated.alter_comment, Permission::Allowed); + assert_eq!( + numbered.alter_default, + Permission::Refused(RefusalReason::IdentityColumn) + ); + assert_eq!(numbered.alter_type, Permission::Allowed); + assert_eq!( + versioned.alter_nullability, + Permission::Refused(RefusalReason::UnmodeledAttribute( + "WITHOUT SYSTEM VERSIONING".to_owned() + )) + ); + assert_eq!( + unsupported.alter_type, + Permission::Refused(RefusalReason::EngineUnsupported) + ); +} + +/// A dialect whose plans only report that they were reached, for the +/// checks every plan runs first. +#[derive(Debug)] +struct CheckingDdl { + capabilities: DdlCapabilities, + sql: TestDialect, +} + +impl DdlDialect for CheckingDdl { + fn capabilities(&self) -> &DdlCapabilities { + &self.capabilities + } + + fn sql_dialect(&self) -> &dyn SqlDialect { + &self.sql + } + + fn type_suggestions(&self) -> &'static [&'static str] { + &["integer"] + } + + fn default_column_type(&self) -> &'static str { + "text" + } + + fn referential_actions(&self) -> &'static [ReferentialAction] { + &[ReferentialAction::NoAction, ReferentialAction::SetNull] + } + + fn plan_create_table(&self, draft: &TableDraft) -> Result { + draft.check_create(self)?; + Ok(DdlPlan::from_sql(Vec::new())) + } + + fn plan_alter_table(&self, original: &TableDefinition, changes: &TableChanges) -> Result { + changes.check(self, original)?; + Ok(DdlPlan::from_sql(Vec::new())) + } +} + +fn checking(capabilities: DdlCapabilities) -> CheckingDdl { + CheckingDdl { + capabilities, + sql: TestDialect::exact(), + } +} + +#[test] +fn a_plan_refuses_what_the_capabilities_refuse() { + let mut table = users(); + table.inbound_foreign_keys = vec![inbound("orders_user_fk", "id")]; + let mut draft = TableDraft::from_definition(&table); + draft.columns.retain(|column| column.name != "id"); + let mut no_drop = every_change(); + no_drop.drop_column = false; + + let refused = checking(every_change()).plan_alter_table(&table, &diff_table(&table, &draft)); + let unsupported = checking(no_drop).plan_alter_table(&table, &diff_table(&table, &draft)); + + assert!( + matches!( + &refused, + Err(BuildDdlError::Refused { + column, + operation: DdlOperation::DropColumn, + reason: RefusalReason::ReferencedByForeignKeys(_), + }) if column == "id" + ), + "{refused:?}" + ); + assert_eq!( + unsupported, + Err(BuildDdlError::Refused { + column: "id".to_owned(), + operation: DdlOperation::DropColumn, + reason: RefusalReason::EngineUnsupported, + }) + ); +} + +#[test] +fn a_plan_refuses_an_action_the_engine_does_not_take() { + let table = users(); + let mut draft = TableDraft::from_definition(&table); + draft.foreign_keys[0].on_delete = Some(ReferentialAction::Restrict); + + let outcome = checking(every_change()).plan_alter_table(&table, &diff_table(&table, &draft)); + + assert_eq!( + outcome, + Err(BuildDdlError::ReferentialAction(ReferentialAction::Restrict)) + ); +} + +#[test] +fn a_create_needs_the_capability_and_a_valid_draft() { + let mut no_create = every_change(); + no_create.create_table = false; + let mut draft = TableDraft::new(TableRef::unqualified("t")); + + assert_eq!( + checking(no_create).plan_create_table(&draft), + Err(BuildDdlError::Unsupported(DdlOperation::CreateTable)) + ); + assert_eq!( + checking(every_change()).plan_create_table(&draft), + Err(BuildDdlError::Draft(DraftProblem::NoColumns)) + ); + draft + .columns + .push(DraftColumn::new("a", DraftType::Typed("int".to_owned()))); + assert!(checking(every_change()).plan_create_table(&draft).is_ok()); +} + +#[test] +fn a_default_drop_takes_the_dialect_default() { + let plan = checking(every_change()) + .plan_drop_table( + &TableRef::new(Some("app".to_owned()), "or\"ders"), + DropTableOptions { + if_exists: true, + cascade: true, + }, + ) + .expect("the drop plans"); + let mut no_cascade = every_change(); + no_cascade.drop_table_cascade = false; + + assert_eq!(plan.sql(), ["DROP TABLE IF EXISTS \"app\".\"or\"\"ders\" CASCADE"]); + assert_eq!( + checking(no_cascade).plan_drop_table( + &TableRef::unqualified("t"), + DropTableOptions { + if_exists: false, + cascade: true + } + ), + Err(BuildDdlError::Unsupported(DdlOperation::DropTableCascade)) + ); +} + +#[test] +fn a_typed_type_is_parsed_and_spliced_as_typed() { + let dialect = TestDialect::exact(); + + let resolved = DraftType::Typed(" varchar(20) ".to_owned()).resolve(&dialect, "name"); + let refused = DraftType::Typed("int; DROP TABLE t".to_owned()).resolve(&dialect, "name"); + let missing = DraftType::Catalog(ColumnType::unknown()).resolve(&dialect, "name"); + + assert_eq!(resolved.map(|resolved| resolved.sql), Ok("varchar(20)".to_owned())); + assert!(matches!(refused, Err(BuildDdlError::InvalidType { column, .. }) if column == "name")); + assert_eq!( + missing, + Err(BuildDdlError::MissingCatalogDeclaration("name".to_owned())) + ); +} + +#[test] +fn a_typed_default_becomes_a_value_or_an_expression() { + let dialect = TestDialect::exact(); + + assert_eq!( + parse_default(&dialect, "'it''s'", &text()), + Ok(ColumnDefault::Literal(Value::Text("it's".to_owned()))) + ); + assert_eq!( + parse_default(&dialect, "42", &integer()), + Ok(ColumnDefault::Literal(Value::Int(42))) + ); + assert!(matches!( + parse_default(&dialect, "now()", &text()), + Ok(ColumnDefault::Expression(expression)) if expression.as_sql() == "now()" + )); + assert_eq!(parse_default(&dialect, " ", &text()), Ok(ColumnDefault::None)); + assert!(parse_default(&dialect, "1; DROP TABLE t", &integer()).is_err()); + assert_eq!( + default_text( + &dialect, + &ColumnDefault::Literal(Value::Text("it's".to_owned())), + &text() + ), + "'it''s'" + ); +} + +/// The review of `edit` made to `users`, over a plan of `statements`. +fn review(statements: &[&str], edit: impl FnOnce(&mut TableDraft)) -> ChangeReview { + let original = users(); + let mut draft = TableDraft::from_definition(&original); + edit(&mut draft); + let plan = DdlPlan::from_sql(statements.iter().map(|statement| (*statement).to_owned())); + ChangeReview::review(&diff_table(&original, &draft), &plan) +} + +#[test] +fn drop_column_is_destructive() { + let reviewed = review(&["ALTER TABLE users DROP COLUMN legacy"], |draft| { + draft.columns.retain(|column| column.name != "legacy"); + draft.indexes.retain(|index| index.name != "by_legacy"); + }); + + assert_eq!( + reviewed.destructive, + [ + DestructiveChange::DropColumn { + column: "legacy".to_owned() + }, + DestructiveChange::DropIndex { + index: "by_legacy".to_owned() + }, + ] + ); + assert_eq!(reviewed.operations, 2); + assert!(reviewed.requires_confirmation(crate::WriteAtomicity::Transactional)); +} + +#[test] +fn rename_is_not_destructive() { + let reviewed = review(&["ALTER TABLE users RENAME COLUMN email TO mail"], |draft| { + draft_column(draft, "email").name = "mail".to_owned(); + }); + + assert!(reviewed.destructive.is_empty(), "{:?}", reviewed.destructive); + assert!(!reviewed.requires_confirmation(crate::WriteAtomicity::StatementByStatement)); +} + +#[test] +fn unchanged_catalog_type_is_not_type_change() { + let catalog = review(&["ALTER TABLE users ALTER COLUMN email SET NOT NULL"], |draft| { + let email = draft_column(draft, "email"); + email.column_type = DraftType::Catalog(text()); + email.nullable = false; + }); + let retyped = review(&["ALTER TABLE users ALTER COLUMN email SET NOT NULL"], |draft| { + let email = draft_column(draft, "email"); + email.column_type = DraftType::Typed(" text ".to_owned()); + email.nullable = false; + }); + + let not_null = [DestructiveChange::SetNotNull { + column: "email".to_owned(), + }]; + assert_eq!(catalog.destructive, not_null); + assert_eq!(retyped.destructive, not_null); +} + +/// MySQL without strict mode and ClickHouse turn the NULLs in a column +/// made NOT NULL into 0 or an empty string, so the change is reviewed. +#[test] +fn set_not_null_is_destructive_and_drop_not_null_is_not() { + let tightened = review(&["ALTER TABLE users ALTER COLUMN legacy SET NOT NULL"], |draft| { + draft_column(draft, "legacy").nullable = false; + }); + assert_eq!( + tightened.destructive, + [DestructiveChange::SetNotNull { + column: "legacy".to_owned() + }] + ); + assert!(tightened.requires_confirmation(crate::WriteAtomicity::Transactional)); + + let mut original = users(); + if let Some(legacy) = original.columns.iter_mut().find(|column| column.name() == "legacy") { + legacy.info.nullable = false; + } + let mut draft = TableDraft::from_definition(&original); + draft_column(&mut draft, "legacy").nullable = true; + let plan = DdlPlan::from_sql(["ALTER TABLE users ALTER COLUMN legacy DROP NOT NULL".to_owned()]); + let loosened = ChangeReview::review(&diff_table(&original, &draft), &plan); + assert!(loosened.destructive.is_empty(), "{:?}", loosened.destructive); +} + +#[test] +fn typed_type_change_is_destructive() { + let reviewed = review(&["ALTER TABLE users ALTER COLUMN email TYPE varchar(40)"], |draft| { + draft_column(draft, "email").column_type = DraftType::Typed("varchar(40)".to_owned()); + }); + + assert_eq!( + reviewed.destructive, + [DestructiveChange::ChangeColumnType { + column: "email".to_owned(), + from: "text".to_owned(), + to: "varchar(40)".to_owned(), + }] + ); +} + +#[test] +fn a_dropped_foreign_key_and_a_rebuild_are_named() { + let original = users(); + let mut draft = TableDraft::from_definition(&original); + draft.foreign_keys.clear(); + let mut plan = DdlPlan::from_sql(["CREATE TABLE _new (id integer)".to_owned()]); + let rebuild = TableRebuild { + recreated: vec![RecreatedObject { + kind: SchemaObjectKind::View, + name: "active_users".to_owned(), + }], + renumbers_rowids: false, + numbered_column: None, + }; + plan.rebuild = Some(rebuild.clone()); + + let reviewed = ChangeReview::review(&diff_table(&original, &draft), &plan); + + assert_eq!( + reviewed.destructive, + [ + DestructiveChange::RebuildTable(rebuild), + DestructiveChange::DropForeignKey { + key: "users_account_fk".to_owned() + }, + ] + ); +} + +#[test] +fn non_atomic_multi_statement_requires_confirmation() { + let two = review( + &[ + "ALTER TABLE users RENAME COLUMN email TO mail", + "CREATE INDEX by_mail ON users (mail)", + ], + |draft| { + draft_column(draft, "email").name = "mail".to_owned(); + draft.indexes.push(index("by_mail", &["mail"])); + }, + ); + let one = review(&["ALTER TABLE users RENAME COLUMN email TO mail"], |draft| { + draft_column(draft, "email").name = "mail".to_owned(); + }); + + assert!(two.requires_confirmation(crate::WriteAtomicity::StatementByStatement)); + assert!(!two.requires_confirmation(crate::WriteAtomicity::Transactional)); + assert!(!one.requires_confirmation(crate::WriteAtomicity::StatementByStatement)); +} + +#[test] +fn transactional_additive_save_does_not() { + let reviewed = review( + &[ + "ALTER TABLE users ADD COLUMN nickname text", + "CREATE INDEX by_nickname ON users (nickname)", + ], + |draft| { + draft + .columns + .push(DraftColumn::new("nickname", DraftType::Typed("text".to_owned()))); + draft.indexes.push(index("by_nickname", &["nickname"])); + }, + ); + let created = ChangeReview::create(&DdlPlan::from_sql(["CREATE TABLE t (a int)".to_owned()])); + + assert!(!reviewed.requires_confirmation(crate::WriteAtomicity::Transactional)); + assert_eq!(reviewed.operations, 2); + assert!(!created.requires_confirmation(crate::WriteAtomicity::StatementByStatement)); + assert_eq!(created.operations, 1); +} + +#[test] +fn a_review_matches_only_the_plan_it_read() { + let reviewed = review(&["ALTER TABLE a", "ALTER TABLE b"], |_| {}); + + assert!(reviewed.matches(&DdlPlan::from_sql([ + "ALTER TABLE a".to_owned(), + "ALTER TABLE b".to_owned() + ]))); + assert!(!reviewed.matches(&DdlPlan::from_sql(["ALTER TABLE a".to_owned()]))); + assert!(!reviewed.matches(&DdlPlan::from_sql([ + "ALTER TABLE a".to_owned(), + "ALTER TABLE c".to_owned() + ]))); + assert_eq!(reviewed.sql(), "ALTER TABLE a;\n\nALTER TABLE b;"); + assert_eq!( + reviewed.sql(), + DdlPlan::from_sql(["ALTER TABLE a".to_owned(), "ALTER TABLE b".to_owned()]).preview() + ); +} diff --git a/linux/crates/core/src/ddl/unmodeled_attribute.rs b/linux/crates/core/src/ddl/unmodeled_attribute.rs new file mode 100644 index 0000000000..f66fab7b62 --- /dev/null +++ b/linux/crates/core/src/ddl/unmodeled_attribute.rs @@ -0,0 +1,15 @@ +/// A column attribute the catalogue reported and this build does not +/// model, such as MariaDB `WITHOUT SYSTEM VERSIONING`. +/// +/// It is kept by name so a redefinition can refuse rather than drop it: +/// restating the column without it would remove it. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct UnmodeledAttribute { + pub name: String, +} + +impl UnmodeledAttribute { + pub fn new(name: impl Into) -> Self { + Self { name: name.into() } + } +} diff --git a/linux/crates/core/src/dialect/bind_error.rs b/linux/crates/core/src/dialect/bind_error.rs new file mode 100644 index 0000000000..46b324affc --- /dev/null +++ b/linux/crates/core/src/dialect/bind_error.rs @@ -0,0 +1,25 @@ +use thiserror::Error; + +/// Why a value could not become a bound parameter. +/// +/// Binding is where a wrong type is caught, before the SQL reaches the +/// server. An undecodable value is refused outright: the driver could +/// not read it, so writing it back would replace bytes it never +/// understood. +#[derive(Debug, Clone, PartialEq, Eq, Error)] +pub enum BindError { + #[error("a {expected} parameter cannot hold a {found} value")] + ValueTypeMismatch { + expected: &'static str, + found: &'static str, + }, + #[error("binding to {column} needs the column's catalogue type, which is not known here")] + MissingCatalogType { column: String }, + #[error("this value could not be read from the server, so it cannot be written back: {reason}")] + Undecodable { reason: String }, + #[error("{found} cannot be represented as {expected}")] + Unrepresentable { + expected: &'static str, + found: &'static str, + }, +} diff --git a/linux/crates/core/src/dialect/bind_target.rs b/linux/crates/core/src/dialect/bind_target.rs new file mode 100644 index 0000000000..51226485ce --- /dev/null +++ b/linux/crates/core/src/dialect/bind_target.rs @@ -0,0 +1,16 @@ +use crate::column::ColumnInfo; +use crate::meta::EngineRowIdPart; + +/// What a value is being bound for, which is what decides its +/// parameter type. +/// +/// The same text binds differently depending on where it lands: as a +/// column's own type, as a LIKE pattern, or as an engine row address +/// the server has to parse. +#[derive(Debug, Clone, Copy)] +pub enum BindTarget<'a> { + Column(&'a ColumnInfo), + /// A LIKE pattern, which is text whatever the column is. + Pattern, + EngineRowId(EngineRowIdPart), +} diff --git a/linux/crates/core/src/dialect/dialect_capabilities.rs b/linux/crates/core/src/dialect/dialect_capabilities.rs new file mode 100644 index 0000000000..9715f07eed --- /dev/null +++ b/linux/crates/core/src/dialect/dialect_capabilities.rs @@ -0,0 +1,8 @@ +/// What an engine can promise about its own SQL. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct DialectCapabilities { + /// Whether an affected-row count is the exact number of rows the + /// statement changed. Where it is not, a write proves itself with + /// a probe instead. + pub exact_row_counts: bool, +} diff --git a/linux/crates/core/src/dialect/insert_batch_limits.rs b/linux/crates/core/src/dialect/insert_batch_limits.rs new file mode 100644 index 0000000000..046417e1a2 --- /dev/null +++ b/linux/crates/core/src/dialect/insert_batch_limits.rs @@ -0,0 +1,24 @@ +/// How much one multi-row INSERT may carry on an engine. A batch of rows +/// is cut into statements that each stay within all three. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct InsertBatchLimits { + /// Rows in one VALUES list. + pub rows: usize, + /// Parameters bound to one statement. + pub params: usize, + /// The values one statement carries, measured as their text, so a + /// statement stays well inside the engine's packet or request size. + pub value_bytes: usize, +} + +impl InsertBatchLimits { + /// Within what PostgreSQL (65,535 parameters), MySQL (65,535 + /// placeholders, a 4 MiB packet on older servers) and SQLite (32,766 + /// variables) accept. A thousand rows already make one round trip + /// a small share of the time a statement takes. + pub const DEFAULT: Self = Self { + rows: 1_000, + params: 30_000, + value_bytes: 1 << 20, + }; +} diff --git a/linux/crates/core/src/dialect/keyset_direction.rs b/linux/crates/core/src/dialect/keyset_direction.rs new file mode 100644 index 0000000000..9ab9b4ea72 --- /dev/null +++ b/linux/crates/core/src/dialect/keyset_direction.rs @@ -0,0 +1,36 @@ +/// Which way a keyset page reads from its anchor row. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum KeysetDirection { + After, + /// The SQL reverses the sort and the fetcher reverses the rows + /// back, so the user sees them in the order they expect. + Before, +} + +impl KeysetDirection { + /// The comparison the row-value predicate uses. + pub fn comparison(self) -> &'static str { + match self { + Self::After => ">", + Self::Before => "<", + } + } + + /// Whether the ORDER BY has to be flipped to read this way. + pub fn reverses_order(self) -> bool { + matches!(self, Self::Before) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn reading_backward_flips_the_comparison_and_the_order() { + assert_eq!(KeysetDirection::After.comparison(), ">"); + assert!(!KeysetDirection::After.reverses_order()); + assert_eq!(KeysetDirection::Before.comparison(), "<"); + assert!(KeysetDirection::Before.reverses_order()); + } +} diff --git a/linux/crates/core/src/dialect/like_form.rs b/linux/crates/core/src/dialect/like_form.rs new file mode 100644 index 0000000000..78d3558673 --- /dev/null +++ b/linux/crates/core/src/dialect/like_form.rs @@ -0,0 +1,46 @@ +/// How a LIKE predicate should read. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct LikeForm { + pub negated: bool, + pub case: LikeCase, + /// Whether the pattern went through `escape_like_text`, which + /// decides if the SQL needs an ESCAPE clause. + pub escaped: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LikeCase { + /// Whatever the column's collation does, which is the engine's own + /// answer and the one the user's other tools give. + EngineDefault, + Insensitive, +} + +impl LikeForm { + /// A Contains, StartsWith or EndsWith filter: the app built the + /// pattern, so its wildcards are escaped. + pub fn contains() -> Self { + Self { + negated: false, + case: LikeCase::EngineDefault, + escaped: true, + } + } + + /// A LIKE the user typed, wildcards and all. + pub fn raw(negated: bool) -> Self { + Self { + negated, + case: LikeCase::EngineDefault, + escaped: false, + } + } + + pub fn insensitive(negated: bool) -> Self { + Self { + negated, + case: LikeCase::Insensitive, + escaped: false, + } + } +} diff --git a/linux/crates/core/src/dialect/literal_error.rs b/linux/crates/core/src/dialect/literal_error.rs new file mode 100644 index 0000000000..4998b399ec --- /dev/null +++ b/linux/crates/core/src/dialect/literal_error.rs @@ -0,0 +1,13 @@ +use thiserror::Error; + +/// Why a value could not be written into SQL as a literal. +/// +/// Only engines without parameters take this path, so the failure is +/// about the text form rather than about a wire type. +#[derive(Debug, Clone, PartialEq, Eq, Error)] +pub enum LiteralError { + #[error("{found} has no literal form in this dialect")] + Unrepresentable { found: &'static str }, + #[error("this value could not be read from the server, so it cannot be written back: {reason}")] + Undecodable { reason: String }, +} diff --git a/linux/crates/core/src/dialect/mod.rs b/linux/crates/core/src/dialect/mod.rs new file mode 100644 index 0000000000..6cb321fe0f --- /dev/null +++ b/linux/crates/core/src/dialect/mod.rs @@ -0,0 +1,26 @@ +//! How one engine spells SQL, and what goes wrong when a value cannot +//! become SQL. Each driver crate implements `SqlDialect` for its engine. + +mod bind_error; +mod bind_target; +mod dialect_capabilities; +mod insert_batch_limits; +mod keyset_direction; +mod like_form; +mod literal_error; +mod placeholder; +mod sql_dialect; +#[cfg(test)] +mod test_dialect; + +pub use bind_error::BindError; +pub use bind_target::BindTarget; +pub use dialect_capabilities::DialectCapabilities; +pub use insert_batch_limits::InsertBatchLimits; +pub use keyset_direction::KeysetDirection; +pub use like_form::{LikeCase, LikeForm}; +pub use literal_error::LiteralError; +pub use placeholder::Placeholder; +pub use sql_dialect::SqlDialect; +#[cfg(test)] +pub(crate) use test_dialect::{RecordedTarget, TestDialect, TestMarkers}; diff --git a/linux/crates/core/src/dialect/placeholder.rs b/linux/crates/core/src/dialect/placeholder.rs new file mode 100644 index 0000000000..a8af823e32 --- /dev/null +++ b/linux/crates/core/src/dialect/placeholder.rs @@ -0,0 +1,29 @@ +use crate::statement::BoundParam; + +/// What a dialect produced for one value: the SQL that names it, and +/// the parameter it stands for. +/// +/// `param` is `None` for an engine with no parameters, where the value +/// is already a literal inside `sql`. +#[derive(Debug, Clone, PartialEq)] +pub struct Placeholder { + pub sql: String, + pub param: Option, +} + +impl Placeholder { + pub fn bound(sql: impl Into, param: BoundParam) -> Self { + Self { + sql: sql.into(), + param: Some(param), + } + } + + /// For an engine that inlines the literal instead of binding it. + pub fn inline(sql: impl Into) -> Self { + Self { + sql: sql.into(), + param: None, + } + } +} diff --git a/linux/crates/core/src/dialect/sql_dialect.rs b/linux/crates/core/src/dialect/sql_dialect.rs new file mode 100644 index 0000000000..61d0d6e4b2 --- /dev/null +++ b/linux/crates/core/src/dialect/sql_dialect.rs @@ -0,0 +1,168 @@ +use crate::column::{ColumnInfo, ColumnType, SqlExpression}; +use crate::ddl::DdlDialect; +use crate::meta::{EngineRowId, EngineRowIdPart, TableRef}; +use crate::sql_syntax::{ExpressionSyntaxError, HistoryRecording, SqlGrammar, TypeSyntaxError}; +use crate::value::Value; + +use super::{ + BindError, BindTarget, DialectCapabilities, InsertBatchLimits, KeysetDirection, LikeCase, LikeForm, LiteralError, + Placeholder, +}; + +/// How one engine spells SQL. +/// +/// Every builder in core goes through this rather than matching on a +/// driver id, so adding an engine is a new implementation rather than a +/// new branch in the browse, filter and write paths. No method reads +/// session state or knows which driver it belongs to. +/// +/// Methods that take SQL fragments must keep them in the order given: +/// the builders bind markers in that order, and an engine with +/// positional markers (`?`) matches parameters by position. +pub trait SqlDialect: Send + Sync + std::fmt::Debug { + fn capabilities(&self) -> DialectCapabilities; + + /// The grammar the engine's SQL is written in, for validating and + /// lexing text the user typed. + fn grammar(&self) -> SqlGrammar; + + /// Wrap an identifier so it cannot be read as anything else, + /// whatever it contains. + fn quote_identifier(&self, name: &str) -> String; + + /// Bind one value, giving back the SQL that names it. + /// + /// `ordinal` is the number of parameters the statement already + /// holds, so a returned parameter lands at that index in + /// `Statement::params`. A numbered engine renders `$`; + /// one with named server-side parameters names it from the ordinal + /// (ClickHouse `{p:}`) and the driver binds + /// `params[i]` under the same name. An engine with no parameters + /// returns a literal and no parameter. + fn placeholder(&self, ordinal: usize, value: Value, target: BindTarget<'_>) -> Result; + + /// A value written into SQL directly, for the places a parameter + /// cannot go. + fn literal(&self, value: &Value, column: &ColumnType) -> Result; + + /// Escape the wildcards in text the app is turning into a pattern, + /// so a user searching for `50%` does not match everything. + fn escape_like_text(&self, text: &str) -> String; + + /// The ORDER BY and row-window tail of a page query. It starts with + /// a space, because the builders append it straight after the FROM + /// or WHERE clause. + fn row_window(&self, order_by: Option<&str>, limit: u64, offset: u64) -> String; + + /// The comparison that reads the page after (or before) a row, + /// given the key columns and the markers bound to its values. + fn row_value_after(&self, key_sql: &[String], markers: &[String], direction: KeysetDirection) -> String; + + /// The extra columns a page selects to carry the engine's own row + /// address, one per part in `EngineRowId::parts` order, or `None` + /// where the engine has none. + fn engine_row_id(&self, id: EngineRowId) -> Option; + + /// The column one part of that address is compared against in a + /// predicate. + fn engine_row_id_column(&self, part: EngineRowIdPart) -> Option; + + fn parse_type(&self, text: &str) -> Result; + + fn parse_expression(&self, text: &str) -> Result; + + /// Whether SQL the user ran may be kept in the query history. Each + /// engine names the statements that can hold a password or a + /// connection secret (`classify_for_history` with its own + /// `CredentialRules`), and those are never recorded. + fn history_recording(&self, sql: &str) -> HistoryRecording; + + /// The engine's structure-change spelling, or `None` where the app + /// does not edit this engine's structure. + fn ddl(&self) -> Option<&dyn DdlDialect> { + None + } + + fn qualify(&self, table: &TableRef) -> String { + match table.schema.as_deref().filter(|schema| !schema.is_empty()) { + Some(schema) => format!( + "{}.{}", + self.quote_identifier(schema), + self.quote_identifier(&table.name) + ), + None => self.quote_identifier(&table.name), + } + } + + /// How a column is selected. A type the driver cannot decode is + /// asked for as text instead, which is what `read_form` says. + fn projection(&self, column: &ColumnInfo) -> String { + self.quote_identifier(&column.name) + } + + fn sort_expression(&self, table: &TableRef, column: &ColumnInfo) -> String { + format!( + "{}.{}", + self.quote_identifier(&table.name), + self.quote_identifier(&column.name) + ) + } + + fn count_all(&self) -> &'static str { + "COUNT(*)" + } + + /// The column as text, where it meets a LIKE pattern or text the user + /// typed, for a type the engine will not compare with text as it is. + /// `None` when the column takes text as it is. + fn text_operand(&self, _column: &ColumnInfo) -> Option { + None + } + + /// Whether `=` and `IN` have to compare `text_operand`, because the + /// type has no equality of its own. + fn equality_as_text(&self, _column: &ColumnInfo) -> bool { + false + } + + /// `!` rather than a backslash: a backslash means something else + /// inside a string literal under some PostgreSQL and MySQL session + /// modes, and is no escape at all on SQLite and SQL Server. + fn like_predicate(&self, column_sql: &str, pattern_sql: &str, form: LikeForm) -> String { + let operator = match (form.negated, form.case) { + (false, LikeCase::EngineDefault) => "LIKE", + (true, LikeCase::EngineDefault) => "NOT LIKE", + (false, LikeCase::Insensitive) => "ILIKE", + (true, LikeCase::Insensitive) => "NOT ILIKE", + }; + match form.escaped { + true => format!("{column_sql} {operator} {pattern_sql} ESCAPE '!'"), + false => format!("{column_sql} {operator} {pattern_sql}"), + } + } + + fn update_statement(&self, table_sql: &str, assignments_sql: &str, predicate_sql: &str) -> String { + format!("UPDATE {table_sql} SET {assignments_sql} WHERE {predicate_sql}") + } + + fn delete_statement(&self, table_sql: &str, predicate_sql: &str) -> String { + format!("DELETE FROM {table_sql} WHERE {predicate_sql}") + } + + /// How much one multi-row INSERT may carry. + fn insert_batch_limits(&self) -> InsertBatchLimits { + InsertBatchLimits::DEFAULT + } + + /// Insert a row of nothing but defaults, where the engine has a + /// spelling for it. + fn insert_default_row(&self, table_sql: &str) -> Option { + Some(format!("INSERT INTO {table_sql} DEFAULT VALUES")) + } + + /// Count the rows a predicate matches, stopping at two: the caller + /// only needs to know whether it is exactly one. + fn key_probe(&self, table_sql: &str, predicate_sql: &str) -> String { + format!("SELECT COUNT(*) FROM (SELECT 1 AS one FROM {table_sql} WHERE {predicate_sql} LIMIT 2) AS probe") + } +} diff --git a/linux/crates/core/src/dialect/test_dialect.rs b/linux/crates/core/src/dialect/test_dialect.rs new file mode 100644 index 0000000000..348df538cd --- /dev/null +++ b/linux/crates/core/src/dialect/test_dialect.rs @@ -0,0 +1,329 @@ +use std::sync::Mutex; + +use crate::column::{ColumnInfo, ColumnType, SqlExpression, SqlTypeExpr}; +use crate::meta::{EngineRowId, EngineRowIdPart}; +use crate::sql_syntax::{ + CredentialRules, ExpressionSyntaxError, HistoryRecording, SqlGrammar, TypeSyntaxError, classify_for_history, + parse_expression, parse_type, +}; +use crate::statement::{BoundParam, ParamType}; +use crate::value::Value; + +use super::{ + BindError, BindTarget, DialectCapabilities, InsertBatchLimits, KeysetDirection, LiteralError, Placeholder, + SqlDialect, +}; + +/// The one ANSI dialect the core builders' own tests share. +/// +/// It records every bind it is asked for, so a test can check which +/// target each value was bound for and in what order, and it can switch +/// marker style to prove a builder keeps positional markers in bind +/// order. +#[derive(Debug)] +pub struct TestDialect { + exact_row_counts: bool, + markers: TestMarkers, + row_ids: bool, + default_row: bool, + count_all: &'static str, + grammar: SqlGrammar, + insert_limits: InsertBatchLimits, + binds: Mutex>, +} + +/// How the test dialect spells a marker. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum TestMarkers { + /// `$1`, `$2`: the style whose ordinals can disagree with the list. + #[default] + Numbered, + /// `?`, where only the position in the text names the parameter. + Positional, + /// `{p0:Type}`, a server-side parameter named by its index. + ServerNamed, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RecordedTarget { + Column(String), + Pattern, + EngineRowId(EngineRowIdPart), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct RecordedBind { + ordinal: usize, + target: RecordedTarget, +} + +impl Default for TestDialect { + fn default() -> Self { + Self { + exact_row_counts: false, + markers: TestMarkers::Numbered, + row_ids: true, + default_row: true, + count_all: "COUNT(*)", + grammar: SqlGrammar::PostgreSql, + insert_limits: InsertBatchLimits::DEFAULT, + binds: Mutex::new(Vec::new()), + } + } +} + +impl TestDialect { + pub fn exact() -> Self { + Self { + exact_row_counts: true, + ..Self::default() + } + } + + pub fn estimated() -> Self { + Self::default() + } + + pub fn with_markers(self, markers: TestMarkers) -> Self { + Self { markers, ..self } + } + + pub fn without_row_ids(self) -> Self { + Self { row_ids: false, ..self } + } + + pub fn without_default_row(self) -> Self { + Self { + default_row: false, + ..self + } + } + + pub fn counting_with(self, count_all: &'static str) -> Self { + Self { count_all, ..self } + } + + pub fn parsing_as(self, grammar: SqlGrammar) -> Self { + Self { grammar, ..self } + } + + pub fn with_insert_limits(self, insert_limits: InsertBatchLimits) -> Self { + Self { insert_limits, ..self } + } + + pub fn targets(&self) -> Vec { + self.recorded().into_iter().map(|bind| bind.target).collect() + } + + pub fn ordinals(&self) -> Vec { + self.recorded().into_iter().map(|bind| bind.ordinal).collect() + } + + fn recorded(&self) -> Vec { + self.binds.lock().map(|binds| binds.clone()).unwrap_or_default() + } + + fn record(&self, ordinal: usize, target: RecordedTarget) { + if let Ok(mut binds) = self.binds.lock() { + binds.push(RecordedBind { ordinal, target }); + } + } + + fn marker(&self, ordinal: usize, declaration: &str) -> String { + match self.markers { + TestMarkers::Numbered => format!("${}", ordinal + 1), + TestMarkers::Positional => "?".to_owned(), + TestMarkers::ServerNamed => format!("{{p{ordinal}:{declaration}}}"), + } + } +} + +impl SqlDialect for TestDialect { + fn capabilities(&self) -> DialectCapabilities { + DialectCapabilities { + exact_row_counts: self.exact_row_counts, + } + } + + fn grammar(&self) -> SqlGrammar { + self.grammar + } + + fn quote_identifier(&self, name: &str) -> String { + format!("\"{}\"", name.replace('"', "\"\"")) + } + + fn placeholder(&self, ordinal: usize, value: Value, target: BindTarget<'_>) -> Result { + let (param_type, declaration, recorded) = match target { + BindTarget::Pattern => (ParamType::Text, "String".to_owned(), RecordedTarget::Pattern), + BindTarget::EngineRowId(part) => ( + row_id_param_type(part), + row_id_type(part).to_owned(), + RecordedTarget::EngineRowId(part), + ), + BindTarget::Column(column) => ( + param_type_for(column), + column + .column_type + .catalog + .as_ref() + .map(|catalog| catalog.declaration.as_sql().to_owned()) + .unwrap_or_else(|| column.column_type.name.clone()), + RecordedTarget::Column(column.name.clone()), + ), + }; + let param = BoundParam::new(value, param_type)?; + self.record(ordinal, recorded); + Ok(Placeholder::bound(self.marker(ordinal, &declaration), param)) + } + + fn literal(&self, value: &Value, _column: &ColumnType) -> Result { + match value { + Value::Null => Ok("NULL".to_owned()), + Value::Bool(flag) => Ok(flag.to_string()), + Value::Int(number) => Ok(number.to_string()), + Value::UInt(number) => Ok(number.to_string()), + Value::Text(text) => Ok(format!("'{}'", text.replace('\'', "''"))), + Value::Undecodable(undecodable) => Err(LiteralError::Undecodable { + reason: format!("{:?}", undecodable.reason), + }), + other => Err(LiteralError::Unrepresentable { + found: other.variant_name(), + }), + } + } + + fn escape_like_text(&self, text: &str) -> String { + text.replace('!', "!!").replace('%', "!%").replace('_', "!_") + } + + fn row_window(&self, order_by: Option<&str>, limit: u64, offset: u64) -> String { + let order = order_by.map(|order| format!(" ORDER BY {order}")).unwrap_or_default(); + match offset { + 0 => format!("{order} LIMIT {limit}"), + _ => format!("{order} LIMIT {limit} OFFSET {offset}"), + } + } + + fn row_value_after(&self, key_sql: &[String], markers: &[String], direction: KeysetDirection) -> String { + format!( + "({}) {} ({})", + key_sql.join(", "), + direction.comparison(), + markers.join(", ") + ) + } + + fn engine_row_id(&self, id: EngineRowId) -> Option { + let projections: Option> = id + .parts() + .iter() + .map(|part| { + let column = self.engine_row_id_column(*part)?; + Some(format!( + "{column} AS {}", + self.quote_identifier(part.projection_alias()) + )) + }) + .collect(); + projections.map(|projections| projections.join(", ")) + } + + fn engine_row_id_column(&self, part: EngineRowIdPart) -> Option { + if !self.row_ids { + return None; + } + Some( + match part { + EngineRowIdPart::PostgresTableOid => "tableoid", + EngineRowIdPart::PostgresCtid => "ctid", + EngineRowIdPart::PostgresXmin => "xmin", + EngineRowIdPart::SqliteRowid => "rowid", + } + .to_owned(), + ) + } + + fn count_all(&self) -> &'static str { + self.count_all + } + + fn insert_batch_limits(&self) -> InsertBatchLimits { + self.insert_limits + } + + fn insert_default_row(&self, table_sql: &str) -> Option { + self.default_row + .then(|| format!("INSERT INTO {table_sql} DEFAULT VALUES")) + } + + fn parse_type(&self, text: &str) -> Result { + let parsed = parse_type(self.grammar, text, crate::sql_syntax::MAX_TYPE_LEN)?; + Ok(ColumnType::untyped_text(parsed.text)) + } + + fn parse_expression(&self, text: &str) -> Result { + let parsed = parse_expression(self.grammar, text, crate::sql_syntax::MAX_EXPRESSION_LEN)?; + Ok(SqlExpression::from_catalog_text(parsed.text)) + } + + fn history_recording(&self, sql: &str) -> HistoryRecording { + const RULES: CredentialRules = CredentialRules { + statement_prefixes: &[&["CREATE", "USER"], &["ALTER", "USER"]], + conditional_prefixes: &[], + carriers: &[], + }; + classify_for_history(self.grammar, &RULES, sql) + } +} + +fn row_id_type(part: EngineRowIdPart) -> &'static str { + match part { + EngineRowIdPart::PostgresTableOid => "oid", + EngineRowIdPart::PostgresCtid => "tid", + EngineRowIdPart::PostgresXmin => "xid", + EngineRowIdPart::SqliteRowid => "Int64", + } +} + +fn row_id_param_type(part: EngineRowIdPart) -> ParamType { + match part { + EngineRowIdPart::SqliteRowid => ParamType::Int64, + other => ParamType::ServerText(SqlTypeExpr::from_catalog_text(row_id_type(other))), + } +} + +/// The parameter type a column takes, reduced to what the tests need. +fn param_type_for(column: &ColumnInfo) -> ParamType { + use crate::column::{BoolStorage, ColumnKind, FloatWidth, IntegerWidth, TemporalZone}; + + match &column.column_type.kind { + ColumnKind::Bool(BoolStorage::Native) => ParamType::Bool, + ColumnKind::Bool(BoolStorage::Integer) => ParamType::Int64, + ColumnKind::Integer(kind) => match (kind.width, kind.signed) { + (IntegerWidth::W8 | IntegerWidth::W16, true) | (IntegerWidth::W8, false) => ParamType::Int16, + (IntegerWidth::W24 | IntegerWidth::W32, true) | (IntegerWidth::W16 | IntegerWidth::W24, false) => { + ParamType::Int32 + } + (IntegerWidth::W64 | IntegerWidth::W128 | IntegerWidth::W256, false) => ParamType::UInt64, + _ => ParamType::Int64, + }, + ColumnKind::Decimal { .. } | ColumnKind::Money { .. } => ParamType::Decimal, + ColumnKind::Float(FloatWidth::F32) => ParamType::Float32, + ColumnKind::Float(FloatWidth::F64) => ParamType::Float64, + ColumnKind::Uuid => ParamType::Uuid, + ColumnKind::Json { .. } => ParamType::Json, + ColumnKind::Date => ParamType::Date, + ColumnKind::Time { with_offset: false, .. } => ParamType::Time, + ColumnKind::Time { with_offset: true, .. } => ParamType::TimeTz, + ColumnKind::Timestamp { + zone: TemporalZone::Offset, + .. + } => ParamType::TimestampTz, + ColumnKind::Timestamp { .. } => ParamType::Timestamp, + ColumnKind::Interval => ParamType::Interval, + ColumnKind::Bytes { .. } => ParamType::Bytes, + ColumnKind::BitString { .. } => ParamType::Bits, + _ => ParamType::Text, + } +} diff --git a/linux/crates/core/src/dml/build_delete.rs b/linux/crates/core/src/dml/build_delete.rs new file mode 100644 index 0000000000..bc738467c2 --- /dev/null +++ b/linux/crates/core/src/dml/build_delete.rs @@ -0,0 +1,22 @@ +use crate::column::ColumnInfo; +use crate::dialect::SqlDialect; +use crate::meta::{RowIdentity, TableRef}; +use crate::statement::{Statement, StatementBuilder}; +use crate::value::Value; + +use super::predicate::key_predicate; +use super::{BuildSqlError, key_components}; + +pub fn build_delete( + dialect: &dyn SqlDialect, + table: &TableRef, + columns: &[ColumnInfo], + identity: &RowIdentity, + key: &[Value], +) -> Result { + let components = key_components(identity, columns)?; + let mut builder = StatementBuilder::new(); + let predicate = key_predicate(&mut builder, dialect, &components, key)?; + builder.push_sql(&dialect.delete_statement(&dialect.qualify(table), &predicate)); + Ok(builder.finish()) +} diff --git a/linux/crates/core/src/dml/build_insert.rs b/linux/crates/core/src/dml/build_insert.rs new file mode 100644 index 0000000000..a3124de0dc --- /dev/null +++ b/linux/crates/core/src/dml/build_insert.rs @@ -0,0 +1,104 @@ +use crate::column::ColumnInfo; +use crate::dialect::{BindTarget, SqlDialect}; +use crate::edit::CellInput; +use crate::meta::TableRef; +use crate::statement::{Statement, StatementBuilder}; +use crate::value::Value; + +use super::BuildSqlError; + +/// The INSERT for one row. +/// +/// A `Default` cell is left out of the statement, so the server applies +/// its own default or next number. A generated column is never written, +/// because the server computes it. Every other value is written, an +/// auto-increment column's included: a value given for one is a key the +/// caller means to keep, and the caller leaves out the identities an +/// engine refuses a value for. +pub fn build_insert( + dialect: &dyn SqlDialect, + table: &TableRef, + columns: &[ColumnInfo], + cells: &[CellInput], +) -> Result { + if cells.len() != columns.len() { + return Err(BuildSqlError::LengthMismatch { + expected: columns.len(), + found: cells.len(), + }); + } + let table_sql = dialect.qualify(table); + + let mut builder = StatementBuilder::new(); + let mut names = Vec::new(); + let mut markers = Vec::new(); + for (column, cell) in columns.iter().zip(cells) { + let Some(value) = cell.value() else { + continue; + }; + if column.is_generated { + continue; + } + markers.push(builder.bind(dialect, value.clone(), BindTarget::Column(column))?); + names.push(dialect.quote_identifier(&column.name)); + } + + if names.is_empty() { + // Nothing to say but "a row": the engine has to have a + // spelling for that, or there is no insert to make. + let sql = dialect + .insert_default_row(&table_sql) + .ok_or(BuildSqlError::NoInsertValues)?; + builder.push_sql(&sql); + return Ok(builder.finish()); + } + + builder.push_sql(&format!( + "INSERT INTO {table_sql} ({}) VALUES ({})", + names.join(", "), + markers.join(", ") + )); + Ok(builder.finish()) +} + +/// One INSERT of several rows into the same columns, each row holding +/// one value per column, in column order. +/// +/// One statement takes one round trip for every row in it, and +/// ClickHouse makes one part of it rather than one per row. +pub fn build_multi_row_insert( + dialect: &dyn SqlDialect, + table: &TableRef, + columns: &[&ColumnInfo], + rows: Vec>, +) -> Result { + if columns.is_empty() || rows.is_empty() { + return Err(BuildSqlError::NoInsertValues); + } + let mut builder = StatementBuilder::new(); + let mut tuples = Vec::with_capacity(rows.len()); + for row in rows { + if row.len() != columns.len() { + return Err(BuildSqlError::LengthMismatch { + expected: columns.len(), + found: row.len(), + }); + } + let mut markers = Vec::with_capacity(columns.len()); + for (&column, value) in columns.iter().zip(row) { + markers.push(builder.bind(dialect, value, BindTarget::Column(column))?); + } + tuples.push(format!("({})", markers.join(", "))); + } + let names: Vec = columns + .iter() + .map(|column| dialect.quote_identifier(&column.name)) + .collect(); + builder.push_sql(&format!( + "INSERT INTO {} ({}) VALUES {}", + dialect.qualify(table), + names.join(", "), + tuples.join(", ") + )); + Ok(builder.finish()) +} diff --git a/linux/crates/core/src/dml/build_key_probe.rs b/linux/crates/core/src/dml/build_key_probe.rs new file mode 100644 index 0000000000..5134ea3a4c --- /dev/null +++ b/linux/crates/core/src/dml/build_key_probe.rs @@ -0,0 +1,24 @@ +use crate::column::ColumnInfo; +use crate::dialect::SqlDialect; +use crate::meta::{RowIdentity, TableRef}; +use crate::statement::{Statement, StatementBuilder}; +use crate::value::Value; + +use super::predicate::key_predicate; +use super::{BuildSqlError, key_components}; + +/// The SELECT that counts what a key matches, for the engines whose +/// affected-row count is not proof that a write hit exactly one row. +pub fn build_key_probe( + dialect: &dyn SqlDialect, + table: &TableRef, + columns: &[ColumnInfo], + identity: &RowIdentity, + key: &[Value], +) -> Result { + let components = key_components(identity, columns)?; + let mut builder = StatementBuilder::new(); + let predicate = key_predicate(&mut builder, dialect, &components, key)?; + builder.push_sql(&dialect.key_probe(&dialect.qualify(table), &predicate)); + Ok(builder.finish()) +} diff --git a/linux/crates/core/src/dml/build_sql_error.rs b/linux/crates/core/src/dml/build_sql_error.rs new file mode 100644 index 0000000000..f2d29f69a2 --- /dev/null +++ b/linux/crates/core/src/dml/build_sql_error.rs @@ -0,0 +1,24 @@ +use thiserror::Error; + +use crate::dialect::BindError; + +/// Why a row change could not be written as SQL. +#[derive(Debug, Clone, PartialEq, Eq, Error)] +pub enum BuildSqlError { + #[error("this table has no way to name a single row")] + NoRowIdentity, + #[error("rows in this table cannot be edited, because nothing identifies one")] + ReadOnlyRows, + #[error("the row key names a column this table does not have: {name}")] + UnknownKeyColumn { name: String }, + #[error("there is nothing to update")] + NothingToUpdate, + #[error("there is nothing to insert")] + NoInsertValues, + #[error("the row key has {found} values but the table needs {expected}")] + LengthMismatch { expected: usize, found: usize }, + #[error("part of this row's key could not be read from the server, so the row cannot be changed")] + UndecodableKey, + #[error(transparent)] + Bind(#[from] BindError), +} diff --git a/linux/crates/core/src/dml/build_update.rs b/linux/crates/core/src/dml/build_update.rs new file mode 100644 index 0000000000..22f2566254 --- /dev/null +++ b/linux/crates/core/src/dml/build_update.rs @@ -0,0 +1,40 @@ +use crate::column::ColumnInfo; +use crate::dialect::{BindTarget, SqlDialect}; +use crate::meta::{RowIdentity, TableRef}; +use crate::statement::{Statement, StatementBuilder}; +use crate::value::Value; + +use super::predicate::key_predicate; +use super::{BuildSqlError, key_components}; + +/// The UPDATE for one row's changed cells. +/// +/// Only the cells that moved are assigned, so a save does not rewrite +/// columns the user never touched and trigger their side effects. +pub fn build_update( + dialect: &dyn SqlDialect, + table: &TableRef, + columns: &[ColumnInfo], + identity: &RowIdentity, + key: &[Value], + assignments: &[(usize, Value)], +) -> Result { + if assignments.is_empty() { + return Err(BuildSqlError::NothingToUpdate); + } + let components = key_components(identity, columns)?; + + let mut builder = StatementBuilder::new(); + let mut set = Vec::with_capacity(assignments.len()); + for (index, value) in assignments { + let column = columns.get(*index).ok_or(BuildSqlError::UnknownKeyColumn { + name: index.to_string(), + })?; + let marker = builder.bind(dialect, value.clone(), BindTarget::Column(column))?; + set.push(format!("{} = {marker}", dialect.quote_identifier(&column.name))); + } + let predicate = key_predicate(&mut builder, dialect, &components, key)?; + + builder.push_sql(&dialect.update_statement(&dialect.qualify(table), &set.join(", "), &predicate)); + Ok(builder.finish()) +} diff --git a/linux/crates/core/src/dml/key_component.rs b/linux/crates/core/src/dml/key_component.rs new file mode 100644 index 0000000000..eab140f91c --- /dev/null +++ b/linux/crates/core/src/dml/key_component.rs @@ -0,0 +1,128 @@ +use crate::column::ColumnInfo; +use crate::meta::{EngineRowIdPart, RowIdentity}; + +use super::BuildSqlError; + +/// One part of the key that names a row. +/// +/// Either a real column the user can see, or a piece of the engine's +/// own row address that the page projected as a hidden column. +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum KeyComponent<'a> { + Column(&'a ColumnInfo), + EngineRowId(EngineRowIdPart), +} + +/// The key's parts, in the one order everything else follows. +/// +/// The page layout projects them in this order, the change set stores +/// its key values in it, and the UPDATE predicate binds them in it. One +/// function so those three cannot drift apart. +pub fn key_components<'a>( + identity: &RowIdentity, + columns: &'a [ColumnInfo], +) -> Result>, BuildSqlError> { + match identity { + RowIdentity::UniqueKey { columns: names } => by_name(names, columns), + RowIdentity::SortingKey { expressions } => by_name(expressions, columns), + RowIdentity::EngineRowId(id) => Ok(id.parts().iter().copied().map(KeyComponent::EngineRowId).collect()), + RowIdentity::Unordered => Err(BuildSqlError::ReadOnlyRows), + } +} + +fn by_name<'a>(names: &[String], columns: &'a [ColumnInfo]) -> Result>, BuildSqlError> { + names + .iter() + .map(|name| { + columns + .iter() + .find(|column| &column.name == name) + .map(KeyComponent::Column) + .ok_or_else(|| BuildSqlError::UnknownKeyColumn { name: name.clone() }) + }) + .collect() +} + +#[cfg(test)] +mod tests { + use crate::column::{ColumnDefault, ColumnKind, ColumnType, IntegerKind, IntegerWidth, ReadForm}; + use crate::meta::EngineRowId; + + use super::*; + + fn column(name: &str) -> ColumnInfo { + ColumnInfo { + name: name.to_owned(), + column_type: ColumnType { + name: "integer".to_owned(), + kind: ColumnKind::Integer(IntegerKind { + width: IntegerWidth::W32, + signed: true, + }), + catalog: None, + dynamic_storage: false, + read_form: ReadForm::Native, + }, + nullable: false, + primary_key: true, + is_auto_increment: false, + is_generated: false, + default: ColumnDefault::None, + comment: None, + } + } + + #[test] + fn a_unique_key_maps_to_its_columns_in_key_order() { + let columns = [column("tenant"), column("id"), column("other")]; + let identity = RowIdentity::UniqueKey { + columns: vec!["id".to_owned(), "tenant".to_owned()], + }; + + let parts = key_components(&identity, &columns).expect("the key"); + + assert_eq!( + parts, + vec![KeyComponent::Column(&columns[1]), KeyComponent::Column(&columns[0])] + ); + } + + #[test] + fn a_key_naming_a_column_the_table_lost_is_an_error() { + let identity = RowIdentity::UniqueKey { + columns: vec!["gone".to_owned()], + }; + + let error = key_components(&identity, &[column("id")]).expect_err("a missing column"); + + assert_eq!( + error, + BuildSqlError::UnknownKeyColumn { + name: "gone".to_owned() + } + ); + } + + #[test] + fn an_engine_row_id_maps_to_its_parts() { + let identity = RowIdentity::EngineRowId(EngineRowId::PostgresTableoidCtid); + + let parts = key_components(&identity, &[]).expect("the key"); + + assert_eq!( + parts, + vec![ + KeyComponent::EngineRowId(EngineRowIdPart::PostgresTableOid), + KeyComponent::EngineRowId(EngineRowIdPart::PostgresCtid), + KeyComponent::EngineRowId(EngineRowIdPart::PostgresXmin), + ] + ); + } + + #[test] + fn key_components_unordered_is_read_only() { + let error = key_components(&RowIdentity::Unordered, &[column("id")]).expect_err("no identity"); + + assert_eq!(error, BuildSqlError::ReadOnlyRows); + } +} diff --git a/linux/crates/core/src/dml/mod.rs b/linux/crates/core/src/dml/mod.rs new file mode 100644 index 0000000000..c833ec9cb9 --- /dev/null +++ b/linux/crates/core/src/dml/mod.rs @@ -0,0 +1,22 @@ +//! Turning a row change into SQL. +//! +//! Every value the user typed is bound, never written into the text, +//! and every statement names the row through the same key ordering the +//! page laid out. + +mod build_delete; +mod build_insert; +mod build_key_probe; +mod build_sql_error; +mod build_update; +mod key_component; +mod predicate; +#[cfg(test)] +mod tests; + +pub use build_delete::build_delete; +pub use build_insert::{build_insert, build_multi_row_insert}; +pub use build_key_probe::build_key_probe; +pub use build_sql_error::BuildSqlError; +pub use build_update::build_update; +pub use key_component::{KeyComponent, key_components}; diff --git a/linux/crates/core/src/dml/predicate.rs b/linux/crates/core/src/dml/predicate.rs new file mode 100644 index 0000000000..8fa47d3674 --- /dev/null +++ b/linux/crates/core/src/dml/predicate.rs @@ -0,0 +1,47 @@ +use crate::dialect::{BindTarget, SqlDialect}; +use crate::statement::StatementBuilder; +use crate::value::Value; + +use super::{BuildSqlError, KeyComponent}; + +/// The predicate that names one row, with its values bound in +/// `builder`. +/// +/// A NULL key part renders `IS NULL` with no marker: `= NULL` is never +/// true, so binding it would silently match nothing. +pub(super) fn key_predicate( + builder: &mut StatementBuilder, + dialect: &dyn SqlDialect, + components: &[KeyComponent<'_>], + key: &[Value], +) -> Result { + if key.len() != components.len() { + return Err(BuildSqlError::LengthMismatch { + expected: components.len(), + found: key.len(), + }); + } + if key.iter().any(|value| matches!(value, Value::Undecodable(_))) { + return Err(BuildSqlError::UndecodableKey); + } + + let mut clauses = Vec::with_capacity(components.len()); + for (component, value) in components.iter().zip(key) { + let (column_sql, target) = match component { + KeyComponent::Column(column) => (dialect.quote_identifier(&column.name), BindTarget::Column(column)), + KeyComponent::EngineRowId(part) => ( + dialect + .engine_row_id_column(*part) + .ok_or(BuildSqlError::NoRowIdentity)?, + BindTarget::EngineRowId(*part), + ), + }; + if value.is_null() { + clauses.push(format!("{column_sql} IS NULL")); + continue; + } + let marker = builder.bind(dialect, value.clone(), target)?; + clauses.push(format!("{column_sql} = {marker}")); + } + Ok(clauses.join(" AND ")) +} diff --git a/linux/crates/core/src/dml/tests.rs b/linux/crates/core/src/dml/tests.rs new file mode 100644 index 0000000000..93eab785f4 --- /dev/null +++ b/linux/crates/core/src/dml/tests.rs @@ -0,0 +1,383 @@ +use crate::column::{ + ColumnDefault, ColumnInfo, ColumnKind, ColumnType, IntegerKind, IntegerWidth, ReadForm, TextLength, +}; +use crate::dialect::{RecordedTarget, TestDialect, TestMarkers}; +use crate::edit::CellInput; +use crate::meta::{EngineRowId, EngineRowIdPart, RowIdentity, TableRef}; +use crate::value::{UndecodableReason, UndecodedValue, Value}; + +use super::*; + +fn column(name: &str, kind: ColumnKind) -> ColumnInfo { + ColumnInfo { + name: name.to_owned(), + column_type: ColumnType { + name: "t".to_owned(), + kind, + catalog: None, + dynamic_storage: false, + read_form: ReadForm::Native, + }, + nullable: true, + primary_key: false, + is_auto_increment: false, + is_generated: false, + default: ColumnDefault::None, + comment: None, + } +} + +fn bigint() -> ColumnKind { + ColumnKind::Integer(IntegerKind { + width: IntegerWidth::W64, + signed: true, + }) +} + +fn columns() -> Vec { + vec![ + column("id", bigint()), + column( + "name", + ColumnKind::Text { + length: TextLength::Unbounded, + }, + ), + column("tenant", bigint()), + ] +} + +fn table() -> TableRef { + TableRef::new(Some("public".to_owned()), "users") +} + +fn by_id() -> RowIdentity { + RowIdentity::UniqueKey { + columns: vec!["id".to_owned()], + } +} + +fn undecodable() -> Value { + Value::Undecodable(Box::new(UndecodedValue { + type_name: "geography".to_owned(), + reason: UndecodableReason::UnsupportedType, + })) +} + +#[test] +fn build_update_binds_every_value_and_names_the_row() { + let dialect = TestDialect::exact(); + let columns = columns(); + + let statement = build_update( + &dialect, + &table(), + &columns, + &by_id(), + &[Value::Int(7)], + &[(1, Value::Text("new".to_owned()))], + ) + .expect("the update"); + + assert_eq!( + statement.sql(), + r#"UPDATE "public"."users" SET "name" = $1 WHERE "id" = $2"# + ); + assert_eq!(statement.params().len(), 2); + assert_eq!(statement.params()[0].value(), &Value::Text("new".to_owned())); + assert_eq!(statement.params()[1].value(), &Value::Int(7)); +} + +#[test] +fn build_update_null_key_renders_is_null_without_marker() { + let dialect = TestDialect::exact(); + let columns = columns(); + let identity = RowIdentity::UniqueKey { + columns: vec!["id".to_owned(), "tenant".to_owned()], + }; + + let statement = build_update( + &dialect, + &table(), + &columns, + &identity, + &[Value::Int(7), Value::Null], + &[(1, Value::Text("new".to_owned()))], + ) + .expect("the update"); + + assert!( + statement.sql().ends_with(r#"WHERE "id" = $2 AND "tenant" IS NULL"#), + "{}", + statement.sql() + ); + assert_eq!(statement.params().len(), 2, "a null key part was bound as a parameter"); +} + +#[test] +fn build_update_undecodable_key_is_error() { + let dialect = TestDialect::exact(); + + let error = build_update( + &dialect, + &table(), + &columns(), + &by_id(), + &[undecodable()], + &[(1, Value::Text("new".to_owned()))], + ) + .expect_err("an unreadable key"); + + assert_eq!(error, BuildSqlError::UndecodableKey); +} + +#[test] +fn build_update_with_no_assignments_is_error() { + let dialect = TestDialect::exact(); + + let error = + build_update(&dialect, &table(), &columns(), &by_id(), &[Value::Int(1)], &[]).expect_err("nothing to change"); + + assert_eq!(error, BuildSqlError::NothingToUpdate); +} + +#[test] +fn a_key_of_the_wrong_length_is_error() { + let dialect = TestDialect::exact(); + + let error = build_delete( + &dialect, + &table(), + &columns(), + &by_id(), + &[Value::Int(1), Value::Int(2)], + ) + .expect_err("too many key values"); + + assert_eq!(error, BuildSqlError::LengthMismatch { expected: 1, found: 2 }); +} + +#[test] +fn an_engine_row_id_needs_a_dialect_that_has_one() { + let dialect = TestDialect::exact().without_row_ids(); + let identity = RowIdentity::EngineRowId(EngineRowId::SqliteRowid); + + let error = build_delete(&dialect, &table(), &columns(), &identity, &[Value::Int(11)]) + .expect_err("an engine with no row id"); + + assert_eq!(error, BuildSqlError::NoRowIdentity); +} + +#[test] +fn build_delete_names_the_row_and_nothing_else() { + let dialect = TestDialect::exact(); + + let statement = build_delete(&dialect, &table(), &columns(), &by_id(), &[Value::Int(7)]).expect("the delete"); + + assert_eq!(statement.sql(), r#"DELETE FROM "public"."users" WHERE "id" = $1"#); + assert_eq!(statement.params().len(), 1); +} + +#[test] +fn build_key_probe_stops_at_two_rows() { + let dialect = TestDialect::default(); + + let statement = build_key_probe(&dialect, &table(), &columns(), &by_id(), &[Value::Int(7)]).expect("the probe"); + + assert!(statement.sql().contains("LIMIT 2"), "{}", statement.sql()); + assert!(statement.sql().starts_with("SELECT COUNT(*)"), "{}", statement.sql()); +} + +#[test] +fn build_insert_omits_generated_columns() { + let dialect = TestDialect::exact(); + let mut columns = columns(); + columns[2].is_generated = true; + + let statement = build_insert( + &dialect, + &table(), + &columns, + &[ + CellInput::Value(Value::Int(1)), + CellInput::Value(Value::Text("a".to_owned())), + CellInput::Value(Value::Int(2)), + ], + ) + .expect("the insert"); + + assert_eq!( + statement.sql(), + r#"INSERT INTO "public"."users" ("id", "name") VALUES ($1, $2)"#, + "a generated column was written" + ); + assert_eq!(statement.params().len(), 2); +} + +#[test] +fn build_insert_writes_the_value_given_for_an_auto_increment_column() { + let dialect = TestDialect::exact(); + let mut columns = columns(); + columns[0].is_auto_increment = true; + + let given = build_insert( + &dialect, + &table(), + &columns, + &[ + CellInput::Value(Value::Int(100)), + CellInput::Value(Value::Text("a".to_owned())), + CellInput::Default, + ], + ) + .expect("the insert"); + let left = build_insert( + &dialect, + &table(), + &columns, + &[ + CellInput::Default, + CellInput::Value(Value::Text("a".to_owned())), + CellInput::Default, + ], + ) + .expect("the insert"); + + assert_eq!( + given.sql(), + r#"INSERT INTO "public"."users" ("id", "name") VALUES ($1, $2)"#, + "the id the row was given was dropped" + ); + assert_eq!(given.params()[0].value(), &Value::Int(100)); + assert_eq!(left.sql(), r#"INSERT INTO "public"."users" ("name") VALUES ($1)"#); +} + +#[test] +fn build_insert_leaves_out_the_cells_the_user_did_not_fill() { + let dialect = TestDialect::exact(); + + let statement = build_insert( + &dialect, + &table(), + &columns(), + &[ + CellInput::Value(Value::Int(1)), + CellInput::Default, + CellInput::Value(Value::Int(2)), + ], + ) + .expect("the insert"); + + assert_eq!( + statement.sql(), + r#"INSERT INTO "public"."users" ("id", "tenant") VALUES ($1, $2)"# + ); +} + +#[test] +fn build_insert_all_default_uses_default_row_or_errors() { + let all_default = [CellInput::Default, CellInput::Default, CellInput::Default]; + + let statement = + build_insert(&TestDialect::exact(), &table(), &columns(), &all_default).expect("the default-row insert"); + let error = build_insert( + &TestDialect::exact().without_default_row(), + &table(), + &columns(), + &all_default, + ) + .expect_err("an engine with no default-row insert"); + + assert_eq!(statement.sql(), r#"INSERT INTO "public"."users" DEFAULT VALUES"#); + assert!(statement.params().is_empty()); + assert_eq!(error, BuildSqlError::NoInsertValues); +} + +#[test] +fn build_update_engine_row_id_uses_part_columns_and_targets() { + let dialect = TestDialect::exact(); + let identity = RowIdentity::EngineRowId(EngineRowId::PostgresTableoidCtid); + let key = [ + Value::Text("16384".to_owned()), + Value::Text("(0,7)".to_owned()), + Value::Text("742".to_owned()), + ]; + + let statement = build_update( + &dialect, + &table(), + &columns(), + &identity, + &key, + &[(1, Value::Text("new".to_owned()))], + ) + .expect("the update"); + + assert_eq!( + statement.sql(), + r#"UPDATE "public"."users" SET "name" = $1 WHERE tableoid = $2 AND ctid = $3 AND xmin = $4"# + ); + assert_eq!( + dialect.targets(), + vec![ + RecordedTarget::Column("name".to_owned()), + RecordedTarget::EngineRowId(EngineRowIdPart::PostgresTableOid), + RecordedTarget::EngineRowId(EngineRowIdPart::PostgresCtid), + RecordedTarget::EngineRowId(EngineRowIdPart::PostgresXmin), + ] + ); +} + +#[test] +fn positional_markers_bind_assignments_before_the_key() { + let dialect = TestDialect::exact().with_markers(TestMarkers::Positional); + let identity = RowIdentity::UniqueKey { + columns: vec!["id".to_owned(), "tenant".to_owned()], + }; + + let statement = build_update( + &dialect, + &table(), + &columns(), + &identity, + &[Value::Int(7), Value::Int(3)], + &[(1, Value::Text("new".to_owned()))], + ) + .expect("the update"); + + assert_eq!( + statement.sql(), + r#"UPDATE "public"."users" SET "name" = ? WHERE "id" = ? AND "tenant" = ?"# + ); + let values: Vec<_> = statement.params().iter().map(|param| param.value().clone()).collect(); + assert_eq!( + values, + vec![Value::Text("new".to_owned()), Value::Int(7), Value::Int(3)] + ); +} + +#[test] +fn a_draft_row_of_the_wrong_width_is_error() { + let dialect = TestDialect::exact(); + + let error = build_insert(&dialect, &table(), &columns(), &[CellInput::Default]).expect_err("a short row"); + + assert_eq!(error, BuildSqlError::LengthMismatch { expected: 3, found: 1 }); +} + +#[test] +fn a_value_of_the_wrong_type_is_refused_before_it_reaches_the_server() { + let dialect = TestDialect::exact(); + + let error = build_update( + &dialect, + &table(), + &columns(), + &by_id(), + &[Value::Int(7)], + &[(0, Value::Text("not a number".to_owned()))], + ) + .expect_err("text into an integer column"); + + assert!(matches!(error, BuildSqlError::Bind(_)), "{error:?}"); +} diff --git a/linux/crates/core/src/driver.rs b/linux/crates/core/src/driver.rs new file mode 100644 index 0000000000..046147bc10 --- /dev/null +++ b/linux/crates/core/src/driver.rs @@ -0,0 +1,29 @@ +use std::sync::Arc; + +use async_trait::async_trait; +use tokio_util::sync::CancellationToken; + +use crate::capabilities::DriverCapabilities; +use crate::connect_options::ConnectOptions; +use crate::connection::Connection; +use crate::error::DriverError; + +/// One database engine, before any connection to it exists. +#[async_trait] +pub trait DatabaseDriver: Send + Sync { + fn id(&self) -> &'static str; + + fn display_name(&self) -> &'static str; + + /// What the engine can do, for the connect dialog and the save + /// paths to decide what to offer before a connection is open. + fn capabilities(&self) -> &'static DriverCapabilities; + + /// Open a connection. `cancel` aborts the connect itself, including a + /// login the server is slow to answer. + async fn connect( + &self, + options: ConnectOptions, + cancel: CancellationToken, + ) -> Result, DriverError>; +} diff --git a/linux/crates/core/src/edit/cell_input.rs b/linux/crates/core/src/edit/cell_input.rs new file mode 100644 index 0000000000..32c9712108 --- /dev/null +++ b/linux/crates/core/src/edit/cell_input.rs @@ -0,0 +1,41 @@ +use crate::value::Value; + +/// What the user put in a cell. +/// +/// Leaving a cell empty in a draft row is not the same as typing NULL: +/// the first asks the server for its default, the second stores a null. +/// Keeping them apart is what lets an insert omit the column. +#[derive(Debug, Clone, PartialEq)] +pub enum CellInput { + /// Omit the column so the server applies its default. + Default, + Value(Value), +} + +impl CellInput { + pub fn value(&self) -> Option<&Value> { + match self { + Self::Value(value) => Some(value), + Self::Default => None, + } + } + + pub fn is_default(&self) -> bool { + matches!(self, Self::Default) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_default_is_not_a_null() { + assert!(CellInput::Default.is_default()); + assert_eq!(CellInput::Default.value(), None); + + let null = CellInput::Value(Value::Null); + assert!(!null.is_default()); + assert_eq!(null.value(), Some(&Value::Null)); + } +} diff --git a/linux/crates/core/src/edit/edit_context.rs b/linux/crates/core/src/edit/edit_context.rs new file mode 100644 index 0000000000..4ff7261dbf --- /dev/null +++ b/linux/crates/core/src/edit/edit_context.rs @@ -0,0 +1,10 @@ +/// Which row an edit lands in, which decides what an empty cell means. +/// +/// Only an insert can omit a column and let the server fill it, so an +/// empty cell asks for the default in a draft row and never in an +/// existing one. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum EditContext { + InsertDraft, + UpdateRow, +} diff --git a/linux/crates/core/src/edit/edit_parse_error.rs b/linux/crates/core/src/edit/edit_parse_error.rs new file mode 100644 index 0000000000..f7059cf36a --- /dev/null +++ b/linux/crates/core/src/edit/edit_parse_error.rs @@ -0,0 +1,67 @@ +use thiserror::Error; + +use crate::column::IntegerKind; +use crate::value::DecimalParseError; + +/// Why the text in a cell could not become a value of the column's +/// type. +/// +/// Each arm is a message the user can act on, which is the point of +/// parsing in the app rather than sending the text and letting the +/// server complain. +#[derive(Debug, Clone, PartialEq, Eq, Error)] +pub enum EditParseError { + #[error("this column needs a value")] + Required, + #[error("enter a whole number")] + InvalidInteger, + #[error("this column holds {kind} integers, and this number is outside its range")] + IntegerOutOfRange { kind: IntegerKind }, + #[error("enter a number")] + InvalidFloat, + #[error("enter a number: {0}")] + InvalidDecimal(#[source] DecimalParseError), + #[error("this column keeps at most {precision} digits")] + PrecisionExceeded { precision: u32 }, + #[error("this column keeps at most {scale} decimal places")] + ScaleExceeded { scale: u32 }, + #[error("enter true or false")] + InvalidBoolean, + #[error("enter a UUID")] + InvalidUuid, + #[error("enter valid JSON: {0}")] + InvalidJson(String), + #[error("enter a date as YYYY-MM-DD")] + InvalidDate, + #[error("enter a time as HH:MM:SS")] + InvalidTime, + #[error("this time is outside the range the column can hold")] + TimeOutOfRange, + #[error("enter a date and time as YYYY-MM-DD HH:MM:SS")] + InvalidTimestamp, + #[error("this column needs a time zone offset")] + OffsetRequired, + #[error("enter an interval")] + InvalidInterval, + #[error("enter a bit string of 0 and 1")] + InvalidBits, + #[error("this column holds {expected} bits")] + BitLengthMismatch { expected: u32 }, + #[error("this is not one of the values this column allows")] + UnknownEnumLabel, + #[error("this value cannot be edited here")] + NotEditable(ReadOnlyReason), +} + +/// Why a cell cannot be typed into. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ReadOnlyReason { + /// Binary, which has no text form to edit. + Bytes, + Spatial, + /// The driver could not read the value, so overwriting it would + /// replace bytes nothing understood. + Undecodable, + Composite, + Variant, +} diff --git a/linux/crates/core/src/edit/edit_text.rs b/linux/crates/core/src/edit/edit_text.rs new file mode 100644 index 0000000000..5ba5037f64 --- /dev/null +++ b/linux/crates/core/src/edit/edit_text.rs @@ -0,0 +1,127 @@ +use crate::column::{ColumnKind, ColumnType}; +use crate::export::value_text; +use crate::value::Value; + +use super::ReadOnlyReason; + +/// What a cell editor starts from. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum EditText { + Editable(String), + ReadOnly(ReadOnlyReason), +} + +/// The full text of a value, for filling an editor. +/// +/// Never the grid's display text: that is truncated and shortened, and +/// committing it would write the shortened form back. A value with no +/// text form the parser could read back is read-only. +pub fn edit_text(value: &Value, column: &ColumnType) -> EditText { + if let Some(reason) = read_only_reason(&column.kind) { + return EditText::ReadOnly(reason); + } + match value { + Value::Null => EditText::Editable(String::new()), + Value::Undecodable(_) => EditText::ReadOnly(ReadOnlyReason::Undecodable), + // A dynamically typed column can hold a blob whatever it + // declares. + Value::Bytes(_) => EditText::ReadOnly(ReadOnlyReason::Bytes), + other => match value_text(other, column) { + Some(text) => EditText::Editable(text), + None => EditText::ReadOnly(ReadOnlyReason::Undecodable), + }, + } +} + +/// The kinds whose values have no text the parser takes back. Matches +/// what `parse_literal_text` refuses, so a cell that opens can commit. +pub fn read_only_reason(kind: &ColumnKind) -> Option { + match kind { + ColumnKind::Bytes { .. } => Some(ReadOnlyReason::Bytes), + ColumnKind::Spatial => Some(ReadOnlyReason::Spatial), + ColumnKind::Variant => Some(ReadOnlyReason::Variant), + ColumnKind::Array { .. } | ColumnKind::Composite => Some(ReadOnlyReason::Composite), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use crate::column::{FloatWidth, ReadForm, TemporalZone, TextLength}; + use crate::value::{Temporal, UndecodableReason, UndecodedValue}; + + use super::*; + + fn column(kind: ColumnKind) -> ColumnType { + ColumnType { + name: "t".to_owned(), + kind, + catalog: None, + dynamic_storage: false, + read_form: ReadForm::Native, + } + } + + #[test] + fn edit_text_is_never_truncated() { + let long = "x".repeat(20_000); + let text = column(ColumnKind::Text { + length: TextLength::Unbounded, + }); + + assert_eq!(edit_text(&Value::Text(long.clone()), &text), EditText::Editable(long)); + } + + #[test] + fn a_timestamp_keeps_its_declared_fraction() { + let stamp = column(ColumnKind::Timestamp { + fractional_digits: Some(6), + zone: TemporalZone::Naive, + }); + let value = Value::Timestamp(Temporal::Finite( + chrono::NaiveDate::from_ymd_opt(2024, 6, 15) + .and_then(|date| date.and_hms_micro_opt(12, 0, 0, 100_000)) + .expect("a timestamp"), + )); + + assert_eq!( + edit_text(&value, &stamp), + EditText::Editable("2024-06-15 12:00:00.100000".to_owned()) + ); + } + + #[test] + fn a_float_round_trips_in_its_shortest_form() { + let float = column(ColumnKind::Float(FloatWidth::F64)); + + assert_eq!( + edit_text(&Value::Float64(0.1), &float), + EditText::Editable("0.1".to_owned()) + ); + } + + #[test] + fn values_without_a_text_form_are_read_only() { + let text = column(ColumnKind::Text { + length: TextLength::Unbounded, + }); + let undecodable = Value::Undecodable(Box::new(UndecodedValue { + type_name: "geography".to_owned(), + reason: UndecodableReason::UnsupportedType, + })); + + assert_eq!( + edit_text(&undecodable, &text), + EditText::ReadOnly(ReadOnlyReason::Undecodable) + ); + assert_eq!( + edit_text(&Value::Bytes(vec![1]), &text), + EditText::ReadOnly(ReadOnlyReason::Bytes) + ); + assert_eq!( + edit_text(&Value::Null, &column(ColumnKind::Spatial)), + EditText::ReadOnly(ReadOnlyReason::Spatial) + ); + assert_eq!(edit_text(&Value::Null, &text), EditText::Editable(String::new())); + } +} diff --git a/linux/crates/core/src/edit/empty_input_meaning.rs b/linux/crates/core/src/edit/empty_input_meaning.rs new file mode 100644 index 0000000000..44e7b7234c --- /dev/null +++ b/linux/crates/core/src/edit/empty_input_meaning.rs @@ -0,0 +1,32 @@ +use crate::column::ColumnInfo; + +use super::EditContext; + +/// What committing an empty cell means. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum EmptyInputMeaning { + /// The empty string, which the column can hold. + EmptyText, + /// Leave the column out of the insert so the server fills it. + Default, + Null, + /// Nothing the column can hold, so the edit is refused. + Required, +} + +/// What an empty cell means for this column in this row. +/// +/// Only an INSERT can leave a column out, so an existing row never gets +/// a default from an empty cell: `SET c = DEFAULT` is not available on +/// SQLite or in a ClickHouse mutation. +pub fn empty_input_meaning(column: &ColumnInfo, context: EditContext) -> EmptyInputMeaning { + if column.column_type.kind.accepts_empty_string() { + return EmptyInputMeaning::EmptyText; + } + let server_fills = !column.default.is_none() || column.is_auto_increment || column.is_generated; + match context { + EditContext::InsertDraft if server_fills => EmptyInputMeaning::Default, + _ if column.nullable => EmptyInputMeaning::Null, + _ => EmptyInputMeaning::Required, + } +} diff --git a/linux/crates/core/src/edit/is_unchanged.rs b/linux/crates/core/src/edit/is_unchanged.rs new file mode 100644 index 0000000000..01b113859c --- /dev/null +++ b/linux/crates/core/src/edit/is_unchanged.rs @@ -0,0 +1,66 @@ +use crate::column::ColumnType; +use crate::export::value_text; +use crate::value::{Value, same_value}; + +/// Whether an edit leaves the cell as it was, so it is not saved. +/// +/// A dynamically typed column also compares the text forms: SQLite +/// hands back `1` for a cell the user retyped as `1`, and the parse of +/// that text may land in another variant without being a change. +pub fn is_unchanged(original: &Value, edited: &Value, column: &ColumnType) -> bool { + if same_value(original, edited) { + return true; + } + column.dynamic_storage + && matches!( + (value_text(original, column), value_text(edited, column)), + (Some(original), Some(edited)) if original == edited + ) +} + +#[cfg(test)] +mod tests { + use crate::column::{ColumnKind, ReadForm, TextLength}; + + use super::*; + + fn column(dynamic_storage: bool) -> ColumnType { + ColumnType { + name: "t".to_owned(), + kind: ColumnKind::Text { + length: TextLength::Unbounded, + }, + catalog: None, + dynamic_storage, + read_form: ReadForm::Native, + } + } + + #[test] + fn the_same_value_is_unchanged() { + assert!(is_unchanged(&Value::Int(1), &Value::Int(1), &column(false))); + assert!(is_unchanged( + &Value::Float64(f64::NAN), + &Value::Float64(f64::NAN), + &column(false) + )); + assert!(!is_unchanged(&Value::Int(1), &Value::Int(2), &column(false))); + } + + #[test] + fn a_dynamic_column_compares_text_forms() { + let typed = Value::Int(1); + let retyped = Value::Text("1".to_owned()); + + assert!(is_unchanged(&typed, &retyped, &column(true))); + assert!( + !is_unchanged(&typed, &retyped, &column(false)), + "a declared column ignored the type" + ); + } + + #[test] + fn null_is_not_the_empty_string() { + assert!(!is_unchanged(&Value::Null, &Value::Text(String::new()), &column(true))); + } +} diff --git a/linux/crates/core/src/edit/mod.rs b/linux/crates/core/src/edit/mod.rs new file mode 100644 index 0000000000..25201079c5 --- /dev/null +++ b/linux/crates/core/src/edit/mod.rs @@ -0,0 +1,22 @@ +//! What the grid hands back when the user edits a row, and how the +//! text they typed becomes a value. + +mod cell_input; +mod edit_context; +mod edit_parse_error; +mod edit_text; +mod empty_input_meaning; +mod is_unchanged; +mod parse_edit_text; +mod parse_literal_text; + +pub use cell_input::CellInput; +pub use edit_context::EditContext; +pub use edit_parse_error::{EditParseError, ReadOnlyReason}; +pub use edit_text::{EditText, edit_text, read_only_reason}; +pub use empty_input_meaning::{EmptyInputMeaning, empty_input_meaning}; +pub use is_unchanged::is_unchanged; +pub use parse_edit_text::parse_edit_text; +pub use parse_literal_text::{ + parse_filter_text, parse_literal_text, parse_time_with_offset, parse_timestamp_with_offset, +}; diff --git a/linux/crates/core/src/edit/parse_edit_text.rs b/linux/crates/core/src/edit/parse_edit_text.rs new file mode 100644 index 0000000000..855cf9d638 --- /dev/null +++ b/linux/crates/core/src/edit/parse_edit_text.rs @@ -0,0 +1,136 @@ +use crate::column::ColumnInfo; +use crate::value::Value; + +use super::{CellInput, EditContext, EditParseError, EmptyInputMeaning, empty_input_meaning, parse_literal_text}; + +/// Turn what the user typed into a cell into the input to save. +/// +/// Empty input follows `empty_input_meaning`. Whitespace alone is empty +/// for a column that cannot hold the empty string, and a value for one +/// that can. +pub fn parse_edit_text(input: &str, column: &ColumnInfo, context: EditContext) -> Result { + let accepts_empty = column.column_type.kind.accepts_empty_string(); + let empty = input.is_empty() || (!accepts_empty && input.trim().is_empty()); + if !empty { + return parse_literal_text(input, &column.column_type).map(CellInput::Value); + } + match empty_input_meaning(column, context) { + EmptyInputMeaning::EmptyText => Ok(CellInput::Value(Value::Text(String::new()))), + EmptyInputMeaning::Default => Ok(CellInput::Default), + EmptyInputMeaning::Null => Ok(CellInput::Value(Value::Null)), + EmptyInputMeaning::Required => Err(EditParseError::Required), + } +} + +#[cfg(test)] +mod tests { + use crate::column::{ + ColumnDefault, ColumnKind, ColumnType, IntegerKind, IntegerWidth, ReadForm, SqlExpression, TextLength, + }; + + use super::*; + + fn column(kind: ColumnKind, nullable: bool) -> ColumnInfo { + ColumnInfo { + name: "c".to_owned(), + column_type: ColumnType { + name: "t".to_owned(), + kind, + catalog: None, + dynamic_storage: false, + read_form: ReadForm::Native, + }, + nullable, + primary_key: false, + is_auto_increment: false, + is_generated: false, + default: ColumnDefault::None, + comment: None, + } + } + + fn integer(nullable: bool) -> ColumnInfo { + column( + ColumnKind::Integer(IntegerKind { + width: IntegerWidth::W32, + signed: true, + }), + nullable, + ) + } + + #[test] + fn empty_text_is_a_value_where_the_column_can_hold_it() { + let text = column( + ColumnKind::Text { + length: TextLength::Unbounded, + }, + false, + ); + + for context in [EditContext::InsertDraft, EditContext::UpdateRow] { + assert_eq!( + parse_edit_text("", &text, context), + Ok(CellInput::Value(Value::Text(String::new()))) + ); + } + assert_eq!( + parse_edit_text(" ", &text, EditContext::UpdateRow), + Ok(CellInput::Value(Value::Text(" ".to_owned()))), + "spaces in a text column were thrown away" + ); + } + + #[test] + fn an_empty_draft_cell_asks_for_the_servers_default() { + let mut defaulted = integer(false); + defaulted.default = ColumnDefault::Expression(SqlExpression::from_catalog_text("0")); + let mut serial = integer(false); + serial.is_auto_increment = true; + + assert_eq!( + parse_edit_text("", &defaulted, EditContext::InsertDraft), + Ok(CellInput::Default) + ); + assert_eq!( + parse_edit_text(" ", &serial, EditContext::InsertDraft), + Ok(CellInput::Default) + ); + assert_eq!( + parse_edit_text("", &integer(true), EditContext::InsertDraft), + Ok(CellInput::Value(Value::Null)) + ); + assert_eq!( + parse_edit_text("", &integer(false), EditContext::InsertDraft), + Err(EditParseError::Required) + ); + } + + #[test] + fn an_existing_row_never_gets_a_default_from_an_empty_cell() { + let mut defaulted = integer(false); + defaulted.default = ColumnDefault::Expression(SqlExpression::from_catalog_text("0")); + + assert_eq!( + parse_edit_text("", &defaulted, EditContext::UpdateRow), + Err(EditParseError::Required), + "an UPDATE writing NULL into a NOT NULL column" + ); + assert_eq!( + parse_edit_text("", &integer(true), EditContext::UpdateRow), + Ok(CellInput::Value(Value::Null)) + ); + } + + #[test] + fn typed_text_parses_as_the_columns_type() { + assert_eq!( + parse_edit_text("42", &integer(false), EditContext::UpdateRow), + Ok(CellInput::Value(Value::Int(42))) + ); + assert_eq!( + parse_edit_text("4x", &integer(false), EditContext::UpdateRow), + Err(EditParseError::InvalidInteger) + ); + } +} diff --git a/linux/crates/core/src/edit/parse_literal_text.rs b/linux/crates/core/src/edit/parse_literal_text.rs new file mode 100644 index 0000000000..7f0270e820 --- /dev/null +++ b/linux/crates/core/src/edit/parse_literal_text.rs @@ -0,0 +1,883 @@ +use chrono::{DateTime, NaiveDate, NaiveDateTime, NaiveTime, Timelike}; + +use crate::column::{BoolStorage, ColumnKind, ColumnType, FloatWidth, IntegerKind, TemporalZone, TimeRange}; +use crate::value::{ + BitString, JsonText, OffsetTimestamp, OtherValue, SqlDecimal, SqlInterval, SqlTime, Temporal, TimeRangeError, + TimeWithOffset, Value, WideInteger, +}; + +use super::{EditParseError, ReadOnlyReason}; + +/// Turn text the user typed into a value of the column's type. +/// +/// Parsing here rather than at the server means a wrong number is a +/// message beside the cell instead of a failed transaction. A column +/// whose server decides the stored form keeps the text as typed when +/// the strict parse fails, because the server is the authority on what +/// it accepts. +pub fn parse_literal_text(input: &str, column: &ColumnType) -> Result { + parse(input, column, Purpose::Store) +} + +/// What the parsed value is for, which decides whether the column's +/// declared precision applies. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Purpose { + /// Written to the column, so it has to fit: the server would round + /// or refuse a value that does not. + Store, + /// Compared against the column. `price > 1.005` is a fair question + /// for a `numeric(10,2)` column. + Compare, +} + +fn parse(input: &str, column: &ColumnType, purpose: Purpose) -> Result { + match strict(input, column, purpose) { + Ok(_) if column.dynamic_storage && keeps_text_when_valid(&column.kind) => { + Ok(Value::Text(input.trim().to_owned())) + } + Ok(value) => Ok(value), + Err(_) if column.dynamic_storage && keeps_text_on_failure(&column.kind) => Ok(Value::Text(input.to_owned())), + Err(error) => Err(error), + } +} + +/// Whether a value that parsed is still stored as the text typed. +/// +/// A column whose server stores whatever text it is given keeps a date +/// or time in the spelling it was written in, such as SQLite's +/// `2024-01-01T11:00:00`. The parse only checks the text: binding the +/// parsed value would store the app's own spelling instead. +fn keeps_text_when_valid(kind: &ColumnKind) -> bool { + matches!( + kind, + ColumnKind::Date | ColumnKind::Time { .. } | ColumnKind::Timestamp { .. } + ) +} + +/// Whether a failed parse should fall back to the text as typed. +/// +/// Only for kinds whose text form is what the server stores anyway. A +/// bad number is still a bad number, and the server validates JSON, so +/// saying so now beats a failed transaction later. +fn keeps_text_on_failure(kind: &ColumnKind) -> bool { + matches!( + kind, + ColumnKind::Text { .. } + | ColumnKind::Enum { .. } + | ColumnKind::Network(_) + | ColumnKind::Xml + | ColumnKind::Other + ) +} + +fn strict(input: &str, column: &ColumnType, purpose: Purpose) -> Result { + let value = strict_value(input, column, purpose)?; + if purpose == Purpose::Store { + check_declared_fraction(&value, column.kind.fractional_digits())?; + } + Ok(value) +} + +fn strict_value(input: &str, column: &ColumnType, purpose: Purpose) -> Result { + let trimmed = input.trim(); + match &column.kind { + ColumnKind::Bool(BoolStorage::Native) => parse_bool(trimmed).map(Value::Bool), + ColumnKind::Bool(BoolStorage::Integer) => parse_integer_bool(trimmed), + ColumnKind::Integer(kind) => parse_integer(trimmed, *kind), + ColumnKind::Decimal { precision, scale } => parse_decimal(trimmed, purpose, *precision, *scale), + ColumnKind::Money { scale } => parse_decimal(trimmed, purpose, None, scale.map(u32::from)), + ColumnKind::Float(width) => parse_float(trimmed, *width), + // Text keeps what the user typed, spaces included: a trailing + // space is a value, not a typo the app gets to remove. + // XML too: every driver reads it back as text, so an edit has to + // be text to bind as one and to compare equal when unchanged. + ColumnKind::Text { .. } | ColumnKind::Enum { .. } | ColumnKind::Xml => Ok(Value::Text(input.to_owned())), + ColumnKind::Json { .. } => JsonText::parse(input.to_owned()) + .map(Value::Json) + .map_err(|error| EditParseError::InvalidJson(error.to_string())), + ColumnKind::Uuid => trimmed + .parse() + .map(Value::Uuid) + .map_err(|_| EditParseError::InvalidUuid), + ColumnKind::Date => parse_date(trimmed), + ColumnKind::Time { with_offset: true, .. } => parse_time_with_offset(trimmed), + ColumnKind::Time { + range: range @ (TimeRange::Duration | TimeRange::LongDuration), + .. + } => parse_duration(trimmed, *range), + ColumnKind::Time { .. } => parse_time(trimmed), + ColumnKind::Timestamp { + zone: TemporalZone::Offset, + .. + } => parse_timestamp_with_offset(trimmed), + ColumnKind::Timestamp { .. } => parse_timestamp(trimmed), + ColumnKind::Interval => trimmed + .parse::() + .map(Value::Interval) + .map_err(|_| EditParseError::InvalidInterval), + ColumnKind::BitString { .. } => parse_bits(trimmed), + ColumnKind::Network(_) | ColumnKind::Other => Ok(Value::Other(Box::new(OtherValue { + type_name: column.name.clone(), + text: input.to_owned(), + }))), + ColumnKind::Bytes { .. } => Err(EditParseError::NotEditable(ReadOnlyReason::Bytes)), + ColumnKind::Spatial => Err(EditParseError::NotEditable(ReadOnlyReason::Spatial)), + ColumnKind::Variant => Err(EditParseError::NotEditable(ReadOnlyReason::Variant)), + ColumnKind::Array { .. } | ColumnKind::Composite => Err(EditParseError::NotEditable(ReadOnlyReason::Composite)), + } +} + +fn parse_bool(input: &str) -> Result { + match input.to_ascii_lowercase().as_str() { + "true" | "t" | "yes" | "y" | "on" | "1" => Ok(true), + "false" | "f" | "no" | "n" | "off" | "0" => Ok(false), + _ => Err(EditParseError::InvalidBoolean), + } +} + +/// A boolean the server stores as an integer. It keeps any integer, so +/// a typed 2 stays 2 rather than becoming true. +fn parse_integer_bool(input: &str) -> Result { + if let Ok(flag) = parse_bool(input) { + return Ok(Value::Int(i64::from(flag))); + } + input + .parse::() + .map(Value::Int) + .map_err(|_| EditParseError::InvalidBoolean) +} + +fn parse_integer(input: &str, kind: IntegerKind) -> Result { + // i128 first so a number that is merely out of range is reported + // as such rather than as unreadable. + let Ok(wide) = input.parse::() else { + return Err(EditParseError::InvalidInteger); + }; + if !wide.fits(kind) { + return Err(EditParseError::IntegerOutOfRange { kind }); + } + Ok(narrow(wide)) +} + +/// Keep a value in the smallest variant that holds it, so a `1` from a +/// bigint column compares equal to a `1` read back from it. +fn narrow(wide: WideInteger) -> Value { + if let Some(value) = wide.to_i128() + && let Ok(value) = i64::try_from(value) + { + return Value::Int(value); + } + if let Some(value) = wide.to_u128() + && let Ok(value) = u64::try_from(value) + { + return Value::UInt(value); + } + Value::WideInt(wide) +} + +fn parse_decimal( + input: &str, + purpose: Purpose, + precision: Option, + scale: Option, +) -> Result { + let decimal = input.parse::().map_err(EditParseError::InvalidDecimal)?; + if purpose == Purpose::Store && decimal.is_finite() { + check_declared_digits(&decimal, precision, scale)?; + } + Ok(Value::Decimal(decimal)) +} + +/// Refuse a value the column would round or overflow, rather than let +/// the server change what the user typed. Trailing zeros past the scale +/// are not a change, so `1.50` fits `numeric(10,1)`. +fn check_declared_digits( + decimal: &SqlDecimal, + precision: Option, + scale: Option, +) -> Result<(), EditParseError> { + match (precision, scale) { + (_, Some(scale)) if !decimal.fits(u32::MAX, scale) => Err(EditParseError::ScaleExceeded { scale }), + (Some(precision), Some(scale)) if !decimal.fits(precision, scale) => { + Err(EditParseError::PrecisionExceeded { precision }) + } + (Some(precision), None) if decimal.integer_digits().is_some_and(|digits| digits > precision) => { + Err(EditParseError::PrecisionExceeded { precision }) + } + _ => Ok(()), + } +} + +/// Refuse a fraction of a second finer than the column keeps. The +/// server rounds it, and `23:59:59.5` in a `timestamp(0)` becomes the +/// next day. +fn check_declared_fraction(value: &Value, digits: Option) -> Result<(), EditParseError> { + let Some(digits) = digits.filter(|digits| *digits < 9) else { + return Ok(()); + }; + let nanos = match value { + Value::Time(time) => time.total_nanos().rem_euclid(1_000_000_000), + Value::TimeTz(time) => time.time.total_nanos().rem_euclid(1_000_000_000), + Value::Timestamp(Temporal::Finite(stamp)) => i128::from(stamp.nanosecond()), + Value::TimestampTz(Temporal::Finite(stamp)) => i128::from(stamp.utc().nanosecond()), + _ => return Ok(()), + }; + match nanos % 10_i128.pow(9 - u32::from(digits)) { + 0 => Ok(()), + _ => Err(EditParseError::ScaleExceeded { + scale: u32::from(digits), + }), + } +} + +fn parse_float(input: &str, width: FloatWidth) -> Result { + if let Some(value) = non_finite(input) { + return Ok(match width { + FloatWidth::F32 => Value::Float32(value as f32), + FloatWidth::F64 => Value::Float64(value), + }); + } + match width { + FloatWidth::F32 => input + .parse::() + .map(Value::Float32) + .map_err(|_| EditParseError::InvalidFloat), + FloatWidth::F64 => input + .parse::() + .map(Value::Float64) + .map_err(|_| EditParseError::InvalidFloat), + } +} + +/// The words each engine uses for the non-finite floats, which +/// `str::parse` does not accept. +fn non_finite(input: &str) -> Option { + match input.to_ascii_lowercase().as_str() { + "nan" => Some(f64::NAN), + "inf" | "infinity" | "+inf" | "+infinity" => Some(f64::INFINITY), + "-inf" | "-infinity" => Some(f64::NEG_INFINITY), + _ => None, + } +} + +fn parse_date(input: &str) -> Result { + if let Some(temporal) = infinite_temporal(input) { + return Ok(Value::Date(temporal)); + } + NaiveDate::parse_from_str(input, "%Y-%m-%d") + .map(|date| Value::Date(Temporal::Finite(date))) + .map_err(|_| EditParseError::InvalidDate) +} + +fn parse_time(input: &str) -> Result { + if let Ok(time) = NaiveTime::parse_from_str(input, "%H:%M:%S%.f") { + return Ok(Value::Time(SqlTime::from_time_of_day(time))); + } + if let Ok(time) = NaiveTime::parse_from_str(input, "%H:%M") { + return Ok(Value::Time(SqlTime::from_time_of_day(time))); + } + // PostgreSQL's time runs to 24:00:00 inclusive, which no NaiveTime + // holds. + match input.parse::() { + Ok(time) if time.total_nanos() == END_OF_DAY_NANOS => Ok(Value::Time(time)), + _ => Err(EditParseError::InvalidTime), + } +} + +const END_OF_DAY_NANOS: i128 = 86_400 * 1_000_000_000; + +/// MySQL TIME or ClickHouse Time, which measure an amount of time and so +/// run past 24 hours and below zero, each to its own bound. +fn parse_duration(input: &str, range: TimeRange) -> Result { + let time = input.parse::().map_err(|error| match error { + TimeRangeError::OutOfRange => EditParseError::TimeOutOfRange, + TimeRangeError::InvalidComponent | TimeRangeError::InvalidSyntax => EditParseError::InvalidTime, + })?; + match range.holds(&time) { + true => Ok(Value::Time(time)), + false => Err(EditParseError::TimeOutOfRange), + } +} + +fn parse_timestamp(input: &str) -> Result { + if let Some(temporal) = infinite_temporal(input) { + return Ok(Value::Timestamp(temporal)); + } + for format in ["%Y-%m-%d %H:%M:%S%.f", "%Y-%m-%dT%H:%M:%S%.f", "%Y-%m-%d %H:%M"] { + if let Ok(stamp) = NaiveDateTime::parse_from_str(input, format) { + return Ok(Value::Timestamp(Temporal::Finite(stamp))); + } + } + Err(EditParseError::InvalidTimestamp) +} + +fn infinite_temporal(input: &str) -> Option> { + match input.to_ascii_lowercase().as_str() { + "infinity" | "inf" => Some(Temporal::Infinity), + "-infinity" | "-inf" => Some(Temporal::NegInfinity), + _ => None, + } +} + +fn parse_bits(input: &str) -> Result { + if input.is_empty() || !input.bytes().all(|byte| byte == b'0' || byte == b'1') { + return Err(EditParseError::InvalidBits); + } + let bit_len = u32::try_from(input.len()).map_err(|_| EditParseError::InvalidBits)?; + let mut bytes = vec![0u8; bit_len.div_ceil(8) as usize]; + for (position, bit) in input.bytes().enumerate() { + if bit == b'1' + && let Some(byte) = bytes.get_mut(position / 8) + { + *byte |= 0x80 >> (position % 8); + } + } + BitString::from_bytes(bit_len, bytes) + .map(Value::Bits) + .map_err(|_| EditParseError::InvalidBits) +} + +/// Parse text typed into a filter box. +/// +/// Same rules as a cell, except that a filter is a comparison rather +/// than a stored value, so a column whose type has no text form still +/// takes one here. +pub fn parse_filter_text(input: &str, column: &ColumnType) -> Result { + match column.kind { + // A filter on a column with no editable text form compares text + // the user typed; the dialect decides what that means. + ColumnKind::Bytes { .. } + | ColumnKind::Spatial + | ColumnKind::Array { .. } + | ColumnKind::Variant + | ColumnKind::Composite => Ok(Value::Text(input.to_owned())), + _ => parse(input, column, Purpose::Compare), + } +} + +/// A `TimeTz` from text, for the engines that have one. +pub fn parse_time_with_offset(input: &str) -> Result { + let trimmed = input.trim(); + // chrono has no time-with-offset parser, so the value is dated to + // a fixed day, parsed, and the date thrown away. + let dated = format!("1970-01-01 {trimmed}"); + for format in ["%Y-%m-%d %H:%M:%S%.f%#z", "%Y-%m-%d %H:%M%#z"] { + if let Ok(stamp) = DateTime::parse_from_str(&dated, format) { + return Ok(Value::TimeTz(TimeWithOffset { + time: SqlTime::from_time_of_day(stamp.time()), + offset: *stamp.offset(), + })); + } + } + if NaiveTime::parse_from_str(trimmed, "%H:%M:%S%.f").is_ok() { + return Err(EditParseError::OffsetRequired); + } + Err(EditParseError::InvalidTime) +} + +/// A `TimestampTz` from text. +pub fn parse_timestamp_with_offset(input: &str) -> Result { + let trimmed = input.trim(); + if let Some(temporal) = infinite_temporal(trimmed) { + return Ok(Value::TimestampTz(temporal)); + } + for format in [ + "%Y-%m-%d %H:%M:%S%.f%#z", + "%Y-%m-%dT%H:%M:%S%.f%#z", + "%Y-%m-%d %H:%M%#z", + ] { + if let Ok(stamp) = DateTime::parse_from_str(trimmed, format) { + return Ok(Value::TimestampTz(Temporal::Finite(OffsetTimestamp::from_datetime( + stamp, + )))); + } + } + if NaiveDateTime::parse_from_str(trimmed, "%Y-%m-%d %H:%M:%S%.f").is_ok() { + return Err(EditParseError::OffsetRequired); + } + Err(EditParseError::InvalidTimestamp) +} + +#[cfg(test)] +mod tests { + use crate::column::{IntegerWidth, ReadForm, TextLength}; + + use super::*; + + fn column(kind: ColumnKind, dynamic_storage: bool) -> ColumnType { + ColumnType { + name: "t".to_owned(), + kind, + catalog: None, + dynamic_storage, + read_form: ReadForm::Native, + } + } + + fn int(width: IntegerWidth, signed: bool) -> ColumnKind { + ColumnKind::Integer(IntegerKind { width, signed }) + } + + fn time(with_offset: bool, range: TimeRange) -> ColumnKind { + ColumnKind::Time { + fractional_digits: None, + with_offset, + range, + } + } + + fn stamp(zone: TemporalZone) -> ColumnKind { + ColumnKind::Timestamp { + fractional_digits: None, + zone, + } + } + + fn bytes() -> ColumnKind { + ColumnKind::Bytes { + length: TextLength::Unbounded, + } + } + + fn parse(kind: ColumnKind, input: &str) -> Result { + parse_literal_text(input, &column(kind, false)) + } + + #[test] + fn parse_filter_text_integer_bounds_i8_to_u64() { + let i8 = IntegerKind { + width: IntegerWidth::W8, + signed: true, + }; + let u8 = IntegerKind { + width: IntegerWidth::W8, + signed: false, + }; + let i24 = IntegerKind { + width: IntegerWidth::W24, + signed: true, + }; + let i32 = IntegerKind { + width: IntegerWidth::W32, + signed: true, + }; + let u64 = IntegerKind { + width: IntegerWidth::W64, + signed: false, + }; + let cases = [ + (i8, "127", true), + (i8, "128", false), + (i8, "-128", true), + (i8, "-129", false), + (u8, "255", true), + (u8, "-1", false), + (i24, "8388607", true), + (i24, "8388608", false), + (i32, "2147483647", true), + (i32, "2147483648", false), + (u64, "18446744073709551615", true), + (u64, "18446744073709551616", false), + ]; + + for (kind, input, accepted) in cases { + let parsed = parse(ColumnKind::Integer(kind), input); + assert_eq!(parsed.is_ok(), accepted, "{kind:?} {input}: {parsed:?}"); + if !accepted { + assert_eq!( + parsed, + Err(EditParseError::IntegerOutOfRange { kind }), + "{kind:?} {input}" + ); + } + } + } + + #[test] + fn parse_literal_text_numeric_scale_exceeded() { + let numeric = ColumnKind::Decimal { + precision: Some(10), + scale: Some(2), + }; + + assert_eq!( + parse(numeric.clone(), "1.005"), + Err(EditParseError::ScaleExceeded { scale: 2 }), + "the server would have rounded the value" + ); + assert_eq!( + parse(numeric.clone(), "123456789.5"), + Err(EditParseError::PrecisionExceeded { precision: 10 }) + ); + assert!(matches!(parse(numeric.clone(), "12345678.50"), Ok(Value::Decimal(_)))); + assert!( + matches!(parse(numeric.clone(), "NaN"), Ok(Value::Decimal(_))), + "a non-finite value is the server's to judge" + ); + assert!( + matches!( + parse_filter_text("1.005", &column(numeric, false)), + Ok(Value::Decimal(_)) + ), + "a comparison was held to the column's scale" + ); + assert_eq!( + parse(ColumnKind::Money { scale: Some(2) }, "0.001"), + Err(EditParseError::ScaleExceeded { scale: 2 }) + ); + } + + #[test] + fn text_that_is_not_a_number_is_not_a_range_problem() { + assert_eq!( + parse(int(IntegerWidth::W32, true), "seven"), + Err(EditParseError::InvalidInteger) + ); + } + + #[test] + fn an_integer_lands_in_the_smallest_variant_that_holds_it() { + assert_eq!(parse(int(IntegerWidth::W64, true), "7"), Ok(Value::Int(7))); + assert_eq!( + parse(int(IntegerWidth::W64, false), "18446744073709551615"), + Ok(Value::UInt(u64::MAX)) + ); + assert!(matches!( + parse(int(IntegerWidth::W128, true), "170141183460469231731687303715884105727"), + Ok(Value::WideInt(_)) + )); + } + + #[test] + fn booleans_take_the_words_each_engine_uses() { + let native = ColumnKind::Bool(BoolStorage::Native); + for input in ["true", "TRUE", "t", "yes", "on", "1"] { + assert_eq!(parse(native.clone(), input), Ok(Value::Bool(true)), "{input}"); + } + for input in ["false", "F", "no", "off", "0"] { + assert_eq!(parse(native.clone(), input), Ok(Value::Bool(false)), "{input}"); + } + assert_eq!(parse(native, "maybe"), Err(EditParseError::InvalidBoolean)); + } + + #[test] + fn an_integer_stored_boolean_keeps_its_exact_integer() { + let stored = ColumnKind::Bool(BoolStorage::Integer); + + assert_eq!(parse(stored.clone(), "true"), Ok(Value::Int(1))); + assert_eq!(parse(stored.clone(), "off"), Ok(Value::Int(0))); + assert_eq!( + parse(stored.clone(), "2"), + Ok(Value::Int(2)), + "a tinyint(1) holding 2 was read as true" + ); + assert_eq!(parse(stored, "maybe"), Err(EditParseError::InvalidBoolean)); + } + + #[test] + fn floats_take_the_non_finite_words() { + assert!( + matches!(parse(ColumnKind::Float(FloatWidth::F64), "NaN"), Ok(Value::Float64(value)) if value.is_nan()) + ); + assert_eq!( + parse(ColumnKind::Float(FloatWidth::F64), "-infinity"), + Ok(Value::Float64(f64::NEG_INFINITY)) + ); + assert_eq!( + parse(ColumnKind::Float(FloatWidth::F32), "1.5"), + Ok(Value::Float32(1.5)) + ); + assert_eq!( + parse(ColumnKind::Float(FloatWidth::F64), "x"), + Err(EditParseError::InvalidFloat) + ); + } + + #[test] + fn text_keeps_what_the_user_typed_including_spaces() { + assert_eq!( + parse( + ColumnKind::Text { + length: TextLength::Varying(20) + }, + " padded " + ), + Ok(Value::Text(" padded ".to_owned())), + "the parser trimmed a value the column can hold" + ); + } + + #[test] + fn xml_is_edited_as_text() { + assert_eq!( + parse(ColumnKind::Xml, " b "), + Ok(Value::Text(" b ".to_owned())) + ); + } + + #[test] + fn a_column_whose_text_is_what_the_server_stores_keeps_unparseable_text() { + // An enum label this build has not seen is still text the + // server may accept, so it is not the app's call to refuse. + let enumeration = column( + ColumnKind::Enum { + labels: vec!["draft".to_owned()], + multiple: false, + }, + true, + ); + + assert_eq!( + parse_literal_text("unexpected", &enumeration), + Ok(Value::Text("unexpected".to_owned())) + ); + } + + #[test] + fn invalid_json_is_refused_rather_than_stored_as_text() { + // The server validates JSON, so saying so beside the cell + // beats a failed transaction later. + for dynamic_storage in [true, false] { + assert!( + matches!( + parse_literal_text("not json", &column(ColumnKind::Json { binary: false }, dynamic_storage)), + Err(EditParseError::InvalidJson(_)) + ), + "dynamic_storage = {dynamic_storage}" + ); + } + } + + #[test] + fn a_bad_number_is_never_kept_as_text() { + let dynamic = column(int(IntegerWidth::W32, true), true); + + assert_eq!( + parse_literal_text("seven", &dynamic), + Err(EditParseError::InvalidInteger) + ); + } + + #[test] + fn temporals_take_their_infinities() { + assert_eq!(parse(ColumnKind::Date, "infinity"), Ok(Value::Date(Temporal::Infinity))); + assert_eq!( + parse(stamp(TemporalZone::Naive), "-infinity"), + Ok(Value::Timestamp(Temporal::NegInfinity)) + ); + assert_eq!( + parse(ColumnKind::Date, "2024-06-15"), + Ok(Value::Date(Temporal::Finite( + NaiveDate::from_ymd_opt(2024, 6, 15).expect("a date") + ))) + ); + assert_eq!(parse(ColumnKind::Date, "15/06/2024"), Err(EditParseError::InvalidDate)); + } + + #[test] + fn a_fractional_second_survives_the_parse() { + let Ok(Value::Time(time)) = parse(time(false, TimeRange::TimeOfDay), "12:00:00.123456") else { + panic!("a fractional time did not parse"); + }; + + assert_eq!(time.format(Some(6)), "12:00:00.123456"); + } + + fn bit_kind() -> ColumnKind { + ColumnKind::BitString { + length: None, + varying: true, + } + } + + #[test] + fn bits_are_zeroes_and_ones_and_nothing_else() { + let Ok(Value::Bits(parsed)) = parse(bit_kind(), "1011") else { + panic!("a bit string did not parse"); + }; + assert_eq!(parsed.to_string(), "1011"); + + assert_eq!(parse(bit_kind(), "1021"), Err(EditParseError::InvalidBits)); + assert_eq!(parse(bit_kind(), ""), Err(EditParseError::InvalidBits)); + } + + #[test] + fn cells_with_no_text_form_are_not_editable_as_text() { + let array = ColumnKind::Array { + element: Box::new(int(IntegerWidth::W32, true)), + }; + let cases = [ + (bytes(), ReadOnlyReason::Bytes), + (ColumnKind::Spatial, ReadOnlyReason::Spatial), + (ColumnKind::Variant, ReadOnlyReason::Variant), + (ColumnKind::Composite, ReadOnlyReason::Composite), + (array, ReadOnlyReason::Composite), + ]; + + for (kind, reason) in cases { + for dynamic_storage in [false, true] { + assert_eq!( + parse_literal_text("x", &column(kind.clone(), dynamic_storage)), + Err(EditParseError::NotEditable(reason)), + "{kind:?} dynamic_storage = {dynamic_storage}" + ); + } + } + } + + #[test] + fn a_filter_on_a_binary_column_compares_text() { + let binary = column(bytes(), false); + + assert_eq!( + parse_filter_text("0xff", &binary), + Ok(Value::Text("0xff".to_owned())), + "a filter could not be typed against a binary column" + ); + } + + #[test] + fn an_offset_is_required_where_the_column_has_one() { + assert_eq!( + parse_timestamp_with_offset("2024-06-15 12:00:00"), + Err(EditParseError::OffsetRequired) + ); + assert_eq!(parse_time_with_offset("12:00:00"), Err(EditParseError::OffsetRequired)); + + let Ok(Value::TimestampTz(Temporal::Finite(stamp))) = parse_timestamp_with_offset("2024-06-15 12:00:00+05:30") + else { + panic!("an offset timestamp did not parse"); + }; + assert_eq!(stamp.offset().local_minus_utc(), 5 * 3600 + 1800); + } + + #[test] + fn an_offset_column_parses_with_its_offset() { + assert!(matches!( + parse(stamp(TemporalZone::Offset), "2024-06-15 12:00:00+05:30"), + Ok(Value::TimestampTz(Temporal::Finite(_))) + )); + assert_eq!( + parse(stamp(TemporalZone::Offset), "2024-06-15 12:00:00"), + Err(EditParseError::OffsetRequired) + ); + assert!(matches!( + parse(time(true, TimeRange::TimeOfDay), "12:00:00+02:00"), + Ok(Value::TimeTz(_)) + )); + assert!(matches!( + parse(stamp(TemporalZone::Named("UTC".to_owned())), "2024-06-15 12:00:00"), + Ok(Value::Timestamp(Temporal::Finite(_))) + )); + } + + #[test] + fn a_duration_runs_past_a_day_and_below_zero() { + let duration = time(false, TimeRange::Duration); + + let Ok(Value::Time(long)) = parse(duration.clone(), "838:59:59") else { + panic!("the longest MySQL TIME did not parse"); + }; + assert_eq!(long.format(None), "838:59:59"); + assert!(matches!(parse(duration.clone(), "-01:30:00"), Ok(Value::Time(_)))); + assert_eq!( + parse(duration.clone(), "839:00:00"), + Err(EditParseError::TimeOutOfRange) + ); + assert_eq!(parse(duration, "noon"), Err(EditParseError::InvalidTime)); + let long = time(false, TimeRange::LongDuration); + assert!(matches!(parse(long.clone(), "-999:59:59"), Ok(Value::Time(_)))); + assert_eq!(parse(long, "1000:00:00"), Err(EditParseError::TimeOutOfRange)); + assert_eq!( + parse(time(false, TimeRange::TimeOfDay), "25:00:00"), + Err(EditParseError::InvalidTime) + ); + } + + #[test] + fn a_fraction_finer_than_the_column_keeps_is_refused() { + let declared = |digits| ColumnKind::Timestamp { + fractional_digits: Some(digits), + zone: TemporalZone::Naive, + }; + + assert_eq!( + parse(declared(0), "2024-12-31 23:59:59.5"), + Err(EditParseError::ScaleExceeded { scale: 0 }), + "the server would have rounded into the next day" + ); + assert!(parse(declared(3), "2024-12-31 23:59:59.120").is_ok()); + assert_eq!( + parse(declared(3), "2024-12-31 23:59:59.1234"), + Err(EditParseError::ScaleExceeded { scale: 3 }) + ); + assert_eq!( + parse( + ColumnKind::Time { + fractional_digits: Some(0), + with_offset: false, + range: TimeRange::Duration, + }, + "-01:00:00.5" + ), + Err(EditParseError::ScaleExceeded { scale: 0 }) + ); + assert!( + parse_filter_text("2024-12-31 23:59:59.5", &column(declared(0), false)).is_ok(), + "a comparison was held to the column's precision" + ); + assert!(parse(stamp(TemporalZone::Naive), "2024-12-31 23:59:59.123456789").is_ok()); + } + + #[test] + fn the_end_of_the_day_is_a_time_of_day() { + let Ok(Value::Time(end)) = parse(time(false, TimeRange::TimeOfDay), "24:00:00") else { + panic!("24:00:00 did not parse"); + }; + assert_eq!(end.format(None), "24:00:00"); + assert_eq!( + parse(time(false, TimeRange::TimeOfDay), "24:00:01"), + Err(EditParseError::InvalidTime) + ); + } + + #[test] + fn a_network_column_keeps_the_servers_own_text() { + let Ok(Value::Other(other)) = parse(ColumnKind::Network(crate::column::NetworkFamily::Cidr), "10.0.0.0/8") + else { + panic!("a network value did not parse"); + }; + + assert_eq!(other.text, "10.0.0.0/8"); + } + + #[test] + fn a_dynamic_column_keeps_a_valid_date_or_time_as_typed() { + let stamp = ColumnKind::Timestamp { + fractional_digits: None, + zone: TemporalZone::Naive, + }; + + assert_eq!( + parse_literal_text(" 2024-01-01T11:00:00 ", &column(stamp.clone(), true)), + Ok(Value::Text("2024-01-01T11:00:00".to_owned())) + ); + assert_eq!( + parse_literal_text("11:00", &column(time(false, TimeRange::TimeOfDay), true)), + Ok(Value::Text("11:00".to_owned())) + ); + assert_eq!( + parse_literal_text("2024-02-30", &column(ColumnKind::Date, true)), + Err(EditParseError::InvalidDate), + "an invalid date was kept as text" + ); + assert!( + matches!( + parse_literal_text("2024-01-01T11:00:00", &column(stamp, false)), + Ok(Value::Timestamp(_)) + ), + "a declared column lost its typed value" + ); + } +} diff --git a/linux/crates/core/src/editor_session.rs b/linux/crates/core/src/editor_session.rs new file mode 100644 index 0000000000..20660f092d --- /dev/null +++ b/linux/crates/core/src/editor_session.rs @@ -0,0 +1,75 @@ +use async_trait::async_trait; + +use crate::call_options::CallOptions; +use crate::error::DriverError; +use crate::result_event::RunSummary; +use crate::result_sink::ResultSink; +use crate::run_options::RunOptions; +use crate::transaction_state::TransactionState; +use crate::user_sql::UserSql; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct EditorSessionId(uuid::Uuid); + +impl EditorSessionId { + pub fn new() -> Self { + Self(uuid::Uuid::new_v4()) + } +} + +impl Default for EditorSessionId { + fn default() -> Self { + Self::new() + } +} + +impl std::fmt::Display for EditorSessionId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum EditorSessionState { + Ready, + Running, + /// The connection is gone. An editor session is never silently + /// reopened, because an open transaction and its temporary tables + /// would not come back with it. + Lost, +} + +/// How an editor session is opened. +#[derive(Debug, Clone, Default)] +pub struct EditorSessionOptions { + pub statement_timeout: Option, +} + +/// One editor tab's own connection to the server. +/// +/// A tab holds its session for as long as it is open, because the state +/// a user builds up there is on that connection and nowhere else: an +/// open transaction, a temporary table, a session setting. Sharing a +/// pooled connection between tabs would lose all of it between runs. +#[async_trait] +pub trait EditorSession: Send + Sync { + fn id(&self) -> EditorSessionId; + + fn state(&self) -> EditorSessionState; + + /// Run the text as the user wrote it, streaming what comes back. + /// + /// An error here is the session itself failing, not the SQL: a + /// statement the server refused ends in a `RunSummary` whose end is + /// `Failed`, which is what the results list shows. + async fn run( + &self, + sql: UserSql, + options: RunOptions, + sink: Box, + ) -> Result; + + async fn transaction_state(&self, options: CallOptions) -> Result; + + async fn close(&self); +} diff --git a/linux/crates/core/src/engine_stop.rs b/linux/crates/core/src/engine_stop.rs new file mode 100644 index 0000000000..d431b7c209 --- /dev/null +++ b/linux/crates/core/src/engine_stop.rs @@ -0,0 +1,57 @@ +use std::sync::Arc; +use std::sync::atomic::AtomicBool; + +use async_trait::async_trait; + +use crate::error::DriverError; + +/// What an engine's own stop addresses. +/// +/// `Connection` means the stop names the physical connection rather +/// than the statement, as PostgreSQL's cancel request does with a +/// backend pid and MySQL's KILL QUERY does with a connection id. Such a +/// stop can arrive late and hit whatever that connection runs next, so +/// a connection it may still be chasing never goes back to the pool. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum StopScope { + Statement, + Connection, +} + +/// How a stop reaches the server. +/// +/// `InBand` means the running request carries it, so nothing else has +/// to be sent and nothing can arrive late. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum StopDelivery { + OutOfBand(StopScope), + InBand, +} + +/// The engine's own way of stopping work already sent. +#[async_trait] +pub trait EngineStop: Send + Sync + 'static { + fn delivery(&self) -> StopDelivery; + + /// Send the stop. Called at most once, and only for + /// `OutOfBand` delivery. A `Connection` stop returns only once the + /// server has acted on it or it failed to reach the server, because + /// its connection can be handed on as soon as it returns. + async fn stop(&self) -> Result<(), DriverError>; + + /// Whether this error is the engine acknowledging the stop rather + /// than a failure of its own. + fn acknowledged(&self, error: &DriverError) -> bool; +} + +/// A connection held for the duration of one call. +/// +/// The flag is what a pool's recycle checks, so a connection whose work +/// was abandoned is rejected instead of handed to the next caller with +/// a half-read result still on the wire. +pub trait CallResource: Send + 'static { + fn abandon_flag(&self) -> Arc; + + /// Take the connection out of its pool and close it. + fn discard(self); +} diff --git a/linux/crates/core/src/error.rs b/linux/crates/core/src/error.rs new file mode 100644 index 0000000000..53242dd4f6 --- /dev/null +++ b/linux/crates/core/src/error.rs @@ -0,0 +1,277 @@ +use std::path::PathBuf; + +use thiserror::Error; + +use crate::commit::CommitToken; +use crate::config_error::ConfigError; +use crate::error_category::ErrorCategory; +use crate::loss_phase::LossPhase; +use crate::read_only_refusal::ReadOnlyRefusal; +use crate::server_diagnostics::ServerDiagnostics; +use crate::timeout_phase::TimeoutPhase; +use crate::tls_failure::TlsFailure; +use crate::transport_error::TransportError; +use crate::write_report::StatementOutcome; + +/// What a driver could not do, in enough detail for the app to say +/// what to try next. +/// +/// The three write variants are the reason this is not a flat list of +/// messages. After a write fails, the only question that matters is +/// what is now in the database, and `RolledBack`, `PartiallyApplied` +/// and `CommitOutcomeUnknown` are three different answers. +#[derive(Debug, Clone, PartialEq, Error)] +#[non_exhaustive] +pub enum DriverError { + #[error(transparent)] + Transport(#[from] TransportError), + + #[error("the server refused the credentials{}", diagnostics.as_ref().map(|d| format!(": {d}")).unwrap_or_default())] + Auth { + diagnostics: Option>, + }, + + /// The server turned the login away for a reason other than the + /// password: its host rules, a role that may not log in. Typing the + /// password again cannot change the answer. + #[error("the server refused the login: {0}")] + LoginRefused(Box), + + #[error("integrated authentication failed: {0}")] + IntegratedAuth(String), + + #[error("TLS failed: {detail}")] + Tls { failure: TlsFailure, detail: String }, + + #[error("{0}")] + Server(Box), + + #[error("{0}")] + ReadOnly(ReadOnlyRefusal), + + #[error("cancelled")] + Cancelled, + + #[error("timed out during {phase}")] + Timeout { + phase: TimeoutPhase, + server_cancelled: bool, + }, + + #[error("the connection was lost while {during}")] + ConnectionLost { during: LossPhase }, + + /// Statement `statement_index` failed and the transaction was + /// rolled back. Nothing was applied. + #[error("rolled back at statement {statement_index}: {source}")] + RolledBack { + statement_index: usize, + source: Box, + }, + + /// Statement `statement_index` was written to match exactly + /// `expected` rows and matched `actual`, so the row it meant to + /// change is not the row that is there. Nothing was applied. + #[error("statement {statement_index} matched {actual} rows, not {expected}")] + RowGuardFailed { + statement_index: usize, + expected: u64, + actual: u64, + }, + + /// The engine cannot report how many rows a statement touched, so + /// a write that has to know cannot run there. + #[error("statement {statement_index} needs a row count this engine does not report")] + GuardUnsupported { statement_index: usize }, + + /// The commit was sent and the answer never arrived. The token, + /// where the engine has one, is what asks the server later. + #[error("the commit was sent and the outcome is unknown: {source}")] + CommitOutcomeUnknown { + token: Option, + source: Box, + }, + + /// `applied` statements are known to have landed, and the one at + /// `statement_index` either failed or is unknown. The engine has no + /// transaction to undo them. + #[error("{applied} statements applied before statement {statement_index}: {source}")] + PartiallyApplied { + applied: usize, + statement_index: usize, + failed_outcome: StatementOutcome, + source: Box, + }, + + #[error("no database file at {}", .path.display())] + FileNotFound { path: PathBuf }, + + #[error("no permission to open {}", .path.display())] + FileAccessDenied { path: PathBuf }, + + #[error("{} is not a database file", .path.display())] + NotADatabase { path: PathBuf }, + + /// The file system refused for a reason other than permission, such + /// as a full disk or a read-only mount. The kind is kept rather than + /// the error, so the variant stays comparable and cloneable. + #[error("could not write {}: {kind}", .path.display())] + FileIo { path: PathBuf, kind: std::io::ErrorKind }, + + #[error("the server has no room for another connection")] + Busy, + + #[error("the editor session is already running a statement")] + EditorSessionBusy, + + #[error(transparent)] + Config(#[from] ConfigError), + + #[error("could not read column {column}: {detail}")] + Decode { column: String, detail: String }, + + #[error("the server sent something the driver could not read: {0}")] + Protocol(String), + + #[error("this engine has no {feature}")] + Unsupported { feature: &'static str }, +} + +impl DriverError { + /// What kind of problem this is, which is what the app acts on. + /// The message is for the user; the category decides which button + /// to offer beside it. + pub fn category(&self) -> ErrorCategory { + match self { + Self::Transport(error) => error.category(), + Self::Auth { .. } | Self::LoginRefused(_) | Self::IntegratedAuth(_) => ErrorCategory::Authentication, + Self::Tls { .. } => ErrorCategory::Tls, + Self::Server(_) => ErrorCategory::Server, + Self::ReadOnly(_) => ErrorCategory::ReadOnly, + Self::Cancelled => ErrorCategory::Interrupted, + Self::Timeout { .. } => ErrorCategory::Timeout, + Self::ConnectionLost { .. } => ErrorCategory::ConnectionLost, + Self::RolledBack { .. } + | Self::RowGuardFailed { .. } + | Self::CommitOutcomeUnknown { .. } + | Self::PartiallyApplied { .. } => ErrorCategory::WriteOutcome, + Self::FileNotFound { .. } + | Self::FileAccessDenied { .. } + | Self::NotADatabase { .. } + | Self::FileIo { .. } => ErrorCategory::File, + Self::Busy | Self::EditorSessionBusy => ErrorCategory::Busy, + Self::Config(_) | Self::Unsupported { .. } => ErrorCategory::Configuration, + Self::GuardUnsupported { .. } | Self::Decode { .. } | Self::Protocol(_) => ErrorCategory::Internal, + } + } + + /// A server error with nothing but its message, for an engine that + /// numbers only some of what it reports. + pub fn server(message: impl Into) -> Self { + Self::reported(ServerDiagnostics::new(None, message)) + } + + /// What the server said, in its own words. + pub fn reported(diagnostics: ServerDiagnostics) -> Self { + Self::Server(Box::new(diagnostics)) + } + + /// Credentials the server refused, with its explanation where it + /// gave one. + pub fn auth(diagnostics: Option) -> Self { + Self::Auth { + diagnostics: diagnostics.map(Box::new), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::server_diagnostics::ServerCode; + + fn assert_send_sync_error() {} + + #[test] + fn a_driver_error_crosses_threads() { + assert_send_sync_error::(); + } + + #[test] + fn a_full_disk_is_a_file_problem_that_names_its_cause() { + let full = DriverError::FileIo { + path: PathBuf::from("/data/app.sqlite3"), + kind: std::io::ErrorKind::StorageFull, + }; + + assert_eq!(full.category(), ErrorCategory::File); + assert!(full.to_string().contains("/data/app.sqlite3"), "{full}"); + assert!( + full.to_string().contains(&std::io::ErrorKind::StorageFull.to_string()), + "{full}" + ); + } + + #[test] + fn a_write_that_rolled_back_is_not_a_server_error() { + let rolled_back = DriverError::RolledBack { + statement_index: 2, + source: Box::new(DriverError::server("duplicate key")), + }; + + assert_eq!(rolled_back.category(), ErrorCategory::WriteOutcome); + assert_eq!( + DriverError::server("duplicate key").category(), + ErrorCategory::Server, + "the cause keeps its own category" + ); + } + + #[test] + fn a_guard_mismatch_reads_as_the_two_counts() { + let failed = DriverError::RowGuardFailed { + statement_index: 0, + expected: 1, + actual: 3, + }; + + assert_eq!(failed.to_string(), "statement 0 matched 3 rows, not 1"); + assert_eq!(failed.category(), ErrorCategory::WriteOutcome); + } + + #[test] + fn an_auth_failure_carries_the_servers_own_words() { + let diagnostics = ServerDiagnostics::new( + Some(ServerCode::SqlState("28P01".to_owned())), + "password authentication failed", + ); + let error = DriverError::auth(Some(diagnostics)); + + assert_eq!( + error.to_string(), + "the server refused the credentials: password authentication failed (28P01)" + ); + assert_eq!(error.category(), ErrorCategory::Authentication); + } + + #[test] + fn a_refused_login_parks_like_a_wrong_password_but_is_not_one() { + let refused = DriverError::LoginRefused(Box::new(ServerDiagnostics::new( + Some(ServerCode::SqlState("28000".to_owned())), + "no pg_hba.conf entry for host", + ))); + + assert_eq!(refused.category(), ErrorCategory::Authentication); + assert_ne!(refused, DriverError::auth(None)); + } + + #[test] + fn an_unreadable_column_is_ours_to_fix_not_the_servers() { + let error = DriverError::Decode { + column: "payload".to_owned(), + detail: "unsupported type".to_owned(), + }; + + assert_eq!(error.category(), ErrorCategory::Internal); + } +} diff --git a/linux/crates/core/src/error_category.rs b/linux/crates/core/src/error_category.rs new file mode 100644 index 0000000000..adb5e393f7 --- /dev/null +++ b/linux/crates/core/src/error_category.rs @@ -0,0 +1,16 @@ +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum ErrorCategory { + Configuration, + Network, + Tls, + Authentication, + ReadOnly, + Server, + Interrupted, + Timeout, + ConnectionLost, + WriteOutcome, + File, + Busy, + Internal, +} diff --git a/linux/crates/core/src/export/csv.rs b/linux/crates/core/src/export/csv.rs new file mode 100644 index 0000000000..f3ff38e741 --- /dev/null +++ b/linux/crates/core/src/export/csv.rs @@ -0,0 +1,517 @@ +use crate::column::{ColumnType, ResultColumn}; +use crate::value::Value; + +use super::csv_options::{CsvDecimal, CsvDelimiter, CsvLineBreak, CsvOptions, CsvQuote}; +use super::encoder::{CsvEncoder, EncodeError, encode_rows}; +use super::value_text::value_text; +use super::{RenderError, finish}; + +/// A neutralised cell keeps its leading apostrophe as data only when the +/// field is quoted, so sanitising quotes every field. +pub fn csv_writer_builder(opts: &CsvOptions) -> csv::WriterBuilder { + let delimiter = match opts.delimiter { + CsvDelimiter::Comma => b',', + CsvDelimiter::Semicolon => b';', + CsvDelimiter::Tab => b'\t', + CsvDelimiter::Pipe => b'|', + }; + let terminator = match opts.line_break { + CsvLineBreak::Lf => csv::Terminator::Any(b'\n'), + CsvLineBreak::CrLf => csv::Terminator::CRLF, + CsvLineBreak::Cr => csv::Terminator::Any(b'\r'), + }; + let quote_style = match opts.quote { + _ if opts.sanitize_formulas => csv::QuoteStyle::Always, + CsvQuote::Always => csv::QuoteStyle::Always, + CsvQuote::IfNeeded => csv::QuoteStyle::Necessary, + CsvQuote::Never => csv::QuoteStyle::Never, + }; + let mut builder = csv::WriterBuilder::new(); + builder + .delimiter(delimiter) + .terminator(terminator) + .quote_style(quote_style); + builder +} + +/// Only free text can smuggle a formula into a spreadsheet. A typed +/// number, date or UUID, and text that is itself a plain number, is data +/// the spreadsheet already reads correctly, and prefixing it would turn +/// `-5` into the string `'-5`. A type the driver could not place is the +/// server's own text, which is as free as any. +pub fn neutralise_formula(value: &Value, text: String, is_header: bool) -> String { + if is_header || matches!(value, Value::Text(_) | Value::Json(_) | Value::Other(_)) { + neutralise_text(text) + } else { + text + } +} + +fn neutralise_text(text: String) -> String { + if text.starts_with(FORMULA_LEADS) && !is_plain_number(&text) { + format!("'{text}") + } else { + text + } +} + +fn collapse_line_breaks(text: String, opts: &CsvOptions) -> String { + if opts.line_break_to_space { + text.replace("\r\n", " ").replace(['\r', '\n'], " ") + } else { + text + } +} + +pub(super) fn csv_cell(value: &Value, column: &ColumnType, opts: &CsvOptions) -> String { + let Some(text) = value_text(value, column) else { + return if opts.null_to_empty { + String::new() + } else { + "NULL".to_string() + }; + }; + let mut text = collapse_line_breaks(text, opts); + let is_numeric = matches!(value, Value::Float32(_) | Value::Float64(_) | Value::Decimal(_)); + if opts.decimal == CsvDecimal::Comma && is_numeric && is_plain_decimal(&text) { + text = text.replace('.', ","); + } + if opts.sanitize_formulas { + neutralise_formula(value, text, false) + } else { + text + } +} + +pub(super) fn csv_header(name: &str, opts: &CsvOptions) -> String { + if opts.sanitize_formulas { + neutralise_formula(&Value::Null, name.to_string(), true) + } else { + name.to_string() + } +} + +fn csv_text_field(text: &str, opts: &CsvOptions) -> String { + let text = collapse_line_breaks(text.to_string(), opts); + if opts.sanitize_formulas { + neutralise_text(text) + } else { + text + } +} + +pub fn render_csv(columns: &[ResultColumn], rows: &[Vec], opts: &CsvOptions) -> Result { + encode_rows(CsvEncoder::new(columns.into(), opts.clone()), rows) +} + +/// CSV for records whose fields are all free text, such as exported query +/// history. +pub fn render_text_csv(header: &[&str], records: &[Vec], opts: &CsvOptions) -> Result { + let mut writer = csv_writer_builder(opts).from_writer(Vec::new()); + if opts.header_row { + writer.write_record(header.iter().map(|name| csv_header(name, opts)))?; + } + for record in records { + writer.write_record(record.iter().map(|field| csv_text_field(field, opts)))?; + } + finish(writer) +} + +fn is_plain_decimal(s: &str) -> bool { + let unsigned = s.strip_prefix(['+', '-']).unwrap_or(s); + let Some((int_part, frac_part)) = unsigned.split_once('.') else { + return false; + }; + !int_part.is_empty() + && !frac_part.is_empty() + && int_part.chars().all(|c| c.is_ascii_digit()) + && frac_part.chars().all(|c| c.is_ascii_digit()) +} + +/// Every leading character a spreadsheet may read as the start of a +/// formula: the four ASCII operators, the whitespace Excel strips before +/// parsing (so `\t=cmd|'/C calc'!A0` reaches the formula engine as +/// `=cmd|…` would), and the full-width forms of the operators. +pub const FORMULA_LEADS: [char; 11] = [ + '=', '+', '-', '@', '\t', '\r', '\n', '\u{FF1D}', '\u{FF0B}', '\u{FF0D}', '\u{FF20}', +]; + +fn is_plain_number(s: &str) -> bool { + let bytes = s.as_bytes(); + let mut i = usize::from(matches!(bytes.first(), Some(b'+' | b'-'))); + let int_start = i; + while bytes.get(i).is_some_and(u8::is_ascii_digit) { + i += 1; + } + let int_digits = i - int_start; + let mut frac_digits = 0; + if bytes.get(i) == Some(&b'.') { + i += 1; + let frac_start = i; + while bytes.get(i).is_some_and(u8::is_ascii_digit) { + i += 1; + } + frac_digits = i - frac_start; + } + if int_digits == 0 && frac_digits == 0 { + return false; + } + if matches!(bytes.get(i), Some(b'e' | b'E')) { + i += 1; + if matches!(bytes.get(i), Some(b'+' | b'-')) { + i += 1; + } + let exp_start = i; + while bytes.get(i).is_some_and(u8::is_ascii_digit) { + i += 1; + } + if i == exp_start { + return false; + } + } + i == bytes.len() +} + +#[cfg(test)] +mod tests { + use super::super::test_columns::cols; + use super::*; + + fn plain() -> CsvOptions { + CsvOptions { + sanitize_formulas: false, + ..Default::default() + } + } + + #[test] + fn csv_defaults_quote_every_field_when_sanitising() { + let columns = cols(&["id", "name"]); + let rows = vec![vec![Value::Int(1), Value::Text("Alice".into())]]; + let out = render_csv(&columns, &rows, &CsvOptions::default()).unwrap(); + assert_eq!(out, "\"id\",\"name\"\n\"1\",\"Alice\"\n"); + } + + #[test] + fn csv_if_needed_leaves_plain_fields_bare() { + let columns = cols(&["id", "name"]); + let rows = vec![vec![Value::Int(1), Value::Text("Alice".into())]]; + let out = render_csv(&columns, &rows, &plain()).unwrap(); + assert_eq!(out, "id,name\n1,Alice\n"); + } + + #[test] + fn csv_quote_if_needed_triggers_on_delimiter() { + let columns = cols(&["a"]); + let rows = vec![vec![Value::Text("has,comma".into())]]; + let out = render_csv(&columns, &rows, &plain()).unwrap(); + assert_eq!(out, "a\n\"has,comma\"\n"); + } + + #[test] + fn csv_quote_if_needed_triggers_on_quote_char() { + let columns = cols(&["a"]); + let rows = vec![vec![Value::Text("say \"hi\"".into())]]; + let out = render_csv(&columns, &rows, &plain()).unwrap(); + assert_eq!(out, "a\n\"say \"\"hi\"\"\"\n"); + } + + #[test] + fn csv_quote_if_needed_triggers_on_line_break() { + let columns = cols(&["a"]); + let rows = vec![vec![Value::Text("line1\nline2".into())]]; + let out = render_csv(&columns, &rows, &plain()).unwrap(); + assert_eq!(out, "a\n\"line1\nline2\"\n"); + } + + #[test] + fn csv_line_break_to_space_leaves_nothing_to_quote() { + let columns = cols(&["a"]); + let rows = vec![vec![Value::Text("line1\r\nline2".into())]]; + let opts = CsvOptions { + line_break_to_space: true, + ..plain() + }; + let out = render_csv(&columns, &rows, &opts).unwrap(); + assert_eq!(out, "a\nline1 line2\n"); + } + + #[test] + fn csv_quote_always_quotes_everything() { + let columns = cols(&["a"]); + let rows = vec![vec![Value::Text("plain".into())]]; + let opts = CsvOptions { + quote: CsvQuote::Always, + ..plain() + }; + let out = render_csv(&columns, &rows, &opts).unwrap(); + assert_eq!(out, "\"a\"\n\"plain\"\n"); + } + + #[test] + fn csv_quote_never_quotes_nothing_even_with_delimiter() { + let columns = cols(&["a"]); + let rows = vec![vec![Value::Text("has,comma".into())]]; + let opts = CsvOptions { + quote: CsvQuote::Never, + ..plain() + }; + let out = render_csv(&columns, &rows, &opts).unwrap(); + assert_eq!(out, "a\nhas,comma\n"); + } + + #[test] + fn csv_sanitising_overrides_quote_never() { + let columns = cols(&["a"]); + let rows = vec![vec![Value::Text("=cmd".into())]]; + let opts = CsvOptions { + quote: CsvQuote::Never, + ..Default::default() + }; + let out = render_csv(&columns, &rows, &opts).unwrap(); + assert_eq!(out, "\"a\"\n\"'=cmd\"\n"); + } + + #[test] + fn csv_sanitizes_formula_prefixes() { + let columns = cols(&["a"]); + for ch in ['=', '+', '-', '@'] { + let rows = vec![vec![Value::Text(format!("{ch}cmd"))]]; + let out = render_csv(&columns, &rows, &CsvOptions::default()).unwrap(); + assert_eq!(out, format!("\"a\"\n\"'{ch}cmd\"\n"), "prefix {ch} should be sanitized"); + } + } + + #[test] + fn csv_sanitizes_formula_lead_hidden_behind_whitespace() { + let columns = cols(&["a"]); + for lead in ['\t', '\r', '\n'] { + let rows = vec![vec![Value::Text(format!("{lead}=cmd|'/C calc'!A0"))]]; + let out = render_csv(&columns, &rows, &CsvOptions::default()).unwrap(); + let expected = format!("\"a\"\n\"'{lead}=cmd|'/C calc'!A0\"\n"); + assert_eq!(out, expected, "lead {lead:?} should be sanitized and quoted"); + } + } + + #[test] + fn csv_sanitizes_full_width_formula_leads() { + let columns = cols(&["a"]); + for lead in ['\u{FF1D}', '\u{FF0B}', '\u{FF0D}', '\u{FF20}'] { + let rows = vec![vec![Value::Text(format!("{lead}SUM(A1)"))]]; + let out = render_csv(&columns, &rows, &CsvOptions::default()).unwrap(); + assert_eq!( + out, + format!("\"a\"\n\"'{lead}SUM(A1)\"\n"), + "lead {lead:?} should be sanitized" + ); + } + } + + #[test] + fn csv_leaves_formula_text_alone_when_sanitising_is_off() { + let columns = cols(&["a"]); + let rows = vec![vec![Value::Text("=SUM(A1)".into())]]; + let out = render_csv(&columns, &rows, &plain()).unwrap(); + assert_eq!(out, "a\n=SUM(A1)\n"); + } + + #[test] + fn csv_does_not_sanitize_non_formula_prefixes() { + let columns = cols(&["a"]); + let rows = vec![vec![Value::Text("plain text".into())]]; + let out = render_csv(&columns, &rows, &CsvOptions::default()).unwrap(); + assert_eq!(out, "\"a\"\n\"plain text\"\n"); + } + + #[test] + fn csv_decimal_comma_only_for_plain_decimals() { + let columns = cols(&["a"]); + let opts = CsvOptions { + decimal: CsvDecimal::Comma, + delimiter: CsvDelimiter::Semicolon, + ..plain() + }; + let render = |value: Value| render_csv(&columns, &[vec![value]], &opts).unwrap(); + assert_eq!(render(Value::Float64(1.5)), "a\n1,5\n"); + assert_eq!(render(Value::Float64(-1.5)), "a\n-1,5\n"); + assert_eq!( + render(Value::Decimal("12.30".parse().expect("a decimal"))), + "a\n12,30\n" + ); + assert_eq!(render(Value::Text("1.5".into())), "a\n1.5\n"); + assert_eq!(render(Value::Text("1e5".into())), "a\n1e5\n"); + assert_eq!(render(Value::Int(12)), "a\n12\n"); + assert_eq!(render(Value::Text("1.2.3".into())), "a\n1.2.3\n"); + } + + #[test] + fn csv_keeps_negative_int() { + let columns = cols(&["a"]); + let out = render_csv(&columns, &[vec![Value::Int(-5)]], &CsvOptions::default()).unwrap(); + assert_eq!(out, "\"a\"\n\"-5\"\n"); + } + + #[test] + fn csv_keeps_negative_decimal() { + let columns = cols(&["a"]); + let rows = vec![vec![Value::Decimal("-12.30".parse().expect("a decimal"))]]; + let out = render_csv(&columns, &rows, &CsvOptions::default()).unwrap(); + assert_eq!(out, "\"a\"\n\"-12.30\"\n"); + } + + #[test] + fn csv_keeps_negative_float() { + let columns = cols(&["a"]); + let out = render_csv(&columns, &[vec![Value::Float64(-1.5)]], &CsvOptions::default()).unwrap(); + assert_eq!(out, "\"a\"\n\"-1.5\"\n"); + } + + #[test] + fn csv_keeps_signed_numeric_text() { + let columns = cols(&["a"]); + for text in ["-12", "+1.5e3", "-.5", "1.", "-7E-3"] { + let rows = vec![vec![Value::Text(text.into())]]; + let out = render_csv(&columns, &rows, &CsvOptions::default()).unwrap(); + assert_eq!(out, format!("\"a\"\n\"{text}\"\n"), "{text} is a number, not a formula"); + } + } + + #[test] + fn csv_neutralises_formula_text() { + let columns = cols(&["a"]); + for text in ["-cmd", "=SUM(A1)", "+1+cmd", "-", "-.", "-1e"] { + let rows = vec![vec![Value::Text(text.into())]]; + let out = render_csv(&columns, &rows, &CsvOptions::default()).unwrap(); + assert_eq!(out, format!("\"a\"\n\"'{text}\"\n"), "{text} must be neutralised"); + } + } + + #[test] + fn csv_formula_rule_unchanged_after_split() { + let columns = cols(&["a"]); + let other = |text: &str| { + Value::Other(Box::new(crate::value::OtherValue { + type_name: "citext".to_owned(), + text: text.to_owned(), + })) + }; + let neutralised = [ + Value::Text("=cmd".into()), + Value::Json(crate::value::JsonText::parse("\t{}".to_owned()).expect("valid json")), + other("-2+cmd"), + ]; + for value in neutralised { + let out = render_csv(&columns, &[vec![value.clone()]], &CsvOptions::default()).unwrap(); + assert!(out.contains("\"'"), "{value:?} reached the sheet as a formula: {out}"); + } + let kept = [ + (Value::Int(-5), "-5"), + (Value::Float64(-1.5), "-1.5"), + (Value::Decimal("-12.30".parse().expect("a decimal")), "-12.30"), + (Value::Text("-12".into()), "-12"), + (other("+1.5e3"), "+1.5e3"), + ]; + for (value, text) in kept { + let out = render_csv(&columns, &[vec![value]], &CsvOptions::default()).unwrap(); + assert_eq!(out, format!("\"a\"\n\"{text}\"\n"), "{text} is data, not a formula"); + } + } + + #[test] + fn csv_neutralises_formula_header() { + let columns = cols(&["=HYPERLINK(\"x\")"]); + let out = render_csv(&columns, &[], &CsvOptions::default()).unwrap(); + assert_eq!(out, "\"'=HYPERLINK(\"\"x\"\")\"\n"); + } + + #[test] + fn text_csv_treats_every_field_as_free_text() { + let records = vec![vec!["-cmd".to_string(), "12".to_string(), String::new()]]; + let out = render_text_csv(&["q", "n", "e"], &records, &CsvOptions::default()).unwrap(); + assert_eq!(out, "\"q\",\"n\",\"e\"\n\"'-cmd\",\"12\",\"\"\n"); + } + + #[test] + fn plain_number_scanner_matches_the_grammar() { + for yes in [ + "0", "12", "-12", "+12", "1.", ".5", "-.5", "1.25", "1e5", "1E+5", "-1.5e-3", + ] { + assert!(is_plain_number(yes), "{yes} should be a plain number"); + } + for no in [ + "", "+", "-", ".", "e5", "1e", "1e+", "1.2.3", "1,5", " 1", "1 ", "0x10", "--1", + ] { + assert!(!is_plain_number(no), "{no:?} should not be a plain number"); + } + } + + #[test] + fn csv_null_modes() { + let columns = cols(&["a"]); + let rows = vec![vec![Value::Null]]; + let out_empty = render_csv(&columns, &rows, &CsvOptions::default()).unwrap(); + assert_eq!(out_empty, "\"a\"\n\"\"\n"); + let opts = CsvOptions { + null_to_empty: false, + ..Default::default() + }; + let out_null = render_csv(&columns, &rows, &opts).unwrap(); + assert_eq!(out_null, "\"a\"\n\"NULL\"\n"); + } + + #[test] + fn csv_lone_empty_field_stays_a_visible_row() { + let columns = cols(&["a"]); + let rows = vec![vec![Value::Null]]; + let out = render_csv(&columns, &rows, &plain()).unwrap(); + assert_eq!(out, "a\n\"\"\n"); + } + + #[test] + fn csv_crlf_line_break() { + let columns = cols(&["a"]); + let rows = vec![vec![Value::Int(1)]]; + let opts = CsvOptions { + line_break: CsvLineBreak::CrLf, + ..plain() + }; + let out = render_csv(&columns, &rows, &opts).unwrap(); + assert_eq!(out, "a\r\n1\r\n"); + } + + #[test] + fn csv_cr_line_break_quotes_embedded_line_feeds() { + let columns = cols(&["a"]); + let rows = vec![vec![Value::Text("x\ny".into())]]; + let opts = CsvOptions { + line_break: CsvLineBreak::Cr, + ..plain() + }; + let out = render_csv(&columns, &rows, &opts).unwrap(); + assert_eq!(out, "a\r\"x\ny\"\r"); + } + + #[test] + fn csv_semicolon_delimiter() { + let columns = cols(&["a", "b"]); + let rows = vec![vec![Value::Int(1), Value::Int(2)]]; + let opts = CsvOptions { + delimiter: CsvDelimiter::Semicolon, + ..plain() + }; + let out = render_csv(&columns, &rows, &opts).unwrap(); + assert_eq!(out, "a;b\n1;2\n"); + } + + #[test] + fn csv_header_off() { + let columns = cols(&["a"]); + let rows = vec![vec![Value::Int(1)]]; + let opts = CsvOptions { + header_row: false, + ..plain() + }; + let out = render_csv(&columns, &rows, &opts).unwrap(); + assert_eq!(out, "1\n"); + } +} diff --git a/linux/crates/core/src/export/csv_options.rs b/linux/crates/core/src/export/csv_options.rs new file mode 100644 index 0000000000..3ff8f7adaf --- /dev/null +++ b/linux/crates/core/src/export/csv_options.rs @@ -0,0 +1,78 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum CsvDelimiter { + Comma, + Semicolon, + Tab, + Pipe, +} + +impl CsvDelimiter { + pub const ALL: [CsvDelimiter; 4] = [ + CsvDelimiter::Comma, + CsvDelimiter::Semicolon, + CsvDelimiter::Tab, + CsvDelimiter::Pipe, + ]; +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum CsvQuote { + Always, + IfNeeded, + Never, +} + +impl CsvQuote { + pub const ALL: [CsvQuote; 3] = [CsvQuote::Always, CsvQuote::IfNeeded, CsvQuote::Never]; +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum CsvLineBreak { + Lf, + CrLf, + Cr, +} + +impl CsvLineBreak { + pub const ALL: [CsvLineBreak; 3] = [CsvLineBreak::Lf, CsvLineBreak::CrLf, CsvLineBreak::Cr]; +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum CsvDecimal { + Period, + Comma, +} + +impl CsvDecimal { + pub const ALL: [CsvDecimal; 2] = [CsvDecimal::Period, CsvDecimal::Comma]; +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct CsvOptions { + pub null_to_empty: bool, + pub line_break_to_space: bool, + pub header_row: bool, + pub sanitize_formulas: bool, + pub delimiter: CsvDelimiter, + pub quote: CsvQuote, + pub line_break: CsvLineBreak, + pub decimal: CsvDecimal, +} + +impl Default for CsvOptions { + fn default() -> Self { + CsvOptions { + null_to_empty: true, + line_break_to_space: false, + header_row: true, + sanitize_formulas: true, + delimiter: CsvDelimiter::Comma, + quote: CsvQuote::IfNeeded, + line_break: CsvLineBreak::Lf, + decimal: CsvDecimal::Period, + } + } +} diff --git a/linux/crates/core/src/export/encoder/csv_encoder.rs b/linux/crates/core/src/export/encoder/csv_encoder.rs new file mode 100644 index 0000000000..a42b639668 --- /dev/null +++ b/linux/crates/core/src/export/encoder/csv_encoder.rs @@ -0,0 +1,55 @@ +use std::sync::Arc; + +use crate::column::ResultColumn; +use crate::value::Value; + +use super::super::column_type; +use super::super::csv::{csv_cell, csv_header, csv_writer_builder}; +use super::super::csv_options::CsvOptions; +use super::{EncodeError, RecordWriter, RowEncoder, readable_cells}; + +/// CSV under the user's options, with formula-like text neutralised +/// when they ask for it. +pub struct CsvEncoder { + columns: Arc<[ResultColumn]>, + options: CsvOptions, + writer: RecordWriter, +} + +impl CsvEncoder { + pub fn new(columns: Arc<[ResultColumn]>, options: CsvOptions) -> Self { + let writer = RecordWriter::new(csv_writer_builder(&options)); + Self { + columns, + options, + writer, + } + } +} + +impl RowEncoder for CsvEncoder { + fn begin(&mut self, out: &mut Vec) { + if self.options.header_row { + self.writer.write( + out, + self.columns + .iter() + .map(|column| csv_header(&column.name, &self.options)), + ); + } + } + + fn row(&mut self, out: &mut Vec, cells: &mut dyn Iterator) -> Result<(), EncodeError> { + let cells = readable_cells(&self.columns, cells)?; + self.writer.write( + out, + cells + .iter() + .enumerate() + .map(|(index, value)| csv_cell(value, column_type(&self.columns, index), &self.options)), + ); + Ok(()) + } + + fn finish(&mut self, _out: &mut Vec) {} +} diff --git a/linux/crates/core/src/export/encoder/encode_error.rs b/linux/crates/core/src/export/encoder/encode_error.rs new file mode 100644 index 0000000000..51a08d790f --- /dev/null +++ b/linux/crates/core/src/export/encoder/encode_error.rs @@ -0,0 +1,14 @@ +use thiserror::Error; + +/// Why a row could not be encoded. The text is for logs; the app says it +/// in its own words. +#[derive(Debug, Clone, PartialEq, Eq, Error)] +pub enum EncodeError { + #[error("column {column} has an undecodable value")] + Undecodable { column: String }, + + /// A control character XML 1.0 has no representation for, not even + /// as a character reference. + #[error("column {column} holds a character XML cannot carry")] + NotXmlText { column: String }, +} diff --git a/linux/crates/core/src/export/encoder/html_encoder.rs b/linux/crates/core/src/export/encoder/html_encoder.rs new file mode 100644 index 0000000000..e465124cbb --- /dev/null +++ b/linux/crates/core/src/export/encoder/html_encoder.rs @@ -0,0 +1,42 @@ +use std::sync::Arc; + +use crate::column::ResultColumn; +use crate::value::Value; + +use super::super::html::{HTML_TAIL, html_head, html_row}; +use super::{EncodeError, RowEncoder, readable_cells}; + +/// The document `render_html` writes, a row at a time. +pub struct HtmlEncoder { + columns: Arc<[ResultColumn]>, + title: String, +} + +impl HtmlEncoder { + pub fn new(columns: Arc<[ResultColumn]>, title: impl Into) -> Self { + Self { + columns, + title: title.into(), + } + } +} + +impl RowEncoder for HtmlEncoder { + fn begin(&mut self, out: &mut Vec) { + let mut head = String::new(); + html_head(&mut head, &self.columns, &self.title); + out.extend_from_slice(head.as_bytes()); + } + + fn row(&mut self, out: &mut Vec, cells: &mut dyn Iterator) -> Result<(), EncodeError> { + let cells = readable_cells(&self.columns, cells)?; + let mut row = String::new(); + html_row(&mut row, &self.columns, cells); + out.extend_from_slice(row.as_bytes()); + Ok(()) + } + + fn finish(&mut self, out: &mut Vec) { + out.extend_from_slice(HTML_TAIL.as_bytes()); + } +} diff --git a/linux/crates/core/src/export/encoder/json_array_encoder.rs b/linux/crates/core/src/export/encoder/json_array_encoder.rs new file mode 100644 index 0000000000..867f2d791b --- /dev/null +++ b/linux/crates/core/src/export/encoder/json_array_encoder.rs @@ -0,0 +1,83 @@ +use std::sync::Arc; + +use serde::Serialize; + +use crate::column::ResultColumn; +use crate::value::Value; + +use super::super::column_type; +use super::super::json::JsonCell; +use super::super::json_field_names::json_field_names; +use super::{EncodeError, RowEncoder, readable_cells}; + +/// A JSON array with one object per row, each on a line of its own. +/// +/// The objects are written key by key in column order rather than +/// built as a map first: a map would cost a copy of every row and, with +/// serde_json's default map, sort the keys. +pub struct JsonArrayEncoder { + columns: Arc<[ResultColumn]>, + /// Each column's key as JSON, followed by its colon. + keys: Vec>, + rows: u64, +} + +impl JsonArrayEncoder { + pub fn new(columns: Arc<[ResultColumn]>) -> Self { + let keys = json_field_names(&columns) + .iter() + .map(|name| { + let mut key = Vec::with_capacity(name.len() + 3); + write_json(&mut key, name); + key.push(b':'); + key + }) + .collect(); + Self { columns, keys, rows: 0 } + } +} + +impl RowEncoder for JsonArrayEncoder { + fn begin(&mut self, out: &mut Vec) { + out.push(b'['); + } + + fn row(&mut self, out: &mut Vec, cells: &mut dyn Iterator) -> Result<(), EncodeError> { + let cells = readable_cells(&self.columns, cells)?; + let lead: &[u8] = if self.rows == 0 { b"\n{" } else { b",\n{" }; + out.extend_from_slice(lead); + for (index, key) in self.keys.iter().enumerate() { + if index > 0 { + out.push(b','); + } + out.extend_from_slice(key); + match cells.get(index).copied() { + Some(value) => write_json( + out, + &JsonCell { + value, + kind: &column_type(&self.columns, index).kind, + }, + ), + // A row shorter than its header: the cell is missing, + // which is not the same as a NULL the row holds. + None => out.extend_from_slice(b"null"), + } + } + out.push(b'}'); + self.rows += 1; + Ok(()) + } + + fn finish(&mut self, out: &mut Vec) { + if self.rows > 0 { + out.push(b'\n'); + } + out.push(b']'); + } +} + +/// serde_json fails only when its writer does, and a Vec never does. +fn write_json(out: &mut Vec, value: &impl Serialize) { + let _ = serde_json::to_writer(out, value); +} diff --git a/linux/crates/core/src/export/encoder/markdown_encoder.rs b/linux/crates/core/src/export/encoder/markdown_encoder.rs new file mode 100644 index 0000000000..66347c3e01 --- /dev/null +++ b/linux/crates/core/src/export/encoder/markdown_encoder.rs @@ -0,0 +1,40 @@ +use std::sync::Arc; + +use crate::column::ResultColumn; +use crate::value::Value; + +use super::super::column_type; +use super::super::markdown::{markdown_cell, markdown_text}; +use super::{EncodeError, RowEncoder, readable_cells}; + +/// A Markdown table. Each row starts on a line of its own, so the table +/// ends without a trailing line break. +pub struct MarkdownEncoder { + columns: Arc<[ResultColumn]>, +} + +impl MarkdownEncoder { + pub fn new(columns: Arc<[ResultColumn]>) -> Self { + Self { columns } + } +} + +impl RowEncoder for MarkdownEncoder { + fn begin(&mut self, out: &mut Vec) { + let header: Vec = self.columns.iter().map(|column| markdown_text(&column.name)).collect(); + let separator: Vec<&str> = self.columns.iter().map(|_| "---").collect(); + out.extend_from_slice(format!("| {} |\n| {} |", header.join(" | "), separator.join(" | ")).as_bytes()); + } + + fn row(&mut self, out: &mut Vec, cells: &mut dyn Iterator) -> Result<(), EncodeError> { + let cells: Vec = readable_cells(&self.columns, cells)? + .into_iter() + .enumerate() + .map(|(index, value)| markdown_cell(value, column_type(&self.columns, index))) + .collect(); + out.extend_from_slice(format!("\n| {} |", cells.join(" | ")).as_bytes()); + Ok(()) + } + + fn finish(&mut self, _out: &mut Vec) {} +} diff --git a/linux/crates/core/src/export/encoder/mod.rs b/linux/crates/core/src/export/encoder/mod.rs new file mode 100644 index 0000000000..830910ea51 --- /dev/null +++ b/linux/crates/core/src/export/encoder/mod.rs @@ -0,0 +1,113 @@ +mod csv_encoder; +mod encode_error; +mod html_encoder; +mod json_array_encoder; +mod markdown_encoder; +mod row_encoder; +mod sql_insert_encoder; +mod tsv_encoder; +mod xml_encoder; + +use std::cell::Cell; +use std::io; + +use crate::column::ResultColumn; +use crate::value::Value; + +pub use csv_encoder::CsvEncoder; +pub use encode_error::EncodeError; +pub use html_encoder::HtmlEncoder; +pub use json_array_encoder::JsonArrayEncoder; +pub use markdown_encoder::MarkdownEncoder; +pub use row_encoder::RowEncoder; +pub use sql_insert_encoder::SqlInsertEncoder; +pub use tsv_encoder::TsvEncoder; +pub use xml_encoder::XmlEncoder; + +/// A whole result through `encoder`, as text. +pub(super) fn encode_rows(mut encoder: impl RowEncoder, rows: &[Vec]) -> Result { + let mut out = Vec::new(); + encoder.begin(&mut out); + for row in rows { + encoder.row(&mut out, &mut row.iter())?; + } + encoder.finish(&mut out); + // Every encoder writes UTF-8 text and ASCII punctuation, so the + // lossy branch is never taken. + Ok(String::from_utf8(out).unwrap_or_else(|error| String::from_utf8_lossy(error.as_bytes()).into_owned())) +} + +/// The row's cells, refused when one of them could not be read: a value +/// the driver could not decode has no text, and writing it as a NULL +/// would put a value in the file that the table does not hold. +fn readable_cells<'a>( + columns: &[ResultColumn], + cells: &mut dyn Iterator, +) -> Result, EncodeError> { + cells + .enumerate() + .map(|(index, value)| match value { + Value::Undecodable(_) => Err(EncodeError::Undecodable { + column: column_name(columns, index), + }), + value => Ok(value), + }) + .collect() +} + +/// A column's name, or its position for a cell past the header. +pub(in crate::export) fn column_name(columns: &[ResultColumn], index: usize) -> String { + columns + .get(index) + .map_or_else(|| (index + 1).to_string(), |column| column.name.clone()) +} + +/// Writes one record at a time into the caller's buffer, through one +/// CSV writer kept for the whole export: building a writer allocates and +/// zeroes its buffer, which per row would cost more than the row. +struct RecordWriter { + writer: csv::Writer, +} + +/// The caller's buffer, lent to the writer for one record. csv::Writer +/// has no `get_mut`, so the buffer goes in and out through a `Cell`. +#[derive(Default)] +struct LentBuffer(Cell>); + +impl io::Write for LentBuffer { + fn write(&mut self, bytes: &[u8]) -> io::Result { + self.0.get_mut().extend_from_slice(bytes); + Ok(bytes.len()) + } + + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } +} + +impl RecordWriter { + /// A row may have more cells than the header, so the writer does + /// not hold every record to the first one's length. + fn new(mut builder: csv::WriterBuilder) -> Self { + builder.flexible(true); + Self { + writer: builder.from_writer(LentBuffer::default()), + } + } + + /// Writing into memory cannot fail, and a flexible writer has no + /// field count to disagree with, so neither result carries anything. + fn write(&mut self, out: &mut Vec, record: I) + where + I: IntoIterator, + T: AsRef<[u8]>, + { + self.writer.get_ref().0.set(std::mem::take(out)); + let _ = self.writer.write_record(record); + let _ = self.writer.flush(); + *out = self.writer.get_ref().0.take(); + } +} + +#[cfg(test)] +mod tests; diff --git a/linux/crates/core/src/export/encoder/row_encoder.rs b/linux/crates/core/src/export/encoder/row_encoder.rs new file mode 100644 index 0000000000..2c6b26c096 --- /dev/null +++ b/linux/crates/core/src/export/encoder/row_encoder.rs @@ -0,0 +1,17 @@ +use crate::value::Value; + +use super::EncodeError; + +/// Writes a result one row at a time into a buffer the caller owns, so +/// the caller decides how much to hold before handing it on. +pub trait RowEncoder: Send { + /// What comes before the first row: a header line, an opening + /// bracket. + fn begin(&mut self, out: &mut Vec); + + /// One row. A row that fails leaves `out` as it was. + fn row(&mut self, out: &mut Vec, cells: &mut dyn Iterator) -> Result<(), EncodeError>; + + /// What comes after the last row. + fn finish(&mut self, out: &mut Vec); +} diff --git a/linux/crates/core/src/export/encoder/sql_insert_encoder.rs b/linux/crates/core/src/export/encoder/sql_insert_encoder.rs new file mode 100644 index 0000000000..dc81b77327 --- /dev/null +++ b/linux/crates/core/src/export/encoder/sql_insert_encoder.rs @@ -0,0 +1,53 @@ +use std::sync::Arc; + +use crate::column::ResultColumn; +use crate::dialect::SqlDialect; +use crate::meta::TableRef; +use crate::value::Value; + +use super::super::sql::{InsertShape, insert_row, insert_shape}; +use super::{EncodeError, RowEncoder, readable_cells}; + +/// The statements `render_sql_insert` writes, a row at a time. +pub struct SqlInsertEncoder { + dialect: Arc, + columns: Arc<[ResultColumn]>, + /// `None` for a result with no column to write, which writes + /// nothing. + shape: Option, +} + +impl SqlInsertEncoder { + /// `generated` names the columns the server computes, which every + /// statement leaves out. + pub fn new( + dialect: Arc, + table: &TableRef, + columns: Arc<[ResultColumn]>, + generated: &[String], + ) -> Self { + let shape = insert_shape(&*dialect, table, &columns, generated); + Self { + dialect, + columns, + shape, + } + } +} + +impl RowEncoder for SqlInsertEncoder { + fn begin(&mut self, _out: &mut Vec) {} + + fn row(&mut self, out: &mut Vec, cells: &mut dyn Iterator) -> Result<(), EncodeError> { + let cells = readable_cells(&self.columns, cells)?; + let Some(shape) = &self.shape else { + return Ok(()); + }; + let mut row = String::new(); + insert_row(&mut row, &*self.dialect, shape, &self.columns, cells); + out.extend_from_slice(row.as_bytes()); + Ok(()) + } + + fn finish(&mut self, _out: &mut Vec) {} +} diff --git a/linux/crates/core/src/export/encoder/tests.rs b/linux/crates/core/src/export/encoder/tests.rs new file mode 100644 index 0000000000..dfc8aebde4 --- /dev/null +++ b/linux/crates/core/src/export/encoder/tests.rs @@ -0,0 +1,384 @@ +use std::sync::Arc; + +use super::super::csv::{csv_cell, csv_header, csv_writer_builder}; +use super::super::csv_options::{CsvDecimal, CsvDelimiter, CsvLineBreak, CsvOptions, CsvQuote}; +use super::super::test_columns::{cols, typed}; +use super::super::tsv::tsv_writer_builder; +use super::super::value_text::value_text; +use super::super::{ + column_type, render_csv, render_html, render_json, render_sql_insert, render_tsv, render_xml, row_to_json, +}; +use super::*; +use crate::column::{ColumnKind, IntegerKind, IntegerWidth}; +use crate::dialect::{SqlDialect, TestDialect}; +use crate::meta::TableRef; +use crate::value::{JsonText, UndecodableReason, UndecodedValue}; + +/// Every call's output taken away as soon as it is written, the way a +/// sink hands each chunk on, so an encoder that leaned on what it wrote +/// earlier would show here. +fn streamed(mut encoder: impl RowEncoder, rows: &[Vec]) -> String { + let mut taken = Vec::new(); + let mut out = Vec::new(); + encoder.begin(&mut out); + taken.append(&mut out); + for row in rows { + encoder.row(&mut out, &mut row.iter()).expect("the row"); + taken.append(&mut out); + } + encoder.finish(&mut out); + taken.append(&mut out); + String::from_utf8(taken).expect("utf-8") +} + +fn fixture() -> (Arc<[ResultColumn]>, Vec>) { + let columns: Arc<[ResultColumn]> = vec![ + typed( + "id", + "bigint", + ColumnKind::Integer(IntegerKind { + width: IntegerWidth::W64, + signed: true, + }), + ), + typed( + "amount", + "numeric(10,2)", + ColumnKind::Decimal { + precision: Some(10), + scale: Some(2), + }, + ), + cols(&["=note"]).remove(0), + ] + .into(); + let rows = vec![ + vec![ + Value::Int(-5), + Value::Decimal("-1.5".parse().expect("a decimal")), + Value::Text("=cmd|'/C calc'!A0".into()), + ], + vec![ + Value::Int(7), + Value::Float64(2.25), + Value::Text("line1\r\nline2, \"quoted\"\ttab".into()), + ], + vec![Value::Null, Value::Null, Value::Text(String::new())], + vec![ + Value::UInt(u64::MAX), + Value::Decimal("12.30".parse().expect("a decimal")), + Value::Text("-12".into()), + ], + ]; + (columns, rows) +} + +/// CSV as one writer over the whole result, the way it was written +/// before the encoders. +fn one_writer_csv(columns: &[ResultColumn], rows: &[Vec], options: &CsvOptions) -> String { + let mut writer = csv_writer_builder(options).from_writer(Vec::new()); + if options.header_row { + writer + .write_record(columns.iter().map(|column| csv_header(&column.name, options))) + .expect("the header"); + } + for row in rows { + writer + .write_record( + row.iter() + .enumerate() + .map(|(index, value)| csv_cell(value, column_type(columns, index), options)), + ) + .expect("the row"); + } + String::from_utf8(writer.into_inner().expect("flushed")).expect("utf-8") +} + +#[test] +fn csv_encoder_matches_render_csv_fixture_matrix() { + let (columns, rows) = fixture(); + let mut checked = 0; + for header_row in [true, false] { + for sanitize_formulas in [true, false] { + for null_to_empty in [true, false] { + for line_break_to_space in [true, false] { + for delimiter in CsvDelimiter::ALL { + for quote in CsvQuote::ALL { + for line_break in CsvLineBreak::ALL { + for decimal in CsvDecimal::ALL { + let options = CsvOptions { + null_to_empty, + line_break_to_space, + header_row, + sanitize_formulas, + delimiter, + quote, + line_break, + decimal, + }; + let expected = one_writer_csv(&columns, &rows, &options); + + let encoded = streamed(CsvEncoder::new(columns.clone(), options.clone()), &rows); + + assert_eq!(encoded, expected, "{options:?}"); + assert_eq!( + render_csv(&columns, &rows, &options).expect("the csv"), + expected, + "{options:?}" + ); + checked += 1; + } + } + } + } + } + } + } + } + assert_eq!(checked, 2 * 2 * 2 * 2 * 4 * 3 * 3 * 2); +} + +/// One writer serves the whole export, so a row wider than the header +/// must not trip the check a writer makes on record lengths. +#[test] +fn csv_and_tsv_encoders_take_rows_wider_than_the_header() { + let columns: Arc<[ResultColumn]> = cols(&["a"]).into(); + let rows = vec![ + vec![Value::Int(1)], + vec![Value::Int(2), Value::Int(3)], + vec![Value::Int(4)], + ]; + let options = CsvOptions { + sanitize_formulas: false, + ..CsvOptions::default() + }; + + assert_eq!( + streamed(CsvEncoder::new(columns.clone(), options), &rows), + "a\n1\n2,3\n4\n" + ); + assert_eq!(streamed(TsvEncoder::new(columns, true), &rows), "a\n1\n2\t3\n4\n"); +} + +#[test] +fn tsv_encoder_matches_render_tsv() { + let (columns, rows) = fixture(); + for with_headers in [true, false] { + let mut writer = tsv_writer_builder().from_writer(Vec::new()); + if with_headers { + writer + .write_record(columns.iter().map(|column| column.name.clone())) + .expect("the header"); + } + for row in &rows { + writer + .write_record(row.iter().enumerate().map(|(index, value)| { + value_text(value, column_type(&columns, index)).unwrap_or_else(|| "NULL".to_owned()) + })) + .expect("the row"); + } + let expected = String::from_utf8(writer.into_inner().expect("flushed")).expect("utf-8"); + + assert_eq!( + streamed(TsvEncoder::new(columns.clone(), with_headers), &rows), + expected + ); + assert_eq!(render_tsv(&columns, &rows, with_headers).expect("the tsv"), expected); + } +} + +#[test] +fn json_encoder_output_parses_and_matches_render_json() { + const CHUNK: usize = 64 * 1024; + let columns: Arc<[ResultColumn]> = cols(&["id", "name", "payload"]).into(); + let rows: Vec> = (0..10_000) + .map(|id| { + vec![ + Value::Int(id), + Value::Text(format!("row \"{id}\"\n")), + Value::Json(JsonText::parse(format!("{{\"n\": {id}}}")).expect("valid json")), + ] + }) + .collect(); + + let mut encoder = JsonArrayEncoder::new(columns.clone()); + let mut chunks: Vec> = Vec::new(); + let mut out = Vec::with_capacity(CHUNK); + encoder.begin(&mut out); + for row in &rows { + encoder.row(&mut out, &mut row.iter()).expect("the row"); + if out.len() >= CHUNK { + chunks.push(std::mem::replace(&mut out, Vec::with_capacity(CHUNK))); + } + } + encoder.finish(&mut out); + chunks.push(out); + + assert!(chunks.len() > 2, "the export fit in one buffer"); + assert!(chunks[..chunks.len() - 1].iter().all(|chunk| chunk.len() >= CHUNK)); + let text = String::from_utf8(chunks.concat()).expect("utf-8"); + // render_json runs this encoder into one buffer, so this shows only + // that the chunks change nothing. + assert_eq!(text, render_json(&columns, &rows).expect("the json")); + let parsed: Vec = serde_json::from_str(&text).expect("a JSON array"); + // The values themselves, against the serializer that wrote whole + // rows before the encoders. + let expected: Vec = rows + .iter() + .map(|row| serde_json::from_str(&row_to_json(&columns, row).expect("a readable row")).expect("a JSON row")) + .collect(); + assert_eq!(parsed, expected); + assert_eq!(parsed.len(), 10_000); + assert_eq!(parsed[9_999]["id"], serde_json::json!(9_999)); + assert_eq!(parsed[3]["name"], serde_json::json!("row \"3\"\n")); + assert_eq!(parsed[3]["payload"]["n"], serde_json::json!(3)); +} + +#[test] +fn json_encoder_keeps_column_order_without_preserve_order() { + let columns: Arc<[ResultColumn]> = cols(&["b", "a", "c"]).into(); + let rows = vec![vec![Value::Int(1), Value::Int(2), Value::Int(3)]]; + + assert_eq!( + streamed(JsonArrayEncoder::new(columns), &rows), + "[\n{\"b\":1,\"a\":2,\"c\":3}\n]" + ); +} + +#[test] +fn json_encoder_duplicate_column_names_use_suffixes() { + let columns: Arc<[ResultColumn]> = cols(&["id", "name", "id"]).into(); + let rows = vec![ + vec![Value::Int(1), Value::Text("a".into()), Value::Int(2)], + vec![Value::Int(3), Value::Null, Value::Int(4)], + ]; + + assert_eq!( + streamed(JsonArrayEncoder::new(columns), &rows), + "[\n{\"id\":1,\"name\":\"a\",\"id_2\":2},\n{\"id\":3,\"name\":null,\"id_2\":4}\n]" + ); +} + +#[test] +fn empty_json_export_is_empty_array() { + let columns: Arc<[ResultColumn]> = cols(&["a"]).into(); + + assert_eq!(streamed(JsonArrayEncoder::new(columns), &[]), "[]"); +} + +#[test] +fn document_encoders_match_their_renderers() { + let (columns, mut rows) = fixture(); + rows.push(vec![Value::Int(1)]); + rows.push(vec![ + Value::Int(2), + Value::Null, + Value::Text("&'".into()), + Value::Int(3), + ]); + let dialect: Arc = Arc::new(TestDialect::default()); + + assert_eq!( + streamed(HtmlEncoder::new(columns.clone(), "rows & "), &rows), + render_html(&columns, &rows, "rows & ") + ); + assert_eq!( + streamed(XmlEncoder::new(columns.clone()), &rows), + render_xml(&columns, &rows).expect("the document") + ); + assert_eq!( + streamed( + SqlInsertEncoder::new(dialect.clone(), &TableRef::unqualified("t"), columns.clone(), &[]), + &rows + ), + render_sql_insert(&*dialect, &TableRef::unqualified("t"), &columns, &[], &rows) + ); +} + +#[test] +fn document_encoders_write_whole_documents_for_no_rows() { + let columns: Arc<[ResultColumn]> = cols(&["a"]).into(); + let dialect: Arc = Arc::new(TestDialect::default()); + + assert_eq!( + streamed(HtmlEncoder::new(columns.clone(), "t"), &[]), + render_html(&columns, &[], "t") + ); + assert_eq!( + streamed(XmlEncoder::new(columns.clone()), &[]), + render_xml(&columns, &[]).expect("the document") + ); + let table = TableRef::unqualified("t"); + assert_eq!( + streamed(SqlInsertEncoder::new(dialect.clone(), &table, columns, &[]), &[]), + "" + ); + assert_eq!( + streamed( + SqlInsertEncoder::new(dialect, &table, Vec::new().into(), &[]), + &[Vec::new()] + ), + "", + "a result with no columns has no statement" + ); +} + +#[test] +fn encoder_reports_undecodable_cell() { + let columns: Arc<[ResultColumn]> = cols(&["id", "shape"]).into(); + let undecodable = Value::Undecodable(Box::new(UndecodedValue { + type_name: "geometry".to_owned(), + reason: UndecodableReason::UnsupportedType, + })); + let encoders: Vec> = vec![ + Box::new(CsvEncoder::new(columns.clone(), CsvOptions::default())), + Box::new(TsvEncoder::new(columns.clone(), true)), + Box::new(JsonArrayEncoder::new(columns.clone())), + Box::new(MarkdownEncoder::new(columns.clone())), + Box::new(HtmlEncoder::new(columns.clone(), "t")), + Box::new(XmlEncoder::new(columns.clone())), + Box::new(SqlInsertEncoder::new( + Arc::new(TestDialect::default()), + &TableRef::unqualified("t"), + columns.clone(), + &[], + )), + ]; + for mut encoder in encoders { + let mut out = Vec::new(); + encoder.begin(&mut out); + let before = out.clone(); + + let error = encoder + .row(&mut out, &mut [Value::Int(1), undecodable.clone()].iter()) + .expect_err("an undecodable cell"); + + assert_eq!( + error, + EncodeError::Undecodable { + column: "shape".to_owned() + } + ); + assert_eq!(out, before, "a failed row left part of itself behind"); + } +} + +#[test] +fn csv_neutralises_text_not_negative_numbers_through_encoder() { + let columns: Arc<[ResultColumn]> = cols(&["a"]).into(); + let rows = vec![ + vec![Value::Text("=cmd".into())], + vec![Value::Text("-cmd".into())], + vec![Value::Int(-5)], + vec![Value::Decimal("-12.30".parse().expect("a decimal"))], + vec![Value::Float64(-1.5)], + vec![Value::Text("-12".into())], + ]; + + let out = streamed(CsvEncoder::new(columns, CsvOptions::default()), &rows); + + assert_eq!( + out, + "\"a\"\n\"'=cmd\"\n\"'-cmd\"\n\"-5\"\n\"-12.30\"\n\"-1.5\"\n\"-12\"\n" + ); +} diff --git a/linux/crates/core/src/export/encoder/tsv_encoder.rs b/linux/crates/core/src/export/encoder/tsv_encoder.rs new file mode 100644 index 0000000000..f4f2d66b69 --- /dev/null +++ b/linux/crates/core/src/export/encoder/tsv_encoder.rs @@ -0,0 +1,52 @@ +use std::sync::Arc; + +use crate::column::ResultColumn; +use crate::value::Value; + +use super::super::column_type; +use super::super::tsv::tsv_writer_builder; +use super::super::value_text::value_text; +use super::{EncodeError, RecordWriter, RowEncoder, readable_cells}; + +/// Tab-separated values, the form a spreadsheet pastes into cells. It +/// is what the clipboard carries, so each value goes as the user sees +/// it: a leading apostrophe on `-` or `@alice` would be pasted into +/// every other place the text goes too. +pub struct TsvEncoder { + columns: Arc<[ResultColumn]>, + with_headers: bool, + writer: RecordWriter, +} + +impl TsvEncoder { + pub fn new(columns: Arc<[ResultColumn]>, with_headers: bool) -> Self { + let writer = RecordWriter::new(tsv_writer_builder()); + Self { + columns, + with_headers, + writer, + } + } +} + +impl RowEncoder for TsvEncoder { + fn begin(&mut self, out: &mut Vec) { + if self.with_headers { + self.writer + .write(out, self.columns.iter().map(|column| column.name.as_str())); + } + } + + fn row(&mut self, out: &mut Vec, cells: &mut dyn Iterator) -> Result<(), EncodeError> { + let cells = readable_cells(&self.columns, cells)?; + self.writer.write( + out, + cells.iter().enumerate().map(|(index, value)| { + value_text(value, column_type(&self.columns, index)).unwrap_or_else(|| "NULL".to_owned()) + }), + ); + Ok(()) + } + + fn finish(&mut self, _out: &mut Vec) {} +} diff --git a/linux/crates/core/src/export/encoder/xml_encoder.rs b/linux/crates/core/src/export/encoder/xml_encoder.rs new file mode 100644 index 0000000000..7be654c35c --- /dev/null +++ b/linux/crates/core/src/export/encoder/xml_encoder.rs @@ -0,0 +1,36 @@ +use std::sync::Arc; + +use crate::column::ResultColumn; +use crate::value::Value; + +use super::super::xml::{XML_HEAD, XML_TAIL, xml_row}; +use super::{EncodeError, RowEncoder, readable_cells}; + +/// The document `render_xml` writes, a row at a time. +pub struct XmlEncoder { + columns: Arc<[ResultColumn]>, +} + +impl XmlEncoder { + pub fn new(columns: Arc<[ResultColumn]>) -> Self { + Self { columns } + } +} + +impl RowEncoder for XmlEncoder { + fn begin(&mut self, out: &mut Vec) { + out.extend_from_slice(XML_HEAD.as_bytes()); + } + + fn row(&mut self, out: &mut Vec, cells: &mut dyn Iterator) -> Result<(), EncodeError> { + let cells = readable_cells(&self.columns, cells)?; + let mut row = String::new(); + xml_row(&mut row, &self.columns, cells)?; + out.extend_from_slice(row.as_bytes()); + Ok(()) + } + + fn finish(&mut self, out: &mut Vec) { + out.extend_from_slice(XML_TAIL.as_bytes()); + } +} diff --git a/linux/crates/core/src/export/html.rs b/linux/crates/core/src/export/html.rs new file mode 100644 index 0000000000..6fe31c0270 --- /dev/null +++ b/linux/crates/core/src/export/html.rs @@ -0,0 +1,123 @@ +use crate::column::ResultColumn; +use crate::value::Value; + +use super::column_type; +use super::value_text::value_text; + +/// A result as a standalone HTML document. +/// +/// A whole document rather than a bare `` because the file is +/// opened in a browser, and a fragment without a charset declaration +/// is read as the browser's default encoding, which turns every +/// non-ASCII name into mojibake. +pub fn render_html(columns: &[ResultColumn], rows: &[Vec], title: &str) -> String { + let mut out = String::with_capacity(rows.len() * columns.len() * 16); + html_head(&mut out, columns, title); + for row in rows { + html_row(&mut out, columns, row); + } + out.push_str(HTML_TAIL); + out +} + +pub(super) fn html_head(out: &mut String, columns: &[ResultColumn], title: &str) { + out.push_str("\n\n\n\n"); + escape_into(out, title); + out.push_str("\n\n\n\n
\n\n"); + for column in columns { + out.push_str(""); + } + out.push_str("\n\n\n"); +} + +pub(super) fn html_row<'a>(out: &mut String, columns: &[ResultColumn], row: impl IntoIterator) { + out.push_str(""); + for (index, value) in row.into_iter().enumerate() { + match value_text(value, column_type(columns, index)) { + Some(text) => { + out.push_str(""); + } + out.push_str("\n"); +} + +pub(super) const HTML_TAIL: &str = "\n
"); + escape_into(out, &column.name); + out.push_str("
"); + escape_into(out, &text); + } + // A NULL is marked rather than written as the word, so it + // is not read back as a row holding the text "NULL". + None => out.push_str(""), + } + out.push_str("
\n\n\n"; + +const STYLE: &str = "table { border-collapse: collapse; font-family: sans-serif; font-size: 14px }\n\ +th, td { border: 1px solid #ccc; padding: 4px 8px; text-align: left; vertical-align: top }\n\ +th { background: #f0f0f0 }\n\ +td.null { background: #fafafa }\n\ +td.null::after { content: \"NULL\"; color: #999; font-style: italic }\n"; + +fn escape_into(out: &mut String, text: &str) { + for ch in text.chars() { + match ch { + '&' => out.push_str("&"), + '<' => out.push_str("<"), + '>' => out.push_str(">"), + '"' => out.push_str("""), + other => out.push(other), + } + } +} + +#[cfg(test)] +mod tests { + use super::super::test_columns::cols; + use super::*; + + #[test] + fn a_document_carries_its_own_encoding() { + let html = render_html(&cols(&["tên"]), &[vec![Value::Text("Đà Nẵng".into())]], "t"); + + assert!(html.starts_with(""), "{html}"); + assert!(html.contains(""), "{html}"); + assert!(html.contains("Đà Nẵng"), "{html}"); + } + + #[test] + fn markup_in_a_value_is_shown_not_run() { + let rows = vec![vec![Value::Text("".into())]]; + + let html = render_html(&cols(&["a"]), &rows, "t"); + + assert!(html.contains("<script>alert(1)</script>"), "{html}"); + assert!(!html.contains("".into())]]; - - let html = render_html(&cols(&["a"]), &rows, "t"); - - assert!(html.contains("<script>alert(1)</script>"), "{html}"); - assert!(!html.contains("