diff --git a/.github/workflows/build-linux.yml b/.github/workflows/build-linux.yml index a0fa116852..8512df9f2a 100644 --- a/.github/workflows/build-linux.yml +++ b/.github/workflows/build-linux.yml @@ -14,16 +14,74 @@ on: - cron: "13 14 * * 1" workflow_dispatch: +permissions: + contents: read + +concurrency: + group: build-linux-${{ github.event_name == 'pull_request' && github.ref || github.sha }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +env: + CARGO_ABOUT_VERSION: 0.9.2 + CARGO_DENY_VERSION: 0.20.2 + CARGO_NEXTEST_VERSION: 0.9.144 + CARGO_SHEAR_VERSION: 1.13.4 + ZIZMOR_VERSION: 1.30.1 + LYCHEE_VERSION: 0.24.2 + LYCHEE_SHA256: 1f4e0ef7f6554a6ed33dd7ac144fb2e1bbed98598e7af973042fc5cd43951c9a + FLATPAK_BUILDER_TOOLS_REV: 41c20aa10819cdb2a4f3ca171758a96d1955c018 + 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 + runs-on: ubuntu-24.04 + timeout-minutes: 5 + outputs: + linux: ${{ steps.tree.outputs.linux }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + 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 + env: + GH_TOKEN: ${{ github.token }} + LINUX_BRANCH: ${{ vars.LINUX_BRANCH || 'linux' }} + run: gh workflow run build-linux.yml --repo "$GITHUB_REPOSITORY" --ref "$LINUX_BRANCH" + fast: name: Fast checks + needs: detect + if: needs.detect.outputs.linux == 'true' runs-on: ubuntu-24.04 - # The host runner ships glib 2.80, but libadwaita 1.6 (workspace pin) - # transitively requires gio-2.0 >= 2.82. ubuntu:26.04 gives us GLib - # 2.88 plus the meson, appstream and desktop-file-utils packages the - # data suite validates with. The integration job stays on the host - # runner: driver crates depend only on tablepro-core, so they don't - # pull in libadwaita. + timeout-minutes: 150 + # The host runner ships GLib 2.80, below the GNOME 50 floor. + # ubuntu:26.04 gives GLib 2.88 plus the meson, appstream and + # desktop-file-utils packages the data suite validates with. container: image: ubuntu:26.04 env: @@ -43,22 +101,19 @@ jobs: apt-get install -y --no-install-recommends ca-certificates git git config --global --add safe.directory "$GITHUB_WORKSPACE" working-directory: / - - uses: actions/checkout@v5 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - ref: ${{ github.event_name == 'schedule' && 'linux' || github.ref }} + persist-credentials: false - name: Install system dependencies - # ubuntu:26.04 is minimal: curl + ca-certificates for - # rust-toolchain's rustup install, then the meson toolchain, the - # -dev packages the workspace links, and the runtime pieces the - # test suites need (xvfb, dbus, gnome-keyring for the Secret - # Service step, openssh-client for the SSH transport). git is - # already in from the step above. - # No sudo (container runs as root by default). + # ubuntu:26.04 is minimal: curl for rust-toolchain's rustup + # install, then the meson toolchain, the -dev packages the + # workspace links, the runtime pieces the test suites need (xvfb, + # dbus, gnome-keyring for the Secret Service step, openssh-client + # for the SSH transport), and jq with the cargo-sources.json + # generator's modules for the Flatpak manifest. run: | - apt-get update apt-get install -y --no-install-recommends \ build-essential \ - ca-certificates \ curl \ pkgconf \ meson \ @@ -82,14 +137,20 @@ jobs: openssh-client \ xvfb \ xauth \ - dbus-daemon - - uses: dtolnay/rust-toolchain@ce678459e9fc7500d337468f904b95f1b5c10b5e # 1.98 + dbus-daemon \ + jq \ + python3-aiohttp \ + python3-tomlkit + - uses: dtolnay/rust-toolchain@e2a55d2ffb04f378e9626c28d38b36d230d1e12f # master with: toolchain: "1.98" components: rustfmt, clippy - - uses: Swatinem/rust-cache@v2 + # Meson points Cargo at _build/cargo-home and _build/cargo-target, + # so those are what the cache keeps. + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 with: - workspaces: linux + workspaces: linux -> _build/cargo-target + cache-directories: linux/_build/cargo-home - name: Generate the en_US.UTF-8 locale # glibc loads no catalogue under C or C.UTF-8, so the i18n # catalogue test needs a real locale on the machine. @@ -139,6 +200,27 @@ jobs: echo "::error::map a tab page to its tab through TabIndex, not set_qdata" exit 1 fi + - name: No em dash in user-facing text + # The GNOME writing style leaves the em dash out. The paths are + # the ones po/POTFILES.in lists. The dash is spelt as its UTF-8 + # bytes so this file holds none. git grep exits 1 for no match, + # and above 1 when it could not search at all. + run: | + em_dash=$(printf '\342\200\224') + status=0 + git grep -nF "$em_dash" -- 'crates/app/src/*.rs' 'data/resources/*.ui' 'data/*.desktop.in.in' \ + 'data/*.gschema.xml' 'data/*.metainfo.xml.in.in' || status=$? + case "$status" in + 0) + echo "::error::write a comma, a colon or two sentences instead of an em dash" + exit 1 + ;; + 1) ;; + *) + echo "::error::git grep failed with status $status, so no file was checked" + exit "$status" + ;; + esac - name: Docs name paths and types that exist # A renamed type or a moved file stays named in prose until # something asks. GTK and GLib names resolve against the GIR @@ -174,39 +256,135 @@ jobs: && meson devenv -C _build cargo test --locked -p tablepro-storage --test secret_service -- --ignored" - name: Dist run: meson dist -C _build --no-tests --formats xztar + - name: Render the release-shaped Flatpak manifest + # Flathub keeps the app's manifest in its own repository and builds + # the release tarball with no network, the crates coming from a + # cargo-sources.json written from Cargo.lock. This is that shape, + # made from the Devel manifest. Meson sets its own CARGO_HOME, so + # Cargo finds the vendored crates through -Dcargo_config. The build + # form takes this commit's screenshots, since the release tag's + # do not exist yet; the lint form names the release asset. + env: + SOURCE_REPOSITORY: ${{ github.event.pull_request.head.repo.full_name || github.repository }} + SOURCE_SHA: ${{ github.event.pull_request.head.sha || github.sha }} + run: | + version="$(meson introspect --projectinfo _build | jq -r .version)" + tarball="tablepro-$version.tar.xz" + mkdir -p _flatpak/build _flatpak/lint + cp "_build/meson-dist/$tarball" _flatpak/build/ + sha256="$(sha256sum "_flatpak/build/$tarball" | cut -d ' ' -f 1)" + curl --fail --silent --show-error --location --output "$RUNNER_TEMP/flatpak-cargo-generator.py" \ + "https://raw.githubusercontent.com/flatpak/flatpak-builder-tools/$FLATPAK_BUILDER_TOOLS_REV/cargo/flatpak-cargo-generator.py" + python3 "$RUNNER_TEMP/flatpak-cargo-generator.py" Cargo.lock -o _flatpak/build/cargo-sources.json + cp _flatpak/build/cargo-sources.json _flatpak/lint/ + # $archive and $opts are jq variables, not shell ones. + # shellcheck disable=SC2016 + release=' + .id = "app.tablepro.TablePro" + | .["build-options"] |= (del(.["build-args"], .["test-args"]) | .env.CARGO_NET_OFFLINE = "true") + | .modules[-1] |= ( + if [.sources[].type] != ["dir"] then error("the app module no longer builds one dir source") else . end + | del(.["run-tests"]) + | .["config-opts"] = [.["config-opts"][] | sub("^-Dprofile=development$"; "-Dprofile=default")] + + ["-Dcargo_config=cargo/config"] + $opts + | .sources = [$archive, "cargo-sources.json"] + )' + devel=build-aux/flatpak/app.tablepro.TablePro.Devel.json + jq --argjson archive "$(jq -n --arg path "$tarball" --arg sha256 "$sha256" \ + '{type: "archive", path: $path, sha256: $sha256}')" \ + --argjson opts "$(jq -n --arg url "https://raw.githubusercontent.com/$SOURCE_REPOSITORY/$SOURCE_SHA/linux/data/screenshots" \ + '["-Dscreenshots_base_url=" + $url]')" \ + "$release" "$devel" > _flatpak/build/app.tablepro.TablePro.json + jq --argjson archive "$(jq -n --arg url "https://github.com/TableProApp/TablePro/releases/download/linux-v$version/$tarball" \ + --arg sha256 "$sha256" '{type: "archive", url: $url, sha256: $sha256}')" \ + --argjson opts '[]' \ + "$release" "$devel" > _flatpak/lint/app.tablepro.TablePro.json + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: flathub-shape + path: linux/_flatpak + if-no-files-found: error + retention-days: 7 - licenses: - name: Third-party licenses + docs: + name: Documentation links + needs: detect + if: needs.detect.outputs.linux == 'true' runs-on: ubuntu-24.04 + timeout-minutes: 10 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Install lychee + run: | + curl --fail --silent --show-error --location --output "$RUNNER_TEMP/lychee.tar.gz" \ + "https://github.com/lycheeverse/lychee/releases/download/lychee-v$LYCHEE_VERSION/lychee-x86_64-unknown-linux-gnu.tar.gz" + echo "$LYCHEE_SHA256 $RUNNER_TEMP/lychee.tar.gz" | sha256sum --check --strict + mkdir "$RUNNER_TEMP/lychee" + tar -xzf "$RUNNER_TEMP/lychee.tar.gz" -C "$RUNNER_TEMP/lychee" --strip-components=1 \ + lychee-x86_64-unknown-linux-gnu/lychee + echo "$RUNNER_TEMP/lychee" >> "$GITHUB_PATH" + - name: Links and anchors in the Linux docs resolve + # Offline, so only links to files and headings in the tree are + # checked: the ones a rename or a move breaks. + run: lychee --offline --include-fragments --no-progress 'linux/**/*.md' + + supply-chain: + name: Supply chain + needs: detect + if: needs.detect.outputs.linux == 'true' + runs-on: ubuntu-24.04 + timeout-minutes: 30 defaults: run: working-directory: linux steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - ref: ${{ github.event_name == 'schedule' && 'linux' || github.ref }} - - uses: dtolnay/rust-toolchain@ce678459e9fc7500d337468f904b95f1b5c10b5e # 1.98 + persist-credentials: false + - uses: dtolnay/rust-toolchain@e2a55d2ffb04f378e9626c28d38b36d230d1e12f # master with: toolchain: "1.98" - uses: taiki-e/install-action@3f74d7c16a4242f1c95561e98edc25d36adb4375 # v2.87.12 with: - tool: cargo-about@0.9.2 + tool: cargo-deny@${{ env.CARGO_DENY_VERSION }},cargo-shear@${{ env.CARGO_SHEAR_VERSION }},cargo-about@${{ env.CARGO_ABOUT_VERSION }},zizmor@${{ env.ZIZMOR_VERSION }} + - name: Advisories, bans, licenses and sources + # deny.toml checks every feature, both Linux targets and the + # dev-dependencies, so a test-only crate is held to the same rules. + run: cargo deny --locked check advisories bans licenses sources + - name: No unused or misplaced dependencies + run: cargo shear - name: Regenerate the notices from Cargo.lock run: build-aux/update-third-party-licenses.sh - name: The committed notices cover every shipped crate # A crate added to or bumped in Cargo.lock changes the regenerated - # files, and so does one whose license text changed. + # files, and so does one whose license text changed. The Debian + # copyright file and the RPM License tag follow data/licenses. run: | - if ! git diff --exit-code -- data/licenses; then - echo "::error::data/licenses does not match Cargo.lock: run linux/build-aux/update-third-party-licenses.sh and commit the result" + if ! git diff --exit-code -- data/licenses build-aux/debian/copyright build-aux/rpm/tablepro.spec; then + echo "::error::the license files do not match Cargo.lock: run linux/build-aux/update-third-party-licenses.sh and commit the result" exit 1 fi + - name: The Linux workflows + # Offline, so the verdict depends on these files alone and not on + # where a branch the pins name has moved since. + working-directory: . + run: | + zizmor --offline --format=github --min-severity=medium \ + .github/workflows/build-linux.yml \ + .github/workflows/linux-packages.yml \ + .github/workflows/release-linux.yml i18n: name: Translations + needs: detect + if: needs.detect.outputs.linux == 'true' runs-on: ubuntu-24.04 - # fedora:44 ships gettext 0.24, the first release whose xgettext reads - # Rust. An older one falls back to its C lexer and misreads lifetimes. + timeout-minutes: 30 + # fedora:44 ships gettext 0.26; 0.24 is the first release whose + # xgettext reads Rust. An older one falls back to its C lexer and + # misreads lifetimes. container: image: fedora:44 defaults: @@ -221,9 +399,9 @@ jobs: dnf install -y --setopt=install_weak_deps=False git git config --global --add safe.directory "$GITHUB_WORKSPACE" working-directory: / - - uses: actions/checkout@v5 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - ref: ${{ github.event_name == 'schedule' && 'linux' || github.ref }} + persist-credentials: false - name: Install system dependencies run: | dnf install -y --setopt=install_weak_deps=False \ @@ -237,16 +415,100 @@ jobs: 'data/*.gschema.xml' 'data/*.metainfo.xml.in.in' > po/POTFILES.in git diff --exit-code po/POTFILES.in - name: Configure + # Fatal, so an xgettext older than 0.24 fails here. run: meson setup _build --fatal-meson-warnings - - name: The POT template still extracts - run: meson compile -C _build tablepro-pot - - name: Discard the generated template - run: git checkout po/tablepro.pot + - name: The POT template is current + # The creation date changes on every run, so it is the one line + # allowed to differ. + run: | + meson compile -C _build tablepro-pot + if ! git diff --exit-code --ignore-matching-lines='^"POT-Creation-Date: ' -- po/tablepro.pot; then + echo "::error::po/tablepro.pot does not match the strings in the code: run meson compile -C _build tablepro-pot with gettext 0.24 or newer, or take the tablepro-pot artifact of this run, and commit it" + exit 1 + fi + - name: Keep the regenerated template + if: failure() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: tablepro-pot + path: linux/po/tablepro.pot + if-no-files-found: ignore + retention-days: 7 + + flatpak-devel: + name: Flatpak Devel (${{ matrix.arch }}) + needs: detect + if: needs.detect.outputs.linux == 'true' + strategy: + fail-fast: false + matrix: + include: + - arch: x86_64 + runner: ubuntu-24.04 + - arch: aarch64 + runner: ubuntu-24.04-arm + runs-on: ${{ matrix.runner }} + timeout-minutes: 180 + container: + image: ghcr.io/flathub-infra/flatpak-github-actions:gnome-50 + options: --privileged + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + # Every Meson suite runs inside the sandbox, from a source tree with + # no .git. The action gives the tests a display and a session bus. + - uses: flatpak/flatpak-github-actions/flatpak-builder@79327416609af08178ad73b352877e51450790b3 # v6.8 + with: + manifest-path: linux/build-aux/flatpak/app.tablepro.TablePro.Devel.json + arch: ${{ matrix.arch }} + run-tests: true + build-bundle: false + cache-key: flatpak-devel-${{ matrix.arch }}-${{ hashFiles('linux/Cargo.lock') }} + + flatpak-flathub: + name: Flatpak release shape + needs: [detect, fast] + if: needs.detect.outputs.linux == 'true' + runs-on: ubuntu-24.04 + timeout-minutes: 180 + container: + image: ghcr.io/flathub-infra/flatpak-github-actions:gnome-50 + options: --privileged + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: flathub-shape + path: linux/_flatpak + # Offline and from the release tarball, as Flathub's builders run it, + # with the screenshots mirrored the way Flathub mirrors them. + - uses: flatpak/flatpak-github-actions/flatpak-builder@79327416609af08178ad73b352877e51450790b3 # v6.8 + with: + manifest-path: linux/_flatpak/build/app.tablepro.TablePro.json + arch: x86_64 + mirror-screenshots-url: https://dl.flathub.org/media + run-tests: false + build-bundle: false + cache-key: flatpak-flathub-${{ hashFiles('linux/Cargo.lock') }} + # The image is built on the linter's own. Only the two findings in + # lint-exceptions.json may remain. + - name: Lint the manifest and the repository + run: | + lint() { + flatpak-builder-lint --exceptions --user-exceptions linux/build-aux/flatpak/lint-exceptions.json "$@" + } + lint manifest linux/_flatpak/lint/app.tablepro.TablePro.json + lint repo repo integration: name: Docker tests (${{ matrix.package }}) + needs: [detect, fast] + if: needs.detect.outputs.linux == 'true' runs-on: ubuntu-24.04 - needs: fast + timeout-minutes: 90 strategy: fail-fast: false matrix: @@ -266,27 +528,32 @@ jobs: run: working-directory: linux steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - ref: ${{ github.event_name == 'schedule' && 'linux' || github.ref }} + persist-credentials: false - name: Install system dependencies + env: + EXTRA_PACKAGES: ${{ matrix.apt }} run: | sudo apt-get update - sudo apt-get install -y build-essential pkg-config ${{ matrix.apt }} - - uses: dtolnay/rust-toolchain@ce678459e9fc7500d337468f904b95f1b5c10b5e # 1.98 + # shellcheck disable=SC2086 # the matrix names zero or more packages + sudo apt-get install -y build-essential pkg-config $EXTRA_PACKAGES + - uses: dtolnay/rust-toolchain@e2a55d2ffb04f378e9626c28d38b36d230d1e12f # master with: toolchain: "1.98" - - uses: Swatinem/rust-cache@v2 + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 with: workspaces: linux key: ${{ matrix.package }} - uses: taiki-e/install-action@3f74d7c16a4242f1c95561e98edc25d36adb4375 # v2.87.12 with: - tool: cargo-nextest@0.9.144 + tool: cargo-nextest@${{ env.CARGO_NEXTEST_VERSION }} - name: Verify docker is available run: docker version - name: Docker tests - run: cargo nextest run --locked --profile ci -p ${{ matrix.package }} --run-ignored only + env: + PACKAGE: ${{ matrix.package }} + run: cargo nextest run --locked --profile ci -p "$PACKAGE" --run-ignored only - name: Upload JUnit report if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 @@ -301,8 +568,10 @@ 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' runs-on: ubuntu-24.04 - needs: fast + timeout-minutes: 90 container: image: ubuntu:26.04 env: @@ -318,9 +587,9 @@ jobs: apt-get install -y --no-install-recommends ca-certificates git git config --global --add safe.directory "$GITHUB_WORKSPACE" working-directory: / - - uses: actions/checkout@v5 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - ref: ${{ github.event_name == 'schedule' && 'linux' || github.ref }} + persist-credentials: false - name: Install system dependencies # The glib, sqlite and clang development packages the fast job # builds storage with, plus openssh-client for the tunnel tests. @@ -334,16 +603,16 @@ jobs: clang \ libclang-dev \ openssh-client - - uses: dtolnay/rust-toolchain@ce678459e9fc7500d337468f904b95f1b5c10b5e # 1.98 + - uses: dtolnay/rust-toolchain@e2a55d2ffb04f378e9626c28d38b36d230d1e12f # master with: toolchain: "1.98" - - uses: Swatinem/rust-cache@v2 + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 with: workspaces: linux key: tablepro-session - uses: taiki-e/install-action@3f74d7c16a4242f1c95561e98edc25d36adb4375 # v2.87.12 with: - tool: cargo-nextest@0.9.144 + tool: cargo-nextest@${{ env.CARGO_NEXTEST_VERSION }} - name: Verify docker is available # No Docker CLI in the image; the API answers on the socket. run: curl --fail --silent --show-error --unix-socket /var/run/docker.sock http://localhost/_ping @@ -362,8 +631,10 @@ 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' runs-on: ubuntu-24.04 - needs: fast + timeout-minutes: 90 container: image: ubuntu:26.04 env: @@ -379,9 +650,9 @@ jobs: apt-get install -y --no-install-recommends ca-certificates git git config --global --add safe.directory "$GITHUB_WORKSPACE" working-directory: / - - uses: actions/checkout@v5 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - ref: ${{ github.event_name == 'schedule' && 'linux' || github.ref }} + persist-credentials: false - name: Install system dependencies # The -dev packages the app links, as in the fast job. run: | @@ -396,16 +667,16 @@ jobs: libkrb5-dev \ clang \ libclang-dev - - uses: dtolnay/rust-toolchain@ce678459e9fc7500d337468f904b95f1b5c10b5e # 1.98 + - uses: dtolnay/rust-toolchain@e2a55d2ffb04f378e9626c28d38b36d230d1e12f # master with: toolchain: "1.98" - - uses: Swatinem/rust-cache@v2 + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 with: workspaces: linux key: tablepro-app - uses: taiki-e/install-action@3f74d7c16a4242f1c95561e98edc25d36adb4375 # v2.87.12 with: - tool: cargo-nextest@0.9.144 + tool: cargo-nextest@${{ env.CARGO_NEXTEST_VERSION }} - name: Verify docker is available # No Docker CLI in the image; the API answers on the socket. run: curl --fail --silent --show-error --unix-socket /var/run/docker.sock http://localhost/_ping diff --git a/.github/workflows/linux-packages.yml b/.github/workflows/linux-packages.yml new file mode 100644 index 0000000000..66fa993938 --- /dev/null +++ b/.github/workflows/linux-packages.yml @@ -0,0 +1,181 @@ +name: Linux packages + +# Builds the .deb and the .rpm from the release source tarballs, each in a +# fresh container that installs only the build dependencies the package +# declares, then installs each package in another fresh container with only +# its runtime dependencies and starts it. release-linux.yml calls this +# workflow and publishes what it builds. + +on: + pull_request: + paths: + - "linux/build-aux/debian/**" + - "linux/build-aux/rpm/**" + - "linux/build-aux/packaging/**" + - "linux/meson.build" + - "linux/meson_options.txt" + - "linux/crates/app/meson.build" + - "linux/data/meson.build" + - "linux/po/meson.build" + - "linux/rust-toolchain.toml" + - ".github/workflows/linux-packages.yml" + push: + branches: [linux] + paths: + - "linux/build-aux/debian/**" + - "linux/build-aux/rpm/**" + - "linux/build-aux/packaging/**" + - "linux/meson.build" + - "linux/meson_options.txt" + - "linux/crates/app/meson.build" + - "linux/data/meson.build" + - "linux/po/meson.build" + - "linux/rust-toolchain.toml" + - ".github/workflows/linux-packages.yml" + workflow_dispatch: + workflow_call: + inputs: + deb-runners: + description: JSON list of runners; one .deb is built per runner architecture. + type: string + default: '["ubuntu-24.04"]' + +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 + run: linux/build-aux/packaging/make-sources.sh --install-deps _packages/sources + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: linux-sources + path: _packages/sources + if-no-files-found: error + + deb: + name: Debian package (${{ matrix.runner }}) + needs: sources + strategy: + fail-fast: false + matrix: + runner: ${{ fromJSON(inputs.deb-runners || '["ubuntu-24.04"]') }} + runs-on: ${{ matrix.runner }} + timeout-minutes: 300 + container: + image: ubuntu:26.04 + 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-deb.sh "$dist" "$vendor" _packages/deb + - name: Keep the Meson test log + if: failure() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: linux-deb-testlog-${{ runner.arch }} + path: _packages/deb/testlog.txt + if-no-files-found: ignore + retention-days: 7 + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: linux-deb-${{ runner.arch }} + path: _packages/deb + if-no-files-found: error + + deb-install: + name: Install the Debian package (${{ matrix.runner }}) + needs: deb + strategy: + fail-fast: false + matrix: + runner: ${{ fromJSON(inputs.deb-runners || '["ubuntu-24.04"]') }} + runs-on: ${{ matrix.runner }} + timeout-minutes: 20 + container: + image: ubuntu:26.04 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: linux-deb-${{ runner.arch }} + path: _packages/deb + - name: Install, start and validate + run: linux/build-aux/packaging/smoke-test.sh "$(find _packages/deb -name 'tablepro_*.deb')" + + rpm: + name: RPM package + needs: sources + runs-on: ubuntu-24.04 + 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 + 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 + path: _packages/rpm + if-no-files-found: error + + rpm-install: + name: Install the RPM package + needs: rpm + runs-on: ubuntu-24.04 + 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 + 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..502d68b0d4 --- /dev/null +++ b/.github/workflows/release-linux.yml @@ -0,0 +1,111 @@ +name: Release Linux + +# A linux-vX.Y.Z tag publishes the Linux release: the source and vendor +# tarballs and the .deb for Ubuntu 26.04 on GitHub Releases, and the .rpm +# for Fedora 44 through COPR. Flathub's own repository picks the new source +# tarball up from the release. +# +# Secret COPR_CONFIG: the API token file copr-cli reads (~/.config/copr). +# Variable LINUX_COPR_PROJECT: the COPR project, as owner/project. + +on: + push: + tags: ["linux-v*"] + +permissions: + contents: read + +concurrency: + group: release-linux-${{ github.ref }} + cancel-in-progress: false + +jobs: + verify: + name: Tag, version and release notes + runs-on: ubuntu-24.04 + timeout-minutes: 10 + container: + image: ubuntu:26.04 + 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: + persist-credentials: false + - name: The tag, metainfo, spec and changelog name one version + working-directory: linux + env: + TAG: ${{ github.ref_name }} + run: | + git config --global --add safe.directory "$GITHUB_WORKSPACE" + 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" + + packages: + name: Packages + needs: verify + uses: ./.github/workflows/linux-packages.yml + with: + deb-runners: '["ubuntu-24.04", "ubuntu-24.04-arm"]' + + copr: + name: COPR build for Fedora 44 + needs: packages + runs-on: ubuntu-24.04 + timeout-minutes: 300 + container: + image: fedora:44 + steps: + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: linux-rpm + path: rpm + - name: Build the source RPM in COPR + env: + COPR_CONFIG: ${{ secrets.COPR_CONFIG }} + COPR_PROJECT: ${{ vars.LINUX_COPR_PROJECT }} + run: | + if [ -z "$COPR_CONFIG" ] || [ -z "$COPR_PROJECT" ]; then + echo "::error::Set the COPR_CONFIG secret and the LINUX_COPR_PROJECT variable to publish the RPM" + exit 1 + fi + dnf install -y --setopt=install_weak_deps=False copr-cli + mkdir -p "$HOME/.config" + printf '%s\n' "$COPR_CONFIG" > "$HOME/.config/copr" + copr-cli build "$COPR_PROJECT" "$(find rpm -name '*.src.rpm')" \ + --chroot fedora-44-x86_64 --chroot fedora-44-aarch64 + + github-release: + name: GitHub release + needs: packages + runs-on: ubuntu-24.04 + 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: + pattern: linux-* + path: artifacts + - name: Publish the release + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ github.ref_name }} + run: | + version="${TAG#linux-v}" + mkdir release + find artifacts -type f \( -name '*.tar.xz' -o -name 'tablepro_*.deb' -o -name 'tablepro-dbgsym_*.ddeb' \) \ + -exec cp {} release/ \; + (cd release && sha256sum -- * > SHA256SUMS) + python3 linux/build-aux/packaging/check_packaging.py --source-root linux \ + --version "$version" --tag "$TAG" --notes notes.md + gh release create "$TAG" release/* --repo "$GITHUB_REPOSITORY" --verify-tag \ + --title "TablePro for Linux $version" --notes-file notes.md diff --git a/linux/.gitignore b/linux/.gitignore index 52b9a8a86a..215e33a75c 100644 --- a/linux/.gitignore +++ b/linux/.gitignore @@ -1,4 +1,5 @@ target/ +_packages/ *.swp .DS_Store diff --git a/linux/AGENTS.md b/linux/AGENTS.md index 5721a65ac4..ada224bebb 100644 --- a/linux/AGENTS.md +++ b/linux/AGENTS.md @@ -56,8 +56,14 @@ From the repository root, through the verification wrapper, which keeps the full `.github/workflows/build-linux.yml` runs on a pull request that touches `linux/`: -- **Fast checks**: format, both clippy runs, build, every Meson suite, the Secret Service tests, `meson dist`. -- **Translations**: the POT template, with gettext 0.24. -- **Third-party licenses**: reruns `build-aux/update-third-party-licenses.sh` and fails when `data/licenses` differs from what it writes, or when a crate has no license file cargo-about can read. +- **Fast checks**: format, both clippy runs, build, every Meson suite, the Secret Service tests, `meson dist`, then the release-shaped Flatpak manifest rendered from the dist tarball. +- **Supply chain**: `cargo deny check` against `deny.toml`, `cargo shear`, and `build-aux/update-third-party-licenses.sh`, failing when `data/licenses`, `build-aux/debian/copyright` or the spec's `License` tag differ from what it writes; then zizmor over the Linux workflows. +- **Translations**: in `fedora:44` (gettext 0.26), `po/POTFILES.in` and `po/tablepro.pot` must match what the tree generates. A change to a user-facing string regenerates the template in the same commit. +- **Documentation links**: lychee, offline, over the Markdown under `linux/`. +- **Flatpak Devel** (x86_64 and aarch64) and **Flatpak release shape**: the Devel manifest with every Meson suite in the sandbox, and the shape Flathub builds, offline from the dist tarball, linted with `build-aux/flatpak/lint-exceptions.json`. - **Docker tests** (`integration`): one matrix job per package on the host runner, for the PostgreSQL, MySQL, SQL Server and ClickHouse drivers, `tablepro-ssh` and `tablepro-test-fixtures`. - **Docker tests (tablepro-session)** (`integration-session`) and **Docker tests (tablepro)** (`integration-app`): in the fast job's `ubuntu:26.04` image with the host's Docker socket, because the session crate links GLib 2.88 and the app GTK 4.22, which the host runner lacks. The app job runs only its named test targets, since the `startup` test needs Meson. + +`docs/testing.md` describes each job. Every action is pinned by commit SHA; a new one is too. + +`.github/workflows/linux-packages.yml` runs on a pull request that touches the packaging (`build-aux/debian`, `build-aux/rpm`, `build-aux/packaging`, the Meson files): it builds the source and vendor tarballs, the .deb in `ubuntu:26.04` and the .rpm in `fedora:44`, each with lintian or rpmlint, then installs and starts each in a fresh container. `release-linux.yml` calls it on a `linux-v*` tag and publishes the result (ADR 0017). diff --git a/linux/ARCHITECTURE.md b/linux/ARCHITECTURE.md index 2493b7d960..158d9a6618 100644 --- a/linux/ARCHITECTURE.md +++ b/linux/ARCHITECTURE.md @@ -9,8 +9,8 @@ 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, icons, GResource, licenses -├── build-aux/flatpak/ Devel Flatpak manifest and krb5.conf +├── data/ desktop entry, D-Bus service, AppStream metainfo and screenshots, icons, GResource, licenses +├── build-aux/flatpak/ Devel Flatpak manifest, lint exceptions and krb5.conf ├── build-aux/about/ templates that turn Cargo.lock into data/licenses ├── po/ translation catalogue └── crates/ @@ -119,13 +119,14 @@ Each window is a `SessionWindow` (`crates/app/src/ui/session_window/`) for one c - 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` and `saves-in-flight-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. +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 results pane, 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. +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, 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 subclass that hosts them through `connect_receiver` or `relm4_bridge`, which put it on that subclass's queue. The two patterns coexist until a rewrite turns each component into a subclass. @@ -235,8 +236,8 @@ On Linux the system store is the distribution's CA bundle. Under Flatpak it is t #### Choosing a CA file under Flatpak - For a server whose certificate comes from a private CA, choose the CA's PEM file in the connection's TLS settings, whatever the host trusts. -- The development manifest grants `--filesystem=home`, so a file under your home folder is read at its real path. -- A file chosen from outside the home folder 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 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 @@ -329,7 +330,7 @@ Meson is the build system and Cargo is the Rust compiler; see [ADR 0006](docs/de The floor is GNOME 50 (GTK 4.22, libadwaita 1.9, GtkSourceView 5.18, GLib 2.88); see [ADR 0015](docs/decisions/0015-gnome-50-platform-floor.md). The host runner image (`ubuntu-24.04`) ships GLib 2.80, well below that, so the system-deps check fails there. -`.github/workflows/build-linux.yml` runs the **Fast checks** job inside a `container: ubuntu:26.04`, which ships GLib 2.88 and the meson, appstream and desktop-file-utils packages the data suite needs. The container is minimal, so the install step pulls `ca-certificates`, `curl` and `git` before rust-toolchain and Swatinem can run. The **integration** job stays on the host runner because the driver crates depend only on `tablepro-core` and `tablepro-net` and don't pull libadwaita. +`.github/workflows/build-linux.yml` runs the **Fast checks** job inside a `container: ubuntu:26.04`, which ships GLib 2.88 and the meson, appstream and desktop-file-utils packages the data suite needs. The container is minimal, so the install step pulls `ca-certificates`, `curl` and `git` before rust-toolchain and Swatinem can run. The **integration** job stays on the host runner because the driver crates depend only on `tablepro-core` and `tablepro-net` and don't pull libadwaita. The translations job runs in `fedora:44`, whose xgettext reads Rust, and both Flatpak jobs in the `gnome-50` Flatpak image: the Devel manifest with every Meson suite, and the release shape Flathub builds, offline from the dist tarball, then linted. The supply-chain job runs cargo-deny, cargo-shear, the license regeneration and zizmor. [docs/testing.md](docs/testing.md#ci) lists every job. ## Out of scope diff --git a/linux/CHANGELOG.md b/linux/CHANGELOG.md index ef7938a0e2..2e986017ee 100644 --- a/linux/CHANGELOG.md +++ b/linux/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- .deb package for Ubuntu 26.04 and .rpm package for Fedora 44. - Password prompt on connect when the keyring holds no password or the server refused it, with a choice to remember it. - SSH tunnels through the system OpenSSH client, with your agent, host aliases and jump hosts, confirming new host keys. - Security group in the connect dialog: encryption mode, CA certificate, client certificate and TLS server name. @@ -31,8 +32,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Review before a structure save that drops data, rebuilds the table or can stop halfway, with the SQL it runs. - Arrow keys move between grid cells; F2 or Return edits one, Tab carries an edit to the next cell, and Shift+F10 opens the cell menu. - Screen readers name check box cells and say when a grid cell is modified or NULL and a row new or marked for deletion. -- Connections open when TablePro quits open again at the next launch; Restore session in Preferences turns it off. -- Save query history in Preferences turns the query history off and keeps the entries already saved. +- Connections open when TablePro quits open again at the next launch; Restore Session in Preferences turns it off. +- Save Query History in Preferences turns the query history off and keeps the entries already saved. - Try Again on a table tab's error page, and Retry on a banner over rows or a structure that could not be read again. - Table and structure tabs show a spinner in the tab bar while they read. - Count Rows in a table tab's paginator, with the reason on Try Again when a count fails. @@ -40,7 +41,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Export a table's current page or every row under its filter and sort, and an editor result's shown rows or all of them. - Banner over an editor result the row limit cut short, saying how many rows it shows, with Export All for every row. - Sort an editor result by clicking a column header, with text in your language's alphabetical order. -- Copy as puts CSV, JSON, Markdown, SQL and tab-separated rows on the clipboard as their own type beside plain text. +- Copy As puts CSV, JSON, Markdown, SQL and tab-separated rows on the clipboard as their own type beside plain text. - Shift+F10 opens the menu of a sidebar table or a query history entry, as the Menu key does. - Screen readers name icon-only buttons, and announce the shortcut of the table, SQL editor and filter buttons that have one. - An icon beside each table row with a pending change, and bold text in edited cells. @@ -50,6 +51,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Sidebar lists the tables of every schema, a section per schema with the default one first, and a middle click opens one in a new tab. - Connect leads a saved connection's menu, which a right click, the Menu key or Shift+F10 opens. - Find and replace in the SQL editor with Ctrl+F and Ctrl+H, matching case, whole words or regular expressions. +- Edit…, Duplicate and Move to Group in a saved connection's menu; an edit keeps the saved passwords unless you type new ones. +- Desktop notification when a query, save or export finishes in a tab you are not looking at; clicking it opens the tab. +- SSH agent sign-in, Kerberos tickets in KCM and SQLite files on removable drives in the Flatpak build. +- Screenshots of the table grid, SQL editor, Structure tab and connect dialog in the software centre listing. ### Changed @@ -57,16 +62,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - New rows in the table grid take a value for a primary key that is not auto-increment. - Show CREATE TABLE gives the server's own statement on MySQL, ClickHouse and SQLite. - Structure tab sets a column NOT NULL when it joins the primary key, and gives its nullability back when it leaves. -- Auto increment in the Structure tab no longer needs the primary key on PostgreSQL and SQL Server. +- Auto Increment in the Structure tab no longer needs the primary key on PostgreSQL and SQL Server. - SQLite foreign keys show the name their CREATE TABLE gives them. -- Delete, Duplicate and Copy as in a grid cell's menu act on every selected row when the clicked row is selected. +- Delete, Duplicate and Copy As in a grid cell's menu act on every selected row when the clicked row is selected. - Grid cells edit in place with the keyboard each type needs; dates open a calendar, and JSON, XML and long text a larger editor. - Set Value in a grid cell's menu is Set to Empty, Set to NULL and Set to DEFAULT, each shown only where the column takes it. - Set a grid cell to NULL with Ctrl+Backspace instead of Ctrl+Shift+N. - Quit asks each window about its unsaved changes in turn, and waits for open connections to close. - JSON exports and copies put one row per line, and a value TablePro couldn't read stops the export instead of becoming NULL. - Each window holds one connection: opening one already open brings its window forward, and Disconnect returns to the list. -- Connecting shows in the window with Cancel, and a failed connect says why, with Retry, Edit Connection and Choose Connection. +- Connecting shows in the window with Cancel, and a failed connect says why with Retry and Edit Connection; screen readers hear both. - Deleting a connection asks once, offers its window when it is open, and can delete its query history too. - SQLite files shared through the document portal show their own path instead of the portal's. - Add Index and Add Foreign Key check the form as you type, and pick the referenced table and columns from the database. @@ -99,6 +104,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Connection buttons in a connected window's header bar; New Window opens another connection. - Ctrl+E for a new SQL editor tab; Ctrl+T opens one. +- Close button in the sidebar and Query History search bars; the search button or Escape closes them. ### Fixed @@ -109,7 +115,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - A failed query history export said nothing. - Copying many rows, or showing a row with a long value as JSON, froze the window. - A query saved just before quitting could be lost. -- The sidebar's table search button had no name for screen readers. +- The sidebar's table search, the find and replace fields and the Query History search had no name for screen readers. - The sidebar was slow to fill and to search on databases with thousands of tables, and said nothing when the list could not be read again. - A connection added, changed or deleted in one window did not show in the other windows' connection lists. - A connection name with & or < in it showed wrong in the connection list. @@ -119,6 +125,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - SQL Server money and smallmoney in the SQL editor and query results showed as approximate floats. - Saving a SQL Server row with a char or varchar key locked the whole table until the save ended. - Stop could not end a SQLite statement waiting for another connection's lock. +- With SQLite 3.51, a file that is not a SQLite database failed to open with SQLite's raw error instead of saying so. +- With SQLite 3.51, a SQLite connection waiting for another program's lock could be taken for lost after a suspend. - Launching TablePro while it was running did nothing instead of showing the open window. - Deleting a connection left its saved tabs and editor drafts on disk. - A change to the connection list made just before quitting could be lost. @@ -142,7 +150,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Text with a NUL character showed cut short in the grid, copied cut short, and editing it saved the cut value. - An edit, Set Value or row action in the table grid could land on another row once a row above it came or went. - Editing, undoing or deleting a row in the table grid dropped it from a selection of several rows. -- Copy as INSERT could copy the wrong row, drop the schema or unsaved edits, or write a value where the server fills one. +- Copy As INSERT could copy the wrong row, drop the schema or unsaved edits, or write a value where the server fills one. - Duplicate left out the row's unsaved edits. - Cells of a table without a primary key opened for editing, then dropped what was typed. - A row whose primary key the driver could not read opened for editing, then failed to save. @@ -195,6 +203,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - A bulk delete confirmed after the table's primary key changed marked rows by their old key. - Ctrl+Home and Ctrl+End jumped to the first or last tab instead of the start or end of the SQL editor or table. - Ctrl+N added a row to a read-only table or a table without a primary key. +- Two SQL editor tabs could both be named Query 3 after an earlier editor tab closed. +- After Clear All, the filter strip could show Match any while the next filter combined its rules with all. +- Drop-downs in the connect and export dialogs cut their value short beside a long description. +- PostgreSQL numbers in SQL editor results lined up on the left, unlike the table grid's. +- Grid columns cut long numbers short at their default width. +- TablePro could crash when its window took the focus just as a new page replaced the focused cell's row. ### Security diff --git a/linux/CONTRIBUTING.md b/linux/CONTRIBUTING.md index 4d5e1327c1..76fa469cd9 100644 --- a/linux/CONTRIBUTING.md +++ b/linux/CONTRIBUTING.md @@ -4,13 +4,13 @@ This file governs the Linux subproject only. [AGENTS.md](AGENTS.md) is the short ## Dev environment -System packages — see [README.md](README.md) for distro-specific commands. After they are installed, work happens entirely from the `linux/` directory. +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 and data suites +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 # cargo against the configured app ID cargo fmt --all # format @@ -44,6 +44,16 @@ 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`. @@ -55,7 +65,7 @@ CI sets `GTK_A11Y=test` for GTK tests. With `GTK_A11Y=none` GTK records no acces ### 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`, and both files install next to `LICENSE`. After a change to `Cargo.lock`, regenerate them and commit the result; the Third-party licenses CI job fails until they match: +`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`, and both files install next to `LICENSE`. 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 @@ -70,10 +80,12 @@ The script stops and names each crate it read no license file for, since the not | Tool | Config | Notes | |---|---|---| -| `rustfmt` | `rustfmt.toml` at workspace root | Run before commit. Pre-commit hook enforces it. | -| `clippy` | `clippy.toml` at workspace root | All workspace crates pass with `-D warnings`. New lints are negotiated per PR. | +| `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` | Every crate passes with `-D warnings`, with and without default features. | | Edition | 2024 | Set per workspace. Do not override per crate. | -| MSRV | 1.98 | Pinned in `rust-toolchain.toml` and `rust-version`; matches the Flatpak rust-stable extension. | +| MSRV | 1.98 | `rust-version` in `Cargo.toml` and `rust-toolchain.toml`; matches the Flatpak rust-stable extension. | + +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`, which reorders a server's JSON. `crates/app/clippy.toml` also bans reaching for the ambient tokio runtime: background work goes through `Tasks`. Conventions, beyond what `rustfmt` decides: @@ -81,11 +93,12 @@ Conventions, beyond what `rustfmt` decides: - **No `unwrap()` or `expect()` in production paths.** Tests and `OnceLock::get_or_init` initialisers are the only acceptable callers. - **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 cross crate boundaries as `thiserror` enums.** Inside a crate, `anyhow::Result` is fine. See [docs/error-handling.md](docs/error-handling.md). +- **Errors are `thiserror` enums.** The workspace has no `anyhow`. See [docs/error-handling.md](docs/error-handling.md). ## Adding a database driver @@ -93,12 +106,11 @@ This is the most common substantive change. Follow [docs/adding-drivers.md](docs ## 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: +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`. ``` -feat(linux-drivers): add ClickHouse driver via clickhouse-arrow -fix(linux-sidebar): debounce selection to avoid duplicate fetches -refactor(linux-core): split DatabaseDriver into Driver + Connection traits +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 ``` @@ -112,13 +124,16 @@ A user-visible change gets one fragment under `[Unreleased]` in [CHANGELOG.md](C 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. UI changes must include before / after screenshots in the PR description, taken at HiDPI on both light and dark themes. +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)). `cargo-sources.json` is not committed: CI writes it from `Cargo.lock`. +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 — that lives in `apps/macos/Plugins/` (post Phase B restructure) or the current `Plugins/` directory. +- macOS plugin work, which lives in the repository's `Plugins/` directory. ## Where to start as a contributor @@ -126,5 +141,5 @@ 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, copy the most recently merged driver crate as a template. Do not copy the spike code. +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.toml b/linux/Cargo.toml index 19b49ad743..949996efc9 100644 --- a/linux/Cargo.toml +++ b/linux/Cargo.toml @@ -104,6 +104,7 @@ print_stderr = "deny" allow_attributes = "deny" allow_attributes_without_reason = "deny" ignore_without_reason = "deny" +too_many_lines = "deny" [profile.release] lto = "fat" diff --git a/linux/README.md b/linux/README.md index c537de7753..bec5a74416 100644 --- a/linux/README.md +++ b/linux/README.md @@ -1,22 +1,26 @@ # TablePro Linux -Native Linux database client. Sister product to the macOS TablePro app, sharing no code but matching the feature set. +Native Linux database client, the Linux sibling of the macOS TablePro app. The two share no code. ## Status -Phases 2 and 3 in progress (see [ROADMAP.md](ROADMAP.md)). The stack (Rust + GTK4 + libadwaita + Relm4 + sqlx / tiberius) runs as an app you can build and use: PostgreSQL, MySQL, SQLite, and MSSQL drivers, workspace tabs, structure editing, SSH tunnels, and query history. It is not beta-shippable yet. Flatpak / Flathub distribution and the remaining hardening items are open. +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: the app is not on Flathub and no `linux-v*` tag exists. [ROADMAP.md](ROADMAP.md) lists what is built, what is not, and the release criteria. ## Stack | Layer | Pick | |---|---| -| Language | Rust 1.98+ | -| GUI toolkit | GTK4 4.22+ + libadwaita 1.9+ + GtkSourceView 5.18+ (GNOME 50, [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)); a few views still on [Relm4](https://relm4.org) | +| Language | Rust 1.98 (`rust-toolchain.toml` and `rust-version`) | +| GUI toolkit | GTK 4.22, libadwaita 1.9, GtkSourceView 5.18 and GLib 2.88: GNOME 50 ([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 | -| DB drivers | sqlx (PG / MySQL / SQLite), tiberius (MSSQL), official `clickhouse` crate; planned: fred (Redis), official mongodb / duckdb crates, etc. | -| Persistence | libsecret (passwords), gio::Settings (prefs), JSON files (connection metadata) | -| Distribution | Flathub primary, .deb / .rpm / AppImage secondary | +| 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 | Flathub primary; .deb for Ubuntu 26.04 on GitHub Releases and .rpm for Fedora 44 on COPR ([ADR 0017](docs/decisions/0017-distribution-channels.md)) | ## What this is not @@ -32,16 +36,18 @@ Phases 2 and 3 in progress (see [ROADMAP.md](ROADMAP.md)). The stack (Rust + GTK System dependencies: ```bash -# Ubuntu / Debian -sudo apt install -y build-essential pkg-config meson ninja-build gettext desktop-file-utils appstream libgtk-4-dev libadwaita-1-dev libgtksourceview-5-dev libssl-dev libsecret-1-dev libsqlite3-dev libkrb5-dev clang libclang-dev +# 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 -sudo dnf install -y gcc pkg-config meson ninja-build gettext desktop-file-utils appstream gtk4-devel libadwaita-devel gtksourceview5-devel openssl-devel libsecret-devel sqlite-devel krb5-devel clang clang-devel +# 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 -sudo pacman -S --needed base-devel pkg-config meson ninja gettext desktop-file-utils appstream gtk4 libadwaita gtksourceview5 openssl libsecret sqlite krb5 clang +# 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. + Verify the right versions are present: ```bash @@ -70,17 +76,93 @@ askpass helper, the desktop entry, the D-Bus service, the AppStream metainfo, the icons and the third-party license notices. See [ADR 0006](docs/decisions/0006-meson-cargo-build.md). -Before pushing, run the [fast-job commands](CONTRIBUTING.md#fast-job-commands). +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` and `clang` are there for the app's default `kerberos` -feature: SQL Server Windows integrated auth links MIT Kerberos and runs -bindgen at build time. `cargo build -p tablepro --no-default-features` -leaves it out, and the connect dialog then hides Windows (Kerberos). +`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, and it is what maintainers send Flathub when they ask +for the two exceptions. + +The Flathub manifest is kept in Flathub's repository for the app, the way +Fractal and Loupe keep theirs. It builds `-Dprofile=default` from the +release tarball, with a `cargo-sources.json` that `flatpak-cargo-generator` +writes from that release's `Cargo.lock`, since Flathub builds offline. +Meson sets its own `CARGO_HOME`, so the manifest also passes +`-Dcargo_config=cargo/config` for Cargo to find the crates +`cargo-sources.json` unpacks. CI builds and lints the same shape on every +pull request, from the tarball `meson dist` writes. + +### Screenshots + +The metainfo lists the PNGs in `data/screenshots`, at the release tag's +URL: Flathub copies them from there when it builds. A build of a commit +that has no tag yet sets `-Dscreenshots_base_url` to an https URL where +that commit's `data/screenshots` can be fetched. + +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 a .deb for Ubuntu 26.04 on GitHub Releases +and an .rpm for Fedora 44 through COPR, both built from `meson install` +with the crates from the release's vendor tarball +([ADR 0017](docs/decisions/0017-distribution-channels.md)). The packaging +is `build-aux/debian` and `build-aux/rpm/tablepro.spec`. + +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 .deb (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. `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 @@ -107,38 +189,44 @@ 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. Three things are +port you typed, not from an SSH tunnel's local forward. Four things are worth knowing: -- tiberius imports that SPN as a raw Kerberos principal, so it picks up - `default_realm` from `/etc/krb5.conf` and nothing else. `[domain_realm]` - does not apply: that lookup only runs for host-based service names, and +- 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, as below. `[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. -- Running from source is the supported path today. Under Flatpak the - sandbox has no `/etc/krb5.conf` and its `/tmp` is private, so a FILE - ticket cache there is invisible; the manifest grants the config file - and the KCM socket, and a FILE cache needs `KRB5CCNAME` pointed - somewhere under `$HOME`. +- Under Flatpak the host's `/etc/krb5.conf` is out of reach. The app + brings its own through `KRB5_CONFIG`. It sets no `default_realm`, so + the realm comes from the `_kerberos` TXT record of this computer's DNS + domain or a parent of it, and the KDC from DNS SRV records. +- Under Flatpak tickets are read from the KCM cache, so a ticket the + host's `kinit` keeps in KCM (Fedora's default, through sssd-kcm) is + used. The sandbox's `/tmp` is private, so a FILE ticket cache there is + invisible. A KEYRING cache fails too, because Flatpak blocks the + kernel keyring calls. ## Documentation index | Topic | File | |---|---| | Layered architecture, crate boundaries, dependency rules | [ARCHITECTURE.md](ARCHITECTURE.md) | -| Roadmap and current phase | [ROADMAP.md](ROADMAP.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 | [docs/testing.md](docs/testing.md) | +| Testing conventions, suites and CI jobs | [docs/testing.md](docs/testing.md) | +| Every guide, driver page and dated audit | [docs/README.md](docs/README.md) | | Architecture decision records | [docs/decisions/](docs/decisions/) | ## License -Same as the parent TablePro project. +AGPL-3.0-or-later, as the parent TablePro project: see [LICENSE](LICENSE). diff --git a/linux/ROADMAP.md b/linux/ROADMAP.md index e014139905..48e1ec90b5 100644 --- a/linux/ROADMAP.md +++ b/linux/ROADMAP.md @@ -1,386 +1,203 @@ # Roadmap -## Where we are (2026-09-18) +**Status, 2026-10-15**: every capability in the tables below is in the tree. Nothing has shipped yet: the app is not on Flathub, and 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. -**Phase 0 is complete. Phase 1 is complete. Large parts of Phase 2 and Phase 3 are already in the tree.** +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). -The app connects to PostgreSQL, MySQL, SQLite, and Microsoft SQL Server; browses tables in a virtualized `GtkColumnView`; supports in-place cell edit with transactional save; hosts an `AdwTabView` workspace (editor / table / structure); filters rows; tunnels over SSH via `russh`; stores passwords in the Secret Service; and records query history in SQLite + FTS5. Drivers ship with testcontainers integration tests. Relm4 architecture is intact. +## Implemented -This is **past demo-grade**, but still **not beta-shippable**. The gap between "works on the developer's machine" and "I would install this from Flathub and use it daily" remains. See [`docs/production-audit.md`](docs/production-audit.md) for the detailed gap analysis. Remaining big-ticket items: +### Connections -| Concern | Status | +| Capability | Detail | |---|---| -| Type system | Done. `Value` covers Null/Bool/Int/Float/Text/Bytes/Date/Time/DateTime/TimestampTz/Decimal/Uuid/Json | -| Result scaling | Streams via sqlx `fetch` with `MAX_QUERY_ROWS` cap; full result still held in the grid model | -| Connection management | Done. One window per connection session, each with its own `AdwTabView` workspace; the sessions open at quit open again at launch | -| Network security | SSH tunnelling + TLS toggle present; cert-path / verify-mode UI still thin | -| Distribution | Flatpak manifest + metainfo + desktop + icon present; never built end-to-end on CI | -| Internationalization | gettext through `crates/app/src/i18n.rs` + `po/` template; extraction reaches every call site and `POTFILES.in` matches the tree. The committed template still predates that fix and needs regenerating on gettext 0.24 or newer | -| Accessibility | Untested with Orca / keyboard nav | -| Integration tests | Postgres, MySQL, MSSQL and ClickHouse docker suites run in CI, one nextest job per package; SQLite has a value suite that needs no container | -| Recovery | `connection_monitor` ping + reconnect loop; cancel drops the client future, the server-side query keeps running | - -**What "production-ready" means for this project**: a user on Fedora 41 or Ubuntu 24.04 can install from Flathub, connect to their everyday Postgres or MySQL database, browse and edit data correctly across all native types, run SQL queries, see schema, and trust the app to handle errors gracefully. - -## Phase legend - -Phases are ordered by **maturity**, not feature count. Each phase has a single concrete exit criterion. - ---- - -## Phase 0 — Foundation ✅ - -**Status**: complete. - -- [x] Cargo workspace with `app`, `core`, `storage`, `ssh`, `drivers/{postgres,sqlite,mysql,mssql}` -- [x] `core::DatabaseDriver`, `core::Connection`, `core::DriverRegistry` traits -- [x] `storage::connections` (JSON, atomic writes, schema versioning) -- [x] `storage::secrets` (Secret Service via `oo7`) -- [x] CI (`build-linux.yml`: fmt, clippy `-D warnings`, build, unit tests + driver integration) -- [x] `rustfmt.toml`, `clippy.toml`, `rust-toolchain.toml` -- [x] Flatpak manifest skeleton (not yet validated end-to-end — see Phase 3) -- [x] Architecture decision records for stack picks - -Exit criterion: a fresh contributor can `cargo run -p tablepro` and reach a working window in under 15 minutes. **Met.** - ---- - -## Phase 1 — Demo MVP ✅ - -**Status**: complete. - -- [x] Drivers wired: PostgreSQL / SQLite / MySQL via `sqlx`, MSSQL via `tiberius` -- [x] `AdwNavigationSplitView` shell with header bar + Connect/Open/Edit/Disconnect -- [x] Multi-driver Connect dialog with engine picker + per-driver form -- [x] Saved connection list with delete + reconnect -- [x] Browse paginated table results in `GtkColumnView` (100k rows scroll smoothly) -- [x] Sidebar table search (case-insensitive substring filter) -- [x] SQL editor pane (GtkSourceView 5 + Run button) -- [x] Modal Insert / Edit / Delete row dialogs (parameterized SQL) -- [x] **True in-place cell edit** with snapshot-on-edit-start + force-cancel-on-recycle -- [x] Connection deduplication by (driver, host, port, db, user) -- [x] PG `fetch_columns` correctly populates `primary_key` -- [x] All `unsafe set_data` confined to grid cell metadata, contained -- [x] All async work via `sender.command` with auto-cancellation on shutdown -- [x] Typed error → user-friendly message layer - -Exit criterion: a developer can demo the basic flows (connect, browse, edit, query) without crashes on their own machine. **Met.** - ---- - -## Phase 2 — Production hardening (in progress) - -**Goal**: handle real-world data and real-world failure modes correctly. - -### Type system expansion ✅ - -- [x] Add to `core::Value`: `Date`, `Time`, `DateTime`, `TimestampTz`, `Decimal`, `Uuid`, `Json` -- [x] Map driver-specific types across PG / MySQL / SQLite / MSSQL -- [x] `chrono`, `rust_decimal`, `serde_json` in the workspace -- [x] `RowObject` / display helpers consume the full `Value` set - -### Streaming results (partial) - -- [x] sqlx `fetch` stream into a bounded collector (`MAX_QUERY_ROWS`) -- [ ] Backpressure model: hold the stream open for next-page reads -- [ ] Memory-bounded grid: drop rows outside viewport (GTK virtualizes paint; model still holds all loaded rows) -- [ ] Cancellation: drop the stream when user navigates away - -### Multi-connection / multi-tab architecture ✅ - -- [x] A `Session` per window, claimed in the `SessionRegistry` so one database opens in one window -- [x] `AdwTabView` for multiple open tables / queries within one connection -- [x] Workspace tab persistence, one file per session under `$XDG_STATE_HOME/tablepro/workspaces/` -- [x] Multi-window: each `SessionWindow` holds its own session, and another connection opens in another window rather than replacing it ([ADR 0012](docs/decisions/0012-window-per-session.md)) -- [x] Session restore: the windows open at quit open again at the next launch, behind the `restore-session` preference -- [x] Open With: a database file from the file manager opens in a window of its own, or brings forward the window that has it open - -### Security baseline (partial) - -- [x] TLS toggle on connect options -- [x] SSH tunnelling via `russh` (host, port, key / password auth) -- [x] SSH jump host: `ProxyJump` chains resolved from the OpenSSH client configuration -- [x] Windows integrated (Kerberos) authentication for SQL Server, from the ambient ticket cache -- [ ] Kerberos against a service outside the client's default realm, which needs an SPN override upstream in tiberius -- [x] Read-only mode toggle per connection -- [x] Cancel running query: button + Esc shortcut -- [ ] `Connection::cancel` driver method, so cancelling stops the server-side query instead of dropping the client future (`core::OwnedCalls` carries the stop and the engine-stop contract; it is wired once `Connection` v2 lands) -- [x] Connection lost recovery: ping monitor + reconnect loop -- [ ] Statement timeout configurable per connection -- [ ] TLS cert path / verify mode / SNI override UI - -### Integration tests (partial) - -- [x] `tests/integration.rs` using `testcontainers-rs` for Postgres, MySQL, MSSQL -- [x] SQLite suite (`crates/drivers/sqlite/tests/values.rs`: storage class per value, declared type, NULL against empty string) -- [x] Connect, list_tables, fetch_columns (PK detection), pagination, value round-trip, bad SQL -- [x] CI runs each driver docker suite through cargo-nextest, one job per package -- [x] Run the MSSQL suite in CI - -**Exit criterion**: Connect to a 10M-row Postgres table, scroll, edit a date column, lose network mid-query, see a recoverable error, reconnect via the same UI flow. - ---- - -## Phase 3 — Beta release (in progress) - -**Goal**: shippable to Flathub. A user installs and uses for real work. - -### Browse UX (partial) - -- [x] Where-filter UI (`filter_strip`) with per-column operators -- [x] ORDER BY wired to `GtkColumnView` header click → server sort -- [x] Multi-row select via shift-click + Ctrl-click -- [x] Bulk delete with confirmation -- [x] Right-click context menu (copy, copy as, set value, export, insert, duplicate, delete) -- [x] Save column widths per (connection, table) -- [ ] Save column order per (connection, table), which needs column reordering in the grid first; `GtkColumnView` does not have it turned on - -### Export / import (~1 week) - -- [x] Export current grid to CSV / JSON from the result grid's right-click menu and the paginator (query results included) -- [x] Export with CSV options: NULL handling, line breaks, header row, formula sanitizing, delimiter, quote style, line endings, decimal separator -- [x] Export as SQL INSERT / Markdown / HTML / XML -- [x] Export as XLSX, with numbers, dates and booleans in the types Excel has for them -- [x] Copy as Rows / With Headers / JSON / CSV / Markdown / IN Clause, Show Row as JSON -- [ ] Paste rows from clipboard; Set Value > NOW() / CURRENT_TIMESTAMP (needs raw SQL expressions in the change tracker) -- [x] Import CSV into a table, from the table's own menu, with a mapping dialog and one transaction for the whole file -- [x] Run SQL file: opened from the editor's menu into the tab, then run like anything else - -### Schema browser (partial) - -- [x] Structure tab: columns, indexes, foreign keys (edit + DDL diff) -- [x] Column metadata: type, nullable, default -- [x] Column comments: read on PostgreSQL, MySQL, SQL Server and ClickHouse, edited in the Structure tab everywhere but SQLite, which stores none -- [ ] Sidebar tabs: Views, Triggers, Functions, Sequences -- [ ] Click view → SELECT * FROM view (re-uses browse view) -- [ ] Click index → show CREATE INDEX DDL + which columns -- [ ] Click FK → highlight columns + jump to referenced table - -### Query history + saved queries (partial) - -- [x] SQLite FTS5 store at `$XDG_STATE_HOME/tablepro/history.db`, versioned with embedded sqlx migrations -- [x] SQL editor runs recorded with timestamp, duration, success, connection name -- [x] Record the SQL the app runs outside the editor (Structure tab DDL saves, grid row saves) -- [x] History pane with full-text search -- [x] Saved queries: named and kept per connection, saved from the editor, reopened from the app menu - -### Connection management (partial) - -- [x] Connection groups: filed from the row's own menu, shown as headers in both connection lists -- [x] Colour tags per connection, from the row's own menu, shown as a dot ahead of the name -- [x] Import / export connections to a JSON file, matched by id on the way back in, with no secrets in the file -- [x] Clone connection, from the row's own menu, secrets carried onto the copy's id -- [x] "Test connection" button in dialog before save - -### Distribution scaffolding (~1 week) - -- [x] `app.tablepro.TablePro.metainfo.xml` skeleton -- [x] App icon: scalable SVG -- [ ] Icon set: 16/32/48/64/128/256/512 PNG -- [ ] 4–5 high-resolution screenshots showing key flows -- [ ] Long description + short description polish in metainfo -- [x] ContentRating (`oars-1.1` in the metainfo) -- [ ] `cargo-sources.json` generation via `flatpak-builder-tools/cargo` -- [ ] CI job: `flatpak-builder` builds the manifest end-to-end on each PR -- [ ] Submit to Flathub `flathub/flathub` PR → review → first publish - -### Observability (~0.5 weeks) - -- [x] Structured logging via `tracing-subscriber` (env-filter) -- [x] JSON log layer, env-toggleable through `TABLEPRO_LOG_FORMAT=json` -- [ ] Crash reporter: panic hook captures backtrace, writes to log, optional anonymous upload (with explicit opt-in) -- [ ] "Help → Report bug" UI helper that opens the issue tracker pre-filled with sanitized log excerpt - -**Exit criterion**: app published to Flathub stable channel; user installs via `flatpak install app.tablepro.TablePro`; runs against their Postgres + MySQL daily for one week without unrecoverable failure. - ---- - -## Phase 4 — Beta polish (3 weeks) - -**Goal**: shipped beta, accepting external bug reports, ready for first wave of public users. - -### Internationalization setup (partial) - -- [x] `gettext` integration via `gettext-rs` -- [x] `tr!` macro + locale bind in `i18n::init` -- [x] `po/` scaffolding: `tablepro.pot`, `POTFILES.in`, `LINGUAS` -- [x] Locale detection: `setlocale(LC_ALL, "")` in `i18n::init` -- [x] Extract all user-facing strings: the extractor is pointed at `gettext`, `gettext_f`, `ngettext_f` and `pgettext_f` rather than the `tr!` macro that no longer exists -- [ ] Regenerate `tablepro.pot`, which needs gettext 0.24 or newer: the committed template is from before the extractor was fixed -- [x] Refresh `POTFILES.in`, generated by the `git ls-files` command in `po/README.md` so CI can diff against it -- [ ] Build pipeline integrates `.po` → `.mo` compilation -- [ ] Ship English-only at first; structure ready for translators - -### Accessibility audit (~1 week) - -- [ ] Test screen-reader flow with Orca on GNOME 47 -- [ ] Keyboard-only flow: tab order, focus indicators, escape close, enter commit -- [ ] High contrast mode rendering -- [ ] Font scaling honored (`gsettings text-scaling-factor`) -- [ ] No color-only UI signals (Connect button has icon + text, Delete has icon + label) -- [ ] Set `Accessible` properties on custom widgets (cells, popover content) - -### Multi-DE / multi-distro testing (~0.5 weeks) +| Saved connections | Create, edit and rename, 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, matched by id on the way back in | +| 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 refusing saves; 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 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 backoff, and reads replayed after it; writes are never replayed | +| Cancel on the server | Stopping a query stops the statement on the server, on every engine | + +### 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; a changed key can be replaced, a revoked one is refused | -- [ ] KDE Plasma 6 visual smoke test (Adwaita styling acceptable; do not adopt KDE styling) -- [ ] Wayland-specific bug fixes (HiDPI fractional scaling, drag handles) -- [ ] X11 fallback works on older distros -- [ ] Manual install + smoke on Fedora 41, Ubuntu 24.04, Arch (latest), Debian 12/13 +### TLS -### Distribution variants (~0.5 weeks) +| 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 | +| Remedies | A TLS failure says what went wrong and jumps to the field that fixes it | -- [ ] AppImage build via `appimagetool` (portable use case) -- [ ] `.deb` build for Debian / Ubuntu (community-maintained or official) -- [ ] `.rpm` build for Fedora (community-maintained or official) -- [ ] AUR PKGBUILD for Arch (community-maintained, mirror in repo) +### Browse and edit -### Documentation (~0.5 weeks) +| Capability | Detail | +|---|---| +| Data grid | A virtualized `GtkColumnView` with keyboard-focusable cells, pages of a chosen size, and a pager with an exact or estimated row count and 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 | +| 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 | +| Rows | Insert, duplicate and delete; Set NULL, Empty and DEFAULT; undo and redo; save or discard pending changes, and stop a save | +| Bulk delete | Deleting 10 rows or more asks first | +| Copy | Copy, and Copy As rows, with headers, JSON, CSV, Markdown, IN clause or INSERT; Show Row as JSON | +| Column widths | Kept per table | +| CSV import | Into an existing table, with a column mapping, in one write batch | +| Sidebar | Tables, views, materialized views and foreign tables by schema, with search, Open in New Tab, Edit Structure, Show CREATE TABLE, Import CSV and Drop Table | + +### 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 and comment; add and drop indexes and foreign keys | +| 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 and keep its rows and indexes | -- [ ] User manual at `docs/user/` (Mintlify) — getting started, connection setup per database, keyboard shortcuts, FAQ -- [ ] Marketing page on the existing TablePro Mintlify site for Linux -- [ ] CHANGELOG.md for the Linux subproject -- [ ] Issue templates: bug report, feature request +### SQL editor -**Exit criterion**: Beta release announced on the marketing site, on Flathub stable, on r/linux. Issue tracker active. First 10 external bug reports triaged. +| 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`, routine and trigger bodies), one result per statement, 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, or drop one on it | +| Results | Several result sets per run, a banner at the 10,000-row limit with Export All, and Go to Error | +| Drafts | The editor's text is kept per connection | + +### 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 imports; a run that sets a password is not recorded, and recording can be turned off | +| Show in Files | For the history database, from the history dialog and Preferences | +| Saved queries | Kept per connection, saved from the editor, opened in a new tab or the current one | ---- +### Export -## Phase 5 — GA expansion (6+ months, ongoing) +| Capability | Detail | +|---|---| +| Formats | CSV with its options, JSON, Markdown, HTML, XML, Excel, and SQL INSERT from a table tab | +| Scope | The current page, every row of a query, or the entire table | +| Running | Off the GTK thread with progress and Stop; closing or quitting asks about a running export | -**Goal**: General Availability. Feature set covers what a daily Postgres/MySQL user expects, plus genuine multi-engine support. +### Windows and sessions -### Additional drivers (parallelizable, ~1 week each) +| 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 open at quit open again at launch, behind a preference | +| Closing safely | Unsaved edits, a save in flight or a running export ask before a window closes or the app quits; logout waits for saves | +| Notifications | Work that finishes in a tab you are not looking at sends a notification | +| Preferences | Restore session, page size, 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 with every Meson suite in the sandbox, and the shape Flathub builds is built offline and linted on every pull request | +| .deb and .rpm | Ubuntu 26.04 and Fedora 44, linted, then installed and started in fresh containers in CI; a `linux-v*` tag publishes them ([ADR 0017](docs/decisions/0017-distribution-channels.md)) | +| Desktop integration | Desktop file with Open With for SQLite files, D-Bus activation, a man page, metainfo with screenshots, and SVG icons | +| Translations | Every user-facing string goes through gettext; CI checks `POTFILES.in` and the template | +| Logs | stderr, which the session journals, with JSON lines on request | +| Tests | Unit, GTK and accessibility suites, the conformance suites on every engine, and Docker suites for PostgreSQL with PgBouncer, MySQL with MariaDB, SQL Server, ClickHouse, SSH and the session ([testing.md](docs/testing.md)); every critical audit finding has named guards ([regression-tests.md](docs/regression-tests.md)) | -- [x] ClickHouse via the official `clickhouse` crate -- [x] MSSQL via `tiberius` -- [ ] Oracle via `oracle` crate (ODPI-C) -- [ ] Redis via `fred` -- [ ] MongoDB via official `mongodb` crate -- [ ] DuckDB via `duckdb` crate -- [ ] Cassandra/Scylla via `scylla` -- [ ] DynamoDB via `aws-sdk-dynamodb` -- [ ] BigQuery (HTTP, third-party crate) -- [ ] Cloudflare D1 (HTTP) +## Backlog -### Editor maturation (~3 weeks total) +Not built yet, in no order. -- [ ] Schema-aware SQL autocomplete (tables, columns, keywords) using cached `current_columns` -- [ ] SQL formatter (multiple dialect support) -- [ ] Multi-statement execution -- [ ] Run-selection-only -- [ ] Find / replace within editor -- [ ] Multi-cursor editing -- [ ] Vim mode (custom impl on top of GtkSourceView 5) -- [ ] Snippets +### Connections -### Schema editor (~2 weeks) +- A statement timeout per connection. Today one preference covers editor runs, and every other call has a fixed 60 seconds. +- Kerberos against a service outside the client's realm, which needs an SPN override in tiberius. -- [x] Create / alter / drop table via Structure tab + DDL materialization -- [x] Add / remove columns -- [x] Rename columns, through a `RenameColumn` op materialized ahead of the alter so the alter addresses a name that exists -- [x] Add / remove indexes -- [x] Add / remove foreign keys -- [ ] Drag-drop column reordering with ALTER TABLE preview +### Browse, edit and structure -### ER diagram (~3 weeks) +- 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. +- Time and timestamp pickers. Dates have a calendar; times are typed. +- Save the column order per table. +- Make the Confirm Before Deleting Rows preference take effect. The switch is stored, but deleting rows does not read it. +- Functions, procedures, sequences and triggers in the sidebar. +- The CREATE INDEX statement for an index. +- 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. -- [ ] Cairo-based custom widget rendering tables as cards -- [ ] Foreign key edges with arrowheads -- [ ] Pan / zoom / save layout -- [ ] Auto-layout via dot graph algorithm +### SQL editor -### Type-aware widgets (~2 weeks) +- Multi-cursor editing. +- Vim mode. +- Snippets. -- [ ] Date / time picker for date columns -- [ ] Number spinner with bounds (INT2/INT4/INT8 ranges) -- [ ] JSON editor with syntax highlighting + validation -- [ ] Boolean toggle -- [ ] File chooser for BLOB columns +### Release and project -### Real product infrastructure +- Submit to Flathub. +- Create the COPR project, the `COPR_CONFIG` secret and the `LINUX_COPR_PROJECT` variable before the first tag. +- 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 crash report a user can send. A panic goes to the journal today, and About offers the debug info and the issue tracker. +- A Linux user guide on docs.tablepro.app, a page on the marketing site, and a support channel. -- [ ] Marketing site updates per release -- [ ] Support channel: GitHub Discussions or Discourse -- [ ] Email support (paid tier?) — depends on business model decision -- [ ] Pricing page (free, paid, enterprise — depends on model) -- [ ] Donation links if open source -- [ ] Telemetry (anonymous, opt-in) for usage analytics +### More engines -**No fixed exit criterion**. This phase ends when the team decides parity is "enough" and shifts to maintenance + driver additions on demand. +Each one is a crate under `crates/drivers` ([adding-drivers.md](docs/adding-drivers.md)), on a client library that meets the [supply-chain policy](docs/decisions/0005-supply-chain-policy.md). ---- +| 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 | -## Phase 6 — Parity ambitions (year+) +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. -**Goal**: feature-competitive with DBeaver / DataGrip on the engines we support. +### Ideas -### Maybe (open questions) +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. -- [ ] Real-time monitoring (active connections, locks, slow queries) -- [ ] Server admin tools (vacuum, reindex, ANALYZE) -- [ ] Backup / restore UI -- [ ] Replication monitoring -- [ ] Multi-tab query results comparison -- [ ] Diff tool: schema diff between two connections -- [ ] Data sync between two connections -- [ ] Cron-style scheduled queries -- [ ] Reporting / dashboard builder (probably out of scope; focus on the IDE shape) +## Release criteria -These are ambitions, not commitments. Phase 6 should only start after Phase 5 has stabilized for at least 6 months. +From the [production-readiness audit](docs/production-audit.md#what-production-ready-means-here), for GNOME 50. A user on Ubuntu 26.04 or Fedora 44: ---- +1. Installs `app.tablepro.TablePro` from Flathub, or the .deb or the .rpm. +2. Connects to their everyday PostgreSQL, MySQL, MariaDB, SQL Server, ClickHouse or SQLite database. +3. Browses tables of real-world data (dates, decimals, JSON, UUIDs, NULLs) and sees correct values. +4. Runs typical queries against tables of a million rows or more without running out of memory or freezing. +5. Edits data in place, and the changes commit correctly even when the connection or the app misbehaves. +6. Trusts the app with credentials: TLS, SSH tunnels, no password in plain text. +7. Recovers when the network drops or the database restarts. +8. Works with a screen reader, the keyboard alone and larger text. +9. Sees no message that cannot be translated, even while only English ships. +10. Uses the app daily for a week without an unrecoverable failure. -## Out of scope (firm) +## Out of scope | Item | Reason | |---|---| -| Plugin system at runtime | [decision 0001](docs/decisions/0001-no-plugin-system.md) — drivers are static | -| Cross-platform builds (Windows / macOS) | Separate apps in the monorepo for those platforms | -| Embedded scripting (JS / Python / Lua) | SQL is enough; adds attack surface | -| Hot-reload of drivers | Compile-time only; use `cargo watch` during dev | -| Cloud sync of connections (proprietary backend) | Out of scope unless business model demands it | -| Snap distribution | Decided to skip; Flathub + AppImage cover the audience | -| KDE-native styling | Run as Adwaita on KDE; users wanting native KDE have other options | - ---- - -## Phase B — Repository restructure (deferred) - -Once Beta has shipped (end of Phase 4) and is stable for at least 6 weeks, the top-level repository layout migrates: - -``` -apps/macos/ (move from TablePro/, TableProTests/, Plugins/, Libs/, LocalPackages/) -apps/ios/ (move from TableProMobile/) -apps/linux/ (move from linux/) -packages/ (move from Packages/TableProCore/) -``` - -This is a separate undertaking and is not blocking any phase. It moves only after Linux is stable and there is a real cost to the current flat layout. - ---- - -## Realistic timeline - -| Stage | Effort | Calendar | -|---|---|---| -| Phase 0 + 1 | done | done | -| Phase 2 remainder (streaming backpressure, driver-side query cancel, TLS UI polish) | ~2 weeks FT | next | -| Phase 3 remainder — Beta release | ~3 weeks FT | following | -| Phase 4 — Beta polish | 3 weeks FT | after Beta | -| **Beta on Flathub** | **~8 weeks FT from this revision** | **after Phase 4** | -| Phase 5 — GA expansion | 6 months FT | rolling | -| Phase 6 — Parity ambitions | 12+ months FT | optional | - -At 50% effort (part-time), double everything. At 25% effort (side project), 4x. - ---- - -## What changed in this revision (2026-07-27) - -The previous "Where we are (2026-04-26)" section still described the project as Phase 0/1 demo-grade with a type system of 6 variants, no multi-tab, no SSH, and a single ignored integration test. The tree has moved on: - -- Four drivers (including MSSQL), full `Value` set, `DatabaseService`, workspace tabs, Structure tab + DDL, filters, SSH, read-only mode, query history, connection monitor, gettext scaffolding, and CI integration jobs are all present. -- Phase 2 and Phase 3 checklists were marked to match the code. Unchecked items are the real remaining work. -- Timeline shortened: Beta is roughly 8 weeks of focused work from this baseline, not 11 weeks from a stale Phase 1. - -This revision does not claim Beta readiness. It stops the roadmap from under-selling what already ships. +| 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 0017](docs/decisions/0017-distribution-channels.md). diff --git a/linux/build-aux/debian/README.source b/linux/build-aux/debian/README.source new file mode 100644 index 0000000000..f3b142eaba --- /dev/null +++ b/linux/build-aux/debian/README.source @@ -0,0 +1,22 @@ +tablepro for Debian and Ubuntu +============================== + +This directory is the debian/ directory of the .deb that TablePro publishes +on GitHub Releases for Ubuntu 26.04. It lives in the upstream tree under +build-aux/debian and is copied to debian/ by +build-aux/packaging/build-deb.sh, which also writes debian/changelog with +dch --create for the version being built. + +Toolchain: the workspace needs Rust 1.98 (rust-toolchain.toml), and Ubuntu +26.04 ships rustc 1.93, so the build installs the pinned toolchain with the +rustup package before dpkg-buildpackage runs. The package therefore cannot +be built in the Ubuntu or Debian archive, and is built as a binary-only +package (dpkg-buildpackage -b). + +Crates: the build is offline. tablepro-X.Y.Z-vendor.tar.xz, published next +to tablepro-X.Y.Z.tar.xz, holds vendor/ from cargo vendor --versioned-dirs +and vendor/cargo-config.toml, which debian/rules hands to Meson as +-Dcargo_config. Unpack it into the source tree before building. + +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/control b/linux/build-aux/debian/control new file mode 100644 index 0000000000..c4d3c438a7 --- /dev/null +++ b/linux/build-aux/debian/control @@ -0,0 +1,42 @@ +Source: tablepro +Section: database +Priority: optional +Maintainer: TablePro Authors +Build-Depends: + debhelper-compat (= 13), + adwaita-icon-theme, + appstream, + cargo (>= 1.98) | rustup, + clang, + dbus-daemon, + 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, + locales-all, + meson (>= 1.4), + openssh-client, + pkgconf, + xauth, + xvfb, +Standards-Version: 4.7.2 +Rules-Requires-Root: no +Homepage: https://tablepro.app + +Package: tablepro +Architecture: any +Depends: + openssh-client, + ${misc:Depends}, + ${shlibs:Depends}, +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..2e61063d34 --- /dev/null +++ b/linux/build-aux/debian/copyright @@ -0,0 +1,5711 @@ +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 under the license + TablePro uses it under, with the license text the crate ships. + +Files: * +Copyright: TablePro Authors +License: AGPL-3.0-or-later + 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: vendor/aes-0.8.4/* +Copyright: 2018 Artyom Pavlov +License: MIT + 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: MIT + 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: MIT + 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: MIT + 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: MIT + 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: MIT + 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/endi-1.1.1/* + vendor/event-listener-5.4.2/* + vendor/event-listener-strategy-0.5.4/* + vendor/fastrand-2.5.0/* + vendor/itoa-1.0.18/* + vendor/linux-raw-sys-0.12.1/* + 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/rustix-1.1.4/* + 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/* + vendor/zmij-1.0.23/* + vendor/zvariant_utils-4.2.0/* +Copyright: the authors of async-trait, clickhouse, clickhouse-macros, + clickhouse-types, displaydoc, endi, event-listener, + event-listener-strategy, fastrand, itoa, linux-raw-sys, once_cell, + ordered-stream, parking, pin-project-lite, proc-macro-crate, + proc-macro2, quote, relm4, relm4-macros, rustix, rustversion, serde, + serde_bytes, serde_core, serde_derive, serde_derive_internals, + serde_json, serde_repr, sqlformat, syn, thiserror, thiserror-impl, + typed-path, zmij, zvariant_utils +License: MIT + 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: MIT + 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: MIT + 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: MIT + =============================================================================== + . + 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: 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 BSD-3-Clause and ISC and MIT + 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: MIT + 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: MIT + 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: MIT + 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: MIT + 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: MIT + 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: MIT + 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: MIT + 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: MIT + 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: MIT + 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: MIT + 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: MIT + 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: MIT + 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: MIT + 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: MIT + 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: MIT + 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: MIT + 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: MIT + 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: MIT + 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: MIT + 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: MIT + 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: MIT + 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: MIT + 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: MIT + 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: MIT + 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: MIT + 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: MIT + 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: MIT + 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: MIT + 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: MIT + 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: MIT + 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: MIT + 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: MIT + 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: MIT + 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: MIT + 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: MIT + # 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: MIT + 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: BSD-3-Clause and MIT + // 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/enumflags2-0.7.12/* +Copyright: 2017-2023 Maik Klein, Maja Kądziołka +License: MIT + 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: MIT + 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: MIT + 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: MIT + 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: MIT + 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: MIT + 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: MIT + 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: MIT + 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: MIT + 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: MIT + 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: MIT + 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: MIT + 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: MIT + 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: MIT + 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/* + vendor/whoami-2.1.3/* +Copyright: the authors of half, whoami +License: MIT + 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: MIT + 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: MIT + 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: MIT + 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: MIT + 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: MIT + 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: MIT + 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: MIT + 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: MIT + 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: MIT + 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: MIT + 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: MIT + 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: MIT + 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: MIT + 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: MIT + 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: MIT + 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: MIT + 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: MIT + 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: MIT + 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: MIT + 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: MIT + 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: MIT + 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/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: MIT + 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: MIT + 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: MIT + 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: MIT + 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: MIT + 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: MIT + 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: MIT + 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: MIT + 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: MIT + 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: MIT + 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: MIT + 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: MIT + 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: MIT + 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: MIT + 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: MIT + 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: MIT + 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: MIT + 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: MIT + 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: MIT + 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: MIT + 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: MIT + 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 + 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: MIT + 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: MIT + 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: MIT + 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: MIT + 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: MIT + 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: MIT + 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: MIT + 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: MIT + 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: MIT + 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: MIT + 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: MIT + 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: MIT + 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: MIT + 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: MIT + 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: MIT + 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: MIT + 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: MIT + 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: MIT + 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: MIT + 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: MIT + 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: MIT + 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: MIT + 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: MIT + 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: MIT + 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: MIT + 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: MIT + 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: MIT + 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: MIT + 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: MIT + 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: MIT 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: MIT + 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: MIT + 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: MIT + 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/winnow-0.7.15/* + vendor/winnow-1.0.4/* +Copyright: the authors of winnow +License: MIT + 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: MIT + 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: MIT + 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: MIT + 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: MIT + 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: MIT + 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: MIT + 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..bf3c2ddb2a --- /dev/null +++ b/linux/build-aux/debian/rules @@ -0,0 +1,23 @@ +#!/usr/bin/make -f + +# The crates come from the vendor tarball unpacked into vendor/. +export CARGO_NET_OFFLINE = true +# 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/ + +override_dh_auto_configure: + dh_auto_configure -- -Dprofile=default -Dkerberos=true -Dcargo_config=vendor/cargo-config.toml + +# The gtk suite opens windows on a display and owns D-Bus names. +override_dh_auto_test: + xvfb-run -a dbus-run-session -- dh_auto_test 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/tablepro.lintian-overrides b/linux/build-aux/debian/tablepro.lintian-overrides new file mode 100644 index 0000000000..06993aa65f --- /dev/null +++ b/linux/build-aux/debian/tablepro.lintian-overrides @@ -0,0 +1,3 @@ +# Built for GitHub Releases and never uploaded to Debian or Ubuntu, so +# there is no bug for the first changelog entry to close. +tablepro: initial-upload-closes-no-bugs diff --git a/linux/build-aux/flatpak/app.tablepro.TablePro.Devel.json b/linux/build-aux/flatpak/app.tablepro.TablePro.Devel.json index 17c1bf1b28..d4ebfcce12 100644 --- a/linux/build-aux/flatpak/app.tablepro.TablePro.Devel.json +++ b/linux/build-aux/flatpak/app.tablepro.TablePro.Devel.json @@ -13,9 +13,14 @@ "--share=network", "--socket=fallback-x11", "--socket=wayland", + "--socket=ssh-auth", "--device=dri", "--filesystem=home", - "--filesystem=/run/.heim_org.h5l.kcm-socket" + "--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", @@ -48,6 +53,9 @@ "skip": [ ".flatpak-builder", "_build", + "_flatpak", + "flatpak_app", + "repo", "target", "build-aux/flatpak/.flatpak-builder" ] diff --git a/linux/build-aux/flatpak/lint-exceptions.json b/linux/build-aux/flatpak/lint-exceptions.json new file mode 100644 index 0000000000..f85e79ac77 --- /dev/null +++ b/linux/build-aux/flatpak/lint-exceptions.json @@ -0,0 +1,10 @@ +{ + "app.tablepro.TablePro": [ + "finish-args-home-filesystem-access", + "finish-args-has-socket-ssh-auth" + ], + "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/build-deb.sh b/linux/build-aux/packaging/build-deb.sh new file mode 100755 index 0000000000..464fcbcf5b --- /dev/null +++ b/linux/build-aux/packaging/build-deb.sh @@ -0,0 +1,64 @@ +#!/bin/sh +# Builds the .deb from the two release tarballs, as root in a fresh +# ubuntu:26.04, with only the build dependencies debian/control declares, +# and checks it with lintian. +# Usage: build-deb.sh +set -eu + +if [ $# -ne 3 ]; then + echo "usage: build-deb.sh " >&2 + exit 2 +fi +here="$(cd "$(dirname "$0")" && pwd)" +dist="$(cd "$(dirname "$1")" && pwd)/$(basename "$1")" +vendor="$(cd "$(dirname "$2")" && pwd)/$(basename "$2")" +mkdir -p "$3" +out="$(cd "$3" && pwd)" + +name="$(basename "$dist" .tar.xz)" +version="${name#tablepro-}" +work="$(mktemp -d)" +trap 'rm -rf "$work"' EXIT + +export DEBIAN_FRONTEND=noninteractive +apt-get update +apt-get install -y --no-install-recommends ca-certificates devscripts dpkg-dev lintian xz-utils + +tar -xf "$dist" -C "$work" +src="$work/$name" +tar -xf "$vendor" -C "$src" +cp -R "$src/build-aux/debian" "$src/debian" + +apt-get build-dep -y --no-install-recommends "$src" + +# debhelper points HOME at a scratch directory during the build, so the +# rustup proxies need RUSTUP_HOME spelled out to find the toolchain. +if command -v rustup >/dev/null; then + RUSTUP_HOME="${RUSTUP_HOME:-$HOME/.rustup}" + export RUSTUP_HOME + RUSTUP_TOOLCHAIN="$("$here/rust-toolchain.sh" "$src")" + export RUSTUP_TOOLCHAIN +fi + +maintainer="$(sed -n 's/^Maintainer: //p' "$src/debian/control")" +( + cd "$src" + DEBEMAIL="$maintainer" dch --create --package tablepro --newversion "$version-1" \ + --distribution resolute --force-distribution --urgency medium \ + "New upstream release $version." +) +# Meson prints only the end of a failed suite's log, so the whole log is +# kept beside the packages. +if ! (cd "$src" && dpkg-buildpackage -b -us -uc); then + find "$src" -path '*/meson-logs/testlog.txt' -exec cp {} "$out/" \; + exit 1 +fi + +lintian --fail-on error,warning --display-info "$work"/tablepro_"$version"-1_*.changes + +for file in "$work"/*.deb "$work"/*.ddeb "$work"/*.buildinfo "$work"/*.changes; do + if [ -e "$file" ]; then + cp "$file" "$out/" + fi +done +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..ac586f5bdd --- /dev/null +++ b/linux/build-aux/packaging/build-packages.sh @@ -0,0 +1,53 @@ +#!/bin/sh +# Builds the .deb and the .rpm from the committed tree the way the release +# does, each step in a fresh container: the source tarballs in ubuntu:26.04, +# each package in a fresh ubuntu:26.04 or fedora:44, and each package +# installed and started in another fresh one. +# Usage: build-packages.sh [deb|rpm|all] +# CONTAINER_ENGINE picks docker (the default) or podman. +set -eu + +engine="${CONTAINER_ENGINE:-docker}" +which="${1:-}" +case "$which" in +deb | rpm | all) ;; +*) + echo "usage: build-packages.sh deb|rpm|all " >&2 + exit 2 + ;; +esac +if [ $# -ne 2 ]; then + echo "usage: build-packages.sh deb|rpm|all " >&2 + exit 2 +fi +mkdir -p "$2" +out="$(cd "$2" && pwd)" + +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)" + +# 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 \ + -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-deb.sh" "$dist" "$vendor" "$out/deb" + run ubuntu:26.04 "$here/smoke-test.sh" "$(find "$out/deb" -name 'tablepro_*.deb')" +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..8566cd469e --- /dev/null +++ b/linux/build-aux/packaging/build-rpm.sh @@ -0,0 +1,41 @@ +#!/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 source RPM in mock. +# 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/" + +dnf install -y --setopt=install_weak_deps=False rpm-build rpmlint 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" +# Meson prints only the end of a failed suite's log, so the whole log is +# kept beside the packages. +if ! rpmbuild --define "_topdir $top" --rebuild "$srpm"; then + find "$top/BUILD" -path '*/meson-logs/testlog.txt' -exec cp {} "$out/" \; + exit 1 +fi + +rpmlint "$spec" "$srpm" "$top"/RPMS/*/*.rpm + +cp "$srpm" "$top"/RPMS/*/*.rpm "$out/" +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..35309be677 --- /dev/null +++ b/linux/build-aux/packaging/check_packaging.py @@ -0,0 +1,123 @@ +#!/usr/bin/env python3 +# Checks that the version Meson builds is the one the metainfo releases and +# the RPM spec package, and that the spec collects translations exactly +# when po/LINGUAS ships one. The release workflow adds the tag and its +# date, and then CHANGELOG.md must carry that version's notes. + +import argparse +import re +import sys +import xml.etree.ElementTree as ElementTree +from datetime import date +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") + +# RPM forbids '-' in Version and git forbids '~' in a tag, so a release +# version is plain X.Y.Z. +RELEASE_VERSION = re.compile(r"^\d+\.\d+\.\d+$") +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) +FIND_LANG = re.compile(r"^%find_lang\b", re.MULTILINE) + + +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 problems(root, version, tag=None, tag_date=None): + found = [] + if not RELEASE_VERSION.match(version): + found.append(f"version {version} is not X.Y.Z") + 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}") + if not release_notes(root / CHANGELOG, version): + found.append(f"{CHANGELOG} has no notes under ## [{version}]") + + 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", "") + try: + date.fromisoformat(released) + except ValueError: + 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}" + ) + + spec = (root / SPEC).read_text(encoding="utf-8") + versions = SPEC_VERSION.findall(spec) + if versions != [version]: + found.append(f"{SPEC} packages version {', '.join(versions) or 'nothing'}, not {version}") + changelog = SPEC_CHANGELOG.search(spec) + if changelog is None or not changelog.group(1).startswith(f"{version}-"): + found.append(f"the first %changelog entry in {SPEC} is not for {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" + ) + 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) 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/license_metadata.py b/linux/build-aux/packaging/license_metadata.py new file mode 100644 index 0000000000..753c1101b8 --- /dev/null +++ b/linux/build-aux/packaging/license_metadata.py @@ -0,0 +1,223 @@ +#!/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. 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 +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" + +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".', +] + +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, +) + +SPEC_LICENSE = re.compile(r"^License:(\s+).*$", re.MULTILINE) + + +class MetadataError(Exception): + pass + + +def load_licenses(path): + with open(path, encoding="utf-8") as handle: + return json.load(handle)["licenses"] + + +def crate_entries(licenses): + entries = {} + for index, license in enumerate(licenses): + for used_by in license["used_by"]: + crate = used_by["crate"] + key = (crate["name"], crate["version"]) + if index not in entries.setdefault(key, []): + entries[key].append(index) + return entries + + +def dep5_name(spdx_id): + # DEP-5 writes an exception as " with exception". + license_id, separator, exception = spdx_id.partition(" WITH ") + if not separator: + return spdx_id + return f"{license_id} with {exception.removesuffix('-exception')} exception" + + +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 render_copyright(licenses, upstream_text): + 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 under the license", + "TablePro uses it under, with the license text the crate ships.", + ], + ), + field("Files", ["*"]) + + field("Copyright", [UPSTREAM_COPYRIGHT]) + + field("License", [UPSTREAM_LICENSE] + text_block([upstream_text])), + ] + + groups = {} + for crate, indices in crate_entries(licenses).items(): + groups.setdefault(tuple(indices), []).append(crate) + + for indices, crates in sorted(groups.items(), key=lambda group: sorted(group[1])[0]): + crates = sorted(crates) + texts = [] + for index in indices: + if licenses[index]["text"] not in texts: + texts.append(licenses[index]["text"]) + ids = [] + for index in indices: + name = dep5_name(licenses[index]["id"]) + if name not in ids: + ids.append(name) + holders = notices(texts) or unnamed_holders(crates) + paragraphs.append( + field("Files", [f"vendor/{name}-{version}/*" for name, version in crates]) + + field("Copyright", holders) + + field("License", [" and ".join(ids)] + text_block(texts)) + ) + + return "\n\n".join("\n".join(paragraph) for paragraph in paragraphs) + "\n" + + +def rpm_license(licenses): + third_party = sorted({license["id"] for license in licenses} - {UPSTREAM_LICENSE}) + return " AND ".join([UPSTREAM_LICENSE] + third_party) + + +def apply_spec_license(spec, expression): + if len(SPEC_LICENSE.findall(spec)) != 1: + raise MetadataError("the spec must have exactly one License tag") + return SPEC_LICENSE.sub(lambda match: f"License:{match.group(1)}{expression}", spec) + + +def expected(args): + licenses = load_licenses(args.licenses) + upstream = Path(args.license_file).read_text(encoding="utf-8") + spec = Path(args.spec).read_text(encoding="utf-8") + return { + Path(args.copyright): render_copyright(licenses, upstream), + Path(args.spec): apply_spec_license(spec, rpm_license(licenses)), + } + + +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, 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..b5f12e9116 --- /dev/null +++ b/linux/build-aux/packaging/make-sources.sh @@ -0,0 +1,59 @@ +#!/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, +# the build dependencies in build-aux/debian/control and the pinned Rust +# toolchain. +# Usage: make-sources.sh [--install-deps] +set -eu + +here="$(cd "$(dirname "$0")" && pwd)" +root="$(cd "$here/../.." && pwd)" + +if [ "${1:-}" = "--install-deps" ]; then + shift + 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 '*' + apt-get build-dep -y --no-install-recommends "$root/build-aux" + RUSTUP_TOOLCHAIN="$("$here/rust-toolchain.sh" "$root")" + export RUSTUP_TOOLCHAIN +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 + +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.sh b/linux/build-aux/packaging/rust-toolchain.sh new file mode 100755 index 0000000000..31364f8e4c --- /dev/null +++ b/linux/build-aux/packaging/rust-toolchain.sh @@ -0,0 +1,15 @@ +#!/bin/sh +# Installs the toolchain rust-toolchain.toml pins through rustup and prints +# its name, for RUSTUP_TOOLCHAIN. Ubuntu 26.04 ships rustc 1.93, below the +# workspace's rust-version, so the Ubuntu builds take Rust from rustup. +# Usage: rust-toolchain.sh +set -eu + +channel="$(sed -n 's/^channel = "\(.*\)"$/\1/p' "$1/rust-toolchain.toml")" +if [ -z "$channel" ]; then + echo "rust-toolchain.sh: no channel in $1/rust-toolchain.toml" >&2 + exit 1 +fi + +rustup toolchain install "$channel" --profile minimal >&2 +echo "$channel" diff --git a/linux/build-aux/packaging/smoke-test.sh b/linux/build-aux/packaging/smoke-test.sh new file mode 100755 index 0000000000..8ec4b89945 --- /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:26.04 or fedora:44, +# 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/tablepro.spec b/linux/build-aux/rpm/tablepro.spec new file mode 100644 index 0000000000..9f8bfbd6ab --- /dev/null +++ b/linux/build-aux/rpm/tablepro.spec @@ -0,0 +1,118 @@ +# 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 + +Name: tablepro +Version: 0.1.0 +Release: 1%{?dist} +Summary: Work with your databases +# build-aux/update-third-party-licenses.sh writes this line from the +# crates the binaries link. +License: AGPL-3.0-or-later AND Apache-2.0 AND BSD-3-Clause AND CDLA-Permissive-2.0 AND ISC AND MIT AND Unicode-3.0 AND Zlib +URL: https://tablepro.app +Source0: https://github.com/TableProApp/TablePro/releases/download/linux-v%{version}/%{name}-%{version}.tar.xz +Source1: https://github.com/TableProApp/TablePro/releases/download/linux-v%{version}/%{name}-%{version}-vendor.tar.xz + +ExclusiveArch: %{rust_arches} + +BuildRequires: cargo >= 1.98 +BuildRequires: rust >= 1.98 +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) +# %%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 + +Requires: hicolor-icon-theme +# SSH tunnels run through the system OpenSSH client. +Requires: openssh-clients + +%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 -a1 +%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 under Fedora's RUSTFLAGS (one codegen +# unit) 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 +# The app's manifest rather than the workspace, so the test-only crates +# are left out. +(cd crates/app && %{cargo_license}) > LICENSE.dependencies +# From the root: cargo2rpm drops the workspace's own crates by the +# working directory in their path, and lists every vendored crate. +%{cargo_vendor_manifest} + +%install +%meson_install + +%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} +desktop-file-validate %{buildroot}%{_datadir}/applications/app.tablepro.TablePro.desktop +appstreamcli validate --no-net %{buildroot}%{_metainfodir}/app.tablepro.TablePro.metainfo.xml +# %%meson_test expands to lines of its own; shrink joins them to this one. +%{shrink:xvfb-run -a dbus-run-session -- %meson_test} + +%files +%license LICENSE.dependencies cargo-vendor.txt +%doc README.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 +# The About dialog reads these where Meson installs them, so the project +# license is marked there rather than copied a second time. +%dir %{_datadir}/licenses/app.tablepro.TablePro +%license %{_datadir}/licenses/app.tablepro.TablePro/LICENSE +%license %{_datadir}/licenses/app.tablepro.TablePro/THIRD-PARTY-NOTICE.md +%{_datadir}/licenses/app.tablepro.TablePro/third-party.json +%{_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..ef8e3d52ca --- /dev/null +++ b/linux/build-aux/tests/test_check_packaging.py @@ -0,0 +1,181 @@ +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: 1%{{?dist}} +{find_lang} +%changelog +* Wed Oct 14 2026 TablePro Authors - {changelog}-1 +- First package +""" + + +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. +""" + + +class ProblemsTests(unittest.TestCase): + def tree( + self, + releases='', + version="1.2.0", + changelog="1.2.0", + find_lang="", + linguas="# comment\n", + notes=NOTES, + ): + 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)), + (check_packaging.LINGUAS, linguas), + (check_packaging.CHANGELOG, notes), + ): + (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): + releases = '' + + found = check_packaging.problems(self.tree(releases=releases), "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.0-rc.1", "1.2", "1.2.0~rc1"): + with self.subTest(version=version): + found = check_packaging.problems(self.tree(), version) + + self.assertTrue(any("is not X.Y.Z" in problem for problem in found)) + + 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]\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_the_changelog_is_only_required_for_a_release(self): + self.assertEqual(check_packaging.problems(self.tree(notes="# Changelog\n"), "1.2.0"), []) + + 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_license_metadata.py b/linux/build-aux/tests/test_license_metadata.py new file mode 100644 index 0000000000..f4e425b700 --- /dev/null +++ b/linux/build-aux/tests/test_license_metadata.py @@ -0,0 +1,248 @@ +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" + +UNICODE = "COPYRIGHT AND PERMISSION NOTICE\n\nCopyright © 1991-2024 Unicode, Inc.\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 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 RenderCopyrightTests(unittest.TestCase): + def render(self, licenses, upstream="GNU AFFERO GENERAL PUBLIC LICENSE\n\nTerms.\n"): + return license_metadata.render_copyright(licenses, upstream) + + 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-or-later", "GNU AFFERO GENERAL PUBLIC LICENSE", ".", "Terms."], + ) + + 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")), + ] + paragraph = files_paragraph(self.render(licenses), "vendor/unicode-ident-1.0.24/*") + + self.assertEqual(paragraph["License"][0], "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")), + ] + paragraph = files_paragraph(self.render(licenses), "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), "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_an_exception_is_written_the_dep5_way(self): + licenses = [entry("Apache-2.0 WITH LLVM-exception", MIT_ALICE, crate("a", "1.0.0"))] + paragraph = files_paragraph(self.render(licenses), "vendor/a-1.0.0/*") + + self.assertEqual(paragraph["License"][0], "Apache-2.0 with LLVM exception") + + 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_third_party_license_once(self): + licenses = [ + entry("MIT", MIT_ALICE, crate("a", "1.0.0")), + entry("Apache-2.0", APACHE, crate("b", "1.0.0")), + entry("MIT", MIT_NO_NOTICE, crate("c", "1.0.0")), + entry("AGPL-3.0-or-later", "x", crate("d", "1.0.0")), + ] + + self.assertEqual( + license_metadata.rpm_license(licenses), + "AGPL-3.0-or-later AND Apache-2.0 AND MIT", + ) + + def test_the_spec_license_line_is_replaced_in_place(self): + spec = "Name: tablepro\nLicense: AGPL-3.0-or-later\nURL: x\n" + + self.assertEqual( + license_metadata.apply_spec_license(spec, "AGPL-3.0-or-later AND MIT"), + "Name: tablepro\nLicense: AGPL-3.0-or-later AND MIT\nURL: x\n", + ) + + def test_a_spec_without_exactly_one_license_line_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) + (self.dir / "third-party.json").write_text( + json.dumps({"licenses": [entry("MIT", MIT_ALICE, crate("a", "1.0.0"))]}), + encoding="utf-8", + ) + (self.dir / "LICENSE").write_text("AGPL text\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: AGPL-3.0-or-later AND MIT\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 index 6569c04094..e12fbb491f 100755 --- a/linux/build-aux/update-third-party-licenses.sh +++ b/linux/build-aux/update-third-party-licenses.sh @@ -1,7 +1,8 @@ #!/bin/sh -# Writes data/licenses from Cargo.lock with cargo-about. CI runs it and -# fails when the committed files differ, so run it after every change to -# Cargo.lock and commit what it writes. +# 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" @@ -43,3 +44,9 @@ 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/crates/app/Cargo.toml b/linux/crates/app/Cargo.toml index 0ea6feecf5..7f67634c2d 100644 --- a/linux/crates/app/Cargo.toml +++ b/linux/crates/app/Cargo.toml @@ -51,6 +51,11 @@ name = "application_session_restore" path = "tests/application_session_restore.rs" harness = false +[[test]] +name = "accessibility" +path = "tests/accessibility/main.rs" +harness = false + [features] default = ["kerberos"] kerberos = ["tablepro-driver-mssql/kerberos"] diff --git a/linux/crates/app/build.rs b/linux/crates/app/build.rs index d8b971dc7a..6195e2d709 100644 --- a/linux/crates/app/build.rs +++ b/linux/crates/app/build.rs @@ -3,6 +3,9 @@ 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"; @@ -56,9 +59,15 @@ fn render_template() -> Result { 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 screenshots = + format!("https://raw.githubusercontent.com/TableProApp/TablePro/linux-v{version}/linux/data/screenshots"); let rendered = template .replace("@app-id@", &app_id) - .replace("@gettext-package@", &package); + .replace("@gettext-package@", &package) + .replace("@version@", version) + .replace("@screenshots-base-url@", &screenshots); if let Some(name) = unfilled_placeholder(&rendered) { return Err(format!( "{METAINFO_TEMPLATE} uses @{name}@, which build.rs does not fill" diff --git a/linux/crates/app/meson.build b/linux/crates/app/meson.build index 0625c4775b..dedb9aa072 100644 --- a/linux/crates/app/meson.build +++ b/linux/crates/app/meson.build @@ -14,6 +14,18 @@ if not get_option('kerberos') cargo_options += ['--no-default-features'] endif +cargo_config = get_option('cargo_config') +if cargo_config != '' + cargo_config = meson.project_source_root() / cargo_config + if not fs.is_file(cargo_config) + error('cargo_config names @0@, which is not a file'.format(cargo_config)) + endif + # Cargo resolves the relative paths inside the file against the parent + # of the file's directory, which for vendor/cargo-config.toml is the + # source root vendor/ sits in. + cargo_options += ['--config', cargo_config] +endif + cargo_built = cargo_target_dir / cargo_profile_dir # The About dialog embeds the metainfo Meson installs, with its @@ -44,19 +56,30 @@ custom_target( 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: cargo_env, + 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 diff --git a/linux/crates/app/src/application/about_dialog.rs b/linux/crates/app/src/application/about_dialog.rs index 7314dd98f4..02424c84f6 100644 --- a/linux/crates/app/src/application/about_dialog.rs +++ b/linux/crates/app/src/application/about_dialog.rs @@ -47,6 +47,7 @@ mod tests { use gtk4 as gtk; use super::*; + use crate::application::metainfo_release::newest_release; fn embedded_metainfo() -> String { crate::register_test_resources(); @@ -102,6 +103,70 @@ mod tests { format!("{}.desktop", config::APP_ID) ); assert!(!xml.contains("@app-id@") && !xml.contains("@gettext-package@"), "{xml}"); + assert!( + !xml.contains("@version@") && !xml.contains("@screenshots-base-url@"), + "{xml}" + ); + } + + #[test] + fn embedded_metainfo_pins_project_links_to_the_release_tag() { + let xml = embedded_metainfo(); + let tree = format!( + "https://github.com/TableProApp/TablePro/blob/linux-v{}/linux", + newest_release(&xml).expect("the metainfo lists a release") + ); + + assert_eq!( + metainfo_text(&xml, ""), + format!("{tree}/po/README.md") + ); + assert_eq!( + metainfo_text(&xml, ""), + format!("{tree}/CONTRIBUTING.md") + ); + } + + #[test] + fn every_metainfo_screenshot_is_a_committed_png() { + let xml = embedded_metainfo(); + let screenshots = metainfo_text(&xml, ""); + 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] diff --git a/linux/crates/app/src/application/accelerators.rs b/linux/crates/app/src/application/accelerators.rs index 006eeac725..110bcd4b30 100644 --- a/linux/crates/app/src/application/accelerators.rs +++ b/linux/crates/app/src/application/accelerators.rs @@ -598,9 +598,9 @@ mod tests { 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("Edit Cell", "cell.edit")).as_deref(), Some("F2")); assert_eq!( - accel(&menu_item("New window", "app.new-window")), + accel(&menu_item("New Window", "app.new-window")), None, "GTK shows its own" ); diff --git a/linux/crates/app/src/application/app_actions.rs b/linux/crates/app/src/application/app_actions.rs index 90df868e73..dbf56c2a1c 100644 --- a/linux/crates/app/src/application/app_actions.rs +++ b/linux/crates/app/src/application/app_actions.rs @@ -1,9 +1,13 @@ +use gtk4::glib; use gtk4::prelude::*; +use gtk4::subclass::prelude::*; use libadwaita::prelude::*; +use uuid::Uuid; use super::about_dialog::build_about_dialog; use super::{TableProApplication, WindowLaunch}; -use crate::ui::PreferencesDialog; +use crate::runtime::DeliverInput; +use crate::ui::{PreferencesDialog, SessionWindowInput}; pub fn install_app_actions(app: &TableProApplication) { let new_window = gio::ActionEntry::builder("new-window") @@ -20,7 +24,36 @@ pub fn install_app_actions(app: &TableProApplication) { let about = gio::ActionEntry::builder("about") .activate(|app: &TableProApplication, _, _| present_about(app)) .build(); - app.add_action_entries([new_window, quit, preferences, about]); + // 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) { diff --git a/linux/crates/app/src/application/app_services.rs b/linux/crates/app/src/application/app_services.rs index 37a0a0c78b..189d694301 100644 --- a/linux/crates/app/src/application/app_services.rs +++ b/linux/crates/app/src/application/app_services.rs @@ -18,6 +18,7 @@ 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}; @@ -37,6 +38,8 @@ pub struct AppServices { 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. @@ -77,6 +80,8 @@ impl AppServices { 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, @@ -88,6 +93,7 @@ impl AppServices { storage: Rc::new(storage), tasks, saved_connections, + saved_connection_editor, file_revealer: Rc::new(FileLauncherRevealer), hold: application_hold.clone(), application_hold, 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 index fefb7486f0..a3be07daf0 100644 --- a/linux/crates/app/src/application/mod.rs +++ b/linux/crates/app/src/application/mod.rs @@ -11,6 +11,8 @@ mod app_window_host; mod debug_info; mod imp; mod legal_section; +#[cfg(test)] +mod metainfo_release; mod open_files; mod quit_sequence; mod runtime_versions; 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/mod.rs b/linux/crates/app/src/services/mod.rs index e4e4169b40..c34dc3f171 100644 --- a/linux/crates/app/src/services/mod.rs +++ b/linux/crates/app/src/services/mod.rs @@ -7,6 +7,7 @@ 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; @@ -15,10 +16,14 @@ 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; 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..7ba0c4eaed --- /dev/null +++ b/linux/crates/app/src/services/saved_connection_editor.rs @@ -0,0 +1,627 @@ +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. +/// +/// Run it on the app runtime, which the Secret Service client needs. +#[derive(Clone)] +pub struct SavedConnectionEditor { + store: ConnectionStore, + secrets: Arc, + tasks: Tasks, +} + +impl SavedConnectionEditor { + pub fn new(store: ConnectionStore, secrets: Arc, tasks: Tasks) -> Self { + Self { store, secrets, tasks } + } + + /// 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 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 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() + } + + #[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 index 799b744289..b70c01675d 100644 --- a/linux/crates/app/src/services/saved_connection_list.rs +++ b/linux/crates/app/src/services/saved_connection_list.rs @@ -7,7 +7,7 @@ use std::sync::Arc; use gtk4::prelude::*; use gtk4::{gio, glib}; -use tablepro_storage::{ConnectionListState, ConnectionStore, SavedConnection}; +use tablepro_storage::{ConnectionGroupName, ConnectionListState, ConnectionStore, SavedConnection}; use uuid::Uuid; use super::saved_connection_object::SavedConnectionObject; @@ -82,7 +82,7 @@ impl SavedConnectionList { } /// The groups the list files connections under, in display order. - pub fn groups(&self) -> Vec { + pub fn groups(&self) -> Vec { saved_connection_order::groups(&self.connections.borrow()) } @@ -299,11 +299,11 @@ mod tests { let before = objects(&list); let mut filed = beta; - filed.group = Some("Work".to_owned()); + filed.group = Some(ConnectionGroupName::new("Work").expect("a group name")); list.apply(ready(&[alpha, filed])); let after = objects(&list); - assert_eq!(list.groups(), ["Work"]); + 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"); } diff --git a/linux/crates/app/src/services/saved_connection_object.rs b/linux/crates/app/src/services/saved_connection_object.rs index fbaf59be73..5577285d17 100644 --- a/linux/crates/app/src/services/saved_connection_object.rs +++ b/linux/crates/app/src/services/saved_connection_object.rs @@ -100,7 +100,7 @@ mod tests { #[gtk4::test] fn the_object_carries_the_connection_as_properties() { let mut connection = saved("sa", AuthMode::Password); - connection.group = Some(" Work ".to_owned()); + 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()); diff --git a/linux/crates/app/src/services/saved_connection_object_imp.rs b/linux/crates/app/src/services/saved_connection_object_imp.rs index 482340bad0..97bf17f6bc 100644 --- a/linux/crates/app/src/services/saved_connection_object_imp.rs +++ b/linux/crates/app/src/services/saved_connection_object_imp.rs @@ -52,7 +52,8 @@ impl SavedConnectionObject { fn group(&self) -> Option { self.saved .get() - .and_then(|saved| super::saved_connection_order::group_of(saved).map(str::to_owned)) + .and_then(|saved| saved.group.as_ref()) + .map(|group| group.as_str().to_owned()) } fn last_opened(&self) -> i64 { diff --git a/linux/crates/app/src/services/saved_connection_order.rs b/linux/crates/app/src/services/saved_connection_order.rs index cdf35d7152..7eacacb295 100644 --- a/linux/crates/app/src/services/saved_connection_order.rs +++ b/linux/crates/app/src/services/saved_connection_order.rs @@ -1,6 +1,6 @@ //! The order every connection picker shows the saved connections in. -use tablepro_storage::SavedConnection; +use tablepro_storage::{ConnectionGroupName, SavedConnection}; /// Sort the list the way every picker shows it. /// @@ -20,12 +20,13 @@ pub(crate) fn sort(connections: &mut [SavedConnection]) { } /// Ungrouped sorts before every group, and groups sort by name -/// regardless of how they were capitalised. -fn group_key(connection: &SavedConnection) -> (bool, String) { - match group_of(connection) { - Some(group) => (true, group.to_lowercase()), - None => (false, String::new()), - } +/// 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 @@ -38,25 +39,13 @@ fn recency_key(connection: &SavedConnection) -> (bool, i64) { } } -/// The group as it is shown, which is `None` for a name that is empty -/// or only spaces. A group nobody can see is not a group. -pub(crate) fn group_of(connection: &SavedConnection) -> Option<&str> { - connection - .group - .as_deref() - .map(str::trim) - .filter(|group| !group.is_empty()) -} - -/// Every group in `sorted`, in the order the list shows them, once each. -pub(crate) fn groups(sorted: &[SavedConnection]) -> Vec { - let mut found: Vec = Vec::new(); - for connection in sorted { - let Some(group) = group_of(connection) else { - continue; - }; - if !found.iter().any(|seen| seen == group) { - found.push(group.to_owned()); +/// 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 @@ -87,7 +76,7 @@ mod tests { .expect("a time") }), color: None, - group: group.map(str::to_owned), + group: group.map(|group| ConnectionGroupName::new(group).expect("a group name")), } } @@ -143,28 +132,32 @@ mod tests { } #[test] - fn a_group_of_only_spaces_is_no_group_at_all() { + fn the_group_list_holds_each_name_once_in_list_order() { let mut list = vec![ - connection("blank", Some(" "), Some(1)), - connection("real", None, Some(2)), + 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); - assert_eq!(group_of(&list[0]), None); - assert_eq!(names(&list), vec!["blank", "real"]); + let names: Vec = groups(&list).into_iter().map(String::from).collect(); + assert_eq!(names, ["Archive", "Work"]); } #[test] - fn the_group_list_holds_each_name_once_in_list_order() { + 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("Archive"), Some(1)), - connection("c", Some("Work"), Some(2)), - connection("d", None, Some(1)), + 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!(groups(&list), vec!["Archive".to_owned(), "Work".to_owned()]); + 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/test_support.rs b/linux/crates/app/src/test_support.rs index 49e3296485..21ad600b28 100644 --- a/linux/crates/app/src/test_support.rs +++ b/linux/crates/app/src/test_support.rs @@ -3,6 +3,7 @@ mod alert_response; mod fake_clocks; mod fake_reachability; mod fake_session; +mod focus_thief; mod icon_buttons; mod key_injection_error; mod key_injector; @@ -20,12 +21,13 @@ pub(crate) use alert_response::{click_response, respond}; 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::{drain_main_context, finish_on_main, wait_until}; +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 signal_log::SignalLog; pub(crate) use unlabelled_widget::UnlabelledWidget; 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/main_loop.rs b/linux/crates/app/src/test_support/main_loop.rs index e6cf62b18b..b26c5e1a60 100644 --- a/linux/crates/app/src/test_support/main_loop.rs +++ b/linux/crates/app/src/test_support/main_loop.rs @@ -1,9 +1,10 @@ -use std::cell::RefCell; +use std::cell::{Cell, RefCell}; use std::future::Future; use std::rc::Rc; use std::time::{Duration, Instant}; -use gtk4::glib; +use gtk4::prelude::*; +use gtk4::{gdk, glib}; use crate::test_support::WaitTimedOut; @@ -12,6 +13,19 @@ pub(crate) fn drain_main_context() { 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); diff --git a/linux/crates/app/src/ui/a11y/icon_button.rs b/linux/crates/app/src/ui/a11y/icon_button.rs index 0e58e15040..c8920f6116 100644 --- a/linux/crates/app/src/ui/a11y/icon_button.rs +++ b/linux/crates/app/src/ui/a11y/icon_button.rs @@ -34,9 +34,9 @@ mod tests { 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")); + describe_icon_button(&button, "Insert Row", Some("browse.insert-row")); - assert_eq!(button.tooltip_text().as_deref(), Some("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, @@ -48,7 +48,7 @@ mod tests { 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")); + describe_icon_button(&button, "First Page", Some("browse.first-page")); assert!(!gtk::test_accessible_has_property( &button, diff --git a/linux/crates/app/src/ui/a11y/tests.rs b/linux/crates/app/src/ui/a11y/tests.rs index 8d87e2b0ed..3860e1fb96 100644 --- a/linux/crates/app/src/ui/a11y/tests.rs +++ b/linux/crates/app/src/ui/a11y/tests.rs @@ -7,8 +7,8 @@ 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, history_dialog, session_window, - ssh_section, structure_tab, welcome_view, workspace, + 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 @@ -17,15 +17,23 @@ use crate::ui::{ 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(), diff --git a/linux/crates/app/src/ui/browse_tab/browse_actions.rs b/linux/crates/app/src/ui/browse_tab/browse_actions.rs index 4d859ae5e3..5bf547a2f7 100644 --- a/linux/crates/app/src/ui/browse_tab/browse_actions.rs +++ b/linux/crates/app/src/ui/browse_tab/browse_actions.rs @@ -3,12 +3,16 @@ use gtk4::gio; use gtk4::prelude::*; use gtk4::subclass::prelude::*; -use super::{BrowseModel, BrowseTab, BrowseTabInput}; +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)] @@ -52,20 +56,12 @@ pub(super) fn install_browse_actions(tab: &BrowseTab) -> BrowseActions { group.add_action(&action); action }; - add("filter", || BrowseTabInput::ToggleFilterStrip); - add("refresh", || BrowseTabInput::RequestRefresh); - add("paste-rows", || BrowseTabInput::PasteNotSupported); - let imp = tab.imp(); - let previous = add("previous-page", || BrowseTabInput::PrevPage); - imp.prev_button - .bind_property("sensitive", &previous, "enabled") - .sync_create() - .build(); - let next = add("next-page", || BrowseTabInput::NextPage); - imp.next_button - .bind_property("sensitive", &next, "enabled") - .sync_create() - .build(); + add("filter", || PagingInput::ToggleFilterStrip.into()); + 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 |_, _| { @@ -73,17 +69,17 @@ pub(super) fn install_browse_actions(tab: &BrowseTab) -> BrowseActions { return; }; let rows = tab.read(BrowseModel::selected_rows).unwrap_or_default(); - tab.send(BrowseTabInput::DeleteRows(rows)); + tab.send(EditInput::DeleteRows(rows)); }); group.add_action(&delete_rows); let actions = BrowseActions { - save: add("save", || BrowseTabInput::CommitSave), - undo: add("undo", || BrowseTabInput::Undo), - redo: add("redo", || BrowseTabInput::Redo), - insert_row: add("insert-row", || BrowseTabInput::InsertRow), + 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", || BrowseTabInput::CopySelectedRowsAsTsv), - clear_selection: add("clear-selection", || BrowseTabInput::ClearSelection), + copy_rows: add("copy-rows", || ClipboardInput::CopySelectedRowsAsTsv.into()), + clear_selection: add("clear-selection", || SelectionInput::ClearSelection.into()), }; for action in [ &actions.save, @@ -96,13 +92,15 @@ pub(super) fn install_browse_actions(tab: &BrowseTab) -> BrowseActions { ] { action.set_enabled(false); } - tab.insert_action_group("browse", Some(&group)); + 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. +/// 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 [ @@ -112,17 +110,11 @@ pub(super) fn describe_buttons(tab: &BrowseTab) { gettext("Delete Selected Rows"), Some("browse.delete-rows"), ), - (&imp.first_button, gettext("First Page"), None), - (&imp.prev_button, gettext("Previous Page"), Some("browse.previous-page")), - (&imp.next_button, gettext("Next Page"), Some("browse.next-page")), - (&imp.last_button, gettext("Last Page"), None), (&imp.export_button, gettext("Export Rows…"), None), ] { describe_icon_button(&button.get(), &label, action); } - for (button, action) in [(&imp.filter_button, "browse.filter"), (&imp.save_button, "browse.save")] { - announce_key_shortcuts(&button.get(), action); - } + announce_key_shortcuts(&imp.filter_button.get(), "browse.filter"); } pub(super) fn install_grid_shortcuts(view: >k::ColumnView) { 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..a0eddfcdab --- /dev/null +++ b/linux/crates/app/src/ui/browse_tab/browse_tab_input.rs @@ -0,0 +1,74 @@ +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::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..4a86af398a --- /dev/null +++ b/linux/crates/app/src/ui/browse_tab/browse_tab_widgets.rs @@ -0,0 +1,25 @@ +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, +} 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..05b685bcb3 --- /dev/null +++ b/linux/crates/app/src/ui/browse_tab/clipboard_handlers.rs @@ -0,0 +1,149 @@ +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.clone(), + } + } + + 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/command_handlers.rs b/linux/crates/app/src/ui/browse_tab/command_handlers.rs new file mode 100644 index 0000000000..6f6a25b1a6 --- /dev/null +++ b/linux/crates/app/src/ui/browse_tab/command_handlers.rs @@ -0,0 +1,149 @@ +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) => { + 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/data_handlers.rs b/linux/crates/app/src/ui/browse_tab/data_handlers.rs new file mode 100644 index 0000000000..ce57963fd1 --- /dev/null +++ b/linux/crates/app/src/ui/browse_tab/data_handlers.rs @@ -0,0 +1,306 @@ +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) { + match &presentation.page { + ContentPage::Spinner => self.show_loading(), + ContentPage::Content => self.widgets.inner_stack.set_visible_child_name("grid"), + ContentPage::Error(failure) => { + // The status page reads its description as markup. + self.widgets + .error_page + .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()); + 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) { + self.pager.go_to(offset); + let PageReply { result, layout, .. } = reply; + 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, + }); + 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(), + ); + 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..1d7ace10ff --- /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 { + 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 index 50ac249745..84a5093a64 100644 --- a/linux/crates/app/src/ui/browse_tab/fetch.rs +++ b/linux/crates/app/src/ui/browse_tab/fetch.rs @@ -5,12 +5,11 @@ use tablepro_core::browse::{BrowseSqlError, PageLayout, PageRequest, SortKey, bu use tablepro_core::meta::{RowIdentity, TableRef}; use tablepro_core::statement::StatementBuilder; use tablepro_core::{BuildFilterError, ColumnInfo, FilterSet, QueryResult, RowLimit}; -use tablepro_session::{LinkGeneration, OperationError, OperationKind}; +use tablepro_session::{OperationError, OperationKind}; -use super::column_recheck::RecheckAction; use super::columns_fetch::{ColumnFreshness, load_columns}; use super::{ - BrowseCommand, BrowseModel, BrowseRequestKind, BrowseTabInput, BrowseTabOutput, ColumnsReply, Effects, PageReply, + BrowseCommand, BrowseModel, BrowseRequestKind, BrowseTabOutput, ColumnsReply, DataInput, Effects, PageReply, }; use crate::runtime::{RequestTicket, Stamped}; use crate::ui::export::TableExport; @@ -66,12 +65,12 @@ impl BrowseModel { /// A new read of the columns, so the page read under them gets its /// own recheck. - fn fetch_columns(&mut self, freshness: ColumnFreshness) { + pub(super) fn fetch_columns(&mut self, freshness: ColumnFreshness) { self.recheck.reset(); self.read_columns(freshness); } - fn read_columns(&mut self, freshness: ColumnFreshness) { + 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(); @@ -169,7 +168,7 @@ impl BrowseModel { 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(BrowseTabInput::Command(command(Stamped { stamp, value }))); + tab.send(DataInput::Command(command(Stamped { stamp, value }))); } }); } @@ -226,141 +225,6 @@ impl BrowseModel { let presentation = self.load.abandon(); self.present(presentation, fx); } - - 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) => { - 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), - } - } - - /// The session reconnected. Reads that failed are asked again on the - /// new connection; a tab whose reads all landed keeps its rows. - pub(super) 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 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); - } - } - - 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); - } - } - } - } } /// A filter the current server cannot write, as the tab reports it. @@ -380,16 +244,3 @@ fn check_shape(result: &QueryResult, layout: &PageLayout) -> Result<(), BrowseSq None => Ok(()), } } - -/// 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/imp.rs b/linux/crates/app/src/ui/browse_tab/imp.rs index d21c3262dd..0695f2f1b3 100644 --- a/linux/crates/app/src/ui/browse_tab/imp.rs +++ b/linux/crates/app/src/ui/browse_tab/imp.rs @@ -11,6 +11,8 @@ 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)] @@ -39,21 +41,7 @@ pub struct BrowseTab { #[template_child] pub delete_button: TemplateChild, #[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, + pub pager_bar: TemplateChild, #[template_child] pub selection_label: TemplateChild, #[template_child] @@ -65,17 +53,7 @@ pub struct BrowseTab { #[template_child] pub page_size_dropdown: TemplateChild, #[template_child] - pub pending_revealer: TemplateChild, - #[template_child] - pub pending_label: 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 pending_bar: TemplateChild, pub(super) model: RefCell>, pub(super) queue: RefCell>, @@ -91,6 +69,8 @@ impl ObjectSubclass for BrowseTab { type ParentType = adw::Bin; fn class_init(klass: &mut Self::Class) { + PagerBar::ensure_type(); + PendingChangesBar::ensure_type(); klass.bind_template(); } 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..50db8b0406 --- /dev/null +++ b/linux/crates/app/src/ui/browse_tab/layout.rs @@ -0,0 +1,173 @@ +use std::rc::Rc; + +use gtk4 as gtk; +use gtk4::glib; +use gtk4::prelude::*; +use gtk4::subclass::prelude::*; +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, DEFAULT_PAGE_SIZE, PAGE_SIZE_OPTIONS, PagingInput, SaveInput, + loading_description, +}; +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); + page_size_choices(&imp.page_size_dropdown, init.page_size); + connect_controls(tab); + 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(), + } +} + +/// 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)); + } + }) +} + +fn page_size_choices(dropdown: >k::DropDown, page_size: u64) { + let labels: Vec = PAGE_SIZE_OPTIONS.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 initial_idx = PAGE_SIZE_OPTIONS + .iter() + .position(|n| *n == page_size) + .unwrap_or_else(|| { + PAGE_SIZE_OPTIONS + .iter() + .position(|n| *n == DEFAULT_PAGE_SIZE) + .unwrap_or(2) + }); + dropdown.set_selected(u32::try_from(initial_idx).unwrap_or_default()); +} + +/// 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) { + 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::DiscardAll)), + (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_SIZE_OPTIONS.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()); +} diff --git a/linux/crates/app/src/ui/browse_tab/load_tests.rs b/linux/crates/app/src/ui/browse_tab/load_tests.rs index c455a45560..9e93933408 100644 --- a/linux/crates/app/src/ui/browse_tab/load_tests.rs +++ b/linux/crates/app/src/ui/browse_tab/load_tests.rs @@ -8,12 +8,12 @@ use libadwaita as adw; use tablepro_core::{ColumnInfo, DriverError, LossPhase, QueryResult, Value}; use tablepro_test_support::{CallCounters, FakeReply}; -use super::BrowseTabInput; use super::tests::{ - Harness, Shown, WAIT, after_next_frame, column, column_view, delete_action, full_page, header_titles, key, - not_null_name, rows, serving_table, text, toasts, + 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::{drain_main_context, wait_until}; +use super::{ClipboardInput, PagingInput}; +use crate::test_support::{after_next_frame, drain_main_context, wait_until}; use crate::ui::grid::GridCell; fn banner(harness: &Harness) -> adw::Banner { @@ -55,7 +55,7 @@ fn refresh_keeps_grid_visible_and_sets_tab_loading() { wait_until(WAIT, || harness.shown() == text(&["before"])).expect("the first page"); let loading = loading_log(&harness); - harness.tab().send(BrowseTabInput::Refresh); + 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"); @@ -78,7 +78,7 @@ fn failed_refresh_keeps_rows_and_reveals_stale_banner() { wait_until(WAIT, || harness.shown() == text(&["kept"])).expect("the first page"); let counters = harness.connection.counters(); - harness.tab().send(BrowseTabInput::Refresh); + 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); @@ -99,7 +99,7 @@ fn stale_banner_retry_reloads_and_hides_banner() { .always(FakeReply::ok(rows(&["fresh"]))); let harness = Harness::open(script, []); wait_until(WAIT, || harness.shown() == text(&["kept"])).expect("the first page"); - harness.tab().send(BrowseTabInput::Refresh); + harness.tab().send(PagingInput::Refresh); wait_until(WAIT, || banner(&harness).is_revealed()).expect("the stale banner"); banner(&harness).emit_by_name::<()>("button-clicked", &[]); @@ -162,7 +162,7 @@ fn refresh_restores_multi_row_selection_and_focus_by_key() { picked.add(3); selection.set_selection(&picked, >k4::Bitset::new_range(0, selection.n_items())); - harness.tab().send(BrowseTabInput::Refresh); + harness.tab().send(PagingInput::Refresh); wait_until(WAIT, || harness.shown().len() == 6).expect("the refreshed page"); after_next_frame(&shown.0); @@ -190,11 +190,11 @@ fn a_selection_is_not_put_back_after_a_failed_refresh() { wait_until(WAIT, || harness.shown().len() == 3).expect("the first page"); let selection = harness.selection(); selection.select_item(0, true); - harness.tab().send(BrowseTabInput::Refresh); + harness.tab().send(PagingInput::Refresh); wait_until(WAIT, || banner(&harness).is_revealed()).expect("the stale banner"); selection.select_item(2, true); - harness.tab().send(BrowseTabInput::Refresh); + harness.tab().send(PagingInput::Refresh); wait_until(WAIT, || !banner(&harness).is_revealed()).expect("the second refresh"); drain_main_context(); @@ -286,7 +286,7 @@ fn refresh_failure_of_columns_keeps_cached_columns() { let harness = Harness::open(script, []); wait_until(WAIT, || harness.shown() == text(&["kept"])).expect("the first page"); - harness.tab().send(BrowseTabInput::Refresh); + 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"]); @@ -342,9 +342,9 @@ fn the_rows_behind_the_error_page_take_no_action() { .connect_export_results(move |_, _| *sink.borrow_mut() += 1); let toasts = toasts(harness.tab()); - harness.tab().send(BrowseTabInput::FilterApplied(not_null_name())); + 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(BrowseTabInput::Export(None)); + harness.tab().send(ClipboardInput::Export(None)); assert_eq!(*exported.borrow(), 0, "Export wrote rows the filter does not match"); assert!( @@ -356,7 +356,7 @@ fn the_rows_behind_the_error_page_take_no_action() { !delete_action(harness.tab()).is_enabled(), "Delete stayed on for rows behind the error page" ); - assert_eq!(harness.tab().imp().paginator_label.label(), ""); + assert_eq!(harness.tab().imp().pager_bar.imp().paginator_label.label(), ""); } /// Next moved the pager before its read failed, so the way back goes @@ -374,10 +374,10 @@ fn a_failed_next_page_leaves_the_way_back_open() { .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(); + let imp = harness.tab().imp().pager_bar.imp(); assert!(!imp.prev_button.is_sensitive()); - harness.tab().send(BrowseTabInput::NextPage); + 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); @@ -455,10 +455,10 @@ fn a_failed_column_read_leaves_no_read_for_a_later_one_to_start() { 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(BrowseTabInput::Refresh); + harness.tab().send(PagingInput::Refresh); wait_until(WAIT, || banner(&harness).is_revealed()).expect("the failed refresh"); - harness.tab().send(BrowseTabInput::SortChanged { + harness.tab().send(PagingInput::SortChanged { col_idx: 0, ascending: false, }); diff --git a/linux/crates/app/src/ui/browse_tab/mod.rs b/linux/crates/app/src/ui/browse_tab/mod.rs index 180b28ad07..7315ecc055 100644 --- a/linux/crates/app/src/ui/browse_tab/mod.rs +++ b/linux/crates/app/src/ui/browse_tab/mod.rs @@ -5,25 +5,47 @@ //! 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; #[cfg(test)] mod load_tests; +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; @@ -32,19 +54,16 @@ mod total_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 gtk4::{gio, glib}; use libadwaita as adw; -use libadwaita::prelude::*; +use tablepro_core::ColumnInfo; use tablepro_core::dialect::SqlDialect; -use tablepro_core::edit::{CellInput, EditParseError, parse_edit_text}; -use tablepro_core::meta::{PagePosition, RowIdentity, TableRef}; -use tablepro_core::{ColumnInfo, QueryResult, Value}; +use tablepro_core::meta::{RowIdentity, TableRef}; use tablepro_session::operation::DeadlinePolicy; use tablepro_session::runtime::{Tasks, WatchForwarder}; use tablepro_session::{LinkGeneration, Session}; @@ -53,32 +72,35 @@ 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::columns_fetch::ColumnFreshness; 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; -use self::row_deletion::{RowDeletion, plan_row_deletion}; +#[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::format::format_thousands; +use super::filter_strip::FilterStrip; use super::grid::{ - CellPreset, ColumnWidthBinding, DeleteAvailability, GridContent, GridMsg, GridRowId, GridRowIndex, GridSink, - GridSorting, RowJsonRequest, RowKeyBuilder, RowObject, RowSnapshot, TabGridContext, build_column_view, - cell_editability, cell_presets, data_columns, delete_availability, edit_context, edit_row, export_snapshot, - present_row_json, + ColumnWidthBinding, GridRowIndex, GridSink, RowJsonRequest, RowObject, TabGridContext, present_row_json, }; -use super::tab_load::{ContentLoad, ContentPage, LoadFailure, LoadPresentation, StaleBanner}; +use super::tab_load::ContentLoad; use crate::runtime::{FailedRequests, RequestSequencer}; -use crate::services::change_origins::ChangeOrigins; -use crate::services::change_tracker::{RowKey, StatementSource, new_draft_cell, pk_identity}; -use crate::services::column_relayout::ColumnRelayout; -use crate::services::save_batch::SaveTarget; +use crate::services::change_tracker::{RowKey, pk_identity}; use crate::services::tab_tracker_handle::TabTrackerHandle; use crate::services::tracker_delta::TrackerDelta; -use crate::services::tracker_edit::TrackerEdit; -use crate::ui::clipboard::{CopyKind, CopyRequest, InClauseRows, InsertCopy, InsertRow}; -use crate::ui::export::{ExportOffer, ExportOrigin, ExportQuery, ExportSource}; +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; @@ -127,64 +149,16 @@ impl BrowseTab { .connection_id .map(|id| init.filter_settings.filters(id, init.schema.as_deref(), &init.table)) .unwrap_or_default(); - let table_label = qualified(init.schema.as_deref(), &init.table); - imp.loading_page - .set_description(Some(&loading_description(&table_label))); - imp.inner_stack.set_visible_child_name("loading"); - imp.read_only_banner.set_revealed(init.read_only); - - let page_size_labels: Vec = PAGE_SIZE_OPTIONS.iter().map(|n| format_thousands(*n)).collect(); - let page_size_strs: Vec<&str> = page_size_labels.iter().map(String::as_str).collect(); - imp.page_size_dropdown - .set_model(Some(>k::StringList::new(&page_size_strs))); - let initial_idx = PAGE_SIZE_OPTIONS - .iter() - .position(|n| *n == init.page_size) - .unwrap_or_else(|| { - PAGE_SIZE_OPTIONS - .iter() - .position(|n| *n == DEFAULT_PAGE_SIZE) - .unwrap_or(2) - }); - imp.page_size_dropdown - .set_selected(u32::try_from(initial_idx).unwrap_or_default()); - - // Filter strip: an inline editor that slides down above the grid. - // It lives in this tab so the user's in-progress rules survive a - // click into a cell or the SQL editor. - let on_apply_filter: Rc = { - let tab = tab.downgrade(); - Rc::new(move |set| { - if let Some(tab) = tab.upgrade() { - tab.send(BrowseTabInput::FilterApplied(set)); - } - }) - }; - let filter_strip = crate::ui::filter_strip::build(Vec::new(), initial_filter.clone(), on_apply_filter); - imp.toolbar.add_top_bar(&filter_strip.widget); - tab.collapse_top_bars_with(&filter_strip.widget); - tab.connect_widgets(); - let stale_banner = StaleBanner::new(imp.stale_banner.get(), { - let tab = tab.downgrade(); - move || { - if let Some(tab) = tab.upgrade() { - tab.send(BrowseTabInput::Refresh); - } - } - }); + 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); - - // 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. - let grid_sink: GridSink = { - let tab = tab.downgrade(); - Rc::new(move |msg| { - if let Some(tab) = tab.upgrade() { - tab.send(BrowseTabInput::from(msg)); - } - }) - }; let link_generation = init.session.link_generation(); let dialect = init.session.current_dialect(); let link_changes = init.session.link_changes(); @@ -218,45 +192,20 @@ impl BrowseTab { total: RowTotal::Unknown, load: ContentLoad::default(), tab_loading: false, - stale_banner, - inner_stack: imp.inner_stack.get(), - grid_holder: imp.grid_holder.get(), - loading_page: imp.loading_page.get(), - error_page: imp.error_page.get(), current_column_view: None, view_columns: Vec::new(), row_index: GridRowIndex::default(), rendered_columns: Vec::new(), - read_only_banner: imp.read_only_banner.get(), - no_pk_banner: imp.no_pk_banner.get(), was_dirty: Cell::new(false), pending_restore: None, - paginator_label: imp.paginator_label.get(), - count_button: imp.count_button.get(), - count_spinner: imp.count_spinner.get(), - count_label: imp.count_label.get(), - selection_label: imp.selection_label.get(), - first_button: imp.first_button.get(), - prev_button: imp.prev_button.get(), - next_button: imp.next_button.get(), - last_button: imp.last_button.get(), - filter_button: imp.filter_button.get(), - filter_badge: imp.filter_badge.get(), - filter_strip: Some(filter_strip), - insert_button: imp.insert_button.get(), - delete_button: imp.delete_button.get(), + filter_strip, actions, saving: Cell::new(false), - pending_revealer: imp.pending_revealer.get(), - save_button: imp.save_button.get(), - discard_button: imp.discard_button.get(), - saving_spinner: imp.saving_spinner.get(), - stop_button: imp.stop_button.get(), - pending_label: imp.pending_label.get(), - grid_sink, + 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( @@ -265,7 +214,7 @@ impl BrowseTab { let tab = tab.downgrade(); move |generation: LinkGeneration| { if let Some(tab) = tab.upgrade() { - tab.send(BrowseTabInput::LinkChanged(generation)); + tab.send(DataInput::LinkChanged(generation)); } } }, @@ -273,16 +222,16 @@ impl BrowseTab { glib::spawn_future_local(future); }, ))); - tab.send(BrowseTabInput::Start); + 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: BrowseTabInput) { + pub(crate) fn send(&self, msg: impl Into) { let imp = self.imp(); - imp.queue.borrow_mut().push_back(msg); + imp.queue.borrow_mut().push_back(msg.into()); if imp.dispatching.replace(true) { return; } @@ -466,87 +415,6 @@ impl BrowseTab { } }); } - - /// 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(&self, filter: >k::Revealer) { - let imp = self.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.reveals_child()); - }) - }; - 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_reveal_child_notify(move |_| sync()); - } - - fn connect_widgets(&self) { - let imp = self.imp(); - let on_click = |button: >k::Button, msg: fn() -> BrowseTabInput| { - let tab = self.downgrade(); - button.connect_clicked(move |_| { - if let Some(tab) = tab.upgrade() { - tab.send(msg()); - } - }); - }; - on_click(&imp.first_button, || BrowseTabInput::FirstPage); - on_click(&imp.prev_button, || BrowseTabInput::PrevPage); - on_click(&imp.next_button, || BrowseTabInput::NextPage); - on_click(&imp.last_button, || BrowseTabInput::LastPage); - on_click(&imp.export_button, || BrowseTabInput::Export(None)); - on_click(&imp.discard_button, || BrowseTabInput::DiscardAll); - on_click(&imp.save_button, || BrowseTabInput::CommitSave); - on_click(&imp.stop_button, || BrowseTabInput::StopSaving); - on_click(&imp.retry_button, || BrowseTabInput::Refresh); - on_click(&imp.count_button, || BrowseTabInput::CountRows); - let tab = self.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_SIZE_OPTIONS.get(index)) { - tab.send(BrowseTabInput::PageSizeChanged(size)); - } - }); - } -} - -impl From for BrowseTabInput { - fn from(msg: GridMsg) -> Self { - match msg { - GridMsg::SortChanged(col_idx, ascending) => Self::SortChanged { col_idx, ascending }, - GridMsg::Edit { row, column, edit } => Self::GridEdit { row, column, edit }, - GridMsg::Copy(request) => Self::GridCopy(request), - GridMsg::ShowRowJson(request) => Self::GridShowRowJson(request), - GridMsg::ShowToast(text) => Self::GridShowToast(text), - GridMsg::CopyRowsAsInsert { rows } => Self::GridCopyRowsAsInsert(rows), - GridMsg::CopyInClause(values) => Self::GridCopyInClause(values), - GridMsg::SetCellValue { row, column, preset } => Self::GridSetCellValue { row, column, preset }, - GridMsg::ExportResults(page) => Self::Export(Some(page)), - GridMsg::DeleteRows { rows } => Self::DeleteRows(rows), - GridMsg::InsertRow => Self::InsertRow, - GridMsg::DuplicateRows { rows } => Self::DuplicateRows(rows), - } - } } /// What one input left to do once it was handled: signals to emit and @@ -564,8 +432,8 @@ impl Effects { self.outputs.push(output); } - fn input(&mut self, input: BrowseTabInput) { - self.inputs.push_back(input); + fn input(&mut self, input: impl Into) { + self.inputs.push_back(input.into()); } } @@ -628,12 +496,7 @@ pub(crate) struct BrowseModel { load: ContentLoad, /// What the tab bar was last told, so it hears only changes. tab_loading: bool, - stale_banner: StaleBanner, - inner_stack: gtk::Stack, - grid_holder: gtk::Box, - loading_page: adw::StatusPage, - error_page: adw::StatusPage, current_column_view: Option, /// The view's columns in the order of a row's cells, which a drag in /// the header leaves alone. @@ -645,136 +508,23 @@ pub(crate) struct BrowseModel { /// 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, - /// Banners are for states the user cannot clear by saving; pending - /// edits show in the footer and the tab title instead. - read_only_banner: adw::Banner, - no_pk_banner: adw::Banner, /// 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, - paginator_label: gtk::Label, - count_button: gtk::Button, - count_spinner: adw::Spinner, - count_label: gtk::Label, - selection_label: gtk::Label, - first_button: gtk::Button, - prev_button: gtk::Button, - next_button: gtk::Button, - last_button: gtk::Button, - filter_button: gtk::Button, - filter_badge: gtk::Label, - filter_strip: Option, - insert_button: gtk::Button, - delete_button: gtk::Button, + filter_strip: FilterStrip, actions: BrowseActions, /// Between the save starting and settling. saving: Cell, - pending_revealer: gtk::Revealer, - save_button: gtk::Button, - discard_button: gtk::Button, - saving_spinner: adw::Spinner, - stop_button: gtk::Button, - pending_label: gtk::Label, 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, -} - -#[derive(Debug)] -pub(crate) enum BrowseTabInput { - /// 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, - /// F5. - Refresh, - /// Esc. A single selected row stays selected: clearing it would - /// strand the keyboard focus indicator. - ClearSelection, - /// F5: the window asks first when the tab has unsaved changes. - RequestRefresh, - ToggleFilterStrip, - /// The strip's rules, or an empty set for "Clear all". - FilterApplied(tablepro_core::FilterSet), - FirstPage, - PrevPage, - NextPage, - LastPage, - SortChanged { - col_idx: usize, - ascending: bool, - }, - PageSizeChanged(u64), - 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, - }, - GridShowToast(String), - /// The paginator's Export button, or the grid's Export with the page - /// as the grid holds it. - Export(Option), - GridCopyRowsAsInsert(Vec), - GridCopyInClause(InClauseRows), - DuplicateRows(Vec), - GridCopy(CopyRequest), - GridShowRowJson(RowJsonRequest), - Undo, - Redo, - CommitSave, - /// The window started saving the tab's changes. - SaveStarted, - StopSaving, - DiscardAll, - DiscardAndRefresh, - /// 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, - }, - /// The save landed, adding and removing these rows. Edits made - /// while it ran stay pending. - SaveCommitted(ChangeOrigins), - /// Count Rows, or Try Again after a count failed. - CountRows, - /// 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), - CopySelectedRowsAsTsv, - PasteNotSupported, + widgets: BrowseTabWidgets, } #[derive(Debug)] @@ -800,11 +550,14 @@ pub(crate) enum BrowseTabOutput { } impl BrowseModel { - /// 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.clone(), + 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), } } @@ -812,10 +565,6 @@ impl BrowseModel { TableRef::new(self.schema.clone(), self.table.clone()) } - fn export_name(&self) -> String { - qualified(self.schema.as_deref(), &self.table) - } - /// 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. @@ -833,167 +582,10 @@ impl BrowseModel { } } - /// 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 - } - - /// 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()), - }))); - } - - /// 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")), - }) - } - fn table_label(&self) -> String { qualified(self.schema.as_deref(), &self.table) } - /// 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.save_button.set_sensitive(!saving); - self.discard_button.set_sensitive(!saving); - self.saving_spinner.set_visible(saving); - self.stop_button.set_visible(saving); - } - - fn refresh_pending_bar(&self, count: usize) { - 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())], - ) - }; - self.pending_label.set_label(&label); - } - self.pending_revealer.set_reveal_child(visible); - self.refresh_banner_visibility(); - } - - /// 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. - 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 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. - 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.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. - 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.delete_button, &rows, self.read_only); - } - /// 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. @@ -1001,311 +593,11 @@ impl BrowseModel { self.page.as_ref().filter(|_| self.load.has_content()) } - /// The footer and the tab's dirty flag, which App turns into the - /// title bullet. App hears only real transitions. - 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)); - } - } - - 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, _) => {} - } - } - - /// 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. - fn reload(&mut self, fx: &mut Effects) { - self.capture_selection(); - self.begin_read(fx); - self.stop_count(true); - self.reload_from_columns(ColumnFreshness::Refetch); - } - - /// 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. - 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. - fn present(&mut self, presentation: LoadPresentation, fx: &mut Effects) { - match &presentation.page { - ContentPage::Spinner => self.show_loading(), - ContentPage::Content => self.inner_stack.set_visible_child_name("grid"), - ContentPage::Error(failure) => { - // The status page reads its description as markup. - self.error_page - .set_description(Some(&glib::markup_escape_text(&failure.message()))); - self.inner_stack.set_visible_child_name("error"); - } - } - self.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. - 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.read_only_banner.set_revealed(read_only); - self.no_pk_banner.set_revealed(!read_only && no_pk); - } - fn list_store(&self) -> Option { let selection = self.current_selection.as_ref()?; selection.model()?.downcast::().ok() } - /// 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(BrowseTabInput::ErrorFlashEnded(key)); - } - }); - } - - /// Keep the selection and the focused cell by row key before a sort, - /// save or refresh reads the page again. - 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); - } - } - - /// 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); - } - }); - } - - /// 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. - 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( - &mut self.row_index, - &store, - &selection, - &column_view, - &self.view_columns, - ); - } - - /// 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.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(), - ); - 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.selection_label.downgrade(); - let button = self.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); - }); - } - browse_actions::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.grid_holder.append(&scrolled); - self.refresh_grid_chrome(); - let presentation = self.load.succeed(); - self.present(presentation, fx); - self.restore_selection(); - fx.output(BrowseTabOutput::StateChanged); - } - fn column_view_matches_current_columns(&self) -> bool { self.current_column_view.is_some() && self.rendered_columns == self.current_columns } @@ -1320,49 +612,6 @@ impl BrowseModel { stale } - /// 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.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); - } - - /// 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. - fn sync_pager_buttons(&self) { - self.first_button.set_sensitive(self.pager.first().is_some()); - self.prev_button.set_sensitive(self.pager.prev().is_some()); - self.next_button - .set_sensitive(self.pager.next(self.rows_on_page()).is_some()); - } - fn rows_on_page(&self) -> u64 { self.shown_page().map_or(0, |page| page.row_count as u64) } @@ -1370,593 +619,6 @@ impl BrowseModel { fn has_primary_key(&self) -> bool { self.current_columns.iter().any(|c| c.primary_key) } - - 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.insert_button.set_visible(false); - self.actions.insert_row.set_enabled(false); - return; - } - self.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.insert_button.set_tooltip_text(Some(&crate::i18n::gettext( - "This table has no primary key. Inline editing is disabled.", - ))); - } else { - self.insert_button - .set_tooltip_text(Some(&crate::i18n::gettext("Insert Row"))); - } - self.refresh_banner_visibility(); - } - - fn refresh_filter_chrome(&self) { - let n = self.current_filter.len(); - if n == 0 { - self.filter_badge.set_visible(false); - self.filter_badge.set_label(""); - self.filter_button - .set_tooltip_text(Some(&crate::i18n::gettext("Filter rows"))); - } else { - self.filter_badge.set_label(&n.to_string()); - self.filter_badge.set_visible(true); - self.filter_button.set_tooltip_text(Some(&crate::i18n::ngettext_f( - "{n} filter rule active. Click to edit", - "{n} filter rules active. Click to edit", - u32::try_from(n).unwrap_or(u32::MAX), - &[("n", &n.to_string())], - ))); - } - } - - fn update_paginator_label(&self) { - let Some(page) = self.shown_page() else { - self.paginator_label.set_label(""); - return; - }; - let n_rows = page.row_count; - if n_rows == 0 { - // Reachable when the user navigated past the end of a - // table that shrank in another session, before the count - // clamps the offset back. - self.paginator_label - .set_label(&crate::i18n::gettext("No rows on this page")); - return; - } - let start = page.start_ordinal + 1; - let end = page.start_ordinal + n_rows as u64; - self.paginator_label.set_label(&self.total.paginator_label(start, end)); - } - - fn show_loading(&self) { - self.loading_page - .set_description(Some(&loading_description(&self.table_label()))); - self.inner_stack.set_visible_child_name("loading"); - } - - /// 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. - fn on_rows_loaded(&mut self, offset: u64, reply: PageReply, fx: &mut Effects) { - self.pager.go_to(offset); - let PageReply { result, layout, .. } = reply; - 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, - }); - 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(), - } - } - - /// 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. - 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); - } - - 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. - if let Some(strip) = self.filter_strip.as_ref() { - strip.update_columns(columns); - } - self.render_grid_if_ready(fx); - } - - fn update(&mut self, msg: BrowseTabInput, fx: &mut Effects) { - match msg { - BrowseTabInput::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); - } - BrowseTabInput::Command(command) => self.on_command(command, fx), - BrowseTabInput::LinkChanged(generation) => self.on_link_changed(generation, fx), - BrowseTabInput::SchemaChanged => { - self.capture_selection(); - self.begin_read(fx); - self.stop_count(true); - self.reload_from_columns(ColumnFreshness::Cached); - } - BrowseTabInput::Refresh => self.reload(fx), - BrowseTabInput::DiscardAndRefresh => { - let delta = self.tracker.discard(); - self.apply_tracker_delta(&delta, fx); - self.reload(fx); - } - BrowseTabInput::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(); - } - BrowseTabInput::RequestRefresh => fx.output(BrowseTabOutput::RefreshRequested), - BrowseTabInput::ToggleFilterStrip => { - if let Some(strip) = self.filter_strip.as_ref() { - strip.toggle(); - } - } - BrowseTabInput::FilterApplied(set) => { - // 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.go_to(0); - self.refresh_filter_chrome(); - if let Some(strip) = self.filter_strip.as_ref() { - strip.update_filter(set); - } - self.stop_count(false); - self.fetch(BrowseRequestKind::Page, fx); - self.fetch(BrowseRequestKind::Estimate, fx); - fx.output(BrowseTabOutput::StateChanged); - } - BrowseTabInput::FirstPage => self.turn_page(self.pager.first(), fx), - BrowseTabInput::PrevPage => self.turn_page(self.pager.prev(), fx), - BrowseTabInput::NextPage => self.turn_page(self.pager.next(self.rows_on_page()), fx), - BrowseTabInput::LastPage => self.turn_page(self.pager.last(&self.total), fx), - BrowseTabInput::SortChanged { col_idx, ascending } => { - // The sorter notifies twice for one click on a new column - // (column, then order), with the same final pair. - let next = (col_idx, ascending); - if self.current_sort == Some(next) { - return; - } - self.current_sort = Some(next); - self.pager.go_to(0); - self.capture_selection(); - self.fetch(BrowseRequestKind::Page, fx); - fx.output(BrowseTabOutput::StateChanged); - } - BrowseTabInput::PageSizeChanged(size) => { - if self.suppress_combo_emit.get() || self.pager.page_size() == size { - return; - } - self.pager.resize(size); - self.fetch(BrowseRequestKind::Page, fx); - fx.output(BrowseTabOutput::StateChanged); - } - BrowseTabInput::DuplicateRows(rows) => { - 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(BrowseTabInput::FocusInsertedDraft); - } - BrowseTabInput::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(BrowseTabInput::FocusInsertedDraft); - } - BrowseTabInput::DeleteRows(rows) => self.delete_rows(&rows, fx), - BrowseTabInput::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); - } - BrowseTabInput::GridSetCellValue { row, column, preset } => { - 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); - } - BrowseTabInput::GridShowToast(message) => { - fx.output(BrowseTabOutput::ShowToast(message)); - } - BrowseTabInput::Export(page) => self.offer_export(page, fx), - BrowseTabInput::GridCopyRowsAsInsert(rows) => { - if !self.grid_is_stale() { - self.copy_rows_as_insert(&rows, fx); - } - } - BrowseTabInput::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.", - ))), - }, - BrowseTabInput::GridCopy(request) => fx.output(BrowseTabOutput::Copy(request)), - BrowseTabInput::GridShowRowJson(request) => fx.output(BrowseTabOutput::ShowRowJson(request)), - BrowseTabInput::CopySelectedRowsAsTsv => self.copy_selected_rows(fx), - BrowseTabInput::PasteNotSupported => { - fx.output(BrowseTabOutput::ShowToast(crate::i18n::gettext( - "Pasting rows isn't supported yet", - ))); - } - BrowseTabInput::CommitSave => fx.output(BrowseTabOutput::SaveRequested), - BrowseTabInput::SaveStarted => self.show_saving(true), - BrowseTabInput::StopSaving => fx.output(BrowseTabOutput::StopSaveRequested), - BrowseTabInput::DiscardAll => { - let delta = self.tracker.discard(); - self.apply_tracker_delta(&delta, fx); - self.fetch(BrowseRequestKind::Page, fx); - } - BrowseTabInput::BulkDeleteAnswered { ticket, confirmed } => { - // 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(BrowseTabInput::MarkRowsDeleted(pending.deletion)); - } - } - BrowseTabInput::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(); - } - } - BrowseTabInput::SaveCommitted(origins) => { - 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); - } - BrowseTabInput::CountRows => { - if let Some(problem) = self.filter_problem() { - fx.output(BrowseTabOutput::ShowToast(fetch::filter_failure(problem).message())); - return; - } - if self.total.exact().is_none() && !self.sequencer.is_pending(BrowseRequestKind::Count) { - self.start_count(self.total.estimate(), fx); - } - } - BrowseTabInput::SaveFailed(message) => { - self.show_saving(false); - fx.output(BrowseTabOutput::ShowSelectionAlert { - title: crate::i18n::gettext("Save failed"), - body: message, - }); - } - BrowseTabInput::SaveOutcomeUnknown(message) => { - self.show_saving(false); - fx.output(BrowseTabOutput::ShowSelectionAlert { - title: crate::i18n::gettext("Save Result Unknown"), - body: message, - }); - } - BrowseTabInput::FlashErrorRow(source) => self.flash_error_row(&source), - BrowseTabInput::ErrorFlashEnded(key) => { - if let Some(row) = self.row_index.get(&key) { - row.bump_revision(); - } - } - BrowseTabInput::FocusInsertedDraft => self.focus_inserted_draft(), - BrowseTabInput::Undo => { - if let Some(delta) = self.tracker.undo() { - self.apply_tracker_delta(&delta, fx); - } - } - BrowseTabInput::Redo => { - if let Some(delta) = self.tracker.redo() { - self.apply_tracker_delta(&delta, fx); - } - } - } - } - - /// Read the page a paginator move landed on, if it moved. - fn turn_page(&mut self, target: Option<(PagePosition, u64)>, fx: &mut Effects) { - let Some((_, start)) = target else { - return; - }; - self.pager.go_to(start); - self.fetch(BrowseRequestKind::Page, fx); - fx.output(BrowseTabOutput::StateChanged); - } - - /// 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 { - fx.input(BrowseTabInput::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(BrowseTabInput::BulkDeleteAnswered { - ticket, - confirmed: response == "delete", - }); - } - }); - dialog.present(Some(&self.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. - 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.", - ))); - } - } - } - - /// 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. - 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 = super::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, - })); - } } /// A status page reads its description as markup, and a table's name @@ -1990,52 +652,3 @@ fn apply_draft_changes(store: >k::gio::ListStore, index: &mut GridRowIndex, de index.insert(&row); } } - -fn selected_rows(selection: >k::MultiSelection) -> Vec { - super::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. -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)); -} - -fn clear_box(b: >k::Box) { - while let Some(child) = b.first_child() { - b.remove(&child); - } -} - -/// 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. -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); -} - -/// 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/page_label.rs b/linux/crates/app/src/ui/browse_tab/page_label.rs new file mode 100644 index 0000000000..ea381ef00e --- /dev/null +++ b/linux/crates/app/src/ui/browse_tab/page_label.rs @@ -0,0 +1,60 @@ +use super::row_total::RowTotal; +use crate::ui::format::format_thousands; + +/// The paginator's text for a page of `rows` rows that starts after +/// `start` rows of the table. +pub(super) fn page_range_label(start: u64, rows: usize, total: &RowTotal) -> String { + label_with(start, rows, total, format_thousands) +} + +fn label_with(start: u64, rows: usize, total: &RowTotal, format: impl Fn(u64) -> String) -> String { + if rows == 0 { + // Reachable when the user navigated past the end of a table that + // shrank in another session, before the count clamps the offset + // back. + return crate::i18n::gettext("No rows on this page"); + } + total.label_with(start + 1, start + rows as u64, format) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ui::format::Thousands; + + fn label(start: u64, rows: usize, total: &RowTotal) -> String { + let english = Thousands::for_posix_locale("en_US.UTF-8"); + label_with(start, rows, 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"); + } +} 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..36c28ccbc3 --- /dev/null +++ b/linux/crates/app/src/ui/browse_tab/pager_bar.rs @@ -0,0 +1,340 @@ +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)] + #[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, + } + + #[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(); + } + } + + 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(); + imp.first_button.set_sensitive(pager.first().is_some()); + imp.prev_button.set_sensitive(pager.prev().is_some()); + imp.next_button.set_sensitive(pager.next(rows_on_page).is_some()); + } + + /// 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.start_ordinal, page.row_count, total)) + .unwrap_or_default(); + imp.paginator_label.set_label(&label); + imp.last_button.set_sensitive(pager.last(total).is_some()); + 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 { .. }); + imp.count_button.set_visible(shown.is_some() && total.exact().is_none()); + imp.count_button.set_sensitive(!counting); + imp.count_spinner.set_visible(counting); + let (label, tooltip) = 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); + imp.count_button.set_tooltip_text(tooltip); + } + + /// 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, + } + } + + #[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); + } + + #[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"); + } +} 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..9ad4c22b66 --- /dev/null +++ b/linux/crates/app/src/ui/browse_tab/paging_handlers.rs @@ -0,0 +1,124 @@ +use gtk4::prelude::*; +use tablepro_core::FilterSet; +use tablepro_core::meta::PagePosition; + +use super::columns_fetch::ColumnFreshness; +use super::fetch::filter_failure; +use super::{BrowseModel, BrowseRequestKind, BrowseTabOutput, Effects, PagingInput}; + +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::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((col_idx, ascending), fx), + PagingInput::PageSizeChanged(size) => { + if self.suppress_combo_emit.get() || self.pager.page_size() == size { + return; + } + self.pager.resize(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.go_to(0); + 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: (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 == Some(next) { + return; + } + self.current_sort = Some(next); + self.pager.go_to(0); + 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() { + fx.output(BrowseTabOutput::ShowToast(filter_failure(problem).message())); + 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<(PagePosition, u64)>, fx: &mut Effects) { + let Some((_, start)) = target else { + return; + }; + self.pager.go_to(start); + self.fetch(BrowseRequestKind::Page, fx); + fx.output(BrowseTabOutput::StateChanged); + } + + 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..2399f326d4 --- /dev/null +++ b/linux/crates/app/src/ui/browse_tab/paging_input.rs @@ -0,0 +1,25 @@ +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, + ToggleFilterStrip, + /// The strip's rules, or an empty set for Clear All. + FilterApplied(FilterSet), + FirstPage, + PrevPage, + NextPage, + LastPage, + SortChanged { + col_idx: usize, + ascending: bool, + }, + 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..863e98d099 --- /dev/null +++ b/linux/crates/app/src/ui/browse_tab/pending_changes_bar.rs @@ -0,0 +1,239 @@ +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())], + ) + }; + imp.pending_label.set_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. + pub(super) fn show_saving(&self, saving: bool) { + let imp = self.imp(); + imp.save_button.set_sensitive(!saving); + imp.discard_button.set_sensitive(!saving); + imp.saving_spinner.set_visible(saving); + imp.stop_button.set_visible(saving); + } + + 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()); + } +} diff --git a/linux/crates/app/src/ui/browse_tab/row_count.rs b/linux/crates/app/src/ui/browse_tab/row_count.rs index e6c3169e3c..d2585af83a 100644 --- a/linux/crates/app/src/ui/browse_tab/row_count.rs +++ b/linux/crates/app/src/ui/browse_tab/row_count.rs @@ -1,4 +1,3 @@ -use gtk4::prelude::*; use tablepro_core::browse::build_count; use tablepro_core::meta::{RowCountEstimate, TableEstimate}; use tablepro_session::{OperationError, OperationKind}; @@ -144,28 +143,8 @@ impl BrowseModel { /// The paginator's label, the Count Rows button and Last, which only /// an exact total enables. pub(super) fn sync_total_chrome(&self) { - self.update_paginator_label(); - let last = self.pager.last(&self.total).is_some(); - self.last_button.set_sensitive(last); - let last_tooltip = match self.total.exact() { - Some(_) => crate::i18n::gettext("Last Page"), - None => crate::i18n::gettext("Count rows to go to the last page"), - }; - self.last_button.set_tooltip_text(Some(&last_tooltip)); - - let counting = matches!(self.total, RowTotal::Counting { .. }); - self.count_button - .set_visible(self.shown_page().is_some() && self.total.exact().is_none()); - self.count_button.set_sensitive(!counting); - self.count_spinner.set_visible(counting); - let (label, tooltip) = match &self.total { - RowTotal::Counting { .. } => (crate::i18n::gettext("Counting…"), None), - RowTotal::Unavailable { reason, .. } => (crate::i18n::gettext("Try Again"), Some(reason.as_str())), - RowTotal::Unknown | RowTotal::Estimated(_) | RowTotal::Exact(_) => { - (crate::i18n::gettext("Count Rows"), None) - } - }; - self.count_label.set_label(&label); - self.count_button.set_tooltip_text(tooltip); + self.widgets + .pager_bar + .show_total(&self.pager, self.shown_page(), &self.total); } } diff --git a/linux/crates/app/src/ui/browse_tab/row_total.rs b/linux/crates/app/src/ui/browse_tab/row_total.rs index 9ea8f9757e..5878364946 100644 --- a/linux/crates/app/src/ui/browse_tab/row_total.rs +++ b/linux/crates/app/src/ui/browse_tab/row_total.rs @@ -1,5 +1,3 @@ -use crate::ui::format::format_thousands; - /// What the tab knows of how many rows the table has under its filter. #[derive(Debug, Clone, PartialEq, Eq, Default)] pub(crate) enum RowTotal { @@ -21,11 +19,7 @@ pub(crate) enum RowTotal { impl RowTotal { /// "Rows 1 – 1,000 of 5,000", "of about 5,000,000", or no total. - pub(crate) fn paginator_label(&self, start: u64, end: u64) -> String { - self.label_with(start, end, format_thousands) - } - - fn label_with(&self, start: u64, end: u64, format: impl Fn(u64) -> String) -> String { + 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( 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..1f8000d853 --- /dev/null +++ b/linux/crates/app/src/ui/browse_tab/save_handlers.rs @@ -0,0 +1,136 @@ +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}; + +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::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 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) { + 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..29a2077a76 --- /dev/null +++ b/linux/crates/app/src/ui/browse_tab/save_input.rs @@ -0,0 +1,26 @@ +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, + 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..1111056738 --- /dev/null +++ b/linux/crates/app/src/ui/browse_tab/selection_handlers.rs @@ -0,0 +1,140 @@ +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( + &mut self.row_index, + &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/tests.rs b/linux/crates/app/src/ui/browse_tab/tests.rs index 4587321513..165a12d696 100644 --- a/linux/crates/app/src/ui/browse_tab/tests.rs +++ b/linux/crates/app/src/ui/browse_tab/tests.rs @@ -13,12 +13,15 @@ use tablepro_session::fakes::FakeEstablish; use tablepro_test_support::{CallCounters, ConnectionScript, FakeConnection, FakeReply}; use super::row_deletion::RowDeletion; -use super::{BrowseTab, BrowseTabInit, BrowseTabInput, Effects, RowTotal, apply_draft_changes}; +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, drain_main_context, wait_until}; +use crate::test_support::{FakeSession, after_next_frame, drain_main_context, wait_until}; use crate::ui::clipboard::InsertRow; use crate::ui::grid::{GridCell, GridRowIndex, RowObject}; @@ -403,7 +406,7 @@ fn stale_page_reply_ignored() { let counters = harness.connection.counters(); wait_until(WAIT, || CallCounters::get(&counters.query_bound) == 1).expect("the first page was asked for"); - harness.tab().send(BrowseTabInput::FilterApplied(not_null_name())); + 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(); @@ -486,7 +489,7 @@ fn schema_change_reads_the_columns_again_then_the_page() { harness .session .invalidate_table(&tablepro_core::TableRef::unqualified("users")); - harness.tab().send(BrowseTabInput::SchemaChanged); + 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); @@ -527,7 +530,7 @@ fn a_renamed_column_builds_the_grid_again() { harness .session .invalidate_table(&tablepro_core::TableRef::unqualified("users")); - harness.tab().send(BrowseTabInput::SchemaChanged); + 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"); @@ -583,7 +586,7 @@ fn a_failed_page_under_a_new_filter_shows_the_error() { let harness = Harness::open(script, []); wait_until(WAIT, || harness.shown() == text(&["unfiltered"])).expect("the first page"); - harness.tab().send(BrowseTabInput::FilterApplied(not_null_name())); + 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"); @@ -622,7 +625,7 @@ fn a_filter_column_renamed_away_drops_the_grid_for_the_error() { harness .session .invalidate_table(&tablepro_core::TableRef::unqualified("users")); - harness.tab().send(BrowseTabInput::SchemaChanged); + 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"); @@ -638,8 +641,8 @@ fn a_refresh_under_a_filter_that_no_longer_fits_keeps_the_rows() { wait_until(WAIT, || harness.shown() == text(&["kept"])).expect("the first page"); let toasts = toasts(harness.tab()); - harness.tab().send(BrowseTabInput::FilterApplied(not_null("nickname"))); - harness.tab().send(BrowseTabInput::Refresh); + harness.tab().send(PagingInput::FilterApplied(not_null("nickname"))); + harness.tab().send(PagingInput::Refresh); wait_until(WAIT, || toasts.borrow().len() == 2).expect("the filter that does not fit was not reported"); assert_eq!(harness.shown(), text(&["kept"])); @@ -658,7 +661,7 @@ fn no_dim_label_class_remains() { .any(|widget| widget.is::()) }) .expect("the grid draws its cells"); - harness.tab().send(BrowseTabInput::ToggleFilterStrip); + harness.tab().send(PagingInput::ToggleFilterStrip); let dimmed: Vec = crate::test_support::descendants(harness.tab()) .into_iter() @@ -688,9 +691,9 @@ fn export_and_save_reach_the_window_as_signals() { .tab() .connect_save_requested(move |_| save_sink.set(save_sink.get() + 1)); - harness.tab().send(BrowseTabInput::Export(None)); - harness.tab().send(BrowseTabInput::InsertRow); - harness.tab().send(BrowseTabInput::CommitSave); + 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); @@ -721,7 +724,7 @@ fn current_page_export_includes_pending_edits() { .tab() .connect_export_results(move |_, offer| sink.borrow_mut().push(offer)); - harness.tab().send(BrowseTabInput::Export(None)); + harness.tab().send(ClipboardInput::Export(None)); let offers = offers.borrow(); let [offer] = offers.as_slice() else { @@ -755,8 +758,9 @@ fn current_page_export_includes_pending_edits() { 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_button_visible_only_while_saving() { +fn stop_saving_follows_the_save_and_reaches_the_window() { use gtk4::subclass::prelude::ObjectSubclassIsExt; let mut script = serving_table(); @@ -768,21 +772,17 @@ fn stop_saving_button_visible_only_while_saving() { harness .tab() .connect_stop_save_requested(move |_| stop_sink.set(stop_sink.get() + 1)); - let imp = harness.tab().imp(); - let (stop, spinner, save) = (imp.stop_button.get(), imp.saving_spinner.get(), imp.save_button.get()); - harness.tab().send(BrowseTabInput::InsertRow); + let stop = harness.tab().imp().pending_bar.imp().stop_button.get(); + harness.tab().send(EditInput::InsertRow); - assert!(!stop.is_visible() && !spinner.is_visible()); - harness.tab().send(BrowseTabInput::SaveStarted); - assert!(stop.is_visible() && spinner.is_visible()); - assert!(!save.is_sensitive(), "Save stayed on while a save ran"); + 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(BrowseTabInput::SaveFailed("rolled back".to_owned())); - assert!(!stop.is_visible() && !spinner.is_visible()); - assert!(save.is_sensitive()); + harness.tab().send(SaveInput::SaveFailed("rolled back".to_owned())); + assert!(!stop.is_visible(), "Stop Saving outlived the save"); } #[gtk4::test] @@ -802,12 +802,12 @@ fn an_edit_held_for_the_running_save_says_so_once() { .map(|model| model.tracker.clone()) .expect("the tab has a model"); let toasts = toasts(harness.tab()); - harness.tab().send(BrowseTabInput::InsertRow); - harness.tab().send(BrowseTabInput::InsertRow); + 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(BrowseTabInput::MarkRowsDeleted(RowDeletion { + harness.tab().send(EditInput::MarkRowsDeleted(RowDeletion { drafts, ..RowDeletion::default() })); @@ -830,7 +830,7 @@ fn browse_tab_and_filter_strip_freed_after_drop() { .model .borrow() .as_ref() - .and_then(|model| model.filter_strip.as_ref().map(|strip| strip.widget.downgrade())) + .map(|model| model.filter_strip.downgrade()) .expect("the filter strip") }; let tab = harness.tab().downgrade(); @@ -851,7 +851,7 @@ fn duplicate_row_copies_pending_edits_of_persisted_row() { let ada = harness.grid_row(0); harness.tab().send(typed(&ada, 1, "Ada Lovelace")); - harness.tab().send(BrowseTabInput::DuplicateRows(vec![ada])); + harness.tab().send(EditInput::DuplicateRows(vec![ada])); let drafts = harness.tracker.drafts(); assert_eq!(drafts.len(), 1, "{drafts:?}"); @@ -870,11 +870,11 @@ 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(BrowseTabInput::InsertRow); + 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(BrowseTabInput::DuplicateRows(vec![draft])); + harness.tab().send(EditInput::DuplicateRows(vec![draft])); let drafts = harness.tracker.drafts(); assert_eq!(drafts.len(), 2, "{drafts:?}"); @@ -892,30 +892,29 @@ fn duplicating_a_selection_is_one_undo_step() { let harness = Harness::open(script, []); wait_until(WAIT, || harness.shown().len() == 2).expect("the first page"); - harness.tab().send(BrowseTabInput::DuplicateRows(vec![ - harness.grid_row(0), - harness.grid_row(1), - ])); + harness + .tab() + .send(EditInput::DuplicateRows(vec![harness.grid_row(0), harness.grid_row(1)])); assert_eq!(draft_rows(&harness.store()), 2); - harness.tab().send(BrowseTabInput::Undo); + 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 copy_row_as_insert_uses_row_below_draft() { +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(BrowseTabInput::InsertRow); + harness.tab().send(EditInput::InsertRow); let below_draft = harness.grid_row(1); let copied = copied_by(harness.tab(), || { harness .tab() - .send(BrowseTabInput::GridCopyRowsAsInsert(vec![below_draft])); + .send(ClipboardInput::GridCopyRowsAsInsert(vec![below_draft])); }); assert_eq!(copied, "INSERT INTO \"users\" (\"id\", \"name\") VALUES (0, 'Ada');"); @@ -948,7 +947,7 @@ fn copy_as_in_clause_writes_the_servers_literals() { }; let copied = copied_by(harness.tab(), || { - harness.tab().send(BrowseTabInput::GridCopyInClause(values)); + harness.tab().send(ClipboardInput::GridCopyInClause(values)); }); assert_eq!( @@ -1063,9 +1062,9 @@ fn copy_as_insert_and_duplicate_contain_no_hidden_columns() { let rows = harness.saved_rows(); let copied = copied_by(harness.tab(), || { - harness.tab().send(BrowseTabInput::GridCopyRowsAsInsert(rows.clone())); + harness.tab().send(ClipboardInput::GridCopyRowsAsInsert(rows.clone())); }); - harness.tab().send(BrowseTabInput::DuplicateRows(rows)); + harness.tab().send(EditInput::DuplicateRows(rows)); assert_eq!(copied.lines().count(), 2, "{copied}"); for hidden in ["16401", "(0,1)", "812", "__tablepro"] { @@ -1089,7 +1088,7 @@ fn copy_as_insert_shares_saved_rows_and_copies_only_drafts() { 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(BrowseTabInput::InsertRow); + harness.tab().send(EditInput::InsertRow); let saved = harness.saved_rows(); let mut rows = vec![harness.grid_row(0)]; rows.extend(saved.iter().cloned()); @@ -1117,7 +1116,7 @@ fn page_export_snapshot_contains_no_hidden_columns() { .tab() .connect_export_results(move |_, offer| sink.borrow_mut().push(offer.origin.shown().clone())); - harness.tab().send(BrowseTabInput::Export(None)); + harness.tab().send(ClipboardInput::Export(None)); let exported = exported.borrow(); let [crate::ui::export::ExportSource::Rows { columns, rows }] = exported.as_slice() else { @@ -1153,7 +1152,7 @@ pub(super) fn full_page(first: i64) -> QueryResult { 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(BrowseTabInput::InsertRow); + 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(); @@ -1161,7 +1160,7 @@ fn page_load_emits_one_items_changed_with_drafts_prepended() { log.borrow_mut().push((position, removed, added)); }); - harness.tab().send(BrowseTabInput::NextPage); + 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"); @@ -1177,7 +1176,7 @@ fn second_page_load_drops_previous_rows() { let _drawn = grid_cell_of(harness.tab(), 0, 1); let first: Vec> = harness.saved_rows().iter().map(ObjectExt::downgrade).collect(); - harness.tab().send(BrowseTabInput::NextPage); + 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"); @@ -1213,7 +1212,7 @@ fn a_page_that_replaces_the_row_being_edited_keeps_the_edit_and_the_focus() { seen.set(seen.get() || focus.is_some_and(|focus| focus.root().is_none())); }); - harness.tab().send(BrowseTabInput::NextPage); + harness.tab().send(PagingInput::NextPage); wait_until(WAIT, || harness.shown() == text(&["Linus"])).expect("the next page"); after_next_frame(&shown.0); @@ -1233,14 +1232,51 @@ fn a_page_that_replaces_the_row_being_edited_keeps_the_edit_and_the_focus() { ); } -pub(super) fn after_next_frame(window: >k4::Window) { - let clock = window.frame_clock().expect("the window's frame clock"); - let painted = std::rc::Rc::new(std::cell::Cell::new(false)); - let flag = painted.clone(); - let handler = clock.connect_after_paint(move |_| flag.set(true)); - clock.request_phase(gtk4::gdk::FrameClockPhase::AFTER_PAINT); - wait_until(WAIT, || painted.get()).expect("the window paints a frame"); - clock.disconnect(handler); +/// 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. @@ -1267,8 +1303,8 @@ fn cell_text(cell: &GridCell) -> String { /// What the grid sends once `text` is typed into `column` of a saved /// row and committed. -fn typed(row: &RowObject, column: usize, text: &str) -> BrowseTabInput { - BrowseTabInput::GridEdit { +fn typed(row: &RowObject, column: usize, text: &str) -> EditInput { + EditInput::GridEdit { row: row.clone(), column, edit: TrackerEdit::Cell { @@ -1409,7 +1445,7 @@ fn refused_edit_restores_cell_display() { let row = cell.row().expect("a bound row"); cell.display().expect("a text cell").set_text(Some("7")); - harness.tab().send(BrowseTabInput::GridEdit { + harness.tab().send(EditInput::GridEdit { row: row.clone(), column: 0, edit: TrackerEdit::Cell { @@ -1436,7 +1472,7 @@ fn undo_under_an_open_editor_leaves_nothing_pending() { crate::ui::grid::begin_edit(&cell); assert!(cell.is_editing()); - harness.tab().send(BrowseTabInput::Undo); + harness.tab().send(EditInput::Undo); cell.cancel_edit(); assert_eq!( @@ -1458,9 +1494,7 @@ fn error_flash_sets_and_clears_on_the_failed_row() { harness .tab() - .send(BrowseTabInput::FlashErrorRow(StatementSource::Update { - row_key: key(1), - })); + .send(SaveInput::FlashErrorRow(StatementSource::Update { row_key: key(1) })); assert!( has_class(&cell, "tp-row-leftmost-error-flash"), @@ -1488,9 +1522,7 @@ fn a_second_failed_row_takes_the_mark_off_the_first() { let fail = |id| { harness .tab() - .send(BrowseTabInput::FlashErrorRow(StatementSource::Update { - row_key: key(id), - })); + .send(SaveInput::FlashErrorRow(StatementSource::Update { row_key: key(id) })); }; fail(1); @@ -1539,10 +1571,10 @@ fn copy_row_as_insert_on_a_draft_leaves_what_the_server_fills_out() { wait_until(WAIT, || harness.shown().len() == 1).expect("the first page"); let toasts = toasts(harness.tab()); - harness.tab().send(BrowseTabInput::InsertRow); + 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(BrowseTabInput::GridEdit { + harness.tab().send(EditInput::GridEdit { row: draft.clone(), column: 1, edit: TrackerEdit::DraftCell { @@ -1552,7 +1584,7 @@ fn copy_row_as_insert_on_a_draft_leaves_what_the_server_fills_out() { }, }); let copied = copied_by(harness.tab(), || { - harness.tab().send(BrowseTabInput::GridCopyRowsAsInsert(vec![draft])); + harness.tab().send(ClipboardInput::GridCopyRowsAsInsert(vec![draft])); }); assert_eq!( @@ -1641,7 +1673,7 @@ fn refresh_after_a_column_is_dropped_keeps_an_edit_on_its_column() { .expect("the first grid"); edit_first_row(&harness, 2, "Grace"); - harness.tab().send(BrowseTabInput::Refresh); + harness.tab().send(PagingInput::Refresh); wait_until(WAIT, || headers_are(harness.tab(), &["id", "name", "email"])).expect("the grid under the new columns"); assert_eq!( @@ -1662,7 +1694,7 @@ fn a_structure_save_that_drops_the_edited_column_refuses_the_save() { harness .session .invalidate_table(&tablepro_core::TableRef::unqualified("users")); - harness.tab().send(BrowseTabInput::SchemaChanged); + 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 @@ -1694,7 +1726,7 @@ fn a_grid_built_for_columns_the_table_lost_takes_no_edits() { harness .session .invalidate_table(&tablepro_core::TableRef::unqualified("users")); - harness.tab().send(BrowseTabInput::SchemaChanged); + 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")); @@ -1730,7 +1762,7 @@ fn draft_rows(store: >k4::gio::ListStore) -> usize { fn pending_bar_shown(tab: &BrowseTab) -> bool { use gtk4::subclass::prelude::ObjectSubclassIsExt; - tab.imp().pending_revealer.reveals_child() + tab.imp().pending_bar.imp().revealer.reveals_child() } #[gtk4::test] @@ -1739,7 +1771,7 @@ fn deleting_a_draft_removes_its_row() { 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(BrowseTabInput::InsertRow); + harness.tab().send(EditInput::InsertRow); let store = harness.store(); assert_eq!(store.n_items(), 3); harness.selection().select_item(0, true); @@ -1873,7 +1905,7 @@ fn browse_actions_follow_the_tab() { "the selection was not cleared" ); - harness.tab().send(BrowseTabInput::InsertRow); + harness.tab().send(EditInput::InsertRow); assert!(action_enabled(harness.tab(), "save")); assert!(action_enabled(harness.tab(), "undo")); assert!(!action_enabled(harness.tab(), "redo")); @@ -1904,18 +1936,18 @@ mod real_keys { 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(BrowseTabInput::DeleteRows(vec![harness.grid_row(0)])); + 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(); - let strip = model.as_ref().and_then(|model| model.filter_strip.as_ref()); - strip.expect("the filter strip").widget.clone() + model + .as_ref() + .map(|model| model.filter_strip.clone()) + .expect("the filter strip") }; - harness.tab().send(BrowseTabInput::ToggleFilterStrip); + harness.tab().send(PagingInput::ToggleFilterStrip); let mut field = None; wait_until(Duration::from_secs(2), || { field = gtk4::prelude::GtkWindowExt::focus(&shown.0) @@ -1946,10 +1978,7 @@ fn refresh_asks_the_window_and_filter_opens_the_strip() { let strip_open = || { use gtk4::subclass::prelude::ObjectSubclassIsExt; let model = harness.tab().imp().model.borrow(); - model - .as_ref() - .and_then(|model| model.filter_strip.as_ref()) - .is_some_and(crate::ui::filter_strip::FilterStrip::is_revealed) + model.as_ref().is_some_and(|model| model.filter_strip.revealed()) }; harness @@ -1980,10 +2009,8 @@ fn a_read_only_tab_refuses_insert_row() { "Ctrl+N added a row to a read-only table" ); - harness.tab().send(BrowseTabInput::InsertRow); - harness - .tab() - .send(BrowseTabInput::DuplicateRows(vec![harness.grid_row(0)])); + 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"); } @@ -2000,9 +2027,9 @@ fn browse_buttons_announce_their_keys() { ("insert row", imp.insert_button.get()), ("delete rows", imp.delete_button.get()), ("filter", imp.filter_button.get()), - ("previous page", imp.prev_button.get()), - ("next page", imp.next_button.get()), - ("save", imp.save_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), @@ -2022,7 +2049,7 @@ fn a_new_page_turns_off_the_selection_keys() { assert!(action_enabled(harness.tab(), "copy-rows")); assert!(action_enabled(harness.tab(), "clear-selection")); - harness.tab().send(BrowseTabInput::NextPage); + harness.tab().send(PagingInput::NextPage); wait_until(WAIT, || harness.shown() == text(&["Linus", "Ken"])).expect("the next page"); assert!( @@ -2066,7 +2093,7 @@ fn deleting_mixed_selection_removes_drafts_and_marks_persisted() { 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(BrowseTabInput::InsertRow); + harness.tab().send(EditInput::InsertRow); harness.selection().select_range(0, 2, true); delete_rows(harness.tab()); @@ -2147,11 +2174,11 @@ fn undo_insert_removes_draft_row() { 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(BrowseTabInput::InsertRow); + harness.tab().send(EditInput::InsertRow); let store = harness.store(); assert_eq!(store.n_items(), 3); - harness.tab().send(BrowseTabInput::Undo); + harness.tab().send(EditInput::Undo); assert_eq!(store.n_items(), 2, "the undone draft stayed in the grid"); assert_eq!(draft_rows(&store), 0); @@ -2164,10 +2191,10 @@ fn redo_insert_restores_draft_row_once() { 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(BrowseTabInput::InsertRow); - harness.tab().send(BrowseTabInput::Undo); + harness.tab().send(EditInput::InsertRow); + harness.tab().send(EditInput::Undo); - harness.tab().send(BrowseTabInput::Redo); + harness.tab().send(EditInput::Redo); let store = harness.store(); assert_eq!(store.n_items(), 3); @@ -2279,7 +2306,7 @@ fn calendar_of(cell: &GridCell) -> Option { } #[gtk4::test] -fn unchanged_40kb_multiline_text_records_no_edit() { +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); @@ -2327,7 +2354,7 @@ fn bigint_edit_is_exact() { clippy::approx_constant, reason = "a double with every digit it can hold, which is the point, not pi" )] -fn pressing_enter_on_float_keeps_full_precision() { +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); @@ -2377,7 +2404,7 @@ 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(BrowseTabInput::InsertRow); + harness.tab().send(EditInput::InsertRow); let mut cell = None; wait_until(WAIT, || { cell = crate::test_support::descendants(harness.tab()) @@ -2399,7 +2426,7 @@ fn draft_not_null_with_default_placeholder_is_default() { } #[gtk4::test] -fn pasted_newlines_in_text_cell_are_kept() { +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); @@ -2684,7 +2711,7 @@ fn change_schema(harness: &Harness, done: impl Fn(&[ColumnInfo]) -> bool) { harness .session .invalidate_table(&tablepro_core::TableRef::unqualified("users")); - harness.tab().send(BrowseTabInput::SchemaChanged); + harness.tab().send(DataInput::SchemaChanged); wait_until(WAIT, || done(&harness.tab().columns())).expect("the new columns"); } @@ -2824,7 +2851,7 @@ fn a_second_delete_replaces_the_waiting_confirmation() { drain_main_context(); let (second, dialog) = confirming(harness.tab()).expect("the closed confirmation cancelled the new one"); assert_ne!(first, second); - harness.tab().send(BrowseTabInput::BulkDeleteAnswered { + harness.tab().send(EditInput::BulkDeleteAnswered { ticket: first, confirmed: true, }); @@ -2845,7 +2872,7 @@ 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(BrowseTabInput::InsertRow); + harness.tab().send(EditInput::InsertRow); let mut editing = None; wait_until(WAIT, || { @@ -2878,7 +2905,11 @@ fn icon_buttons_are_labelled_without_accelerator_hints() { crate::test_support::tooltips_with_keys(harness.tab()), Vec::::new() ); - for button in [imp.insert_button.get(), imp.next_button.get(), imp.save_button.get()] { + 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", @@ -2886,3 +2917,26 @@ fn icon_buttons_are_labelled_without_accelerator_hints() { ); } } + +#[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"); +} diff --git a/linux/crates/app/src/ui/browse_tab/total_tests.rs b/linux/crates/app/src/ui/browse_tab/total_tests.rs index e807dfd869..9a7a9cae20 100644 --- a/linux/crates/app/src/ui/browse_tab/total_tests.rs +++ b/linux/crates/app/src/ui/browse_tab/total_tests.rs @@ -7,24 +7,24 @@ use tablepro_core::{DriverError, TimeoutPhase}; use tablepro_session::fakes::FakeEstablish; use tablepro_test_support::{CallCounters, ConnectionScript, FakeReply}; -use super::BrowseTabInput; use super::tests::{ Harness, LogLines, WAIT, estimate, full_page, not_null, not_null_name, paging, rows, rows_from, serving_table, text, toasts, }; +use super::{PagingInput, SaveInput}; use crate::services::change_origins::ChangeOrigins; use crate::test_support::{drain_main_context, wait_until}; fn paginator(harness: &Harness) -> String { - harness.tab().imp().paginator_label.label().to_string() + harness.tab().imp().pager_bar.imp().paginator_label.label().to_string() } fn count_button(harness: &Harness) -> gtk4::Button { - harness.tab().imp().count_button.get() + harness.tab().imp().pager_bar.imp().count_button.get() } fn count_label(harness: &Harness) -> String { - harness.tab().imp().count_label.label().to_string() + harness.tab().imp().pager_bar.imp().count_label.label().to_string() } fn count_calls(harness: &Harness) -> usize { @@ -55,7 +55,7 @@ fn large_estimate_offers_count_without_counting() { 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().last_button.is_sensitive()); + assert!(!harness.tab().imp().pager_bar.imp().last_button.is_sensitive()); } #[gtk4::test] @@ -129,7 +129,7 @@ fn count_rows_button_counts_and_shows_progress() { assert_eq!(count_label(&harness), "Counting…"); assert!(!count_button(&harness).is_sensitive()); - assert!(harness.tab().imp().count_spinner.is_visible()); + 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()); } @@ -147,7 +147,7 @@ fn count_is_cancelled_when_filter_changes() { wait_until(WAIT, || count_calls(&harness) == 1).expect("the count was asked for"); let counters = harness.connection.counters(); - harness.tab().send(BrowseTabInput::FilterApplied(not_null_name())); + 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"); @@ -169,7 +169,7 @@ fn stale_count_reply_does_not_overwrite_newer_total() { let harness = Harness::open(script, []); wait_until(WAIT, || count_calls(&harness) == 1).expect("the first count was asked for"); - harness.tab().send(BrowseTabInput::FilterApplied(not_null_name())); + 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(); @@ -216,7 +216,7 @@ fn save_adjusts_unfiltered_total_without_recount() { harness .tab() - .send(BrowseTabInput::SaveCommitted(ChangeOrigins { inserts: 2, deletes: 1 })); + .send(SaveInput::SaveCommitted(ChangeOrigins { inserts: 2, deletes: 1 })); wait_until(WAIT, || { CallCounters::get(&harness.connection.counters().query_bound) == queries + 1 }) @@ -234,7 +234,7 @@ fn save_on_filtered_view_demotes_total_to_about() { harness .tab() - .send(BrowseTabInput::SaveCommitted(ChangeOrigins { inserts: 1, deletes: 0 })); + .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"); @@ -266,13 +266,13 @@ fn last_page_waits_for_an_exact_total() { 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().last_button.get(); + 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(BrowseTabInput::LastPage); + harness.tab().send(PagingInput::LastPage); drain_main_context(); assert_eq!(CallCounters::get(&harness.connection.counters().query_bound), 1); @@ -308,7 +308,7 @@ fn a_failed_count_does_not_run_again_after_a_reconnect() { 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(BrowseTabInput::FilterApplied(not_null_name())); + harness.tab().send(PagingInput::FilterApplied(not_null_name())); wait_until(WAIT, || count_label(&harness) == "Count Rows").expect("the filtered total"); harness.connection.mark_lost(); @@ -334,13 +334,13 @@ fn saving_away_the_last_page_moves_to_the_new_last_page() { 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(BrowseTabInput::NextPage); + 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(BrowseTabInput::SaveCommitted(ChangeOrigins { inserts: 0, deletes: 1 })); + .send(SaveInput::SaveCommitted(ChangeOrigins { inserts: 0, deletes: 1 })); wait_until(WAIT, || { CallCounters::get(&harness.connection.counters().query_bound) == queries + 1 }) @@ -376,7 +376,7 @@ fn an_estimate_that_lands_during_a_count_keeps_it_counting() { assert_eq!(count_label(&harness), "Counting…"); assert!(!count_button(&harness).is_sensitive()); - assert!(harness.tab().imp().count_spinner.is_visible()); + 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); } @@ -386,7 +386,7 @@ 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 toasts = toasts(harness.tab()); - harness.tab().send(BrowseTabInput::FilterApplied(not_null("nickname"))); + harness.tab().send(PagingInput::FilterApplied(not_null("nickname"))); wait_until(WAIT, || toasts.borrow().len() == 1).expect("the filter that does not fit"); count_button(&harness).emit_clicked(); 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..591b0794c8 --- /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); + adw::Window::builder().content(&group).build().present(); + 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 index c3b06887e5..0ec09a4750 100644 --- a/linux/crates/app/src/ui/connect_dialog/connect_form.rs +++ b/linux/crates/app/src/ui/connect_dialog/connect_form.rs @@ -1,17 +1,19 @@ use std::path::{Path, PathBuf}; -use secrecy::SecretString; +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, SavedSshConfig}; +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}; @@ -98,6 +100,9 @@ pub fn shows_server_name(caps: &TlsCapabilities, route: TransportClass) -> bool /// 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, @@ -126,6 +131,7 @@ pub struct ValidatedForm { 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), @@ -142,25 +148,30 @@ impl ConnectForm { } } - /// The form a saved connection fills, for editing it after a failed - /// connect. Secrets are not read: they live in the keyring. + /// 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(); - return form; - } - 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); + } 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 } @@ -294,7 +305,16 @@ impl ConnectForm { }) } - /// How the entry is named in the connection list. + /// 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 @@ -349,7 +369,7 @@ impl ConnectForm { }; SavedConnection { id, - name: self.label(caps), + name: self.display_name(caps), driver_id: self.driver_id.clone(), host, port, @@ -365,6 +385,27 @@ impl ConnectForm { } } + /// The entry a connect from the New Connection dialog writes over + /// `existing`, the entry it describes again. 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. @@ -403,6 +444,93 @@ impl ConnectForm { } } + /// 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!( @@ -415,6 +543,26 @@ impl ConnectForm { } } +/// 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 + } +} + /// The saved entry the form describes again, so connecting twice to /// the same database updates one entry instead of adding a second. pub fn match_saved_connection( @@ -829,4 +977,221 @@ mod tests { }; assert_eq!(match_saved_connection(entries, &finance, false), None); } + + 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_connect_from_new_connection_keeps_the_name_of_a_renamed_match() { + let form = filled("postgres", postgres()); + let valid = form.validate(postgres()).expect("valid"); + let renamed = SavedConnection { + name: "Shop".to_owned(), + ..saved_from(&form, postgres()) + }; + let candidate = form.saved_connection(postgres(), &valid, Uuid::new_v4(), None); + assert_eq!( + match_saved_connection(std::slice::from_ref(&renamed), &candidate, false), + Some(renamed.id) + ); + + 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/imp.rs b/linux/crates/app/src/ui/connect_dialog/imp.rs index 5b7b339ec7..2dd081ef6b 100644 --- a/linux/crates/app/src/ui/connect_dialog/imp.rs +++ b/linux/crates/app/src/ui/connect_dialog/imp.rs @@ -10,6 +10,7 @@ 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; @@ -27,6 +28,8 @@ pub struct ConnectDialog { #[template_child] pub toast_overlay: TemplateChild, #[template_child] + pub name_row: TemplateChild, + #[template_child] pub driver_row: TemplateChild, #[template_child] pub driver_names: TemplateChild, @@ -68,6 +71,10 @@ pub struct ConnectDialog { /// 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, pub pem: PemValidation, /// The TLS settings the last file check read, so a keystroke in an /// unrelated row does not read the files again. @@ -106,6 +113,8 @@ impl ObjectImpl for ConnectDialog { 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(), ] }) } diff --git a/linux/crates/app/src/ui/connect_dialog/mod.rs b/linux/crates/app/src/ui/connect_dialog/mod.rs index 6f26def22f..3ea7fd30ab 100644 --- a/linux/crates/app/src/ui/connect_dialog/mod.rs +++ b/linux/crates/app/src/ui/connect_dialog/mod.rs @@ -2,6 +2,7 @@ //! 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; @@ -15,6 +16,7 @@ mod sqlite_file_row; mod tests; mod tls_section; +use std::rc::Rc; use std::sync::Arc; use gtk4::glib; @@ -28,19 +30,23 @@ use tablepro_core::{ AuthMode, ConfigError, DatabaseDriver, DriverCapabilities, DriverRegistry, TlsConfig, TransportClass, WorkspaceSettings, }; -use tablepro_session::runtime::Tasks; -use tablepro_session::{ConnectError, ConnectRequest, ConnectionMetadata, Connector, PendingSave}; -use tablepro_storage::SavedConnection; +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; pub use self::connect_form::ConnectForm; -use self::connect_form::match_saved_connection; +use self::connect_form::{ValidatedForm, match_saved_connection}; 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; @@ -61,7 +67,15 @@ pub struct ConnectDialogInit { 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. @@ -77,6 +91,16 @@ pub(crate) struct DialogServices { 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)] @@ -114,24 +138,35 @@ impl ConnectDialog { 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); - let wanted = init - .prefill - .as_ref() - .map_or(init.remembered_driver.as_str(), |prefill| { - prefill.connection.driver_id.as_str() - }); + // 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(init.prefill.as_ref().map(|prefill| prefill.connection.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 (init.prefill, dialog.entry_at(index)) { + 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 { @@ -159,6 +194,36 @@ impl ConnectDialog { ) } + /// 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) { @@ -220,7 +285,7 @@ impl ConnectDialog { dialog.refresh(); } }); - let entries: [&adw::EntryRow; 3] = [&imp.host_row, &imp.database_row, &imp.username_row]; + 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 |_| { @@ -282,6 +347,7 @@ impl ConnectDialog { .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, @@ -328,6 +394,9 @@ impl ConnectDialog { 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); @@ -370,10 +439,11 @@ impl ConnectDialog { imp.read_only_row .set_subtitle(&read_only_row::read_only_subtitle(caps.read_only)); self.show_auth_rows(); - self.set_title(&crate::i18n::gettext_f( - "Connect to {name}", - &[("name", &entry.display_name)], - )); + 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) { @@ -479,27 +549,33 @@ impl ConnectDialog { (PemState::Pending, None) => Some(crate::i18n::gettext("Checking the certificate files…")), (PemState::Valid, None) => None, }; - imp.connect_button.set_sensitive(blocker.is_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()); } - /// Everything a Connect or a Test sends, or the reason there is - /// nothing to send yet. - fn build_submission(&self) -> Result { + /// 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 caps = entry.capabilities(); let form = self.form(); let valid = form - .validate(caps) + .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()) @@ -514,17 +590,8 @@ impl ConnectDialog { .and_then(|id| saved_connections.iter().find(|saved| saved.id == id)) }); let id = existing.map_or(candidate.id, |saved| saved.id); - let connection = form.saved_connection(caps, &valid, id, existing); - let request = ConnectRequest { - metadata: ConnectionMetadata { - 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), - }; + 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), @@ -534,9 +601,31 @@ impl ConnectDialog { 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. + /// 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(); @@ -547,13 +636,78 @@ impl ConnectDialog { } } + /// 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()]); + 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 submission = match self.build_submission() { - Ok(submission) => submission, + let (request, kept) = match self.test_request() { + Ok(test) => test, Err(message) => { self.show_toast(&message, None); return; @@ -568,11 +722,11 @@ impl ConnectDialog { 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"))); + .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 = submission.request; + let mut request = request; request.metadata.id = Uuid::new_v4(); let connector = services.connector.clone(); let interaction = services.interaction.clone(); @@ -580,6 +734,17 @@ impl ConnectDialog { // 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"); @@ -614,9 +779,8 @@ impl ConnectDialog { 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, then disconnect", - ))); + imp.test_button + .set_tooltip_text(Some(&crate::i18n::gettext("Connect Once to Check the Settings"))); self.refresh(); } @@ -637,6 +801,25 @@ impl ConnectDialog { } } +/// 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 { diff --git a/linux/crates/app/src/ui/connect_dialog/tests.rs b/linux/crates/app/src/ui/connect_dialog/tests.rs index 5bd689d43c..94207c2904 100644 --- a/linux/crates/app/src/ui/connect_dialog/tests.rs +++ b/linux/crates/app/src/ui/connect_dialog/tests.rs @@ -2,7 +2,7 @@ use std::sync::Arc; use std::time::Duration; use gtk4::{AccessibleProperty, AccessibleState}; -use tablepro_core::credentials::{CredentialInteraction, SecretKind}; +use tablepro_core::credentials::{CredentialInteraction, SecretKind, SecretVault}; use tablepro_core::{DriverError, FileOpenMode, TlsFailure, TlsMode}; use tablepro_session::RequestTarget; use tablepro_session::fakes::{FakeConnector, FakeEstablish}; @@ -17,7 +17,10 @@ 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 { @@ -26,6 +29,7 @@ struct Options { environment: SshEnvironment, saved: Vec, prefill: Option, + mode: ConnectDialogMode, } impl Default for Options { @@ -36,6 +40,7 @@ impl Default for Options { environment: SshEnvironment::default(), saved: Vec::new(), prefill: None, + mode: ConnectDialogMode::Create, } } } @@ -55,6 +60,16 @@ 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()), @@ -64,11 +79,22 @@ fn open(options: Options) -> Harness { 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, } } @@ -266,6 +292,41 @@ fn encryption_combo_lists_only_capability_modes() { } } +/// 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 = adw::Window::builder().default_width(1000).default_height(700).build(); + window.present(); + harness.dialog.present(Some(&window)); + 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(); @@ -458,6 +519,27 @@ fn a_reopened_entry_keeps_its_id_after_the_host_changes() { 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_keeps_a_renamed_entry_s_name() { + let saved = shop(); + let harness = open(Options { + saved: vec![saved.clone()], + ..Options::default() + }); + fill_network(&harness.dialog); + assert_eq!(harness.dialog.imp().name_row.text(), ""); + let log = SignalLog::connect(&harness.dialog, "submitted", |values| { + values[1].get::().expect("a submission") + }); + + harness.dialog.imp().connect_button.emit_clicked(); + + let submitted = log.take().remove(0).pending_save; + assert_eq!(submitted.connection.id, saved.id); + assert_eq!(submitted.connection.name, "Shop", "the connect undid the rename"); } #[gtk4::test] @@ -599,6 +681,7 @@ fn every_row_has_an_accessible_name() { 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(), @@ -634,3 +717,152 @@ fn every_row_has_an_accessible_name() { ); } } + +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 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") + }); + imp.name_row.set_text("Shop (EU)"); + imp.host_row.set_text("db2.example.com"); + + imp.connect_button.emit_clicked(); + wait_until(WAIT, || log.count() == 1).expect("the save"); + + 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 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 index 411d430c43..77956dc765 100644 --- a/linux/crates/app/src/ui/connect_dialog/tls_section.rs +++ b/linux/crates/app/src/ui/connect_dialog/tls_section.rs @@ -100,6 +100,7 @@ mod imp { ] { 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 |_| { diff --git a/linux/crates/app/src/ui/editor/imp.rs b/linux/crates/app/src/ui/editor/imp.rs index 2847301e02..fcdf1d7782 100644 --- a/linux/crates/app/src/ui/editor/imp.rs +++ b/linux/crates/app/src/ui/editor/imp.rs @@ -83,6 +83,9 @@ impl ObjectImpl for SqlEditor { 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(), diff --git a/linux/crates/app/src/ui/editor/mod.rs b/linux/crates/app/src/ui/editor/mod.rs index e6ca1f9e55..b6fdc59a9a 100644 --- a/linux/crates/app/src/ui/editor/mod.rs +++ b/linux/crates/app/src/ui/editor/mod.rs @@ -275,6 +275,19 @@ impl SqlEditor { ) } + /// 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", @@ -474,7 +487,7 @@ impl SqlEditor { imp.run_button.set_sensitive(true); imp.stop_button.set_visible(false); imp.results.set_running(false); - let (summary, label, dirty, first, entry) = { + let (summary, label, dirty, first, entry, outcome) = { let log = imp.log.borrow(); let Some(log) = log.as_ref() else { return; @@ -485,6 +498,7 @@ impl SqlEditor { 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); @@ -496,6 +510,7 @@ impl SqlEditor { 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]); } @@ -787,10 +802,10 @@ fn show_cursor_position(label: >k4::Label, buffer: &sourceview5::Buffer) { 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"))); + 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"))); + button.set_tooltip_text(Some(&crate::i18n::gettext("Run the Script"))); } } @@ -901,5 +916,5 @@ pub fn derive_tab_label(query: &str) -> String { } return cleaned; } - crate::i18n::gettext("Empty query") + crate::i18n::gettext("Empty Query") } diff --git a/linux/crates/app/src/ui/editor/result_widgets.rs b/linux/crates/app/src/ui/editor/result_widgets.rs index cfcfe63889..3f6533d7fd 100644 --- a/linux/crates/app/src/ui/editor/result_widgets.rs +++ b/linux/crates/app/src/ui/editor/result_widgets.rs @@ -32,8 +32,8 @@ where EntryKind::Command { rows_affected } => { let title = match rows_affected { Some(rows) => crate::i18n::ngettext_f( - "{n} row affected", - "{n} rows affected", + "{n} Row Affected", + "{n} Rows Affected", u32::try_from(*rows).unwrap_or(u32::MAX), &[("n", &rows.to_string())], ), diff --git a/linux/crates/app/src/ui/editor/results_pane.rs b/linux/crates/app/src/ui/editor/results_pane.rs index 3815d4f72b..4124bda55f 100644 --- a/linux/crates/app/src/ui/editor/results_pane.rs +++ b/linux/crates/app/src/ui/editor/results_pane.rs @@ -267,20 +267,20 @@ pub fn entry_title(index: usize, entry: &RunLogEntry) -> (String, &'static str) rows_affected: Some(rows), } => ( crate::i18n::ngettext_f( - "{n} row affected", - "{n} rows affected", + "{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::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), + EntryKind::TimedOut { .. } => (crate::i18n::gettext("Timed Out"), icons::DIALOG_WARNING), + EntryKind::NotRun => (crate::i18n::gettext("Not Run"), icons::STATEMENT_PENDING), } } diff --git a/linux/crates/app/src/ui/editor/run_history.rs b/linux/crates/app/src/ui/editor/run_history.rs index 9d6a60aa6d..b0a5d73adf 100644 --- a/linux/crates/app/src/ui/editor/run_history.rs +++ b/linux/crates/app/src/ui/editor/run_history.rs @@ -97,5 +97,12 @@ mod tests { 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 index 02dcb41beb..861aeeb35d 100644 --- a/linux/crates/app/src/ui/editor/run_log.rs +++ b/linux/crates/app/src/ui/editor/run_log.rs @@ -6,6 +6,7 @@ use tablepro_core::{ 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)] @@ -35,7 +36,8 @@ pub enum EntryKind { TimedOut { server_cancelled: bool, }, - /// Queued behind a failure, so never sent. + /// Never sent: a statement before it failed, or the run was + /// stopped first. NotRun, } @@ -349,10 +351,13 @@ impl RunLog { }) } + /// 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 { - self.entries - .iter() - .any(|entry| matches!(entry.kind, EntryKind::Cancelled)) + 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 { @@ -361,6 +366,18 @@ impl RunLog { .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 @@ -556,6 +573,55 @@ mod tests { ); } + #[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); diff --git a/linux/crates/app/src/ui/error_text/mod.rs b/linux/crates/app/src/ui/error_text/mod.rs index 1df76bf2c6..5fa70446de 100644 --- a/linux/crates/app/src/ui/error_text/mod.rs +++ b/linux/crates/app/src/ui/error_text/mod.rs @@ -9,6 +9,7 @@ mod ddl; mod driver; mod edit; mod operation; +mod saved_connection; mod script; mod secret; mod ssh; @@ -21,5 +22,6 @@ 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/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/export/export_job.rs b/linux/crates/app/src/ui/export/export_job.rs index f98cf1f165..dc6e8a64c7 100644 --- a/linux/crates/app/src/ui/export/export_job.rs +++ b/linux/crates/app/src/ui/export/export_job.rs @@ -1,4 +1,5 @@ use gtk4::gio; +use uuid::Uuid; use super::{ExportFormat, ExportSource}; @@ -9,6 +10,8 @@ pub(crate) struct ExportJob { 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. diff --git a/linux/crates/app/src/ui/export/export_ledger.rs b/linux/crates/app/src/ui/export/export_ledger.rs index 7e1fa17487..8840c8c9bc 100644 --- a/linux/crates/app/src/ui/export/export_ledger.rs +++ b/linux/crates/app/src/ui/export/export_ledger.rs @@ -13,6 +13,7 @@ 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::{ @@ -21,12 +22,14 @@ use super::{ }; use crate::i18n::{gettext, ngettext_f}; use crate::ui::format::plural_count; -use crate::workspace::{CloseBlockPrompt, CloseBlocker, InhibitReason, WindowInhibitor}; +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 @@ -40,6 +43,7 @@ pub(crate) struct ExportLedger { /// The running jobs, as the operations popover lists them. jobs: gio::ListStore, state: RefCell, + ended: RefCell>, } #[derive(Default)] @@ -90,9 +94,16 @@ impl ExportLedger { 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() } @@ -127,9 +138,13 @@ impl ExportLedger { 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 }) @@ -195,6 +210,13 @@ impl ExportLedger { } } + 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); diff --git a/linux/crates/app/src/ui/export/export_ledger/tests.rs b/linux/crates/app/src/ui/export/export_ledger/tests.rs index 27b7d6b716..f84d8bc725 100644 --- a/linux/crates/app/src/ui/export/export_ledger/tests.rs +++ b/linux/crates/app/src/ui/export/export_ledger/tests.rs @@ -21,7 +21,7 @@ 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, answer, visible_alert}; +use crate::workspace::{InhibitTarget, WorkOutcome, answer, visible_alert}; const WAIT: Duration = Duration::from_secs(20); @@ -144,6 +144,7 @@ fn job(path: &Path, count: usize) -> ExportJob { format: ExportFormat::Rows(RowFormat::Csv(CsvOptions::default())), source: ExportSource::Rows { columns, rows }, expected_rows: Some(ExpectedRows::Exact(count as u64)), + tab: None, } } @@ -218,6 +219,7 @@ fn read_job(path: &Path, session: &FakeSession, query: ExportQuery) -> ExportJob query, }, expected_rows: None, + tab: None, } } @@ -580,6 +582,59 @@ fn close_blocker_reports_running_exports_and_stop_waits_for_idle() { 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(); diff --git a/linux/crates/app/src/ui/export/export_outcome.rs b/linux/crates/app/src/ui/export/export_outcome.rs index 08116a3531..996c9c89b2 100644 --- a/linux/crates/app/src/ui/export/export_outcome.rs +++ b/linux/crates/app/src/ui/export/export_outcome.rs @@ -4,6 +4,7 @@ use tablepro_session::runtime::TaskFailure; use thiserror::Error; use super::{AbortOutcome, ExportStreamFailure, OpenError}; +use crate::workspace::WorkOutcome; /// How an export ended. #[derive(Debug)] @@ -20,6 +21,16 @@ pub(crate) enum ExportOutcome { }, } +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)] 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 index 87266aeda8..7d88ebc38c 100644 --- a/linux/crates/app/src/ui/export/replace_file_writer/tests.rs +++ b/linux/crates/app/src/ui/export/replace_file_writer/tests.rs @@ -257,8 +257,13 @@ fn a_dropped_writer_frees_its_file_once_cleaned_up() { let path = dir.path().join("export.csv"); drop(write(open(&path), &["partial"])); - wait_until(WAIT, || entries(dir.path()).is_empty()).expect("the abandoned file to go"); - crate::test_support::drain_main_context(); + // 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"); diff --git a/linux/crates/app/src/ui/export_dialog.rs b/linux/crates/app/src/ui/export_dialog.rs index 02ad86b11b..256958ccd9 100644 --- a/linux/crates/app/src/ui/export_dialog.rs +++ b/linux/crates/app/src/ui/export_dialog.rs @@ -9,6 +9,7 @@ use relm4::{adw, gtk}; use tablepro_core::dialect::SqlDialect; use tablepro_core::export::{CsvDecimal, CsvDelimiter, CsvLineBreak, CsvOptions, CsvQuote, XLSX_MAX_DATA_ROWS}; use tablepro_session::Session; +use uuid::Uuid; use super::export::{ DocumentFormat, ExpectedRows, ExportFormat, ExportJob, ExportLedger, ExportOffer, ExportOrigin, ExportSource, @@ -142,6 +143,93 @@ struct CsvRows { } 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); @@ -186,10 +274,12 @@ fn switch_row(title: &str, subtitle: Option<&str>) -> adw::SwitchRow { } fn combo_row(title: &str, choices: &[&str]) -> adw::ComboRow { - adw::ComboRow::builder() + let row = adw::ComboRow::builder() .title(title) .model(>k::StringList::new(choices)) - .build() + .build(); + super::combo_value::show_whole_value(&row); + row } /// What the dialog exports, and where it hands the job. @@ -199,6 +289,8 @@ pub(crate) struct ExportDialogInit<'a> { 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 @@ -245,6 +337,7 @@ pub(crate) fn present(init: ExportDialogInit<'_>) { offer, dialect, settings, + tab, } = init; let ExportOffer { origin, @@ -267,103 +360,62 @@ pub(crate) fn present(init: ExportDialogInit<'_>) { format_group.add(&format_row); page.add(&format_group); - let csv_group = adw::PreferencesGroup::builder() - .title(crate::i18n::gettext("CSV options")) - .build(); - let rows = Rc::new(CsvRows { - 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 (,)")], - ), - }); + let rows = Rc::new(CsvRows::new()); rows.show(&settings.csv_options()); - for row in [ - &rows.null_to_empty, - &rows.line_break_to_space, - &rows.header_row, - &rows.sanitize_formulas, - ] { - csv_group.add(row); - } - for row in [&rows.delimiter, &rows.quote, &rows.line_break, &rows.decimal] { - csv_group.add(row); - } + 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); + }); - // GSettings is the single copy, so two open dialogs converge instead - // of overwriting each other. - let persist = { - let rows = rows.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 [ - &rows.null_to_empty, - &rows.line_break_to_space, - &rows.header_row, - &rows.sanitize_formulas, - ] { - let persist = persist.clone(); - row.connect_active_notify(move |_| persist()); - } - for row in [&rows.delimiter, &rows.quote, &rows.line_break, &rows.decimal] { - let persist = persist.clone(); - row.connect_selected_notify(move |_| persist()); - } + 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 csv_group_for_format = csv_group.clone(); - format_row.connect_selected_notify(move |row| { - csv_group_for_format.set_visible(pick(formats, row.selected()) == Format::Csv); + 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)); }); - let reset_button = gtk::Button::builder() + 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(); - reset_button.add_css_class("flat"); - let rows_for_reset = rows.clone(); - let settings_for_reset = settings.clone(); - reset_button.connect_clicked(move |_| { - settings_for_reset.reset_csv_options(); - rows_for_reset.show(&settings_for_reset.csv_options()); + 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 +} - let export_button = gtk::Button::builder().label(crate::i18n::gettext("Export…")).build(); - export_button.add_css_class("suggested-action"); - +fn footer(reset_button: >k::Button, export_button: >k::Button) -> gtk::Box { let footer = gtk::Box::builder() .orientation(gtk::Orientation::Horizontal) .margin_top(6) @@ -371,60 +423,62 @@ pub(crate) fn present(init: ExportDialogInit<'_>) { .margin_start(12) .margin_end(12) .build(); - footer.append(&reset_button); + footer.append(reset_button); footer.append(>k::Box::builder().hexpand(true).build()); - footer.append(&export_button); - - let expected = scope.choices.iter().map(|choice| choice.expected).collect(); - refuse_what_cannot_fit(&format_row, formats, &scope.row, expected, &export_button); + 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); + 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.set_default_widget(Some(export_button)); + dialog +} - // One dialog, one export: the rows go to the job rather than being - // copied for each click. - let unexported = Rc::new(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 format = pick(formats, format_row.selected()); - let Some(job_format) = format.job_format(rows.read(), &name, target.as_ref()) else { - return; - }; - let Some(choice) = scope.selected().cloned() else { - return; - }; +/// 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; + return None; } - let Some(Unexported { origin, session }) = unexported.borrow_mut().take() else { - return; - }; - let Some(source) = origin.into_source(choice.source, session) else { - return; - }; - dialog_for_export.close(); - let chosen = Chosen { + let Unexported { origin, session } = self.unexported.borrow_mut().take()?; + let source = origin.into_source(choice.source, session)?; + Some(Chosen { format, job_format, - name: name.clone(), + name: self.name.clone(), source, expected_rows: choice.expected, - }; - glib::spawn_future_local(save_with_file_dialog(window.clone(), exports.clone(), chosen)); - }); - - dialog.present(Some(parent)); + tab: self.tab, + }) + } } /// Keeps Export off, with the reason under the format, while the chosen @@ -473,6 +527,7 @@ struct Chosen { name: String, source: ExportSource, expected_rows: Option, + tab: Uuid, } async fn save_with_file_dialog(parent: adw::ApplicationWindow, exports: Rc, chosen: Chosen) { @@ -482,6 +537,7 @@ async fn save_with_file_dialog(parent: adw::ApplicationWindow, exports: Rc> so -/// every input handler can call it through one slot, avoiding the -/// type-complexity hit clippy raises on the raw signature. -type Rebuilder = Rc; -type RebuilderSlot = Rc>>; - -fn extra_is_blank(extra: Option<&str>) -> bool { - extra.map(|s| s.trim().is_empty()).unwrap_or(true) -} - -/// Operator rendered in the Op dropdown — label, FilterOp, and -/// whether the rule needs a value. -struct OpEntry { - op: FilterOp, - label: &'static str, - /// Shape of the value widget: None / Single / Pair / List. - shape: ValueShape, -} - -#[derive(Clone, Copy, PartialEq, Eq)] -enum ValueShape { - None, - Single, - Pair, - List, -} - -/// 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 dialog offers and what -/// the builder accepts cannot drift apart: an operator the user can -/// pick is one the filter can be built from. -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. -fn is_filterable(col: &ColumnInfo) -> bool { - !matches!(col.column_type.kind, tablepro_core::column::ColumnKind::Bytes { .. }) -} - -/// A column the dialog can fall back on when the schema has not -/// loaded, so a saved rule still renders with its own name. -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, - } -} - -/// The inline filter editor. BrowseTab owns one of these per tab, -/// adds `widget` as a top bar on its `AdwToolbarView`, and toggles -/// reveal via the Filter button / Ctrl+F / Esc. -pub struct FilterStrip { - pub widget: gtk::Revealer, - state: Rc>, - columns: Rc>>, - rebuild: RebuilderSlot, - raw_entry: gtk::Entry, -} - -impl FilterStrip { - pub fn is_revealed(&self) -> bool { - self.widget.reveals_child() - } - - pub fn set_revealed(&self, revealed: bool) { - self.widget.set_reveal_child(revealed); - } - - pub fn toggle(&self) { - let opening = !self.is_revealed(); - self.set_revealed(opening); - if opening { - // Drop the cursor in the raw SQL field so the user can - // start typing immediately. Raw is the primary path; the - // rule editor is one expander click away for the click- - // driven case. - self.raw_entry.grab_focus(); - } - } - - /// Refresh column metadata after a `ColumnsLoaded`. Drops any - /// existing operator dropdowns whose column type changed and - /// rebuilds the rule list against the new schema. - pub fn update_columns(&self, columns: Vec) { - *self.columns.borrow_mut() = columns.into_iter().filter(is_filterable).collect(); - if let Some(f) = self.rebuild.borrow().as_ref() { - f(); - } - } - - /// Replace the strip's editing state with `set` and rebuild the - /// rule list. Called when a filter applies from outside the - /// strip (e.g. saved-filter restore on tab open) so the editor - /// reflects what's actually in effect. - pub fn update_filter(&self, set: FilterSet) { - let extra = set.extra_sql.clone().unwrap_or_default(); - *self.state.borrow_mut() = set; - // Raw entry mirrors state too — without this the entry's - // text still shows the previous raw fragment after a - // FilterApplied that cleared it. - self.raw_entry.set_text(&extra); - if let Some(f) = self.rebuild.borrow().as_ref() { - f(); - } - } -} - -pub fn build(columns: Vec, initial: FilterSet, on_apply: Rc) -> FilterStrip { - let state = Rc::new(RefCell::new(initial)); - let columns: Rc>> = - Rc::new(RefCell::new(columns.into_iter().filter(is_filterable).collect())); - - // Outer revealer — slides the strip into / out of view. Slide-down - // matches GtkSearchBar's reveal direction, so the strip reads as - // a transient editor descending from the toolbar. - let revealer = gtk::Revealer::builder() - .transition_type(gtk::RevealerTransitionType::SlideDown) - .reveal_child(false) - .build(); - - let outer = gtk::Box::builder() - .orientation(gtk::Orientation::Vertical) - .spacing(0) - .build(); - outer.add_css_class("toolbar"); - outer.add_css_class("inline-toolbar"); - revealer.set_child(Some(&outer)); - - // Top bar: title on the left, action buttons on the right. Inline - // (not an AdwHeaderBar) because the strip doesn't own a window - // chrome — it's a piece of toolbar inside the BrowseTab's - // ToolbarView. - let header = gtk::Box::builder() - .orientation(gtk::Orientation::Horizontal) - .spacing(6) - .margin_top(8) - .margin_bottom(0) - .margin_start(12) - .margin_end(12) - .build(); - // Header reads as a single concise row: small "Match … of these - // rules:" label on the left with the combinator dropdown inline - // (only revealed once 2+ rules exist, since AND/OR is meaningless - // with 0–1 rules), spacer, action buttons on the right. Drops the - // earlier "Filter rows" title — Apply / Clear / × already mark - // this as the filter editor. - let match_label = gtk::Label::builder().label(crate::i18n::gettext("Match")).build(); - match_label.add_css_class("dimmed"); - let combinator_dropdown = gtk::DropDown::from_strings(&[ - &crate::i18n::pgettext("filter operator", "all"), - &crate::i18n::pgettext("filter operator", "any"), - ]); - combinator_dropdown.set_valign(gtk::Align::Center); - combinator_dropdown.set_selected(match state.borrow().combinator { - Combinator::And => 0, - Combinator::Or => 1, - }); - let match_suffix = gtk::Label::builder() - .label(crate::i18n::gettext("of these rules")) - .build(); - match_suffix.add_css_class("dimmed"); - let match_row_box = gtk::Box::builder() - .orientation(gtk::Orientation::Horizontal) - .spacing(6) - .build(); - match_row_box.append(&match_label); - match_row_box.append(&combinator_dropdown); - match_row_box.append(&match_suffix); - let match_revealer = gtk::Revealer::builder() - .transition_type(gtk::RevealerTransitionType::None) - .reveal_child(state.borrow().rules.len() >= 2) - .child(&match_row_box) - .build(); - let spacer = gtk::Box::builder().hexpand(true).build(); - let clear_btn = gtk::Button::with_label(&crate::i18n::gettext("Clear all")); - clear_btn.add_css_class("flat"); - let apply_btn = gtk::Button::with_label(&crate::i18n::gettext("Apply")); - apply_btn.add_css_class("suggested-action"); - let close_btn = gtk::Button::builder() - .icon_name(crate::ui::icons::WINDOW_CLOSE) - .action_name("filter.close") - .build(); - close_btn.add_css_class("flat"); - crate::ui::a11y::describe_icon_button(&close_btn, &crate::i18n::gettext("Close"), Some("filter.close")); - header.append(&match_revealer); - header.append(&spacer); - header.append(&clear_btn); - header.append(&apply_btn); - header.append(&close_btn); - outer.append(&header); - - let content = gtk::Box::builder() - .orientation(gtk::Orientation::Vertical) - .spacing(8) - .margin_top(6) - .margin_bottom(8) - .margin_start(12) - .margin_end(12) - .build(); - - let state_for_combinator = state.clone(); - combinator_dropdown.connect_selected_notify(move |dd| { - state_for_combinator.borrow_mut().combinator = match dd.selected() { - 1 => Combinator::Or, - _ => Combinator::And, - }; - }); - - // Rebuild closure — captured by every input-changed callback. - // Drains the rules list, walks `state.rules`, builds a row per - // rule. Re-entrancy guard: a CHANGED signal fired while we're - // rebuilding (programmatic set_text on an EntryRow) would - // re-enter and double-update state. The suppress flag - // short-circuits during rebuild. - let suppress: Rc> = Rc::new(std::cell::Cell::new(false)); - let rebuild: RebuilderSlot = Rc::new(RefCell::new(None)); - - // Raw SQL input — primary, always-visible. The strip is aimed - // at developers who already think in WHERE clauses; making them - // expand a section to type SQL would be backwards. Structured - // rules become the secondary affordance below. - let raw_row = gtk::Box::builder() - .orientation(gtk::Orientation::Horizontal) - .spacing(8) - .build(); - let where_label = gtk::Label::builder().label("WHERE").build(); - where_label.add_css_class("monospace"); - where_label.add_css_class("dimmed"); - let raw_entry = gtk::Entry::builder() - .placeholder_text(crate::i18n::gettext("e.g. created_at > now() - interval '1 day'")) - .hexpand(true) - .build(); - raw_entry.add_css_class("monospace"); - raw_entry.set_text(state.borrow().extra_sql.as_deref().unwrap_or("")); - let state_for_raw = state.clone(); - let rebuild_for_raw = rebuild.clone(); - raw_entry.connect_changed(move |e| { - let text = e.text().to_string(); - let trimmed = text.trim(); - state_for_raw.borrow_mut().extra_sql = if trimmed.is_empty() { None } else { Some(text) }; - // Re-evaluate the Match revealer — combinator visibility - // depends on whether raw + ≥1 rule are both present. - if let Some(f) = rebuild_for_raw.borrow().as_ref() { - f(); - } - }); - raw_entry.connect_activate(glib::clone!( - #[weak] - apply_btn, - move |_| { - apply_btn.activate(); - } - )); - raw_row.append(&where_label); - raw_row.append(&raw_entry); - content.append(&raw_row); - - // Structured rules — secondary, collapsed by default. Power - // users who think in raw SQL never need to expand this; users - // building filters by clicking get a dropdown-driven editor - // when they reach for it. Pre-expanded only when the saved - // FilterSet already has rules from a previous session. - let rules_expander = gtk::Expander::builder() - .label(crate::i18n::gettext("Or use the rule editor")) - .expanded(!state.borrow().rules.is_empty()) - .build(); - let rules_body = gtk::Box::builder() - .orientation(gtk::Orientation::Vertical) - .spacing(8) - .margin_top(8) - .build(); - rules_expander.set_child(Some(&rules_body)); - content.append(&rules_expander); - - let rules_list = gtk::ListBox::builder().selection_mode(gtk::SelectionMode::None).build(); - rules_list.add_css_class("boxed-list"); - rules_body.append(&rules_list); - - // Inline "Add rule" button — small, left-aligned, flat. - let add_rule_btn = gtk::Button::builder() - .icon_name(crate::ui::icons::LIST_ADD) - .label(crate::i18n::gettext("Add rule")) - .halign(gtk::Align::Start) - .build(); - add_rule_btn.add_css_class("flat"); - rules_body.append(&add_rule_btn); - - { - let state = state.clone(); - let columns = columns.clone(); - let suppress = suppress.clone(); - // Every rule row holds the slot, and the slot holds this closure, - // so a strong slot or list here would keep the strip alive. - let rebuild_inner = Rc::downgrade(&rebuild); - let rules_list = rules_list.downgrade(); - let match_revealer = match_revealer.downgrade(); - let closure: Rebuilder = Rc::new(move || { - let (Some(rebuild_inner), Some(rules_list), Some(match_revealer)) = - (rebuild_inner.upgrade(), rules_list.upgrade(), match_revealer.upgrade()) - else { - return; - }; - suppress.set(true); - while let Some(child) = rules_list.first_child() { - rules_list.remove(&child); - } - let rules_snapshot = state.borrow().rules.clone(); - for (i, rule) in rules_snapshot.iter().enumerate() { - let row = build_rule_row( - i, - rule, - &columns, - state.clone(), - rebuild_inner.clone(), - suppress.clone(), - ); - rules_list.append(&row); - } - // Match dropdown is meaningful when at least two clauses - // need a combinator — that's ≥2 structured rules, or 1 - // structured rule combined with raw SQL. Hide it - // otherwise so the user doesn't see a control that has - // no effect on the resulting WHERE. - let raw_present = !extra_is_blank(state.borrow().extra_sql.as_deref()); - let needs_combinator = rules_snapshot.len() >= 2 || (!rules_snapshot.is_empty() && raw_present); - match_revealer.set_reveal_child(needs_combinator); - // Visually mute the entire rules list when empty so the - // strip reads as ready-for-input rather than already- - // populated. - rules_list.set_visible(!rules_snapshot.is_empty()); - suppress.set(false); - }); - *rebuild.borrow_mut() = Some(closure); - } - if let Some(f) = rebuild.borrow().as_ref() { - f(); - } - - let state_for_add = state.clone(); - let columns_for_add = columns.clone(); - let rebuild_for_add = rebuild.clone(); - add_rule_btn.connect_clicked(move |_| { - let default_col = columns_for_add - .borrow() - .first() - .map(|c| c.name.clone()) - .unwrap_or_default(); - state_for_add.borrow_mut().rules.push(FilterRule { - column: default_col, - op: FilterOp::Eq, - value: Some(FilterValue::Single(String::new())), - }); - if let Some(f) = rebuild_for_add.borrow().as_ref() { - f(); - } - }); - - let scroller = gtk::ScrolledWindow::builder() - .child(&content) - .hscrollbar_policy(gtk::PolicyType::Never) - .vexpand(false) - .max_content_height(420) - .propagate_natural_height(true) - .hexpand(true) - .build(); - outer.append(&scroller); - - // The buttons and the close action sit inside the revealer, so each - // holds it weakly. Escape closes without applying. - let close = gio::SimpleAction::new("close", None); - close.connect_activate(glib::clone!( - #[weak] - revealer, - move |_, _| revealer.set_reveal_child(false) - )); - let actions = gio::SimpleActionGroup::new(); - actions.add_action(&close); - outer.insert_action_group("filter", Some(&actions)); - outer.add_controller(local_shortcut_controller(ShortcutOwner::FilterStrip)); - - let on_apply_for_clear = on_apply.clone(); - clear_btn.connect_clicked(glib::clone!( - #[weak] - revealer, - move |_| { - on_apply_for_clear(FilterSet::default()); - revealer.set_reveal_child(false); - } - )); - - let state_for_apply = state.clone(); - apply_btn.connect_clicked(glib::clone!( - #[weak] - revealer, - move |_| { - let snapshot = state_for_apply.borrow().clone(); - on_apply(snapshot); - revealer.set_reveal_child(false); - } - )); - - FilterStrip { - widget: revealer, - state, - columns, - rebuild, - raw_entry, - } -} - -fn build_rule_row( - index: usize, - rule: &FilterRule, - columns: &Rc>>, - state: Rc>, - rebuild: RebuilderSlot, - suppress: Rc>, -) -> adw::ActionRow { - let columns_snapshot = columns.borrow().clone(); - let row = adw::ActionRow::builder().build(); - - // Column dropdown (prefix). - let names: Vec<&str> = columns_snapshot.iter().map(|c| c.name.as_str()).collect(); - let column_dd = gtk::DropDown::from_strings(&names); - column_dd.set_valign(gtk::Align::Center); - let initial_col_idx = columns_snapshot.iter().position(|c| c.name == rule.column).unwrap_or(0) as u32; - column_dd.set_selected(initial_col_idx); - - let state_for_col = state.clone(); - let columns_for_col = columns.clone(); - let rebuild_for_col = rebuild.clone(); - let suppress_for_col = suppress.clone(); - column_dd.connect_selected_notify(move |dd| { - if suppress_for_col.get() { - return; - } - let idx = dd.selected() as usize; - let cols = columns_for_col.borrow(); - let Some(new_col) = cols.get(idx) else { - return; - }; - if let Some(rule) = state_for_col.borrow_mut().rules.get_mut(index) { - rule.column = new_col.name.clone(); - // Reset the operator to the first valid one for the new - // column type — text-only ops on a new int column would - // produce SQL the driver rejects at fetch time. - let ops = operators_for(new_col); - rule.op = ops[0].op; - rule.value = match ops[0].shape { - ValueShape::None => None, - ValueShape::Single => Some(FilterValue::Single(String::new())), - ValueShape::Pair => Some(FilterValue::Pair(String::new(), String::new())), - ValueShape::List => Some(FilterValue::List(Vec::new())), - }; - } - drop(cols); - if let Some(f) = rebuild_for_col.borrow().as_ref() { - f(); - } - }); - row.add_prefix(&column_dd); - - // Operator dropdown. - let col = columns_snapshot - .get(initial_col_idx as usize) - .cloned() - .unwrap_or_else(|| placeholder_column(&rule.column.clone())); - let ops = operators_for(&col); - let op_labels: Vec<&str> = ops.iter().map(|e| e.label).collect(); - let op_dd = gtk::DropDown::from_strings(&op_labels); - op_dd.set_valign(gtk::Align::Center); - let op_idx = ops.iter().position(|e| e.op == rule.op).unwrap_or(0) as u32; - op_dd.set_selected(op_idx); - - let state_for_op = state.clone(); - let columns_for_op = columns.clone(); - let rebuild_for_op = rebuild.clone(); - let suppress_for_op = suppress.clone(); - op_dd.connect_selected_notify(move |dd| { - if suppress_for_op.get() { - return; - } - let new_idx = dd.selected() as usize; - let mut state_mut = state_for_op.borrow_mut(); - let Some(rule) = state_mut.rules.get_mut(index) else { - return; - }; - let col = columns_for_op - .borrow() - .iter() - .find(|c| c.name == rule.column) - .cloned() - .unwrap_or_else(|| placeholder_column(&rule.column.clone())); - let ops = operators_for(&col); - if let Some(entry) = ops.get(new_idx) { - rule.op = entry.op; - rule.value = match entry.shape { - ValueShape::None => None, - ValueShape::Single => Some(FilterValue::Single(String::new())), - ValueShape::Pair => Some(FilterValue::Pair(String::new(), String::new())), - ValueShape::List => Some(FilterValue::List(Vec::new())), - }; - } - drop(state_mut); - if let Some(f) = rebuild_for_op.borrow().as_ref() { - f(); - } - }); - row.add_suffix(&op_dd); - - // Value widget(s) — shape depends on operator. - let shape = ops - .iter() - .find(|e| e.op == rule.op) - .map(|e| e.shape) - .unwrap_or(ValueShape::Single); - match shape { - ValueShape::None => { - // No input widget; the title carries enough meaning. - } - ValueShape::Single => { - let entry = gtk::Entry::builder() - .placeholder_text(crate::i18n::gettext("Value")) - .valign(gtk::Align::Center) - .hexpand(true) - .build(); - entry.set_input_purpose(input_purpose_for(&col)); - if let Some(FilterValue::Single(s)) = rule.value.as_ref() { - entry.set_text(s); - } - let state_for_value = state.clone(); - let suppress_for_value = suppress.clone(); - entry.connect_changed(move |e| { - if suppress_for_value.get() { - return; - } - if let Some(rule) = state_for_value.borrow_mut().rules.get_mut(index) { - rule.value = Some(FilterValue::Single(e.text().to_string())); - } - }); - row.add_suffix(&entry); - } - ValueShape::Pair => { - let lo = gtk::Entry::builder() - .placeholder_text(crate::i18n::gettext("From")) - .valign(gtk::Align::Center) - .build(); - let hi = gtk::Entry::builder() - .placeholder_text(crate::i18n::gettext("To")) - .valign(gtk::Align::Center) - .build(); - lo.set_input_purpose(input_purpose_for(&col)); - hi.set_input_purpose(input_purpose_for(&col)); - if let Some(FilterValue::Pair(a, b)) = rule.value.as_ref() { - lo.set_text(a); - hi.set_text(b); - } - // Each entry reads the other, so neither may hold it strongly. - let state_for_lo = state.clone(); - let suppress_for_lo = suppress.clone(); - lo.connect_changed(glib::clone!( - #[weak] - hi, - move |e| { - if suppress_for_lo.get() { - return; - } - if let Some(rule) = state_for_lo.borrow_mut().rules.get_mut(index) { - rule.value = Some(FilterValue::Pair(e.text().to_string(), hi.text().to_string())); - } - } - )); - let state_for_hi = state.clone(); - let suppress_for_hi = suppress.clone(); - hi.connect_changed(glib::clone!( - #[weak] - lo, - move |e| { - if suppress_for_hi.get() { - return; - } - if let Some(rule) = state_for_hi.borrow_mut().rules.get_mut(index) { - rule.value = Some(FilterValue::Pair(lo.text().to_string(), e.text().to_string())); - } - } - )); - let pair_box = gtk::Box::builder() - .orientation(gtk::Orientation::Horizontal) - .spacing(6) - .build(); - pair_box.append(&lo); - pair_box.append(&hi); - row.add_suffix(&pair_box); - } - ValueShape::List => { - let entry = gtk::Entry::builder() - .placeholder_text(crate::i18n::gettext("a, b, c")) - .valign(gtk::Align::Center) - .hexpand(true) - .build(); - if let Some(FilterValue::List(items)) = rule.value.as_ref() { - entry.set_text(&items.join(", ")); - } - let state_for_value = state.clone(); - let suppress_for_value = suppress.clone(); - entry.connect_changed(move |e| { - if suppress_for_value.get() { - return; - } - if let Some(rule) = state_for_value.borrow_mut().rules.get_mut(index) { - let items: Vec = e - .text() - .split(',') - .map(|s| s.trim().to_string()) - .filter(|s| !s.is_empty()) - .collect(); - rule.value = Some(FilterValue::List(items)); - } - }); - row.add_suffix(&entry); - } - } - - let remove = gtk::Button::builder() - .icon_name(crate::ui::icons::USER_TRASH) - .valign(gtk::Align::Center) - .build(); - remove.add_css_class("flat"); - crate::ui::a11y::describe_icon_button(&remove, &crate::i18n::gettext("Remove Rule"), None); - let state_for_remove = state.clone(); - let rebuild_for_remove = rebuild.clone(); - remove.connect_clicked(move |_| { - let mut s = state_for_remove.borrow_mut(); - if index < s.rules.len() { - s.rules.remove(index); - } - drop(s); - if let Some(f) = rebuild_for_remove.borrow().as_ref() { - f(); - } - }); - row.add_suffix(&remove); - - row -} - -/// 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 super::*; - use crate::test_support::{ - 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 strip_with(rules: Vec) -> FilterStrip { - build( - vec![placeholder_column("a"), placeholder_column("b")], - FilterSet { - rules, - ..FilterSet::default() - }, - Rc::new(|_| {}), - ) - } - - fn row_count(list: >k::ListBox) -> usize { - std::iter::successors(list.first_child(), |row| row.next_sibling()).count() - } - - #[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 outer = strip.widget.child().expect("the strip's content"); - let escape = shortcuts(&owner_controller(&outer, 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(), &outer, None)); - - assert!(!strip.is_revealed()); - } - - #[gtk4::test] - fn strip_freed_after_drop() { - let strip = strip_with(vec![rule("a"), rule("b")]); - let rules = first_descendant_of_type::(&strip.widget) - .expect("the rule list") - .downgrade(); - let revealer = strip.widget.downgrade(); - - drop(strip); - drain_main_context(); - - assert!(rules.upgrade().is_none(), "the rule list outlived the strip"); - assert!(revealer.upgrade().is_none(), "the strip outlived its owner"); - } - - #[gtk4::test] - fn a_between_rule_frees_its_entries() { - 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 strip = build( - vec![amount], - FilterSet { - rules: vec![FilterRule { - column: "amount".to_owned(), - op: FilterOp::Between, - value: Some(FilterValue::Pair("1".to_owned(), "2".to_owned())), - }], - ..FilterSet::default() - }, - Rc::new(|_| {}), - ); - let entry = |text: &str| { - crate::test_support::descendants(&strip.widget) - .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.state.borrow().rules[0].value, - Some(FilterValue::Pair("5".to_owned(), "2".to_owned())) - ); - to.set_text("9"); - assert_eq!( - strip.state.borrow().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")]); - let remove = crate::test_support::descendants(&strip.widget) - .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_eq!(unlabelled_icon_buttons(&strip.widget), Vec::::new()); - assert_eq!(tooltips_with_keys(&strip.widget), Vec::::new()); - } - - #[gtk4::test] - fn an_applied_filter_rebuilds_the_rule_list() { - let strip = strip_with(vec![rule("a"), rule("b")]); - let rules = first_descendant_of_type::(&strip.widget).expect("the rule list"); - assert_eq!(row_count(&rules), 2); - - strip.update_filter(FilterSet { - rules: vec![rule("b")], - ..FilterSet::default() - }); - - assert_eq!(row_count(&rules), 1); - } -} 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..1c757320a2 --- /dev/null +++ b/linux/crates/app/src/ui/filter_strip/imp.rs @@ -0,0 +1,94 @@ +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>, +} + +#[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.set_revealed(false)); + 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..406c674463 --- /dev/null +++ b/linux/crates/app/src/ui/filter_strip/mod.rs @@ -0,0 +1,211 @@ +//! 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. Opening puts the cursor in the SQL field, the + /// strip's main input. + pub fn toggle(&self) { + let opening = !self.revealed(); + self.set_revealed(opening); + if opening { + self.imp().raw_entry.grab_focus(); + } + } + + /// 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.set_revealed(false); + } + + 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..0e76f37fd1 --- /dev/null +++ b/linux/crates/app/src/ui/filter_strip/rule_row.rs @@ -0,0 +1,282 @@ +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); + } + } + + 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..928d0829bc --- /dev/null +++ b/linux/crates/app/src/ui/filter_strip/rule_value_editor.rs @@ -0,0 +1,190 @@ +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 entry = gtk::Entry::builder() + .placeholder_text(crate::i18n::gettext("Value")) + .valign(gtk::Align::Center) + .hexpand(true) + .input_purpose(input_purpose_for(column)) + .build(); + 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| { + gtk::Entry::builder() + .placeholder_text(placeholder) + .valign(gtk::Align::Center) + .input_purpose(input_purpose_for(column)) + .build() + }; + 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(); + 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 +} + +/// 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..2c7dffd113 --- /dev/null +++ b/linux/crates/app/src/ui/filter_strip/tests.rs @@ -0,0 +1,428 @@ +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, 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()); +} + +#[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()); +} + +#[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/grid/cell_editor_kind.rs b/linux/crates/app/src/ui/grid/cell_editor_kind.rs index 0966a9ac35..ff88848b00 100644 --- a/linux/crates/app/src/ui/grid/cell_editor_kind.rs +++ b/linux/crates/app/src/ui/grid/cell_editor_kind.rs @@ -113,17 +113,31 @@ mod tests { }) } - #[test] - fn editor_kind_for_cell_matrix() { - let free = |accepts_line_breaks| CellEditorKind::Inline { + 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, - }; - let number = CellEditorKind::Inline { - accepts_line_breaks: false, - purpose: gtk::InputPurpose::Number, - }; - let cases = [ + } + } + + 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), @@ -139,16 +153,22 @@ mod tests { purpose: gtk::InputPurpose::Digits, }, ), - (integer(true), number), - (ColumnKind::Float(FloatWidth::F64), number), + (integer(true), NUMBER), + (ColumnKind::Float(FloatWidth::F64), NUMBER), ( ColumnKind::Decimal { precision: Some(10), scale: Some(2), }, - number, + NUMBER, ), - (ColumnKind::Money { 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, @@ -188,6 +208,12 @@ mod tests { 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") }, @@ -218,15 +244,11 @@ mod tests { ColumnKind::Composite, CellEditorKind::ReadOnly(CellLock::Value(ReadOnlyReason::Composite)), ), - ]; - for (kind, expected) in cases { - assert_eq!( - CellEditorKind::for_cell(&column(kind.clone()), Ok(())), - expected, - "{kind:?}" - ); - } + ]); + } + #[test] + fn a_locked_cell_is_read_only_for_its_lock() { let text = column(ColumnKind::Text { length: TextLength::Unbounded, }); diff --git a/linux/crates/app/src/ui/grid/cell_focus.rs b/linux/crates/app/src/ui/grid/cell_focus.rs index e003c67411..0c7405638b 100644 --- a/linux/crates/app/src/ui/grid/cell_focus.rs +++ b/linux/crates/app/src/ui/grid/cell_focus.rs @@ -122,8 +122,6 @@ pub(crate) fn edit_row(view: >k::ColumnView, position: u32) { #[cfg(test)] mod tests { - use std::cell::Cell; - use std::rc::Rc; use std::time::Duration; use gtk4::prelude::*; @@ -133,7 +131,7 @@ mod tests { use tablepro_core::value::Temporal; use super::edit_row; - use crate::test_support::{drain_main_context, first_descendant_of_type, wait_until}; + use crate::test_support::{FocusThief, after_next_frame, 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}; @@ -249,6 +247,32 @@ mod tests { assert!(grid.take_messages().is_empty(), "Escape sent the typed text"); } + /// 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(); @@ -302,18 +326,6 @@ mod tests { panic!("the editor popover never showed"); } - /// GTK moves a focus it finds in a hidden popover once the frame is - /// painted, so the focus is checked after that. - fn after_next_frame(grid: &Grid) { - let clock = grid.window.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(WAIT, || painted.get()).expect("the window paints a frame"); - clock.disconnect(handler); - } - fn button(popover: >k::Popover, label: &str) -> gtk::Button { crate::test_support::descendants(popover) .into_iter() @@ -343,7 +355,7 @@ mod tests { 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); + after_next_frame(&grid.window); assert!(!cell.is_editing()); assert_eq!(grid.focused_cell().as_ref(), Some(&cell), "the focus left the grid"); @@ -361,7 +373,7 @@ mod tests { // As Escape does. popover.popdown(); - after_next_frame(&grid); + after_next_frame(&grid.window); assert!(!cell.is_editing()); assert_eq!(grid.focused_cell().as_ref(), Some(&cell), "the focus left the grid"); @@ -378,7 +390,7 @@ mod tests { let popover = open_popover(&grid, &cell); grid.store.remove(0); - after_next_frame(&grid); + after_next_frame(&grid.window); assert!(popover.parent().is_none(), "the popover outlived its row"); assert!(!cell.is_editing()); @@ -400,7 +412,7 @@ mod tests { let popover = open_popover(&grid, &cell); button(&popover, "Cancel").emit_clicked(); - after_next_frame(&grid); + after_next_frame(&grid.window); assert!(!cell.is_editing()); assert_eq!(grid.focused_cell().as_ref(), Some(&cell), "Cancel left the grid"); @@ -408,7 +420,7 @@ mod tests { 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); + after_next_frame(&grid.window); assert!(!cell.is_editing()); assert_eq!(grid.focused_cell().as_ref(), Some(&cell), "Save left the grid"); assert_eq!( diff --git a/linux/crates/app/src/ui/grid/column_drawing.rs b/linux/crates/app/src/ui/grid/column_drawing.rs index 6ea52ff156..0759379a4a 100644 --- a/linux/crates/app/src/ui/grid/column_drawing.rs +++ b/linux/crates/app/src/ui/grid/column_drawing.rs @@ -7,6 +7,7 @@ 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}; @@ -34,6 +35,7 @@ pub struct ColumnDrawing { /// rather than an empty cell the user should type in. pub auto_filled: bool, pub attributes: Rc, + pub figures: ColumnFigures, } impl ColumnDrawing { @@ -100,6 +102,7 @@ impl ColumnDrawing { (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()); diff --git a/linux/crates/app/src/ui/grid/column_factory.rs b/linux/crates/app/src/ui/grid/column_factory.rs index f0078651df..edd6dd5c9f 100644 --- a/linux/crates/app/src/ui/grid/column_factory.rs +++ b/linux/crates/app/src/ui/grid/column_factory.rs @@ -9,6 +9,7 @@ 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; @@ -47,6 +48,13 @@ pub(crate) fn build_column_view( } = 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 again before the view binds any of them. + store.connect_items_changed({ + let grid_columns = grid_columns.clone(); + move |store, _, _, _| grid_columns.measure(store) + }); // Tab leaves the grid, and the arrow keys move between cells. let column_view = gtk::ColumnView::builder() .show_row_separators(true) @@ -71,15 +79,14 @@ pub(crate) fn build_column_view( 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 column with no saved width - // starts at a readable minimum there. + // 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 grid_columns = Rc::new(GridColumns::default()); let attributes = Rc::new(GridAttributes::new()); for (i, column) in result_columns.iter().enumerate() { let sorter = match sorting { @@ -91,6 +98,7 @@ pub(crate) fn build_column_view( let col = build_column(ColumnInit { info: column, idx: i, + rows: &rows, catalog: schema_columns.get(i).cloned(), sink: sink.clone(), sorter, @@ -179,6 +187,7 @@ enum CellKind { 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, @@ -195,6 +204,7 @@ fn build_column(init: ColumnInit<'_>) -> gtk::ColumnViewColumn { let ColumnInit { info, idx, + rows, catalog, sink, sorter, @@ -207,26 +217,20 @@ fn build_column(init: ColumnInit<'_>) -> gtk::ColumnViewColumn { } = init; let factory = gtk::SignalListItemFactory::new(); - let kind = match catalog.as_ref() { - 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, - }; + 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 numeric = info.column_type.kind.is_numeric(); let setup_drawing = drawing.clone(); let setup_columns = grid_columns.clone(); @@ -246,17 +250,47 @@ fn build_column(init: ColumnInit<'_>) -> gtk::ColumnViewColumn { grid_cell.set_sink(sink.clone()); grid_cell.set_columns(setup_columns.clone()); match kind { - CellKind::ReadOnly => grid_cell.set_content(&text_display(numeric)), + 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(numeric)); + 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()); + size_column(&column, &info.name, &tab_ctx, default_min_width); + grid_columns.push(idx, &column, drawing); + column +} +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; @@ -278,21 +312,18 @@ fn build_column(init: ColumnInit<'_>) -> gtk::ColumnViewColumn { grid_cell.unbind(); } }); +} - let column = gtk::ColumnViewColumn::builder() - .title(&info.name) - .factory(&factory) - .resizable(true) - .expand(true) - .build(); - column.set_sorter(Some(&sorter)); +/// 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) { if let Some(widths) = tab_ctx.column_widths.clone() { - if let Some(saved) = widths.width(&info.name) { + 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 = info.name.clone(); + let column_name = name.to_owned(); column.connect_fixed_width_notify(move |column| { widths.record(&column_name, column.fixed_width()); }); @@ -300,26 +331,18 @@ fn build_column(init: ColumnInit<'_>) -> gtk::ColumnViewColumn { // Editor results remember no widths, but can be just as wide. column.set_fixed_width(min); } - grid_columns.push(idx, &column, drawing); - column } /// 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. A -/// number lines up on its last digit in tabular figures. -fn text_display(numeric: bool) -> gtk::Inscription { +/// 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_xalign(if numeric { 1.0 } else { 0.0 }); display.set_hexpand(true); display.set_text_overflow(gtk::InscriptionOverflow::EllipsizeEnd); - display.set_min_chars(3); - display.set_nat_chars(14); display.set_margin_start(8); display.set_margin_end(8); display.add_css_class("tp-cell-text"); - if numeric { - display.add_css_class("numeric"); - } + figures.apply(&display, "", true); display } @@ -345,7 +368,7 @@ mod tests { use crate::ui::grid::{CellPreset, ColumnWidthBinding, GridCell, GridMsg, RowObject, TabGridContext, begin_edit}; #[gtk4::test] - fn edit_after_draft_insert_targets_edited_row() { + fn f004_edit_after_draft_insert_targets_edited_row() { let grid = two_text_rows(); grid.insert_draft(); @@ -362,7 +385,7 @@ mod tests { } #[gtk4::test] - fn menu_set_null_after_draft_removal_targets_clicked_row() { + 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); 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..fc6f823011 --- /dev/null +++ b/linux/crates/app/src/ui/grid/column_figures.rs @@ -0,0 +1,303 @@ +use std::cell::Cell; + +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, Default)] +pub struct ColumnFigures { + numbers: Cell, + /// The longest number the column has held, in characters. + chars: Cell, +} + +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]) { + let values = || { + rows.iter() + .filter_map(|row| row.cell(index)) + .filter(|value| !value.is_null()) + }; + let numbers = holds_numbers(&column_type.kind, values()); + self.numbers.set(numbers); + self.chars.set(if numbers { widest(column_type, values()) } else { 0 }); + } + + 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(digits_width(display, self.chars.get())); + } +} + +fn holds_numbers<'a>(kind: &ColumnKind, values: impl Iterator) -> bool { + if kind.is_numeric() { + return true; + } + // PostgreSQL editor results carry no type (ADR 0016), so such a + // column aligns by what it holds, as a spreadsheet does. + if !matches!(kind, ColumnKind::Other | ColumnKind::Variant) { + return false; + } + let mut values = values.peekable(); + values.peek().is_some() && values.all(is_number) +} + +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:?}"); + } + } + + /// 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"); + } +} diff --git a/linux/crates/app/src/ui/grid/context_menu.rs b/linux/crates/app/src/ui/grid/context_menu.rs index 6ebe217508..342dd151b1 100644 --- a/linux/crates/app/src/ui/grid/context_menu.rs +++ b/linux/crates/app/src/ui/grid/context_menu.rs @@ -110,11 +110,10 @@ pub(super) fn attach_cell_gesture(cell: &GridCell, column_view: &glib::WeakRef) -> Vec { - let rows: Vec> = ids - .map(|id| vec![Value::Int(id), Value::Text(format!("row {id}"))]) - .collect(); - persisted(&rows) - } - #[gtk4::test] fn a_page_that_lands_under_an_open_menu_leaves_its_items_acting_on_nothing() { let grid = two_text_rows(); @@ -177,6 +169,39 @@ mod tests { assert_eq!(acted_on(&grid), Vec::new(), "the menu acted on a row of the new page"); } + 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] diff --git a/linux/crates/app/src/ui/grid/grid_cell.rs b/linux/crates/app/src/ui/grid/grid_cell.rs index d08e753691..30e3f371f9 100644 --- a/linux/crates/app/src/ui/grid/grid_cell.rs +++ b/linux/crates/app/src/ui/grid/grid_cell.rs @@ -12,7 +12,7 @@ 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; +use super::inline_editor::{InlineEditor, commit_inline}; use super::{CellLock, EditSnapshot, GridSink, RowObject}; /// Enough for any number, date or short text. @@ -333,9 +333,12 @@ impl GridCell { let held = window .as_ref() .and_then(gtk::prelude::GtkWindowExt::focus) - .is_some_and(|focus| focus.is_ancestor(self) || focus == *self.upcast_ref::()); + .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 { @@ -360,6 +363,17 @@ impl GridCell { 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() } @@ -404,8 +418,10 @@ mod tests { use super::GridCell; use crate::services::tracker_edit::TrackerEdit; - use crate::test_support::{descendants, wait_until}; - use crate::ui::grid::test_grid::{Grid, WAIT, flag_rows, key, named, recorder, two_text_rows}; + 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 { @@ -533,6 +549,43 @@ mod tests { 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(); diff --git a/linux/crates/app/src/ui/grid/grid_columns.rs b/linux/crates/app/src/ui/grid/grid_columns.rs index 2255ec7fad..8f5c12bf5a 100644 --- a/linux/crates/app/src/ui/grid/grid_columns.rs +++ b/linux/crates/app/src/ui/grid/grid_columns.rs @@ -2,7 +2,7 @@ use std::cell::RefCell; use std::rc::Rc; use gtk4::prelude::*; -use gtk4::{self as gtk, glib}; +use gtk4::{self as gtk, gio, glib}; use super::RowObject; use super::cell_editor_kind::CellEditorKind; @@ -55,6 +55,17 @@ impl GridColumns { }); } + /// Measure every column against the rows `store` holds now. + pub(super) fn measure(&self, store: &gio::ListStore) { + let rows: Vec = (0..store.n_items()) + .filter_map(|position| store.item(position).and_downcast()) + .collect(); + for column in self.columns.borrow().iter() { + let drawing = &column.drawing; + drawing.figures.measure(&drawing.column_type, column.index, &rows); + } + } + pub(super) fn shown(&self, view: >k::ColumnView) -> Vec { let columns = self.columns.borrow(); let model = view.columns(); diff --git a/linux/crates/app/src/ui/grid/grid_menus.rs b/linux/crates/app/src/ui/grid/grid_menus.rs index 9adbf9d2de..9556f4b0cf 100644 --- a/linux/crates/app/src/ui/grid/grid_menus.rs +++ b/linux/crates/app/src/ui/grid/grid_menus.rs @@ -1,7 +1,8 @@ use std::rc::Rc; +use std::sync::Arc; use gtk4::prelude::*; -use gtk4::{self as gtk, gio}; +use gtk4::{self as gtk, gio, glib}; use tablepro_core::column::ResultColumn; @@ -108,7 +109,7 @@ fn build_cell_menu(shape: MenuShape) -> gio::Menu { edit_section.append_item(&item); }; if shape.edit_cell { - hidden_while_disabled(crate::i18n::gettext("Edit cell"), "cell.edit"); + 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 @@ -130,7 +131,7 @@ fn build_cell_menu(shape: MenuShape) -> gio::Menu { 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(&crate::i18n::gettext("CSV With Headers")), Some("cell.copy-csv-headers"), ); copy_as.append(Some(&crate::i18n::gettext("Markdown")), Some("cell.copy-markdown")); @@ -145,9 +146,9 @@ fn build_cell_menu(shape: MenuShape) -> gio::Menu { } 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_submenu(Some(&crate::i18n::gettext("Copy As")), ©_as); copy_section.append( - Some(&crate::i18n::gettext("Copy column name")), + Some(&crate::i18n::gettext("Copy Column Name")), Some("cell.copy-column-name"), ); menu.append_section(None, ©_section); @@ -162,7 +163,7 @@ fn build_cell_menu(shape: MenuShape) -> gio::Menu { 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("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")); } @@ -173,7 +174,7 @@ fn build_cell_menu(shape: MenuShape) -> gio::Menu { pub(super) struct GridMenuInit<'a> { pub(super) column_view: &'a gtk::ColumnView, pub(super) sink: GridSink, - pub(super) columns: std::sync::Arc<[ResultColumn]>, + pub(super) columns: Arc<[ResultColumn]>, pub(super) tab_ctx: TabGridContext, } @@ -185,108 +186,150 @@ pub(super) fn install_grid_context_menus(init: GridMenuInit<'_>) -> GridMenus { tab_ctx, } = init; let editable = tab_ctx.editable; - let context = Rc::new(CellContext::default()); - // 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. - let view = column_view.downgrade(); - let tab = Rc::new(tab_ctx); + 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)); - macro_rules! cell_action { - ($name:literal, |$cell:ident, $row:ident, $rows:ident| $body:expr) => {{ - let ctx = context.clone(); - let view = view.clone(); - gio::ActionEntry::builder($name) - .activate(move |_, _, _| { - let Some(cv) = view.upgrade() else { return }; - let Some(target) = ctx.target(&cv) else { return }; - let MenuTarget { - cell: $cell, - row: $row, - rows: $rows, - } = target; - let $cell = &$cell; - $body; - }) - .build() - }}; + 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(), + }; + + 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()); } - macro_rules! rows_action { - ($name:literal, |$cell:ident, $rows:ident| $body:expr) => {{ cell_action!($name, |$cell, _row, $rows| $body) }}; + if editable { + popovers_for_destroy.push(install_empty_space_menu(column_view)); } - // The rows are taken as the grid shows them, sharing its cells, and - // the owner renders the copy off the GTK thread. - macro_rules! copy_action { - ($name:literal, |$cell:ident| $kind:expr) => {{ - let s = sink.clone(); - let tab = tab.clone(); - let columns = columns.clone(); - rows_action!($name, |$cell, rows| { - s(GridMsg::Copy(CopyRequest::Rows { - columns: columns.clone(), - rows: snapshots(&tab, &rows), - kind: $kind, - })); - }) - }}; + column_view.connect_destroy(move |_| { + for popover in &popovers_for_destroy { + popover.unparent(); + } + }); + + GridMenus { + context: kit.context, + editable_popover, + readonly_popover, + actions, } - macro_rules! send_action { - ($name:literal, |$cell:ident, $row:ident| $msg:expr) => {{ - let s = sink.clone(); - cell_action!($name, |$cell, $row, _rows| s($msg)) - }}; +} + +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() } - // Editing opens on the cell, which must still show the row. - let edit_action = cell_action!("edit", |cell, row, _rows| if cell.row().as_ref() == Some(&row) { - begin_edit(cell) - }); - // 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. - let copy_action = { - let s = sink.clone(); - let tab = tab.clone(); - let columns = columns.clone(); - rows_action!("copy", |cell, rows| { - let kind = match rows.len() { - 1 => CopyKind::Value(cell.column()), - _ => CopyKind::Rows, - }; + /// 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: kind(cell, &rows), })); }) - }; - let copy_rows_action = copy_action!("copy-rows", |_cell| CopyKind::Rows); - let copy_rows_headers_action = copy_action!("copy-rows-headers", |_cell| CopyKind::RowsWithHeaders); - let copy_json_action = copy_action!("copy-json", |_cell| CopyKind::Json); - let copy_csv_action = copy_action!("copy-csv", |_cell| CopyKind::Csv); - let copy_csv_headers_action = copy_action!("copy-csv-headers", |_cell| CopyKind::CsvWithHeaders); - let copy_markdown_action = copy_action!("copy-markdown", |_cell| CopyKind::Markdown); - let copy_in_clause_action = { - let s = sink.clone(); - let tab = tab.clone(); - let columns = columns.clone(); - rows_action!("copy-in-clause", |cell, rows| { - s(GridMsg::CopyInClause(InClauseRows { + } + + 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 + } + + 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(), - })); + }) }) - }; - let copy_column_name_action = { - let s = sink.clone(); - let cols = columns.clone(); - let view = view.clone(); - cell_action!("copy-column-name", |cell, _row, _rows| { + } + + 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()) @@ -294,42 +337,24 @@ pub(super) fn install_grid_context_menus(init: GridMenuInit<'_>) -> GridMenus { .unwrap_or_default(); crate::ui::clipboard::copy_text(&cv, name, |message| s(GridMsg::ShowToast(message.to_owned()))); }) - }; - let copy_row_insert_action = { - let s = sink.clone(); - rows_action!("copy-row-insert", |_cell, rows| s(GridMsg::CopyRowsAsInsert { rows })) - }; - let show_row_json_action = { - let s = sink.clone(); - let cols = columns.clone(); - let tab = tab.clone(); - cell_action!("show-row-json", |_cell, row, _rows| s(GridMsg::ShowRowJson( - RowJsonRequest { + } + + 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), - } - ))) - }; - let set_empty_action = send_action!("set-empty", |cell, row| GridMsg::SetCellValue { - row, - column: cell.column(), - preset: CellPreset::Empty, - }); - let set_null_action = send_action!("set-null", |cell, row| GridMsg::SetCellValue { - row, - column: cell.column(), - preset: CellPreset::Null, - }); - let set_default_action = send_action!("set-default", |cell, row| GridMsg::SetCellValue { - row, - column: cell.column(), - preset: CellPreset::Default, - }); - let export_action = { - let s = sink.clone(); - let view = view.clone(); - let cols = columns.clone(); - let tab = tab.clone(); + }) + }) + } + + 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 }; @@ -339,61 +364,47 @@ pub(super) fn install_grid_context_menus(init: GridMenuInit<'_>) -> GridMenus { })); }) .build() - }; - let insert_row_action = { - let s = sink.clone(); - gio::ActionEntry::builder("insert-row") - .activate(move |_, _, _| s(GridMsg::InsertRow)) - .build() - }; - let delete_row_action = { - let s = sink.clone(); - rows_action!("delete-row", |_cell, rows| s(GridMsg::DeleteRows { rows })) - }; - let duplicate_row_action = { - let s = sink.clone(); - rows_action!("duplicate-row", |_cell, rows| s(GridMsg::DuplicateRows { rows })) - }; - - let mut entries = vec![ - copy_action, - copy_rows_action, - copy_rows_headers_action, - copy_json_action, - copy_csv_action, - copy_csv_headers_action, - copy_markdown_action, - copy_in_clause_action, - copy_column_name_action, - show_row_json_action, - export_action, - ]; - // 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([ - edit_action, - set_empty_action, - set_null_action, - set_default_action, - copy_row_insert_action, - insert_row_action, - delete_row_action, - duplicate_row_action, - ]); } - 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(), - }; + /// 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 @@ -404,56 +415,39 @@ pub(super) fn install_grid_context_menus(init: GridMenuInit<'_>) -> GridMenus { popover.set_parent(column_view); popover }; - // A read-only grid has a single shape and both fields name it. - let (editable_popover, readonly_popover) = if editable { + if editable { (make_popover(MenuShape::FULL), make_popover(MenuShape::ROW_OPS)) } else { let readonly = make_popover(MenuShape::READ_ONLY); (readonly.clone(), readonly) - }; - - let mut popovers_for_destroy = vec![editable_popover.clone()]; - if readonly_popover != editable_popover { - popovers_for_destroy.push(readonly_popover.clone()); } +} - if editable { - 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); - popovers_for_destroy.push(empty_popover.clone()); - - let view_for_empty = view.clone(); - let empty_gesture = gtk::GestureClick::builder().button(3).build(); - empty_gesture.connect_pressed(move |g, _, x, y| { - let Some(cv) = view_for_empty.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); - empty_popover.set_pointing_to(Some(>k::gdk::Rectangle::new(x as i32, y as i32, 1, 1))); - empty_popover.popup(); - }); - column_view.add_controller(empty_gesture); - } +/// 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); - column_view.connect_destroy(move |_| { - for popover in &popovers_for_destroy { - popover.unparent(); + 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(); }); - - GridMenus { - context, - editable_popover, - readonly_popover, - actions, - } + column_view.add_controller(empty_gesture); + empty_popover } /// The rows a menu item copies, as the grid shows them. diff --git a/linux/crates/app/src/ui/grid/inline_editor.rs b/linux/crates/app/src/ui/grid/inline_editor.rs index 424d8c5f6f..11beea5b6f 100644 --- a/linux/crates/app/src/ui/grid/inline_editor.rs +++ b/linux/crates/app/src/ui/grid/inline_editor.rs @@ -67,6 +67,11 @@ impl InlineEditor { } } +/// 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 leaves before the commit is /// sent, so the redraw the commit causes meets no open editor. fn finish_inline(cell: &GridCell, commit: bool) { @@ -148,10 +153,16 @@ fn connect_commit(cell: &GridCell, text: >k::Text) { let focus = gtk::EventControllerFocus::new(); let weak = cell.downgrade(); - focus.connect_leave(move |_| { + 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; @@ -161,6 +172,14 @@ fn connect_commit(cell: &GridCell, text: >k::Text) { 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, diff --git a/linux/crates/app/src/ui/grid/mod.rs b/linux/crates/app/src/ui/grid/mod.rs index 35779dd881..88a5aa631d 100644 --- a/linux/crates/app/src/ui/grid/mod.rs +++ b/linux/crates/app/src/ui/grid/mod.rs @@ -10,6 +10,7 @@ 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; diff --git a/linux/crates/app/src/ui/grid/result_grid.rs b/linux/crates/app/src/ui/grid/result_grid.rs index 4fee174e87..b0de508e93 100644 --- a/linux/crates/app/src/ui/grid/result_grid.rs +++ b/linux/crates/app/src/ui/grid/result_grid.rs @@ -37,7 +37,7 @@ mod tests { use super::*; use crate::test_support::{descendants, drain_main_context, wait_until}; use crate::ui::clipboard::{CopyKind, CopyRequest}; - use crate::ui::grid::test_grid::{named, recorder}; + use crate::ui::grid::test_grid::{Grid, named, recorder}; use crate::ui::grid::{GridCell, GridMsg}; const WAIT: Duration = Duration::from_secs(10); @@ -218,6 +218,58 @@ mod tests { 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 copy_after_sort_copies_displayed_row() { let columns = [named("id", "integer", false), named("name", "text", false)]; diff --git a/linux/crates/app/src/ui/grid/test_grid.rs b/linux/crates/app/src/ui/grid/test_grid.rs index c4d34de0d8..de69a97fd2 100644 --- a/linux/crates/app/src/ui/grid/test_grid.rs +++ b/linux/crates/app/src/ui/grid/test_grid.rs @@ -218,6 +218,20 @@ impl Drop for Grid { } } +/// 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( vec![named("id", "integer", true), named("name", "text", false)], diff --git a/linux/crates/app/src/ui/history_dialog/history_formatting.rs b/linux/crates/app/src/ui/history_dialog/history_formatting.rs index c12e043878..730771b05c 100644 --- a/linux/crates/app/src/ui/history_dialog/history_formatting.rs +++ b/linux/crates/app/src/ui/history_dialog/history_formatting.rs @@ -16,7 +16,7 @@ pub(super) fn preview_title(query: &str) -> String { .find(|line| !line.is_empty() && !line.starts_with("--")) .unwrap_or(""); if first_line.is_empty() { - return gettext("Empty query"); + return gettext("Empty Query"); } truncated(first_line, TITLE_CHARS) } @@ -138,7 +138,7 @@ mod tests { #[test] fn a_query_of_only_comments_is_called_empty() { - assert_eq!(preview_title("-- nothing\n\n"), "Empty query"); + assert_eq!(preview_title("-- nothing\n\n"), "Empty Query"); } #[test] diff --git a/linux/crates/app/src/ui/history_dialog/tests.rs b/linux/crates/app/src/ui/history_dialog/tests.rs index aeeba8237b..5afa83779a 100644 --- a/linux/crates/app/src/ui/history_dialog/tests.rs +++ b/linux/crates/app/src/ui/history_dialog/tests.rs @@ -548,6 +548,22 @@ fn filters_are_labelled() { } } +#[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(); diff --git a/linux/crates/app/src/ui/icons.rs b/linux/crates/app/src/ui/icons.rs index 081bdc549e..ce292c2685 100644 --- a/linux/crates/app/src/ui/icons.rs +++ b/linux/crates/app/src/ui/icons.rs @@ -23,6 +23,7 @@ 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"; @@ -76,6 +77,7 @@ pub const ALL: &[&str] = &[ LIST_ADD, LIST_REMOVE, MEDIA_PLAYBACK_STOP, + NETWORK_ERROR, NETWORK_SERVER, OPEN_MENU, PAN_DOWN, diff --git a/linux/crates/app/src/ui/import_dialog.rs b/linux/crates/app/src/ui/import_dialog.rs index b40e012f5c..c4a39f6b6e 100644 --- a/linux/crates/app/src/ui/import_dialog.rs +++ b/linux/crates/app/src/ui/import_dialog.rs @@ -128,7 +128,7 @@ impl Component for ImportCsvDialog { ])) .build(); let header_row = adw::SwitchRow::builder() - .title(crate::i18n::gettext("First row is a header")) + .title(crate::i18n::gettext("First Row Is a Header")) .subtitle(crate::i18n::gettext( "Its values name the fields instead of being imported.", )) diff --git a/linux/crates/app/src/ui/mod.rs b/linux/crates/app/src/ui/mod.rs index b5c8d36802..07fcfdd166 100644 --- a/linux/crates/app/src/ui/mod.rs +++ b/linux/crates/app/src/ui/mod.rs @@ -1,6 +1,7 @@ mod a11y; mod browse_tab; pub(crate) mod clipboard; +mod combo_value; mod connect_dialog; mod connection_list_banner; pub(crate) mod credential_prompt; diff --git a/linux/crates/app/src/ui/picker/connection_menu.rs b/linux/crates/app/src/ui/picker/connection_menu.rs index 33dc6aa97d..1f5cdbfeff 100644 --- a/linux/crates/app/src/ui/picker/connection_menu.rs +++ b/linux/crates/app/src/ui/picker/connection_menu.rs @@ -1,15 +1,16 @@ use gtk4::prelude::*; use gtk4::{gio, glib}; -use tablepro_storage::ConnectionColor; +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 SET_GROUP: &str = "set-group"; +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. @@ -27,15 +28,23 @@ pub(super) const NONE: &str = ""; /// 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: &[String]) -> gio::Menu { +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)); - open.append_item(&item(&crate::i18n::gettext("Duplicate"), PICKER, DUPLICATE, &target)); - open.append_submenu(Some(&crate::i18n::gettext("Colour")), &color_menu()); - open.append_submenu(Some(&crate::i18n::gettext("Group")), &group_menu(&target, groups)); 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)); @@ -67,9 +76,9 @@ fn color_menu() -> gio::Menu { menu } -/// The groups already in the list, as a radio group, with a way to -/// start a new one at the end. -fn group_menu(target: &glib::Variant, groups: &[String]) -> gio::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"), @@ -80,7 +89,12 @@ fn group_menu(target: &glib::Variant, groups: &[String]) -> gio::Menu { if !groups.is_empty() { let existing = gio::Menu::new(); for group in groups { - existing.append_item(&item(&menu_label(group), ROW, ROW_GROUP, &group.to_variant())); + existing.append_item(&item( + &menu_label(group.as_str()), + ROW, + ROW_GROUP, + &group.as_str().to_variant(), + )); } menu.append_section(None, &existing); } @@ -164,12 +178,35 @@ mod tests { } } + 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(), - &["Archive".to_owned(), "Work".to_owned()], - )); + 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:?}"); @@ -179,7 +216,7 @@ mod tests { #[test] fn a_group_name_keeps_its_underscores_in_the_menu() { - let found = items(connection_menu(Uuid::new_v4(), &["dev_db".to_owned()]).upcast_ref()); + let found = items(connection_menu(Uuid::new_v4(), &[group("dev_db")]).upcast_ref()); let group = found .iter() diff --git a/linux/crates/app/src/ui/picker/connection_row.rs b/linux/crates/app/src/ui/picker/connection_row.rs index 484f178b46..65fdf39156 100644 --- a/linux/crates/app/src/ui/picker/connection_row.rs +++ b/linux/crates/app/src/ui/picker/connection_row.rs @@ -6,10 +6,10 @@ use gtk4::{gdk, gio, glib}; use libadwaita as adw; use libadwaita::prelude::*; use tablepro_session::runtime::Tasks; -use tablepro_storage::{ConnectionColor, SavedConnection}; +use tablepro_storage::{ConnectionColor, ConnectionGroupName, SavedConnection}; use super::connection_menu::{ - CONNECT, NONE, PICKER, ROW, ROW_COLOR, ROW_GROUP, ROW_MENU, SET_COLOR, SET_GROUP, color_name, 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; @@ -20,7 +20,11 @@ use crate::ui::a11y::describe_icon_button; /// 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: &[String], tasks: Option<&Tasks>) -> gtk::Widget { +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) @@ -74,10 +78,10 @@ fn row_actions(row: &adw::ActionRow, saved: &SavedConnection, menu_button: >k: }); actions.add_action(&menu); let current_color = saved.color.map_or(NONE, |color| color.id()); - let current_group = crate::services::saved_connection_order::group_of(saved).unwrap_or(NONE); + 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, SET_GROUP), + (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()); diff --git a/linux/crates/app/src/ui/picker/mod.rs b/linux/crates/app/src/ui/picker/mod.rs index aa6d7b2f74..1524c0172b 100644 --- a/linux/crates/app/src/ui/picker/mod.rs +++ b/linux/crates/app/src/ui/picker/mod.rs @@ -2,6 +2,7 @@ mod connection_menu; mod connection_row; +mod new_group_dialog; mod picker_actions; #[cfg(test)] mod tests; 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 index fa20cb3522..7e4a2317a0 100644 --- a/linux/crates/app/src/ui/picker/picker_actions.rs +++ b/linux/crates/app/src/ui/picker/picker_actions.rs @@ -1,14 +1,12 @@ use std::rc::Rc; use gtk4 as gtk; -use gtk4::gio; use gtk4::prelude::*; -use libadwaita as adw; -use libadwaita::prelude::*; -use tablepro_storage::{ConnectionColor, SavedConnection}; +use gtk4::{gio, glib}; +use tablepro_storage::{ConnectionColor, ConnectionGroupName, SavedConnection}; use uuid::Uuid; -use super::connection_menu::{CONNECT, DELETE, DUPLICATE, NEW_GROUP, NONE, PICKER, SET_COLOR, SET_GROUP}; +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; @@ -25,6 +23,7 @@ pub(crate) fn install_picker_actions( let actions = gio::SimpleActionGroup::new(); let by_id = [ (CONNECT, OnConnection::Connect), + (EDIT, OnConnection::Edit), (DELETE, OnConnection::Delete), (DUPLICATE, OnConnection::Duplicate), (NEW_GROUP, OnConnection::NewGroup), @@ -38,14 +37,19 @@ pub(crate) fn install_picker_actions( }; 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(Box::new(saved))), - OnConnection::NewGroup => ask_for_group(root.upgrade().as_ref(), saved.id, &sender), + 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), (SET_GROUP, Setting::Group)] { + 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| { @@ -56,12 +60,17 @@ pub(crate) fn install_picker_actions( let Some(saved) = connection(&list, Some(id), name) else { return; }; - sender.deliver(match picked { + let input = match picked { Setting::Color => SessionWindowInput::SetConnectionColor(saved.id, ConnectionColor::from_id(&value)), - Setting::Group => { - SessionWindowInput::SetConnectionGroup(saved.id, Some(value).filter(|group| group != NONE)) - } - }); + 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); } @@ -72,6 +81,7 @@ pub(crate) fn install_picker_actions( #[derive(Debug, Clone, Copy)] enum OnConnection { Connect, + Edit, Delete, Duplicate, NewGroup, @@ -84,6 +94,14 @@ enum Setting { 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. @@ -99,24 +117,31 @@ fn connection(list: &SavedConnectionList, target: Option, action: &str) saved } -/// Ask for a group's name, then file the connection under it. -fn ask_for_group(parent: Option<>k::Widget>, id: Uuid, sender: &InputSender) { - let dialog = adw::AlertDialog::new(Some(&crate::i18n::gettext("New Group")), None); - let entry = adw::EntryRow::builder().title(crate::i18n::gettext("Name")).build(); - 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("add", &crate::i18n::gettext("Add")); - dialog.set_response_appearance("add", adw::ResponseAppearance::Suggested); - dialog.set_default_response(Some("add")); - dialog.set_close_response("cancel"); - let sender = sender.clone(); - dialog.connect_response(Some("add"), move |_, _| { - let name = entry.text().trim().to_owned(); - if !name.is_empty() { - sender.deliver(SessionWindowInput::SetConnectionGroup(id, Some(name))); +/// 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))); } }); - dialog.present(parent); +} + +#[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 index 0429df5d2c..c1b31830d1 100644 --- a/linux/crates/app/src/ui/picker/tests.rs +++ b/linux/crates/app/src/ui/picker/tests.rs @@ -35,7 +35,7 @@ fn connection(name: &str, group: Option<&str>) -> SavedConnection { ssh: None, last_opened_at: None, color: None, - group: group.map(str::to_owned), + group: group.map(|group| tablepro_storage::ConnectionGroupName::new(group).expect("a group name")), } } @@ -130,7 +130,7 @@ fn successful_connect_stamp_reorders_every_picker() { } #[gtk4::test] -fn a_group_of_connections_is_a_section_of_its_own() { +fn picker_renders_one_group_per_section() { let pickers = Pickers::new(); let (loose, a, b, c) = ( connection("loose", None), @@ -146,7 +146,7 @@ fn a_group_of_connections_is_a_section_of_its_own() { assert_eq!( view.sections(), [ - ("Saved Connections".to_owned(), vec!["loose".to_owned()]), + ("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()]), ] @@ -159,7 +159,7 @@ fn a_group_of_connections_is_a_section_of_its_own() { assert_eq!( view.sections(), [ - ("Saved Connections".to_owned(), vec!["d".to_owned()]), + ("Connections".to_owned(), vec!["d".to_owned()]), ("Work".to_owned(), vec!["a".to_owned()]), ], "a group with no connection left kept its section" @@ -172,6 +172,14 @@ fn a_group_of_connections_is_a_section_of_its_own() { [("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] @@ -360,3 +368,72 @@ fn unreadable_list_reveals_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/preferences_dialog/data_browser_group.rs b/linux/crates/app/src/ui/preferences_dialog/data_browser_group.rs new file mode 100644 index 0000000000..3a2c492dd0 --- /dev/null +++ b/linux/crates/app/src/ui/preferences_dialog/data_browser_group.rs @@ -0,0 +1,110 @@ +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::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 {} + 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_DESTRUCTIVE, &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_destructive(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_destructive()); + } + + #[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"); + } +} 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..52d26f1325 --- /dev/null +++ b/linux/crates/app/src/ui/preferences_dialog/general_page.rs @@ -0,0 +1,166 @@ +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: Option, + 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, tasks: &Tasks) -> GeneralPage { + crate::register_test_resources(); + let page = GeneralPage::default(); + page.bind( + settings.get(), + storage, + None, + 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 page = bound_page(&settings, &storage, &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 page = bound_page(&settings, &storage, &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 page = bound_page(&settings, &storage, &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_destructive()); + 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..cebecbe578 --- /dev/null +++ b/linux/crates/app/src/ui/preferences_dialog/history_group.rs @@ -0,0 +1,336 @@ +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 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 { + #[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 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: Option, + 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)); + + // The database opens on a background task, so the button stays + // insensitive until it is there to clear. + imp.clear_button.set_sensitive(history.is_some()); + let group = self.downgrade(); + let tasks = tasks.clone(); + imp.clear_button.connect_clicked(move |_| { + if let Some(group) = group.upgrade() { + group.confirm_clear(history.clone(), &tasks); + } + }); + + 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)), + ) + } + + fn confirm_clear(&self, history: Option, tasks: &Tasks) { + let alert = adw::AlertDialog::new( + Some(&crate::i18n::gettext("Clear All Query History?")), + Some(&crate::i18n::gettext( + "This permanently deletes every saved query, including pinned ones.", + )), + ); + 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 Some(history) = history.clone() else { + return; + }; + 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, + tasks: &Tasks, + revealer: Rc, + ) -> HistoryGroup { + crate::register_test_resources(); + let group = HistoryGroup::default(); + group.bind(settings.get(), storage, None, 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 group = bound_group( + &settings, + &storage, + &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 group = bound_group( + &settings, + &storage, + &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 group = bound_group(&settings, &storage, &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 group = bound_group( + &settings, + &storage, + &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 group = bound_group( + &settings, + &storage, + &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_history_is_insensitive_until_the_database_opens() { + let settings = MemorySettings::new(); + let runtime = test_runtime(); + let storage = test_storage(&runtime.tasks()); + + let starting = bound_group( + &settings, + &storage, + &runtime.tasks(), + Rc::new(RecordingRevealer::succeeding()), + ); + + assert!( + !starting.imp().clear_button.is_sensitive(), + "Clear offered to clear a database that is not open" + ); + } +} diff --git a/linux/crates/app/src/ui/preferences_dialog/imp.rs b/linux/crates/app/src/ui/preferences_dialog/imp.rs index 15011d0935..5f9bb97027 100644 --- a/linux/crates/app/src/ui/preferences_dialog/imp.rs +++ b/linux/crates/app/src/ui/preferences_dialog/imp.rs @@ -2,42 +2,22 @@ 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 restore_session_row: TemplateChild, - #[template_child] - pub page_size_row: TemplateChild, - #[template_child] - pub confirm_row: TemplateChild, - #[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, - #[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, + pub general_page: TemplateChild, #[template_child] - pub timeout_row: TemplateChild, + pub editor_page: TemplateChild, pub settings: RefCell>>, } @@ -48,6 +28,8 @@ impl ObjectSubclass for PreferencesDialog { type ParentType = adw::PreferencesDialog; fn class_init(klass: &mut Self::Class) { + GeneralPage::ensure_type(); + EditorPage::ensure_type(); klass.bind_template(); } diff --git a/linux/crates/app/src/ui/preferences_dialog/mod.rs b/linux/crates/app/src/ui/preferences_dialog/mod.rs index 5eedaef3e1..2435bd66f4 100644 --- a/linux/crates/app/src/ui/preferences_dialog/mod.rs +++ b/linux/crates/app/src/ui/preferences_dialog/mod.rs @@ -1,19 +1,25 @@ +mod data_browser_group; +mod editor_page; +mod general_page; +mod history_group; mod imp; use std::rc::Rc; -use gio::prelude::SettingsExtManual; 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 crate::ui::file_reveal::{FileRevealer, reveal_failure_toast}; -use crate::ui::style_scheme_grid; +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) @@ -22,6 +28,8 @@ glib::wrapper! { } 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, @@ -30,164 +38,40 @@ impl PreferencesDialog { revealer: Rc, ) -> Self { let dialog: Self = glib::Object::new(); - dialog.imp().settings.replace(Some(settings.clone())); - dialog.bind(settings, storage); - dialog.connect_history_actions(storage, history, tasks, revealer); + 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 } +} - /// Every row binds straight to GSettings, so a change reaches any - /// other view through `changed::` without a save step. - fn bind(&self, settings: &Rc, storage: &crate::storage::SharedStorage) { - let imp = self.imp(); - let gio_settings = settings.gio(); - - gio_settings - .bind(keys::RESTORE_SESSION, &imp.restore_session_row.get(), "active") - .build(); - gio_settings - .bind(keys::DEFAULT_PAGE_SIZE, &imp.page_size_row.get(), "value") - .build(); - gio_settings - .bind(keys::CONFIRM_DESTRUCTIVE, &imp.confirm_row.get(), "active") - .build(); - // 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(); - 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); - - // The path is known from the configuration even before the - // database opens, so the row is never blank. - let path = storage.paths().history_database().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)); - } - - fn connect_history_actions( - &self, - storage: &crate::storage::SharedStorage, - history: Option, - tasks: &Tasks, - revealer: Rc, - ) { - let imp = self.imp(); - let history_path = storage.paths().history_database(); - - // The database opens on a background task, so the button stays - // insensitive until it is there to clear. - imp.clear_button.set_sensitive(history.is_some()); - - let dialog = self.clone(); - let tasks = tasks.clone(); - let history_for_clear = history; - imp.clear_button.connect_clicked(move |_| { - let alert = adw::AlertDialog::new( - Some(&crate::i18n::gettext("Clear all query history?")), - Some(&crate::i18n::gettext( - "This permanently deletes every saved query, including pinned ones.", - )), - ); - 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 history_for_clear = history_for_clear.clone(); - let tasks = tasks.clone(); - let dialog_for_toast = dialog.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 Some(history) = history_for_clear.clone() else { - return; - }; - let clearing = tasks.spawn_task(async move { history.clear_all().await }); - let dialog = dialog_for_toast.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.") - } - }; - dialog.add_toast(crate::ui::plain_toast(&message)); - }); - }); - alert.present(Some(&dialog)); - }); - - let dialog = self.downgrade(); - imp.storage_button.connect_clicked(move |_| { - let Some(shown_from) = dialog.upgrade() else { - return; - }; - let revealing = revealer.reveal(gio::File::for_path(&history_path), shown_from.root().and_downcast()); - let dialog = dialog.clone(); - glib::spawn_future_local(async move { - let revealed = revealing.await; - if let (Some(toast), Some(dialog)) = (reveal_failure_toast(&revealed), dialog.upgrade()) { - dialog.add_toast(toast); - } - }); - }); - } +/// 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 tablepro_storage::{EditorFont, StoragePaths}; - - use crate::test_support::{MemorySettings, test_runtime}; - use crate::ui::file_reveal::RecordingRevealer; + use tablepro_storage::StoragePaths; 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 @@ -208,145 +92,75 @@ mod tests { ) } - fn test_storage(tasks: &Tasks) -> crate::storage::SharedStorage { - // A temporary root keeps the dialog's storage row and history - // actions away from the developer's own files. - let root = std::env::temp_dir().join(format!("tablepro-prefs-{}", std::process::id())); - let paths = 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, - )) - } - - #[gtk4::test] - fn preferences_dialog_template_builds() { - let settings = MemorySettings::new(); - - let runtime = test_runtime(); - let storage = test_storage(&runtime.tasks()); - let dialog = new_dialog(&settings, &storage, None, &runtime.tasks()); - let imp = dialog.imp(); - - assert_eq!(imp.page_size_row.title(), "Default page size"); - assert_eq!(imp.timeout_row.title(), "Query timeout (seconds)"); - assert!(imp.style_scheme_box.first_child().is_some(), "no style scheme previews"); - } - - #[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_destructive(false).expect("store the flag"); - - let runtime = test_runtime(); - let storage = test_storage(&runtime.tasks()); - let dialog = new_dialog(&settings, &storage, None, &runtime.tasks()); + /// 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(); - - 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_destructive()); + [imp.general_page.get().upcast(), imp.editor_page.get().upcast()] } #[gtk4::test] - fn the_restore_session_row_is_bound() { - let settings = MemorySettings::new(); - let runtime = test_runtime(); - let storage = test_storage(&runtime.tasks()); - let dialog = new_dialog(&settings, &storage, None, &runtime.tasks()); - let row = dialog.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 preferences_history_switch_bound_to_setting() { - let settings = MemorySettings::new(); - let runtime = test_runtime(); - let storage = test_storage(&runtime.tasks()); - let dialog = new_dialog(&settings, &storage, None, &runtime.tasks()); - let row = dialog.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_font_row_is_insensitive_while_the_system_font_is_on() { + fn preferences_dialog_template_builds() { let settings = MemorySettings::new(); - let runtime = test_runtime(); let storage = test_storage(&runtime.tasks()); let dialog = new_dialog(&settings, &storage, None, &runtime.tasks()); - let imp = dialog.imp(); + let [general, editor] = pages(&dialog); - 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!(general.title(), "General"); + assert_eq!(editor.title(), "Editor"); + assert_eq!(dialog.visible_page().as_ref(), Some(&general)); + dialog.set_visible_page(&editor); assert_eq!( - imp.font_button.font_desc().map(|font| font.to_str().to_string()), - Some("Monospace 11".to_owned()) + dialog.visible_page().as_ref(), + Some(&editor), + "the Editor page is not in the dialog" ); } #[gtk4::test] - fn the_storage_row_names_the_history_database() { + fn every_preferences_page_audits_clean() { let settings = MemorySettings::new(); - let runtime = test_runtime(); let storage = test_storage(&runtime.tasks()); let dialog = new_dialog(&settings, &storage, None, &runtime.tasks()); - let subtitle = dialog.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() { - crate::register_test_resources(); - let settings = MemorySettings::new(); - let runtime = test_runtime(); - let storage = test_storage(&runtime.tasks()); - let revealer = Rc::new(RecordingRevealer::succeeding()); - let dialog = PreferencesDialog::new(settings.get(), &storage, None, &runtime.tasks(), revealer.clone()); - - dialog.imp().storage_button.emit_clicked(); - - assert_eq!(revealer.revealed(), [storage.paths().history_database()]); + 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 clear_history_is_insensitive_until_the_database_opens() { + fn the_dialog_is_freed_once_dropped() { let settings = MemorySettings::new(); let runtime = test_runtime(); let storage = test_storage(&runtime.tasks()); + let dialog = new_dialog(&settings, &storage, None, &runtime.tasks()); + let history_group = dialog.imp().general_page.history_group().downgrade(); + let freed = dialog.downgrade(); - let starting = new_dialog(&settings, &storage, None, &runtime.tasks()); + drop(dialog); + crate::test_support::drain_main_context(); + assert!(freed.upgrade().is_none(), "the dialog outlived its last reference"); assert!( - !starting.imp().clear_button.is_sensitive(), - "Clear offered to clear a database that is not open" + history_group.upgrade().is_none(), + "the history group outlived its dialog" ); } @@ -362,9 +176,9 @@ mod tests { assert_eq!(count(&runtime, &history), 1); let dialog = new_dialog(&settings, &storage, Some(history.clone()), &runtime.tasks()); - assert!(dialog.imp().clear_button.is_sensitive()); + assert!(clear_button(&dialog).is_sensitive()); let window = present_on_a_window(&dialog); - dialog.imp().clear_button.emit_clicked(); + clear_button(&dialog).emit_clicked(); confirm_the_alert(&window); // The toast is posted once the clear has finished, so it is @@ -385,6 +199,10 @@ mod tests { 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 @@ -397,8 +215,7 @@ mod tests { let window = adw::Window::new(); window.present(); dialog.present(Some(&window)); - crate::test_support::wait_until(WAIT, || dialog.imp().clear_button.is_mapped()) - .expect("the dialog never appeared"); + crate::test_support::wait_until(WAIT, || clear_button(dialog).is_mapped()).expect("the dialog never appeared"); window } diff --git a/linux/crates/app/src/ui/saved_queries_dialog.rs b/linux/crates/app/src/ui/saved_queries_dialog.rs index b6a0510696..5cf5faab47 100644 --- a/linux/crates/app/src/ui/saved_queries_dialog.rs +++ b/linux/crates/app/src/ui/saved_queries_dialog.rs @@ -115,7 +115,7 @@ impl Component for SavedQueriesDialog { let status_page = adw::StatusPage::builder() .icon_name(crate::ui::icons::DOCUMENT_OPEN_RECENT) - .title(crate::i18n::gettext("No saved queries")) + .title(crate::i18n::gettext("No Saved Queries")) .description(crate::i18n::gettext( "Save a query from the editor and it will be listed here.", )) @@ -256,12 +256,12 @@ impl SavedQueriesDialog { 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_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_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.", ))); 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 index cffbf1b497..a3653a9d66 100644 --- a/linux/crates/app/src/ui/session_window/connect_failed_page.rs +++ b/linux/crates/app/src/ui/session_window/connect_failed_page.rs @@ -1,10 +1,12 @@ use gtk4 as gtk; use gtk4::glib; +use gtk4::prelude::*; use gtk4::subclass::prelude::*; use libadwaita as adw; -use libadwaita::prelude::*; use tablepro_core::credentials::SecretKind; +use super::connect_failure_view::ConnectFailureView; + mod imp { use super::*; use libadwaita::subclass::prelude::*; @@ -24,6 +26,10 @@ mod imp { 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] @@ -55,22 +61,26 @@ glib::wrapper! { } impl ConnectFailedPage { - /// `edit` labels the button that opens the connect dialog, and is - /// `None` for a connection the dialog cannot edit, such as a file - /// nobody saved. `reenter` is the secret the server refused, which - /// the user is offered to type again. - pub fn new(message: &str, edit: Option<&str>, reenter: Option, app: Option<>k::Application>) -> Self { + /// `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)); // 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(message))); - if let Some(label) = edit { - imp.edit_button.set_label(label); - imp.edit_button.set_visible(true); - } + .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)); imp.reenter_button @@ -81,6 +91,40 @@ impl ConnectFailedPage { 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() } @@ -97,4 +141,21 @@ impl ConnectFailedPage { 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() + } } 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..e27602a0bf --- /dev/null +++ b/linux/crates/app/src/ui/session_window/connect_failure_view.rs @@ -0,0 +1,102 @@ +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, +} + +impl ConnectFailureView { + pub(crate) fn new(failure: &ConnectFailure, source: &ConnectSource) -> Self { + Self { + message: failure.message.clone(), + can_edit: can_edit(source), + } + } +} + +/// 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/connecting_page.rs b/linux/crates/app/src/ui/session_window/connecting_page.rs index bf4c540588..05798b1d5c 100644 --- a/linux/crates/app/src/ui/session_window/connecting_page.rs +++ b/linux/crates/app/src/ui/session_window/connecting_page.rs @@ -1,5 +1,6 @@ use gtk4 as gtk; use gtk4::glib; +use gtk4::prelude::*; use gtk4::subclass::prelude::*; use libadwaita as adw; @@ -16,6 +17,10 @@ mod imp { 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] @@ -47,16 +52,50 @@ glib::wrapper! { } impl ConnectingPage { - pub fn new(name: &str, app: Option<>k::Application>) -> Self { + pub fn new(app: Option<>k::Application>) -> Self { let page: Self = glib::Object::new(); - let imp = page.imp(); - imp.status_page - .set_title(&crate::i18n::gettext_f("Connecting to {name}", &[("name", name)])); - imp.header_bar.pack_end(&crate::ui::shell::primary_menu_button(app)); + 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 index 891068a9bf..f07d003d69 100644 --- a/linux/crates/app/src/ui/session_window/connections.rs +++ b/linux/crates/app/src/ui/session_window/connections.rs @@ -9,14 +9,16 @@ use libadwaita::prelude::*; use tablepro_session::connect::StoreConnectionWrites; use tablepro_session::{ConnectSource, PendingSave, RequestTarget, SessionKey}; -use tablepro_storage::{ConnectionListState, SavedConnection}; +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::ui::connect_dialog::{ConnectDialog, ConnectDialogInit, ConnectPrefill, FormField}; +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; @@ -33,28 +35,29 @@ fn typed_password(target: &RequestTarget) -> Option { } } -/// What the connect dialog opens on to edit a connection that failed: +/// 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::Saved(saved) => Some(ConnectPrefill { - connection: (**saved).clone(), - password: None, - }), ConnectSource::Intent(intent) => intent.pending_save.as_ref().map(|save| ConnectPrefill { connection: save.connection.clone(), password: typed_password(&intent.request.target), }), - ConnectSource::File(_) => None, + ConnectSource::Saved(_) | ConnectSource::File(_) => None, } } impl SessionWindow { - /// Show the connect dialog over this window, 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, prefill: Option, focus: Option) { + /// 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; }; @@ -74,12 +77,18 @@ impl SessionWindow { }); if let Some(window) = window.upgrade() { window.imp().dialog_opening.set(false); - window.show_connect_dialog(prefill, focus, environment); + window.show_connect_dialog(mode, prefill, focus, environment); } }); } - fn show_connect_dialog(&self, prefill: Option, focus: Option, ssh: SshEnvironment) { + fn show_connect_dialog( + &self, + mode: ConnectDialogMode, + prefill: Option, + focus: Option, + ssh: SshEnvironment, + ) { let (Some(state), Some(input)) = (self.state(), self.input()) else { return; }; @@ -96,11 +105,17 @@ impl SessionWindow { 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 { @@ -301,98 +316,121 @@ impl SessionWindow { }); } + /// 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. - /// - /// The copy takes a new id, so it carries its own secrets rather - /// than sharing the original's: removing either then leaves the - /// other able to connect. - pub(super) fn on_duplicate_connection(&self, source: SavedConnection) { - let (Some(state), Some(input)) = (self.state(), self.input()) else { + /// 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 copy = tablepro_storage::duplicate(&source, &state.services.saved_connections.connections()); - let name = copy.name.clone(); - let connections = state.services.storage.connections().clone(); - let secrets = state.services.storage.secrets().clone(); - let tasks = state.services.tasks.clone(); - crate::runtime::spawn_held(&state.services.tasks, &*state.services.hold(), async move { - let copy_id = copy.id; - let saved = tasks - .spawn_blocking_task(move || connections.upsert_blocking(copy)) - .await; - if let Ok(Err(error)) = saved { - tracing::warn!(%error, "could not save the duplicated connection"); - input.deliver(SessionWindowInput::ShowToast(crate::i18n::gettext( - "The connection could not be duplicated.", - ))); - return; - } - // A copy with no password still connects once the user types - // one, so a keyring that refuses is reported rather than - // undoing the copy. - let carried = copy_secrets(secrets.as_ref(), source.id, copy_id, &name).await; - input.deliver(SessionWindowInput::ShowToast(if carried { - crate::i18n::gettext_f("Duplicated as {name}", &[("name", &name)]) - } else { - crate::i18n::gettext_f( - "Duplicated as {name}. Its password could not be copied, so enter it again.", - &[("name", &name)], - ) - })); - }); + 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. An empty name means no group. - pub(super) fn on_set_connection_group(&self, id: Uuid, group: Option) { - let group = group.map(|name| name.trim().to_owned()).filter(|name| !name.is_empty()); - let Some(mut saved) = self.saved_connection(id) else { + /// is in. + pub(super) fn on_set_connection_group(&self, id: Uuid, group: Option) { + let Some(state) = self.state() else { return; }; - if saved.group == group { - return; - } - saved.group = group; - self.write_connection(saved, crate::i18n::gettext("The group could not be saved.")); + 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. - /// - /// The whole entry is rewritten because that is what the store - /// takes, and it comes from the list the window already holds rather - /// than from a re-read: the row that asked is showing that entry. - pub(super) fn on_set_connection_color(&self, id: Uuid, color: Option) { - let Some(mut saved) = self.saved_connection(id) else { + pub(super) fn on_set_connection_color(&self, id: Uuid, color: Option) { + let Some(state) = self.state() else { return; }; - if saved.color == color { - return; - } - saved.color = color; - self.write_connection(saved, crate::i18n::gettext("The colour could not be saved.")); + 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)]) + }); } - /// Write one connection back and reload the list from disk, so the - /// picker redraws from what was actually saved. The process is held - /// until the write lands, so a quit right after it keeps it. - fn write_connection(&self, saved: SavedConnection, failure: String) { - let (Some(state), Some(input)) = (self.state(), self.input()) else { + /// 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 connections = state.services.storage.connections().clone(); - let tasks = state.services.tasks.clone(); - crate::runtime::spawn_held(&state.services.tasks, &*state.services.hold(), async move { - let written = tasks - .spawn_blocking_task(move || connections.upsert_blocking(saved)) - .await; - if let Ok(Err(error)) = written { - tracing::warn!(%error, "could not save the connection"); - input.deliver(SessionWindowInput::ShowToast(failure)); + 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 @@ -573,36 +611,11 @@ impl SessionWindow { } } -/// Move every secret the original holds onto the copy's own id. -/// -/// Returns whether all of them made it: a keyring that is locked or -/// absent is a reason to tell the user, not to refuse the copy. -async fn copy_secrets( - secrets: &dyn tablepro_core::credentials::SecretVault, - from: Uuid, - to: Uuid, - label: &str, -) -> bool { - use tablepro_core::credentials::{SecretKind, SecretLookup}; - - let mut carried = true; - for kind in SecretKind::ALL { - match secrets.load(from, kind).await { - Ok(SecretLookup::Found(secret)) => { - if let Err(error) = secrets.store(to, kind, &secret, label).await { - tracing::warn!(%error, ?kind, "could not copy a secret onto the duplicate"); - carried = false; - } - } - // Nothing stored for this kind, so nothing to carry. - Ok(SecretLookup::NotStored) => {} - Err(error) => { - tracing::warn!(%error, ?kind, "could not read a secret to duplicate"); - carried = false; - } - } - } - carried +/// 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) { @@ -672,7 +685,7 @@ mod tests { use tablepro_test_support::Call; use super::*; - use crate::test_support::{finish_on_main, respond, wait_until}; + 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> { @@ -867,7 +880,7 @@ mod tests { "the hold ended before the write" ); - test.window.on_duplicate_connection(test.saved.clone()); + 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() @@ -882,6 +895,32 @@ mod tests { 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"); @@ -981,4 +1020,44 @@ mod tests { 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/imp.rs b/linux/crates/app/src/ui/session_window/imp.rs index 5260a96a0d..32cb4e34b2 100644 --- a/linux/crates/app/src/ui/session_window/imp.rs +++ b/linux/crates/app/src/ui/session_window/imp.rs @@ -11,6 +11,7 @@ use tablepro_session::{ConnectAttempts, SessionKey}; 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; @@ -70,6 +71,13 @@ pub struct SessionWindow { /// 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] diff --git a/linux/crates/app/src/ui/session_window/mod.rs b/linux/crates/app/src/ui/session_window/mod.rs index 9989252198..acea2afd5f 100644 --- a/linux/crates/app/src/ui/session_window/mod.rs +++ b/linux/crates/app/src/ui/session_window/mod.rs @@ -3,12 +3,19 @@ //! 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 imp; mod locate_file; +mod notification_request; +mod notification_sink; +#[cfg(test)] +mod notification_tests; +mod notifications; #[cfg(test)] mod open_file_tests; mod picker_page; @@ -23,6 +30,7 @@ mod sqlite_location_dialog; #[cfg(test)] pub(crate) mod tests; mod window_actions; +mod work_notifier; use std::rc::Rc; @@ -46,6 +54,7 @@ use tablepro_session::{ 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}; @@ -56,7 +65,7 @@ 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::ConnectSubmission; +use crate::ui::connect_dialog::{ConnectDialogMode, ConnectSubmission}; use crate::ui::welcome_view::WelcomeView; use crate::ui::workspace::{Workspace, WorkspaceInit, WorkspaceInput}; use crate::workspace::{ @@ -121,6 +130,7 @@ impl SessionWindow { } 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, @@ -243,6 +253,9 @@ impl SessionWindow { 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(); } @@ -288,13 +301,17 @@ impl SessionWindow { 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(None, None), + 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() { @@ -340,6 +357,9 @@ impl SessionWindow { 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), }, @@ -351,7 +371,7 @@ impl SessionWindow { 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(saved) => self.on_duplicate_connection(*saved), + 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(), @@ -383,45 +403,54 @@ impl SessionWindow { self.tear_down(); let imp = self.imp(); let app = self.application(); - let (child, toasts): (Option, Option) = match &page { + 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(&source_name(source), app.as_ref()); + let connecting = ConnectingPage::new(app.as_ref()); let toasts = connecting.toast_overlay(); - (Some(connecting.upcast()), Some(toasts)) + ( + Some(connecting.clone().upcast()), + Some(toasts), + ShownPage::Connecting(connecting, source_name(source)), + ) } SessionPage::ConnectFailed { source, failure, .. } => { - let failed = ConnectFailedPage::new( - &failure.message, - edit_label(source, failure).as_deref(), - failure.reenter, - app.as_ref(), - ); + 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.upcast()), Some(toasts)) - } - SessionPage::Workspace { workspace, .. } => { - (Some(workspace.clone().upcast()), Some(workspace.toast_overlay())) + (Some(failed.clone().upcast()), Some(toasts), ShownPage::Failed(failed)) } + SessionPage::Workspace { workspace, .. } => ( + Some(workspace.clone().upcast()), + Some(workspace.toast_overlay()), + ShownPage::Other, + ), }; - let editable = match &page { - SessionPage::ConnectFailed { source, .. } => is_editable(source), - _ => false, - }; - window_actions::sync(self.upcast_ref(), &page, editable); + 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); + // 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); } @@ -533,6 +562,7 @@ impl SessionWindow { 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))); } @@ -799,14 +829,28 @@ impl SessionWindow { } } + /// 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 prefill = match &*self.imp().page.borrow() { - SessionPage::ConnectFailed { source, failure, .. } => connections::prefill_for(source) - .map(|prefill| (prefill, failure.remedy.map(crate::ui::connect_dialog::remedy_field))), - _ => None, + 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, }; - if let Some((prefill, focus)) = prefill { - self.present_connect_dialog(Some(prefill), focus); + 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) => {} } } @@ -934,124 +978,151 @@ fn connect_source( demand: CredentialDemand, cancel: tokio_util::sync::CancellationToken, ) -> BoxFuture<'static, Result> { - let context = &state.context; + 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(); - match source { - ConnectSource::Saved(saved) => { - 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)); - 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) { - AttemptError::Failed(failure) => AttemptError::Failed(ConnectFailure { - reenter: secret_retry::refused_secret(&error, &saved), - ..failure - }), - cancelled => cancelled, - }), - } - } - .boxed() + 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)); + 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) { + AttemptError::Failed(failure) => AttemptError::Failed(ConnectFailure { + reenter: secret_retry::refused_secret(&error, &saved), + ..failure + }), + cancelled => cancelled, + }), } - ConnectSource::Intent(intent) => { - let mut request = intent.request.clone(); - request.workspace_settings.read_only |= read_only; - let Some(pending_save) = intent.pending_save.clone() else { - return async move { - connect_flow::connect_request(&*connector, request, interaction, cancel) - .await - .map(|pending| Connected { - pending, - saved: None, - warnings: Vec::new(), - session_only: Vec::new(), - }) - .map_err(|error| attempt_error(&error)) - } - .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)), - Err(connect_flow::SubmitError::Save(error)) => { - Err(AttemptError::Failed(ConnectFailure::new(crate::i18n::gettext_f( - "The connection could not be saved: {error}", - &[("error", &error.to_string())], - )))) - } - } - } - .boxed() + } + .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 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)) } - ConnectSource::File(identity) => { - let request = resolve_file_identity::file_request(identity, &context.drivers, read_only); - async move { - let request = request.map_err(|error| attempt_error(&error))?; - connect_flow::connect_request(&*connector, request, interaction, cancel) - .await - .map(|pending| Connected { - pending, - saved: None, - warnings: Vec::new(), - session_only: Vec::new(), - }) - .map_err(|error| attempt_error(&error)) + .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)), + Err(connect_flow::SubmitError::Save(error)) => { + Err(AttemptError::Failed(ConnectFailure::new(crate::i18n::gettext_f( + "The connection could not be saved: {error}", + &[("error", &error.to_string())], + )))) } - .boxed() } } + .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))?; + connect_flow::connect_request(&*connector, request, interaction, cancel) + .await + .map(unsaved) + .map_err(|error| attempt_error(&error)) + } + .boxed() +} + +fn unsaved(pending: PendingSession) -> Connected { + Connected { + pending, + saved: None, + warnings: Vec::new(), + session_only: Vec::new(), + } } fn attempt_error(error: &ConnectError) -> AttemptError { @@ -1084,20 +1155,19 @@ fn file_display_name(uri: &str) -> String { .map_or_else(|| uri.to_owned(), |name| name.to_string_lossy().into_owned()) } -/// Only a connection the connect dialog shows can be edited from a -/// failed connect. -fn is_editable(source: &ConnectSource) -> bool { - matches!(source, ConnectSource::Saved(_) | ConnectSource::Intent(_)) -} - /// The failed page's Edit button: named after the setting that fixes a /// TLS failure when there is one. -fn edit_label(source: &ConnectSource, failure: &ConnectFailure) -> Option { - is_editable(source).then(|| { - failure - .remedy - .map_or_else(|| crate::i18n::gettext("Edit Connection…"), |remedy| remedy.label()) - }) +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 { 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/notification_tests.rs b/linux/crates/app/src/ui/session_window/notification_tests.rs new file mode 100644 index 0000000000..66c1f581aa --- /dev/null +++ b/linux/crates/app/src/ui/session_window/notification_tests.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::SessionWindowInput; +use super::application_notification_sink::ApplicationNotificationSink; +use super::notification_request::NotificationRequest; +use super::tests::{TestWindow, WAIT, editors}; +use super::work_notifier::tests::{Notice, RecordingNotificationSink}; +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/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/session_teardown.rs b/linux/crates/app/src/ui/session_window/session_teardown.rs index f73dd201ce..8b5f7de86b 100644 --- a/linux/crates/app/src/ui/session_window/session_teardown.rs +++ b/linux/crates/app/src/ui/session_window/session_teardown.rs @@ -54,6 +54,7 @@ impl SessionWindow { 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 @@ -167,7 +168,11 @@ mod tests { 1, "the connection closed more than once" ); - assert!(workspace.upgrade().is_none(), "the workspace outlived its teardown"); + // 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 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 index efd048cb55..dc682fc313 100644 --- a/linux/crates/app/src/ui/session_window/session_window_input.rs +++ b/linux/crates/app/src/ui/session_window/session_window_input.rs @@ -1,6 +1,6 @@ use tablepro_core::credentials::SecretKind; use tablepro_session::SessionKey; -use tablepro_storage::{ConnectionColor, SavedConnection}; +use tablepro_storage::{ConnectionColor, ConnectionGroupName, SavedConnection}; use uuid::Uuid; use super::resolve_file_identity::ResolvedFile; @@ -8,7 +8,7 @@ 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; +use crate::workspace::{CloseReason, CompletedWork}; #[derive(Debug)] pub(crate) enum SessionWindowInput { @@ -44,6 +44,10 @@ pub(crate) enum SessionWindowInput { /// 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, @@ -64,6 +68,12 @@ pub(crate) enum SessionWindowInput { /// 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. @@ -88,13 +98,13 @@ pub(crate) enum SessionWindowInput { DeleteConnection(Uuid), /// The user confirmed the delete. ConfirmDelete(Uuid, DeletionOptions), - DuplicateConnection(Box), + 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), + SetConnectionGroup(Uuid, Option), ExportConnections, ImportConnections, /// The file the user picked to import, read off the GTK thread. diff --git a/linux/crates/app/src/ui/session_window/tests.rs b/linux/crates/app/src/ui/session_window/tests.rs index e4686c8de9..08c3c56b75 100644 --- a/linux/crates/app/src/ui/session_window/tests.rs +++ b/linux/crates/app/src/ui/session_window/tests.rs @@ -80,11 +80,7 @@ impl TestWindow { } pub(crate) fn with_script(name: &str, script: Script) -> Self { - 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); - } + init_gtk(); let runtime = crate::test_support::test_runtime(); let settings = MemorySettings::new(); let dir = tempfile::tempdir().expect("a temporary directory"); @@ -102,31 +98,13 @@ impl TestWindow { unreadable_list, window_size, } = script; - if let Some((width, height)) = window_size { - settings - .get() - .set_window_geometry(tablepro_storage::WindowGeometry { - width, - height, - maximized: false, - }) - .expect("store the window size"); + if let Some(size) = window_size { + remember_window_size(&settings, size); } let network = network || tunnel.is_some(); - let saved = match tunnel { - Some(tunnel) => SavedConnection { - ssh: Some(tunnel), - auth_mode: AuthMode::Kerberos, - ..network_connection() - }, - None if network => network_connection(), - None => saved_connection(&dir.path().join("fake.db")), - }; + let saved = scripted_connection(network, tunnel, dir.path()); if unreadable_list { - 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"); + write_unreadable_list(&list_file); } else { storage .connections() @@ -146,7 +124,7 @@ impl TestWindow { let connector = FakeConnector::new(establish).always(FakeEstablish::Connect(connection)); let liveness = LivenessPolicy::DESKTOP; let hold = Rc::new(CountingHold::new()); - let mut services = AppServices::new(AppServicesParts { + let parts = AppServicesParts { drivers, connector: Arc::new(connector.clone()), deadlines: Arc::new(DefaultDeadlines::new(liveness)), @@ -154,21 +132,9 @@ impl TestWindow { settings: settings.get().clone(), storage, tasks: runtime.tasks(), - }) - .with_hold(hold.clone()); - if let Some(credentials) = credentials { - services = services.with_credentials(Rc::new(FakeSources(credentials))); - } - if let Some(ssh) = ssh { - services = services.with_ssh(ssh); - } - let app = TableProApplication::new(services); - app.set_application_id(Some(&format!("{}.Test.Window{name}", crate::config::SCHEMA_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"); + }; + let services = scripted_services(parts, hold.clone(), credentials, ssh); + let (app, window) = open_picker_window(name, services); let services = app.services().expect("the application's services"); let test = Self { app, @@ -246,6 +212,77 @@ impl TestWindow { } } +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::SCHEMA_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() { @@ -443,7 +480,7 @@ fn claiming_open_key_presents_owner_window() { } #[gtk4::test] -fn cancel_connect_discards_late_attempt() { +fn f002_cancel_connect_discards_late_attempt() { let test = TestWindow::with_script( "CancelLate", Script { @@ -554,6 +591,179 @@ fn a_failed_connect_shows_why_and_choose_connection_returns_to_the_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( @@ -757,7 +967,7 @@ fn a_file_window_that_could_not_show_the_owner_stays_open_from_then_on() { } #[gtk4::test] -fn late_show_create_reply_after_disconnect_is_dropped() { +fn f002_late_show_create_reply_after_disconnect_is_dropped() { let test = TestWindow::connected_with("LateShowCreate", |script| { script.dialect = Arc::new(drivers_postgres::PgDialect::new(&tablepro_core::ServerInfo::new( "PostgreSQL", 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/ssh_section.rs b/linux/crates/app/src/ui/ssh_section.rs index d0de2c89a2..d3827dcdf8 100644 --- a/linux/crates/app/src/ui/ssh_section.rs +++ b/linux/crates/app/src/ui/ssh_section.rs @@ -75,6 +75,7 @@ mod imp { 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(); 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 index 3fec403af3..4ebd23b281 100644 --- a/linux/crates/app/src/ui/structure_tab/columns.rs +++ b/linux/crates/app/src/ui/structure_tab/columns.rs @@ -6,12 +6,12 @@ use gtk4 as gtk; use gtk4::gio; use libadwaita as adw; use libadwaita::prelude::*; -use tablepro_core::ddl::{DraftColumn, DraftType, Permission}; +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, StructureTabInput, StructureTabSender}; +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; @@ -26,7 +26,7 @@ struct ColumnEdits { impl ColumnEdits { fn send(&self, field: ColumnField) { if let Some(column) = self.column.upgrade() { - self.sender.input(StructureTabInput::ColumnEdited { column, field }); + self.sender.input(ColumnsInput::ColumnEdited { column, field }); } } } @@ -125,6 +125,26 @@ pub(super) fn build_column_row( 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) @@ -132,87 +152,112 @@ pub(super) fn build_column_row( 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, &permissions.drop); + 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(StructureTabInput::RemoveColumn(column)); + sender_for_remove.input(ColumnsInput::RemoveColumn(column)); } }); - row.add_suffix(&remove_button); + 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, &permissions.rename); - let edits_for_name = edits.clone(); - name_row.connect_changed(move |entry| edits_for_name.send(ColumnField::Name(entry.text().to_string()))); - row.add_row(&name_row); + 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. +/// 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(), + type_error(context.dialect.as_deref(), draft, draft.column_type.text()).as_deref(), ); - gate(&type_row, &permissions.alter_type); - let edits_for_type = edits.clone(); + 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_for_type + 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_for_type.send(ColumnField::Type(text)); + edits.send(ColumnField::Type(text)); }); if type_row.is_sensitive() { type_row.add_suffix(&build_type_suggestions_button(context.type_suggestions(), &type_row)); } - row.add_row(&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, &permissions.alter_nullability); - let edits_for_null = edits.clone(); - nullable_row.connect_active_notify(move |switch| edits_for_null.send(ColumnField::Nullable(switch.is_active()))); - row.add_row(&nullable_row); + 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. +/// 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); + .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, &permissions.alter_default); - let edits_for_default = edits.clone(); - default_row.connect_changed(move |entry| edits_for_default.send(ColumnField::Default(entry.text().to_string()))); - row.add_row(&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")) + .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")) + .title(crate::i18n::gettext("Auto Increment")) .active(draft.auto_increment) .build(); auto_row.set_widget_name("col-auto"); @@ -233,7 +278,7 @@ pub(super) fn build_column_row( // 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); + hold_not_null(nullable_row, draft.primary_key); } let edits_for_pk = edits.clone(); // The row owns both switches, so this holds them weakly. @@ -262,26 +307,25 @@ pub(super) fn build_column_row( auto.set_active(false); } }); - row.add_row(&pk_row); let edits_for_auto = edits.clone(); auto_row.connect_active_notify(move |switch| edits_for_auto.send(ColumnField::AutoIncrement(switch.is_active()))); - row.add_row(&auto_row); + (pk_row, auto_row) +} - // Empty means the column carries no description; clearing one the - // server had drops it. +/// 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, &permissions.alter_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)); }); - row.add_row(&comment_row); - - row + comment_row } /// A picked type is written into `target`, whose `changed` handler 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/fks.rs b/linux/crates/app/src/ui/structure_tab/fks.rs index 5e07b0e830..82367e227d 100644 --- a/linux/crates/app/src/ui/structure_tab/fks.rs +++ b/linux/crates/app/src/ui/structure_tab/fks.rs @@ -9,7 +9,7 @@ use tablepro_core::ddl::Permission; use super::columns::gate; use super::foreign_key_object::ForeignKeyObject; use super::referential_action_label::action_text; -use super::{StructureTabInput, StructureTabSender}; +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 @@ -56,7 +56,7 @@ pub(super) fn build_fk_row( let sender_for_remove = sender.clone(); remove_button.connect_clicked(move |_| { if let Some(key) = key_for_remove.upgrade() { - sender_for_remove.input(StructureTabInput::RemoveForeignKey(key)); + sender_for_remove.input(ForeignKeysInput::RemoveForeignKey(key)); } }); row.add_suffix(&remove_button); 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 index c30a834cb2..8d2ba194d9 100644 --- a/linux/crates/app/src/ui/structure_tab/foreign_key_dialog.rs +++ b/linux/crates/app/src/ui/structure_tab/foreign_key_dialog.rs @@ -90,70 +90,25 @@ impl ForeignKeyDialog { details.add(&name_row); let tables = gio::ListStore::new::(); - 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); + let (table_row, table_spinner) = table_row(&tables); let references = adw::PreferencesGroup::new(); references.add(&table_row); - 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: Vec<(String, gtk::CheckButton)> = 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(); - + 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); - let labels = action_labels(actions); - let labels: Vec<&str> = labels.iter().map(String::as_str).collect(); - let on_delete_row = adw::ComboRow::builder() - .title(crate::i18n::gettext("On Delete")) - .model(>k::StringList::new(&labels)) - .build(); - let on_update_row = adw::ComboRow::builder() - .title(crate::i18n::gettext("On Update")) - .model(>k::StringList::new(&labels)) - .build(); - let actions_group = adw::PreferencesGroup::builder() - .title(crate::i18n::gettext("Actions")) - .build(); - actions_group.add(&on_delete_row); - actions_group.add(&on_update_row); - - for group in [&details, &references, &checklist, &pairs_group, &actions_group] { + for group in [&details, &references, &checklist, &pairs_group, &actions_group.group] { form.page.add(group); } let inner = Rc::new(Inner { form, - name_row: name_row.clone(), - table_row: table_row.clone(), + name_row, + table_row, table_spinner, tables, column_checks, @@ -173,17 +128,109 @@ impl ForeignKeyDialog { 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, + } + } - let weak = Rc::downgrade(&inner); - name_row.connect_changed(move |row| { + 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 &inner.column_checks { - let (weak, column) = (Rc::downgrade(&inner), column.clone()); + 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); @@ -196,8 +243,9 @@ impl ForeignKeyDialog { } }); } - for (row, on_delete) in [(&on_delete_row, true), (&on_update_row, false)] { - let weak = Rc::downgrade(&inner); + 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()); @@ -209,28 +257,14 @@ impl ForeignKeyDialog { } }); } - let weak = Rc::downgrade(&inner); - inner.form.submit.connect_clicked(move |_| { + let weak = Rc::downgrade(self); + self.form.submit.connect_clicked(move |_| { if let Some(inner) = weak.upgrade() { inner.submit(); } }); - 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)); } -} -impl Inner { fn submit(&self) { let valid = self.draft.borrow().validate(&self.existing); if let Ok(key) = valid { 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_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/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/indexes.rs b/linux/crates/app/src/ui/structure_tab/indexes.rs index 6e7e7826f4..5cd2dbbaa5 100644 --- a/linux/crates/app/src/ui/structure_tab/indexes.rs +++ b/linux/crates/app/src/ui/structure_tab/indexes.rs @@ -10,7 +10,7 @@ use tablepro_core::meta::IndexKey; use super::columns::gate; use super::index_object::IndexObject; -use super::{StructureTabInput, StructureTabSender}; +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(); @@ -58,7 +58,7 @@ pub(super) fn build_index_row( let sender_for_remove = sender.clone(); remove_button.connect_clicked(move |_| { if let Some(index) = index_for_remove.upgrade() { - sender_for_remove.input(StructureTabInput::RemoveIndex(index)); + sender_for_remove.input(IndexesInput::RemoveIndex(index)); } }); row.add_suffix(&remove_button); 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..ac5858448f --- /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::Discard.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 index ded58e523c..52e9871a8e 100644 --- a/linux/crates/app/src/ui/structure_tab/mod.rs +++ b/linux/crates/app/src/ui/structure_tab/mod.rs @@ -36,27 +36,47 @@ //! 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; @@ -71,29 +91,28 @@ use gtk4::subclass::prelude::*; use gtk4::{gio, glib}; use libadwaita as adw; use sourceview5::prelude::BufferExt; -use tablepro_core::ddl::{DraftColumn, DraftType, TableChanges, TableDraft, diff_table}; +use tablepro_core::ddl::{DraftColumn, TableChanges, TableDraft, diff_table}; use tablepro_core::meta::TableRef; -use tablepro_core::{ForeignKeyInfo, IndexInfo}; use tablepro_session::runtime::{Tasks, WatchForwarder}; use tablepro_session::{LinkGeneration, Session}; -use self::columns::build_column_row; +pub(crate) use self::columns_input::{ColumnField, ColumnsInput}; use self::context::StructureContext; -use self::draft_column_object::{DraftColumnObject, UnparsedDefault}; -use self::fks::build_fk_row; -use self::foreign_key_dialog::{ForeignKeyDialog, ForeignKeyDialogInit, reference_scope}; +use self::draft_column_object::DraftColumnObject; use self::foreign_key_object::ForeignKeyObject; -use self::index_dialog::IndexDialog; +pub(crate) use self::foreign_keys_input::ForeignKeysInput; use self::index_object::IndexObject; -use self::indexes::build_index_row; +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}; -use self::structure_fetch::fetch_structure; -use crate::runtime::{FailedRequests, RequestSequencer, RequestTicket, Stamped}; +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::error_text::{build_error_text, save_blocker_text}; -use crate::ui::handler_guard::HandlerGuard; -use crate::ui::tab_load::{ContentLoad, ContentPage, LoadFailure, LoadPresentation, StaleBanner}; +use crate::ui::tab_load::ContentLoad; glib::wrapper! { pub struct StructureTab(ObjectSubclass) @@ -128,7 +147,7 @@ pub(crate) struct StructureTabSender { } impl StructureTabSender { - pub(crate) fn input(&self, msg: StructureTabInput) { + pub(crate) fn input(&self, msg: impl Into) { if let Some(tab) = self.tab.upgrade() { tab.send(msg); } @@ -139,56 +158,7 @@ impl StructureTab { pub fn new(init: StructureTabInit) -> Self { let tab: Self = glib::Object::new(); 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())); - } - }); - } - }); - - imp.name_entry.set_text(&init.table); - imp.name_row.set_visible(matches!(init.mode, StructureMode::New)); - // A read-only connection is offered nothing that writes, as the - // sidebar's table menu offers no Drop Table on one. let read_only = init.session.read_only(); - imp.drop_button - .set_visible(matches!(init.mode, StructureMode::Edit) && !read_only); - imp.inner_stack.set_visible_child_name(match init.mode { - StructureMode::New => "editor", - StructureMode::Edit => "loading", - }); - let name_changed = tab.connect_widgets(); - let sender = tab.sender(); - let stale_banner = StaleBanner::new(imp.stale_banner.get(), move || { - sender.input(StructureTabInput::Retry); - }); - tab.install_actions(); - let context = Rc::new(RefCell::new(StructureContext { mode: init.mode, dialect: match init.mode { @@ -201,14 +171,24 @@ impl StructureTab { let columns = gio::ListStore::new::(); let indexes = gio::ListStore::new::(); let foreign_keys = gio::ListStore::new::(); - tab.bind_lists(&context, &columns, &indexes, &foreign_keys); - tab.connect_add_dialogs( + 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, - (&columns, &indexes, &foreign_keys), - init.schema.clone(), - &init.session, - &init.tasks, + &layout::DraftStores { + columns: &columns, + indexes: &indexes, + foreign_keys: &foreign_keys, + }, ); + tab.install_actions(); let link_changes = init.session.link_changes(); let model = StructureModel { @@ -225,25 +205,7 @@ impl StructureTab { columns, indexes, foreign_keys, - inner_stack: imp.inner_stack.get(), - error_status: imp.error_status.get(), - 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(), read_only, - saving_spinner: imp.saving_spinner.get(), - stop_button: imp.stop_button.get(), saving: false, last_dirty: false, refetching: false, @@ -253,9 +215,8 @@ impl StructureTab { StructureMode::Edit => ContentLoad::default(), }, tab_loading: false, - stale_banner, definition_stale: false, - _theme_handler: theme_handler, + widgets, }; imp.model.replace(Some(model)); imp.link_watch.replace(Some(WatchForwarder::new( @@ -264,7 +225,7 @@ impl StructureTab { let tab = tab.downgrade(); move |generation: LinkGeneration| { if let Some(tab) = tab.upgrade() { - tab.send(StructureTabInput::LinkChanged(generation)); + tab.send(LoadInput::LinkChanged(generation)); } } }, @@ -272,7 +233,7 @@ impl StructureTab { glib::spawn_future_local(future); }, ))); - tab.send(StructureTabInput::Start); + tab.send(LoadInput::Start); tab } @@ -283,9 +244,9 @@ impl StructureTab { /// 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: StructureTabInput) { + pub(crate) fn send(&self, msg: impl Into) { let imp = self.imp(); - imp.queue.borrow_mut().push_back(msg); + imp.queue.borrow_mut().push_back(msg.into()); if imp.dispatching.replace(true) { return; } @@ -405,130 +366,6 @@ impl StructureTab { StructureTabOutput::LoadingChanged(loading) => self.emit_by_name::<()>("loading-changed", &[&loading]), } } - - /// Connects the template's controls, and gives back the name entry's - /// handler, which the tab blocks while it writes the name itself. - fn connect_widgets(&self) -> glib::SignalHandlerId { - let imp = self.imp(); - let on_click = |button: >k::Button, msg: fn() -> StructureTabInput| { - let sender = self.sender(); - button.connect_clicked(move |_| sender.input(msg())); - }; - on_click(&imp.save_button, || StructureTabInput::Save); - on_click(&imp.discard_button, || StructureTabInput::Discard); - on_click(&imp.drop_button, || StructureTabInput::DropTableRequested); - on_click(&imp.stop_button, || StructureTabInput::StopSaving); - // Without a retry a transient network blip on a remote database - // would force the user to close the tab and open it again. - on_click(&imp.retry_button, || StructureTabInput::Retry); - let sender = self.sender(); - imp.add_column_row - .connect_activated(move |_| sender.input(StructureTabInput::AddColumn)); - // An add row that is hidden would leave its list's frame behind. - for (row, list) in [ - (&imp.add_column_row, &imp.add_column_list), - (&imp.add_index_row, &imp.add_index_list), - (&imp.add_fk_row, &imp.add_fk_list), - ] { - row.bind_property("visible", &**list, "visible").sync_create().build(); - } - let sender = self.sender(); - imp.name_entry.connect_changed(move |entry| { - sender.input(StructureTabInput::TableNameEdited(entry.text().to_string())); - }) - } - - /// 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 bind_lists( - &self, - context: &Rc>, - columns: &gio::ListStore, - indexes: &gio::ListStore, - foreign_keys: &gio::ListStore, - ) { - let imp = self.imp(); - let (context_for_columns, sender) = (context.clone(), self.sender()); - bind_list(&imp.columns_list, columns, move |column: &DraftColumnObject| { - build_column_row(column, &context_for_columns.borrow(), &sender) - }); - let (context_for_indexes, sender) = (context.clone(), self.sender()); - bind_list(&imp.indexes_list, indexes, move |index: &IndexObject| { - let context = context_for_indexes.borrow(); - let removable = context.table_permission(context.capabilities().drop_index); - build_index_row(index, &removable, &sender) - }); - let (context_for_keys, sender) = (context.clone(), self.sender()); - bind_list(&imp.fks_list, 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) - }); - } - - /// Each dialog takes the draft as it is when it opens: its columns, - /// and the names its indexes or foreign keys already take. `schema` - /// is the table's, which a foreign key may have to reference within. - fn connect_add_dialogs( - &self, - context: &Rc>, - (columns, indexes, foreign_keys): (&gio::ListStore, &gio::ListStore, &gio::ListStore), - schema: Option, - session: &Arc, - tasks: &Tasks, - ) { - let imp = self.imp(); - let (columns_for_index, indexes, sender) = (columns.downgrade(), indexes.downgrade(), self.sender()); - imp.add_index_row.connect_activated(move |row| { - let (Some(columns), Some(indexes)) = (columns_for_index.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(StructureTabInput::AddIndex(index)); - }) - .present(row.upcast_ref()); - }); - let (columns_for_key, keys, context, sender) = ( - columns.downgrade(), - foreign_keys.downgrade(), - context.clone(), - self.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_for_key.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(StructureTabInput::AddForeignKey(key))) - .present(row.upcast_ref()); - }); - } } #[derive(Default)] @@ -542,8 +379,8 @@ impl StructureEffects { self.outputs.push(output); } - fn input(&mut self, input: StructureTabInput) { - self.inputs.push_back(input); + fn input(&mut self, input: impl Into) { + self.inputs.push_back(input.into()); } } @@ -571,32 +408,8 @@ pub(crate) struct StructureModel { columns: gio::ListStore, indexes: gio::ListStore, foreign_keys: gio::ListStore, - - inner_stack: gtk::Stack, - /// The status page shown when the read fails, with the driver's - /// error as its description. - error_status: adw::StatusPage, - name_entry: adw::EntryRow, - /// The group around `name_entry`, visible only in New mode. - name_row: adw::PreferencesGroup, - /// Blocked while the tab writes the draft's name into the entry, - /// which is no edit. - name_changed: glib::SignalHandlerId, - columns_box: gtk::Box, - indexes_box: gtk::Box, - fks_box: gtk::Box, - add_column_row: adw::ButtonRow, - add_index_row: adw::ButtonRow, - add_fk_row: adw::ButtonRow, - sql_buffer: sourceview5::Buffer, - pending_label: gtk::Label, - save_button: gtk::Button, - discard_button: gtk::Button, - drop_button: gtk::Button, /// The connection refuses writes, so Drop is not offered. read_only: bool, - saving_spinner: adw::Spinner, - stop_button: gtk::Button, /// A save of the tab is running, so Save and Discard stay off. saving: bool, @@ -611,86 +424,11 @@ pub(crate) struct StructureModel { load: ContentLoad, /// What the tab bar was last told, so it hears only changes. tab_loading: bool, - stale_banner: StaleBanner, /// 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, - /// The SQL preview follows the desktop theme through the app-wide - /// style manager, which would keep the handler past the tab. - _theme_handler: HandlerGuard, -} - -#[derive(Debug)] -pub(crate) enum StructureTabInput { - /// The tab is built: read the table, or draw an empty draft. - Start, - /// A reply to the tab's read. - Command(StructureCommand), - /// 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, - Save, - /// The window started saving the tab's pending structure. - SaveStarted, - StopSaving, - Discard, - /// Read the table again and drop the draft, for a save whose outcome - /// nobody knows. - Reload, - DropTableRequested, - 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), - ColumnEdited { - column: DraftColumnObject, - field: ColumnField, - }, - AddColumn, - RemoveColumn(DraftColumnObject), - AddIndex(IndexInfo), - RemoveIndex(IndexObject), - AddForeignKey(ForeignKeyInfo), - RemoveForeignKey(ForeignKeyObject), - TableNameEdited(String), -} - -impl StructureTabInput { - fn edits_draft(&self) -> bool { - matches!( - self, - Self::TableNameEdited(_) - | Self::ColumnEdited { .. } - | Self::AddColumn - | Self::RemoveColumn(_) - | Self::AddIndex(_) - | Self::RemoveIndex(_) - | Self::AddForeignKey(_) - | Self::RemoveForeignKey(_) - ) - } -} - -#[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), + widgets: StructureTabWidgets, } #[derive(Debug)] @@ -776,47 +514,17 @@ impl StructureModel { } } - fn refresh_buttons(&self, pending_count: usize, defaults_parsed: bool) { - let has_pending = pending_count > 0 && !self.saving; - self.save_button.set_sensitive(has_pending && defaults_parsed); - self.discard_button.set_sensitive(has_pending); - if has_pending { - let label = crate::i18n::ngettext_f( - "{n} pending change", - "{n} pending changes", - u32::try_from(pending_count).unwrap_or(u32::MAX), - &[("n", &pending_count.to_string())], - ); - self.pending_label.set_label(&label); - self.pending_label.set_visible(true); - } else { - self.pending_label.set_visible(false); - } - } - fn regenerate_sql_preview(&self, pending: &PendingStructure) { - let context = self.context.borrow(); - let text = match (&context.dialect, context.ddl()) { - (None, _) => crate::i18n::gettext("-- Connect to a database to see the SQL."), - (Some(_), None) => crate::i18n::gettext("-- This database's structure can't be edited here."), - (Some(_), Some(_)) if !pending.unparsed_defaults.is_empty() => { - crate::i18n::gettext("-- Fix the highlighted fields to see the SQL.") - } - (Some(_), Some(_)) if !pending.has_changes() => crate::i18n::gettext("-- No pending changes."), - (Some(_), Some(ddl)) => match pending.plan(ddl) { - Ok(plan) => plan.preview(), - Err(error) => format!("-- {}", build_error_text(&error)), - }, - }; - self.sql_buffer.set_text(&text); + 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.add_column_row.set_visible(context.can_add_column()); - self.add_index_row.set_visible(context.can_add_index()); - self.add_fk_row.set_visible(context.can_add_foreign_key()); + 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 @@ -839,474 +547,20 @@ impl StructureModel { replace_items(&self.foreign_keys, items::(&self.foreign_keys)); } - /// `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() - } - - /// Write the draft's name into the entry without it reading as an - /// edit. - fn show_table_name(&self) { - if self.name_entry.text() == self.table.name { - return; - } - self.name_entry.block_signal(&self.name_changed); - self.name_entry.set_text(&self.table.name); - self.name_entry.unblock_signal(&self.name_changed); - } - - /// Read the table's definition. A definition on screen stays there - /// while it is read, and through a failure. - 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.inner_stack.set_visible_child_name("loading"), - ContentPage::Content => self.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.error_status - .set_description(Some(&glib::markup_escape_text(&failure.message()))); - self.inner_stack.set_visible_child_name("error"); - } - } - self.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(StructureTabInput::Command(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); - } - } - 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::Start => { - if self.mode() == StructureMode::Edit { - self.read_definition(fx); - } - self.sync_add_rows(); - self.regenerate_sql_preview(&self.pending()); - } - StructureTabInput::Command(command) => self.on_command(command, fx), - StructureTabInput::LinkChanged(generation) => self.on_link_changed(generation, fx), - StructureTabInput::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), - } - } - } - StructureTabInput::Retry => self.read_definition(fx), - StructureTabInput::TableNameEdited(text) => { - if self.table.name == text { - return; - } - self.table.name = text; - self.recompute_dirty_state(fx); - } - StructureTabInput::ColumnEdited { column, field } => self.edit_column(&column, field, fx), - StructureTabInput::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); - } - StructureTabInput::RemoveColumn(column) => { - if let Some(position) = self.columns.find(&column) { - self.columns.remove(position); - self.recompute_dirty_state(fx); - } - } - StructureTabInput::AddIndex(index) => { - self.indexes.append(&IndexObject::new(index)); - self.recompute_dirty_state(fx); - } - StructureTabInput::RemoveIndex(index) => { - if let Some(position) = self.indexes.find(&index) { - self.indexes.remove(position); - self.recompute_dirty_state(fx); - } - } - StructureTabInput::AddForeignKey(key) => { - self.foreign_keys.append(&ForeignKeyObject::new(key)); - self.recompute_dirty_state(fx); - } - StructureTabInput::RemoveForeignKey(key) => { - if let Some(position) = self.foreign_keys.find(&key) { - self.foreign_keys.remove(position); - self.recompute_dirty_state(fx); - } - } - StructureTabInput::Save => self.save(fx), - StructureTabInput::SaveStarted => self.show_saving(true), - StructureTabInput::StopSaving => fx.output(StructureTabOutput::StopSaveRequested), - StructureTabInput::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(StructureTabInput::Discard), - } - } - StructureTabInput::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(StructureTabInput::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); - } - StructureTabInput::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, - }); - } - StructureTabInput::SaveCompleted { new_table_name } => self.save_completed(new_table_name, fx), - StructureTabInput::SaveFailed(message) => { - self.show_saving(false); - self.recompute_dirty_state(fx); - fx.output(StructureTabOutput::ShowAlert { - title: crate::i18n::gettext("Save failed"), - body: message, - }); - } - StructureTabInput::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.", - ), - }); - } - StructureTabInput::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 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); - } - - fn save(&self, fx: &mut StructureEffects) { - let pending = self.pending(); - if let Err(blocker) = pending.check() { - fx.output(StructureTabOutput::ShowToast(save_blocker_text(&blocker))); - return; - } - if !pending.has_changes() { - 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.saving_spinner.set_visible(saving); - self.stop_button.set_visible(saving); - if saving { - self.save_button.set_sensitive(false); - self.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. - fn edits_locked(&self) -> bool { - self.saving || self.refetching || self.tracker.is_saving() - } - - fn sync_editable(&self) { - let editable = !self.saving && !self.refetching; - for widget in [ - self.name_row.upcast_ref::(), - self.columns_box.upcast_ref(), - self.indexes_box.upcast_ref(), - self.fks_box.upcast_ref(), - ] { - widget.set_sensitive(editable); + 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), } } - - /// 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. - fn refuse_edit(&self, msg: &StructureTabInput, fx: &mut StructureEffects) { - self.show_table_name(); - if let StructureTabInput::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.drop_button.set_visible(!self.read_only); - self.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.sql_buffer - .set_text(&crate::i18n::gettext("-- 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); - } -} - -/// 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 items>(store: &gio::ListStore) -> Vec { @@ -1315,13 +569,6 @@ fn items>(store: &gio::ListStore) -> Vec { .collect() } -fn column_names(store: &gio::ListStore) -> Vec { - items::(store) - .iter() - .map(DraftColumnObject::name) - .collect() -} - fn draft_columns(store: &gio::ListStore) -> Vec { items::(store) .iter() 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..f6ff7485af --- /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 = drivers_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..771e7610ee --- /dev/null +++ b/linux/crates/app/src/ui/structure_tab/save_handlers.rs @@ -0,0 +1,207 @@ +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, +}; +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::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} pending change", + "{n} pending changes", + u32::try_from(pending_count).unwrap_or(u32::MAX), + &[("n", &pending_count.to_string())], + ); + self.widgets.pending_label.set_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..0864810bcf --- /dev/null +++ b/linux/crates/app/src/ui/structure_tab/save_input.rs @@ -0,0 +1,23 @@ +/// 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, + /// 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..25dc8352a9 --- /dev/null +++ b/linux/crates/app/src/ui/structure_tab/sql_preview.rs @@ -0,0 +1,146 @@ +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(drivers_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(), + }); + 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 index e970072d72..f0263c0a04 100644 --- a/linux/crates/app/src/ui/structure_tab/structure_actions.rs +++ b/linux/crates/app/src/ui/structure_tab/structure_actions.rs @@ -2,7 +2,7 @@ use gtk4::gio; use gtk4::prelude::*; use gtk4::subclass::prelude::*; -use super::{StructureTab, StructureTabInput}; +use super::{SaveInput, StructureTab}; use crate::application::{ShortcutOwner, local_shortcut_controller}; impl StructureTab { @@ -11,7 +11,7 @@ impl StructureTab { let imp = self.imp(); let save = gio::SimpleAction::new("save", None); let sender = self.sender(); - save.connect_activate(move |_, _| sender.input(StructureTabInput::Save)); + save.connect_activate(move |_, _| sender.input(SaveInput::Save)); imp.save_button .bind_property("sensitive", &save, "enabled") .sync_create() 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_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 index e827d5095c..2dad1970f2 100644 --- a/linux/crates/app/src/ui/structure_tab/tests.rs +++ b/linux/crates/app/src/ui/structure_tab/tests.rs @@ -195,7 +195,7 @@ fn style_handler_disconnected_on_shutdown() { #[gtk4::test] fn structure_tab_freed_after_drop() { let mut harness = Harness::open(StructureMode::New, ConnectionScript::default(), []); - harness.tab().send(StructureTabInput::AddColumn); + harness.tab().send(ColumnsInput::AddColumn); drain_main_context(); let row = first_descendant_of_type::(harness.tab()) .expect("the added column's row") @@ -224,7 +224,7 @@ fn structure_tab_freed_after_drop() { #[gtk4::test] fn a_draft_takes_its_dialect_from_the_session() { let harness = Harness::open(StructureMode::New, postgres_script(), []); - harness.tab().send(StructureTabInput::AddColumn); + harness.tab().send(ColumnsInput::AddColumn); drain_main_context(); let buffer = sql_buffer(harness.tab()); @@ -285,8 +285,8 @@ fn a_schema_change_leaves_a_tab_with_edits_alone() { wait_until(WAIT, || harness.page().as_deref() == Some("editor")).expect("the structure"); let counters = harness.fake.connection.counters(); - harness.tab().send(StructureTabInput::AddColumn); - harness.tab().send(StructureTabInput::SchemaChanged); + harness.tab().send(ColumnsInput::AddColumn); + harness.tab().send(LoadInput::SchemaChanged); drain_main_context(); std::thread::sleep(Duration::from_millis(100)); drain_main_context(); @@ -331,7 +331,7 @@ fn a_read_only_connection_offers_no_drop() { .connect_drop_table_requested(move |_, _, _| sink.set(sink.get() + 1)); assert!(!harness.tab().imp().drop_button.is_visible()); - harness.tab().send(StructureTabInput::DropTableRequested); + harness.tab().send(TableInput::DropTableRequested); assert_eq!(asked.get(), 0, "the tab asked to drop the table"); } @@ -387,7 +387,7 @@ fn structure_refresh_failure_keeps_editor_and_shows_banner() { wait_until(WAIT, || harness.page().as_deref() == Some("editor")).expect("the structure"); let toasts = toasts(harness.tab()); - harness.tab().send(StructureTabInput::SchemaChanged); + 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")); @@ -410,9 +410,7 @@ 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(StructureTabInput::SaveCompleted { new_table_name: None }); + 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")); @@ -429,13 +427,11 @@ fn a_failed_read_after_a_save_keeps_the_saved_draft_locked() { 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(StructureTabInput::SaveCompleted { new_table_name: None }); + 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(StructureTabInput::AddColumn); + harness.tab().send(ColumnsInput::AddColumn); assert_eq!( *toasts.borrow(), @@ -489,7 +485,7 @@ fn index_fetch_failure_keeps_loaded_indexes() { let harness = Harness::open(StructureMode::Edit, script, []); wait_until(WAIT, || harness.page().as_deref() == Some("editor")).expect("the structure"); - harness.tab().send(StructureTabInput::SchemaChanged); + 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(); @@ -507,7 +503,7 @@ fn structure_retry_after_failure_hides_banner() { .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(StructureTabInput::SchemaChanged); + harness.tab().send(LoadInput::SchemaChanged); wait_until(WAIT, || banner(&harness).is_revealed()).expect("the stale banner"); banner(&harness).emit_by_name::<()>("button-clicked", &[]); @@ -548,9 +544,7 @@ fn schema_save_shows_new_column_list() { .tab() .connect_loading_changed(move |_, busy| sink.borrow_mut().push(busy)); - harness - .tab() - .send(StructureTabInput::SaveCompleted { new_table_name: None }); + harness.tab().send(SaveInput::SaveCompleted { new_table_name: None }); assert_eq!( harness.page().as_deref(), @@ -739,7 +733,7 @@ fn an_edit_previews_the_plan_and_saves_through_the_tracker() { let counter = saves.clone(); tab.connect_save_requested(move |_| counter.set(counter.get() + 1)); - tab.send(StructureTabInput::ColumnEdited { + tab.send(ColumnsInput::ColumnEdited { column: column_object(&tab, 0), field: ColumnField::Default("42".to_owned()), }); @@ -755,7 +749,7 @@ fn an_edit_previews_the_plan_and_saves_through_the_tracker() { crate::workspace::RollbackCause::NotApplied, )); - tab.send(StructureTabInput::ColumnEdited { + tab.send(ColumnsInput::ColumnEdited { column: column_object(&tab, 0), field: ColumnField::Default("1 +".to_owned()), }); @@ -786,7 +780,7 @@ fn stop_saving_shows_only_while_a_save_runs() { harness .tab() .connect_stop_save_requested(move |_| stop_sink.set(stop_sink.get() + 1)); - harness.tab().send(StructureTabInput::ColumnEdited { + harness.tab().send(ColumnsInput::ColumnEdited { column: harness.column(0), field: ColumnField::Comment(Some("key".to_owned())), }); @@ -795,14 +789,14 @@ fn stop_saving_shows_only_while_a_save_runs() { assert!(!stop.is_visible()); assert!(save.is_sensitive()); - harness.tab().send(StructureTabInput::SaveStarted); + 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(StructureTabInput::ColumnEdited { + harness.tab().send(ColumnsInput::ColumnEdited { column: harness.column(0), field: ColumnField::Comment(Some("the key".to_owned())), }); @@ -816,9 +810,7 @@ fn stop_saving_shows_only_while_a_save_runs() { stop.emit_clicked(); assert_eq!(stops.get(), 1); - harness - .tab() - .send(StructureTabInput::SaveFailed("rolled back".to_owned())); + 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()); } @@ -839,23 +831,21 @@ fn an_edit_waits_for_the_read_after_a_committed_save() { let counters = harness.fake.connection.counters(); let tracker = harness.tracker(); let imp = harness.tab().imp(); - harness.tab().send(StructureTabInput::ColumnEdited { + 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(StructureTabInput::ColumnEdited { + harness.tab().send(ColumnsInput::ColumnEdited { column: harness.column(0), field: ColumnField::Comment(Some("before the tab heard".to_owned())), }); - harness.tab().send(StructureTabInput::SaveStarted); + harness.tab().send(SaveInput::SaveStarted); tracker.settle_save(&crate::workspace::SaveOutcome::Committed { rows: None }); - harness.tab().send(StructureTabInput::AddColumn); - harness - .tab() - .send(StructureTabInput::SaveCompleted { new_table_name: None }); - harness.tab().send(StructureTabInput::AddColumn); + 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"]); @@ -864,7 +854,7 @@ fn an_edit_waits_for_the_read_after_a_committed_save() { CallCounters::get(&counters.fetch_table_definition) == 2 && imp.columns_box.is_sensitive() }) .expect("the read after the save"); - harness.tab().send(StructureTabInput::AddColumn); + harness.tab().send(ColumnsInput::AddColumn); assert_eq!(harness.column_names(), ["id", "column_1"]); } @@ -1045,13 +1035,13 @@ fn reads_after_discard(change: Option) -> usize { 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(StructureTabInput::AddColumn); + harness.tab().send(ColumnsInput::AddColumn); let read_again = change.is_some(); if let Some(change) = change { harness.tab().send(change); } - harness.tab().send(StructureTabInput::Discard); + harness.tab().send(SaveInput::Discard); if read_again { wait_until(WAIT, || CallCounters::get(&counters.fetch_table_definition) == 2) @@ -1070,12 +1060,12 @@ fn a_plain_discard_restores_the_definition_it_read() { #[gtk4::test] fn a_discard_after_a_change_elsewhere_reads_the_table_again() { - assert_eq!(reads_after_discard(Some(StructureTabInput::SchemaChanged)), 2); + 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(StructureTabInput::TableChangedBeforeSave)), 2); + assert_eq!(reads_after_discard(Some(SaveInput::TableChangedBeforeSave.into())), 2); } /// The titles of the add buttons the tab shows under `container`. @@ -1146,7 +1136,7 @@ fn columns_store(tab: &StructureTab) -> gio::ListStore { fn table_name_edit_is_not_dropped() { let harness = Harness::open(StructureMode::New, postgres_script(), []); let tab = harness.tab(); - tab.send(StructureTabInput::AddColumn); + tab.send(ColumnsInput::AddColumn); tab.imp().name_entry.set_text("people"); @@ -1163,7 +1153,7 @@ fn expanded_row_stays_expanded_after_editing_another_column() { let first = column_row(tab, 0); first.set_expanded(true); - tab.send(StructureTabInput::AddColumn); + tab.send(ColumnsInput::AddColumn); entry(tab, 2, "col-name").set_text("points"); entry(tab, 1, "col-type").set_text("bigint"); drain_main_context(); @@ -1177,7 +1167,7 @@ fn expanded_row_stays_expanded_after_editing_another_column() { fn remove_by_identity_after_reorder_removes_right_column() { let harness = open_edit(postgres(), keyed_users()); let tab = harness.tab(); - tab.send(StructureTabInput::AddColumn); + tab.send(ColumnsInput::AddColumn); let store = columns_store(tab); let added = store.item(2).expect("the added column"); store.remove(2); @@ -1205,7 +1195,7 @@ fn add_column_changes_one_item() { sink.borrow_mut().push((position, removed, added)); }); - tab.send(StructureTabInput::AddColumn); + tab.send(ColumnsInput::AddColumn); assert_eq!(*changes.borrow(), [(2, 0, 1)]); let after = column_rows(tab); @@ -1226,7 +1216,7 @@ fn discard_restores_without_phantom_ops() { 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(StructureTabInput::AddColumn); + tab.send(ColumnsInput::AddColumn); assert_eq!(imp.pending_label.label(), "3 pending changes"); imp.discard_button.emit_clicked(); @@ -1382,7 +1372,7 @@ fn add_foreign_key_from_the_tab_adds_the_key() { 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"); + 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"); @@ -1447,7 +1437,7 @@ fn ctrl_s_saves_while_save_is_on() { let press = || ctrl_s.activate(gtk::ShortcutActionFlags::empty(), &tab, None); assert!(!press(), "Ctrl+S was taken with nothing to save"); - tab.send(StructureTabInput::ColumnEdited { + tab.send(ColumnsInput::ColumnEdited { column: column_object(&tab, 0), field: ColumnField::Default("42".to_owned()), }); diff --git a/linux/crates/app/src/ui/welcome_view.rs b/linux/crates/app/src/ui/welcome_view.rs index 3ab0fb8a4a..26b04bee0a 100644 --- a/linux/crates/app/src/ui/welcome_view.rs +++ b/linux/crates/app/src/ui/welcome_view.rs @@ -2,7 +2,7 @@ //! every window shares, with a group for the connections in no group //! and one for each group the user made. -use std::cell::{OnceCell, RefCell}; +use std::cell::{Cell, OnceCell, RefCell}; use std::rc::Rc; use gtk4 as gtk; @@ -12,7 +12,8 @@ use gtk4::subclass::prelude::*; use libadwaita as adw; use libadwaita::prelude::*; use tablepro_session::runtime::Tasks; -use tablepro_storage::ConnectionListState; +use tablepro_storage::{ConnectionGroupName, ConnectionListState}; +use uuid::Uuid; use crate::services::saved_connection_list::{SavedConnectionList, StateObserverId}; use crate::services::saved_connection_object::SavedConnectionObject; @@ -59,6 +60,26 @@ mod imp { 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, @@ -69,7 +90,9 @@ pub(super) struct Parts { list: Rc, tasks: Option, /// The groups after the ungrouped one, by name, in list order. - groups: RefCell>, + groups: RefCell>, + /// A connection whose row takes the focus once the list shows it. + focus: Cell>, rows_changed: RefCell>, state_changed: RefCell>, } @@ -83,6 +106,7 @@ impl WelcomeView { list: list.clone(), tasks, groups: RefCell::new(Vec::new()), + focus: Cell::new(None), rows_changed: RefCell::new(None), state_changed: RefCell::new(None), }); @@ -100,6 +124,7 @@ impl WelcomeView { let rows_changed = list.model().connect_items_changed(move |_, _, _, _| { if let Some(view) = weak.upgrade() { view.refresh(); + view.take_focus(); } }); let weak = view.downgrade(); @@ -150,6 +175,30 @@ impl WelcomeView { }); } + /// 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(); @@ -163,6 +212,12 @@ impl WelcomeView { 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; @@ -174,9 +229,9 @@ impl WelcomeView { 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()) + .title(glib::markup_escape_text(name.as_str()).as_str()) .build(); - self.bind(&group, &self.filtered(Some(&name))); + self.bind(&group, &self.filtered(Some(name.as_str()))); imp.page.add(&group); built.push((name, group)); } 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/imp.rs b/linux/crates/app/src/ui/workspace/imp.rs index b9ed9f42f2..9d6d917dfd 100644 --- a/linux/crates/app/src/ui/workspace/imp.rs +++ b/linux/crates/app/src/ui/workspace/imp.rs @@ -105,6 +105,10 @@ impl ObjectImpl for Workspace { 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(), ] }) } diff --git a/linux/crates/app/src/ui/workspace/import.rs b/linux/crates/app/src/ui/workspace/import.rs index 93a78572f2..1c2cf195c9 100644 --- a/linux/crates/app/src/ui/workspace/import.rs +++ b/linux/crates/app/src/ui/workspace/import.rs @@ -135,7 +135,7 @@ impl WorkspaceModel { let rows = match import_rows(&import) { Ok(rows) => rows, Err(message) => { - self.show_error_alert(&crate::i18n::gettext("Couldn't import"), &message); + self.show_error_alert(&crate::i18n::gettext("Couldn't Import"), &message); return; } }; @@ -171,7 +171,7 @@ impl WorkspaceModel { Err(error) => { let message = crate::ui::error_text::operation_error_message(&error); input.deliver(WorkspaceInput::ShowAlert { - title: crate::i18n::gettext("Couldn't import"), + title: crate::i18n::gettext("Couldn't Import"), body: message, }); } diff --git a/linux/crates/app/src/ui/workspace/mod.rs b/linux/crates/app/src/ui/workspace/mod.rs index 58bdc858c7..23a3249400 100644 --- a/linux/crates/app/src/ui/workspace/mod.rs +++ b/linux/crates/app/src/ui/workspace/mod.rs @@ -4,11 +4,13 @@ mod close_flow; mod editor_label; +mod finished_work; mod health_banner; mod imp; mod import; mod row_ops; mod saves; +mod session_watches; mod shell; mod sidebar; mod status_pages; @@ -45,10 +47,10 @@ use relm4::{ComponentController, Controller}; use tablepro_core::EditorSession; use tablepro_core::meta::{ObjectInfo, SchemaInfo}; -use tablepro_session::runtime::WatchForwarder; 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}; @@ -159,12 +161,29 @@ pub(crate) struct WorkspaceModel { /// Recently closed tabs for Ctrl+Shift+T. Pending edits are not /// kept: the close has already cleared them. closed_tabs_stack: Rc>>, - /// Feed the reconnect banner and the sidebar, and stop with the - /// workspace, so nothing reads for a session it let go of. - _health_watch: WatchForwarder, - _catalog_watch: WatchForwarder, - _reconnect_watch: WatchForwarder, - _link_watch: WatchForwarder, + _watches: SessionWatches, +} + +/// 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 +} + +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 @@ -356,6 +375,7 @@ pub(crate) enum WorkspaceInput { NewEditorTab, CloseActiveWorkspaceTab, EditorTabRunStateChanged(Uuid, bool), + EditorTabRunFinished(Uuid, crate::workspace::WorkOutcome), EditorTabContentChanged(Uuid), /// The session's saved tabs and their editor text, read off the GTK /// thread. @@ -390,7 +410,15 @@ pub(crate) enum WorkspaceInput { approved: bool, }, RefreshPage, - ExportResults(Box), + ExportResults { + tab: Uuid, + offer: Box, + }, + /// An export a tab offered ended. + ExportEnded { + tab: Uuid, + outcome: crate::workspace::WorkOutcome, + }, /// Text to copy as it is, such as a saved query. CopyText(String), WorkspaceTabClosed(Uuid), @@ -443,6 +471,8 @@ pub(crate) enum WorkspaceInput { ReopenClosedTab, /// A browse tab's F5. RefreshTab(Uuid), + /// Bring the tab to the front, for a notification about it. + FocusTab(Uuid), } impl Workspace { @@ -457,21 +487,8 @@ impl Workspace { } = init; let workspace: Self = glib::Object::new(); let imp = workspace.imp(); - let (input, receiver) = crate::runtime::input_queue::(); - imp.pump.replace(Some(crate::runtime::pump_inputs( - &workspace, - receiver, - |workspace, message| workspace.handle_input(message), - ))); - - // The workspace's own slot: a tab reads it for the session, and - // it empties when the workspace goes. - let context = Rc::new(SessionContext { - active: crate::services::active_session::ActiveSession::new(), - ..(*context).clone() - }); - context.active.replace(session.clone()); - + 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(); @@ -481,92 +498,23 @@ impl Workspace { tasks.clone(), ); let persistence = workspace_persistence::WorkspacePersistence::new(storage.workspaces().clone()); - 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 = 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(history.clone()), - 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 exports = ExportLedger::new(ExportLedgerInit { - tasks: tasks.clone(), - hold: context.hold.clone(), - reporter: ExportReporter::new(&window, &imp.toast_overlay.get()), - inhibitor: WindowInhibitor::new(ApplicationInhibit::new(&window)), - }); - imp.export_operations_button.bind(&exports.jobs()); - + let exports = workspace.export_ledger(&window, &tasks, &context, &input); let sidebar = sidebar::build(&workspace, &input, sidebar::SidebarAffordances::for_session(&session)); - 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: imp.toast_overlay.downgrade(), - context: context.clone(), - settings: settings.clone(), - storage: storage.clone(), - history: Some(history.clone()), - 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); + 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 health_input = input.clone(); - let health_watch = WatchForwarder::new( - session.health(), - move |health| health_input.deliver(WorkspaceInput::HealthChanged(health)), - sidebar::spawn_local, - ); - let (listed, failed) = (input.clone(), input.clone()); - let catalog_watch = 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_watch = sidebar::reconnect_watch(&session, move |snapshot| { - committed.deliver(WorkspaceInput::DefaultListCommitted(snapshot)); - }); - let relinked = input.clone(); - let link_watch = WatchForwarder::new( - session.link_changes(), - move |_| relinked.deliver(WorkspaceInput::LinkChanged), - sidebar::spawn_local, - ); + let watches = SessionWatches::new(&session, &input); let driver_id = session.metadata().driver_id.clone(); let driver_name = context @@ -619,16 +567,114 @@ impl Workspace { read_only: session.read_only(), workspace: persistence, closed_tabs_stack: Rc::new(RefCell::new(VecDeque::with_capacity(CLOSED_TABS_CAPACITY))), - _health_watch: health_watch, - _catalog_watch: catalog_watch, - _reconnect_watch: reconnect_watch, - _link_watch: link_watch, + _watches: watches, }; imp.model.replace(Some(model)); workspace.register_close_blocker(exports); 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, + tasks: &tablepro_session::runtime::Tasks, + context: &SessionContext, + input: &WorkspaceSender, + ) -> Rc { + let imp = self.imp(); + let exports = ExportLedger::new(ExportLedgerInit { + tasks: tasks.clone(), + hold: context.hold.clone(), + reporter: ExportReporter::new(window, &imp.toast_overlay.get()), + inhibitor: WindowInhibitor::new(ApplicationInhibit::new(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. @@ -657,6 +703,12 @@ impl Workspace { 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)); @@ -880,6 +932,9 @@ impl WorkspaceModel { 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); + } WorkspaceInput::EditorTabContentChanged(id) => self.on_editor_tab_content_changed(id), WorkspaceInput::WorkspaceLoaded(loaded) => self.on_workspace_loaded(loaded), WorkspaceInput::ResetWorkspaceLayout => self.on_reset_workspace_layout(), @@ -899,7 +954,7 @@ impl WorkspaceModel { 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(offer) => { + 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 { @@ -908,12 +963,17 @@ impl WorkspaceModel { offer: *offer, dialect, settings: &self.settings, + tab, }); } } + WorkspaceInput::ExportEnded { tab, outcome } => { + self.report_work(tab, crate::workspace::WorkKind::Export, outcome); + } WorkspaceInput::CopyText(text) => self.on_copy_text(text), 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/saves.rs b/linux/crates/app/src/ui/workspace/saves.rs index daf4f7fe21..fd235f68bf 100644 --- a/linux/crates/app/src/ui/workspace/saves.rs +++ b/linux/crates/app/src/ui/workspace/saves.rs @@ -7,9 +7,10 @@ use tablepro_core::WriteKind; use super::{WorkspaceInput, WorkspaceModel}; use crate::runtime::DeliverInput; -use crate::ui::browse_tab::BrowseTabInput; -use crate::ui::structure_tab::StructureTabInput; -use crate::workspace::{CloseScope, DirtyTab, SaveOutcome, SaveSettled, SaveStart, WorkspaceEvent}; +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 @@ -42,20 +43,20 @@ impl WorkspaceModel { pub(super) fn on_workspace_event(&mut self, event: WorkspaceEvent) { match event { WorkspaceEvent::SaveStarted { tab } => { - self.dispatch_to_tab(tab, BrowseTabInput::SaveStarted); - self.send_to_structure_tab(tab, StructureTabInput::SaveStarted); + self.dispatch_to_tab(tab, browse_tab::SaveInput::SaveStarted); + self.send_to_structure_tab(tab, structure_tab::SaveInput::SaveStarted); } - WorkspaceEvent::SaveSettled(settled) => self.on_save_settled(*settled), + 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 => BrowseTabInput::DiscardAndRefresh, - false => BrowseTabInput::DiscardAll, + true => browse_tab::SaveInput::DiscardAndRefresh, + false => browse_tab::SaveInput::DiscardAll, }; self.dispatch_to_tab(tab, browse); let structure = match reload { - true => StructureTabInput::Reload, - false => StructureTabInput::Discard, + true => structure_tab::SaveInput::Reload, + false => structure_tab::SaveInput::Discard, }; self.send_to_structure_tab(tab, structure); } @@ -73,7 +74,7 @@ impl WorkspaceModel { self.refresh_unsaved(); } - fn on_save_settled(&mut self, settled: SaveSettled) { + fn on_save_settled(&mut self, settled: SaveSettled, in_prompt: bool) { let SaveSettled { tab, kind, @@ -82,6 +83,7 @@ impl WorkspaceModel { 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 @@ -93,35 +95,40 @@ impl WorkspaceModel { .timeout(4) .build(); self.toast_overlay.add_toast(toast); - self.dispatch_to_tab(tab, BrowseTabInput::SaveCommitted(origins)); + 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, BrowseTabInput::FlashErrorRow(source)); + self.dispatch_to_tab(tab, browse_tab::SaveInput::FlashErrorRow(source)); } - self.dispatch_to_tab(tab, BrowseTabInput::SaveFailed(cause.message())); + 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, BrowseTabInput::SaveOutcomeUnknown(message)); + 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 => StructureTabInput::TableChangedBeforeSave, - false => StructureTabInput::SaveFailed(cause.message()), + 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, StructureTabInput::SaveOutcomeUnknown(message)); + 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 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/sidebar/catalog_watch.rs b/linux/crates/app/src/ui/workspace/sidebar/catalog_watch.rs index f2ef4c82b9..b236f53468 100644 --- a/linux/crates/app/src/ui/workspace/sidebar/catalog_watch.rs +++ b/linux/crates/app/src/ui/workspace/sidebar/catalog_watch.rs @@ -114,7 +114,7 @@ mod tests { /// A workspace drops its catalog watch with itself, so a list read /// for its session after that reaches nothing. #[gtk4::test] - fn a_late_catalog_commit_after_the_watch_drops_is_not_delivered() { + 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 { diff --git a/linux/crates/app/src/ui/workspace/structure.rs b/linux/crates/app/src/ui/workspace/structure.rs index a5fa743865..913262aa14 100644 --- a/linux/crates/app/src/ui/workspace/structure.rs +++ b/linux/crates/app/src/ui/workspace/structure.rs @@ -17,7 +17,7 @@ use tokio_util::sync::CancellationToken; use crate::runtime::DeliverInput; use crate::ui::error_text; -use crate::ui::structure_tab::{StructureMode, StructureTabInput}; +use crate::ui::structure_tab::{LoadInput, SaveInput, StructureMode, StructureTabInput}; use crate::ui::workspace::{WorkspaceInput, WorkspaceModel, WorkspaceTab}; use super::structure_target::StructureTarget; @@ -104,7 +104,7 @@ impl WorkspaceModel { 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("Cannot Drop Table"), &crate::i18n::gettext("No active connection."), ); return; @@ -132,7 +132,7 @@ impl WorkspaceModel { match settled(result).and_then(|(_, written)| written) { Ok(_) => input.deliver(WorkspaceInput::DropTableSucceeded(dropped)), Err(error) => input.deliver(WorkspaceInput::ShowAlert { - title: crate::i18n::gettext("Drop failed"), + title: crate::i18n::gettext("Drop Failed"), body: error_text::operation_error_message(&error), }), } @@ -218,7 +218,7 @@ impl WorkspaceModel { if new_table_name.is_some() { self.retitle_tab(tab_id); } - self.send_to_structure_tab(tab_id, StructureTabInput::SaveCompleted { new_table_name }); + self.send_to_structure_tab(tab_id, SaveInput::SaveCompleted { new_table_name }); self.input.deliver(WorkspaceInput::SchemaChanged { schema, table: Some(table), @@ -261,7 +261,7 @@ impl WorkspaceModel { }); } - pub(super) fn send_to_structure_tab(&self, tab_id: Uuid, msg: StructureTabInput) { + pub(super) fn send_to_structure_tab(&self, tab_id: Uuid, msg: impl Into) { let structure = self .workspace_tabs .borrow() @@ -298,10 +298,10 @@ impl WorkspaceModel { }) .unzip(); for browse in browse_tabs.into_iter().flatten() { - browse.send(crate::ui::browse_tab::BrowseTabInput::SchemaChanged); + browse.send(crate::ui::browse_tab::DataInput::SchemaChanged); } for structure in structure_tabs.into_iter().flatten() { - structure.send(StructureTabInput::SchemaChanged); + structure.send(LoadInput::SchemaChanged); } } // The catalog watch takes the new list to the sidebar, and a diff --git a/linux/crates/app/src/ui/workspace/tab_factory.rs b/linux/crates/app/src/ui/workspace/tab_factory.rs index 96c66e418d..e401b2c40f 100644 --- a/linux/crates/app/src/ui/workspace/tab_factory.rs +++ b/linux/crates/app/src/ui/workspace/tab_factory.rs @@ -1,4 +1,5 @@ use std::cell::Cell; +use std::collections::HashSet; use std::rc::Rc; use gtk4 as gtk; @@ -109,7 +110,7 @@ impl TabFactory { close_timeout: parts.context.close_timeout(), }); connect_editor(&editor, id, &parts.input, &parts.toasts); - let ordinal = self.editor_count() + 1; + let ordinal = self.free_ordinal(); let page = self.append(&editor, id); let label = match opened_empty { true => EditorLabel::Untitled(ordinal), @@ -192,13 +193,21 @@ impl TabFactory { })) } - fn editor_count(&self) -> usize { - self.parts - .tabs - .borrow() + /// 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(|tab| matches!(tab, WorkspaceTab::Editor(_))) - .count() + .filter_map(|tab| match tab { + WorkspaceTab::Editor(slot) => Some(slot.ordinal), + _ => None, + }) + .collect(); + let mut ordinal = 1; + while taken.contains(&ordinal) { + ordinal += 1; + } + ordinal } } @@ -262,7 +271,12 @@ 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(Box::new(offer)))); + 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(); @@ -290,6 +304,10 @@ pub(super) fn connect_editor( 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 transaction = input.clone(); @@ -297,7 +315,12 @@ pub(super) fn connect_editor( 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(Box::new(offer)))); + 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(); diff --git a/linux/crates/app/src/ui/workspace/tab_title.rs b/linux/crates/app/src/ui/workspace/tab_title.rs index 82778adfdd..180f183a41 100644 --- a/linux/crates/app/src/ui/workspace/tab_title.rs +++ b/linux/crates/app/src/ui/workspace/tab_title.rs @@ -31,7 +31,7 @@ pub(crate) fn resolve_tab_title(identity: &TabIdentity, naming: TableNaming) -> tooltip: None, }, TabIdentity::Editor(EditorLabel::Untitled(ordinal)) => TabTitle { - label: crate::i18n::gettext_f("Query {n}", &[("n", &ordinal.to_string())]), + label: numbered_query(*ordinal), tooltip: None, }, TabIdentity::Editor(EditorLabel::Query { prefix, truncated }) => { @@ -47,6 +47,10 @@ pub(crate) fn resolve_tab_title(identity: &TabIdentity, naming: TableNaming) -> } } +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 { diff --git a/linux/crates/app/src/ui/workspace/tab_title_tests.rs b/linux/crates/app/src/ui/workspace/tab_title_tests.rs index 74d19e3dcd..a1b4d14f17 100644 --- a/linux/crates/app/src/ui/workspace/tab_title_tests.rs +++ b/linux/crates/app/src/ui/workspace/tab_title_tests.rs @@ -215,3 +215,27 @@ fn tabs_name_the_schema_once_the_session_has_two() { "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/workspace_tabs.rs b/linux/crates/app/src/ui/workspace/workspace_tabs.rs index 2f4caeaeb4..5d1a44680d 100644 --- a/linux/crates/app/src/ui/workspace/workspace_tabs.rs +++ b/linux/crates/app/src/ui/workspace/workspace_tabs.rs @@ -12,7 +12,7 @@ use tablepro_session::SessionKey; use tablepro_storage::{DraftId, DraftScope}; use uuid::Uuid; -use crate::ui::browse_tab::BrowseTabInput; +use crate::ui::browse_tab::{BrowseTabInput, PagingInput, SaveInput}; use crate::ui::structure_tab::StructureMode; use crate::workspace::{ RestoredDrafts, TabRecord, WorkspaceFileName, WorkspaceLoad, WorkspaceRecord, WorkspaceSaveError, @@ -218,6 +218,7 @@ impl WorkspaceModel { 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 @@ -341,19 +342,19 @@ impl WorkspaceModel { .get(id) .is_some_and(|tracker| tracker.pending_count() > 0); if !dirty { - self.dispatch_to_tab(id, BrowseTabInput::Refresh); + 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("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.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"); @@ -368,7 +369,7 @@ impl WorkspaceModel { .and_then(|tab| tab.browse_tab()) .cloned(); if let Some(browse) = browse { - browse.send(BrowseTabInput::DiscardAndRefresh); + browse.send(SaveInput::DiscardAndRefresh); } }); dialog.present(self.window().as_ref()); @@ -388,6 +389,7 @@ impl WorkspaceModel { 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 { @@ -432,7 +434,7 @@ impl WorkspaceModel { /// 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: BrowseTabInput) { + pub(super) fn dispatch_to_tab(&self, tab_id: Uuid, msg: impl Into) { let browse = self .workspace_tabs .borrow() diff --git a/linux/crates/app/src/workspace/close_guard.rs b/linux/crates/app/src/workspace/close_guard.rs index 47d5328351..ce2ec0e974 100644 --- a/linux/crates/app/src/workspace/close_guard.rs +++ b/linux/crates/app/src/workspace/close_guard.rs @@ -28,6 +28,7 @@ impl CloseGuard { 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, @@ -124,10 +125,10 @@ fn show_refused(parent: >k::Widget, tabs: &[DirtyTab], refused: &[(Uuid, SaveR mod tests { use std::cell::RefCell; use std::rc::Rc; - use std::time::Duration; 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; @@ -226,6 +227,33 @@ mod tests { 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] @@ -244,11 +272,14 @@ mod tests { 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::Bounded { - after: Duration::from_millis(500), + script.write_batch.always(FakeReply::UntilReleased { + release: release.clone(), outcome: Ok(WriteReport::new(vec![Some(1)])), }); let workspace = Workspace::open(script); @@ -260,6 +291,11 @@ mod tests { 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(); 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/mod.rs b/linux/crates/app/src/workspace/mod.rs index 585865f4fe..c10e2443d3 100644 --- a/linux/crates/app/src/workspace/mod.rs +++ b/linux/crates/app/src/workspace/mod.rs @@ -11,6 +11,7 @@ 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; @@ -46,11 +47,13 @@ mod tab_entry; mod tab_record; mod tab_snapshot; #[cfg(test)] -mod test_workspace; +pub(crate) mod test_workspace; 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; @@ -70,6 +73,7 @@ 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}; @@ -106,6 +110,8 @@ 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}; diff --git a/linux/crates/app/src/workspace/test_workspace.rs b/linux/crates/app/src/workspace/test_workspace.rs index 17a7e694cb..c333020f31 100644 --- a/linux/crates/app/src/workspace/test_workspace.rs +++ b/linux/crates/app/src/workspace/test_workspace.rs @@ -182,7 +182,7 @@ impl Workspace { structures: structures.clone(), directory: tabs.clone(), events: Rc::new(move |event| { - if let WorkspaceEvent::SaveSettled(settled) = &event { + if let WorkspaceEvent::SaveSettled { settled, .. } = &event { settled_sink.borrow_mut().push((**settled).clone()); } event_sink.borrow_mut().push(event); @@ -265,6 +265,23 @@ impl Workspace { 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() } 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 index 913b783f1c..370614c1ea 100644 --- a/linux/crates/app/src/workspace/workspace_event.rs +++ b/linux/crates/app/src/workspace/workspace_event.rs @@ -8,7 +8,12 @@ pub enum WorkspaceEvent { SaveStarted { tab: Uuid, }, - SaveSettled(Box), + 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 { diff --git a/linux/crates/app/src/workspace/workspace_handle.rs b/linux/crates/app/src/workspace/workspace_handle.rs index 7f9c71df4b..3b885ee383 100644 --- a/linux/crates/app/src/workspace/workspace_handle.rs +++ b/linux/crates/app/src/workspace/workspace_handle.rs @@ -1,4 +1,4 @@ -use std::cell::RefCell; +use std::cell::{Cell, RefCell}; use std::collections::HashSet; use std::rc::Rc; use std::sync::Arc; @@ -56,6 +56,8 @@ struct Inner { 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 { @@ -63,8 +65,25 @@ impl Inner { /// 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(Box::new(settled))); + (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); } } @@ -113,9 +132,21 @@ impl WorkspaceHandle { 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) { @@ -703,6 +734,36 @@ mod tests { 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 { diff --git a/linux/crates/app/tests/accessibility/audit.rs b/linux/crates/app/tests/accessibility/audit.rs new file mode 100644 index 0000000000..cdcf43f36a --- /dev/null +++ b/linux/crates/app/tests/accessibility/audit.rs @@ -0,0 +1,239 @@ +//! 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 { + if row.title().is_empty() { + 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 { + 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..79dcdb2f03 --- /dev/null +++ b/linux/crates/app/tests/accessibility/main.rs @@ -0,0 +1,415 @@ +//! 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; +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); 6] = [ + ("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), + ("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 sidebar's search and the editor's find +/// and replace open. +fn workspace() -> Result, String> { + let script = one_table(); + let world = World::start( + "Workspace", + fake_driver(), + FakeConnector::new([]).always(FakeEstablish::Connect(script)), + )?; + 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) + })?; + 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("sidebar.open-in-new-tab", Some(&table)) + .map_err(|error| format!("sidebar.open-in-new-tab: {error}"))?; + wait_until("the table's rows", || shows_text(window.upcast_ref(), CELL))?; + sidebar + .activate_action("sidebar.edit-structure", Some(&table)) + .map_err(|error| format!("sidebar.edit-structure: {error}"))?; + 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")) + })?; + 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)); + } + Ok(findings) +} + +/// 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(drivers_postgres::PgDriver), + Arc::new(drivers_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(drivers_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..96ccd6f79b --- /dev/null +++ b/linux/crates/app/tests/accessibility/world.rs @@ -0,0 +1,159 @@ +//! A TablePro over fakes, driven on the main context the way a user's +//! clicks drive it. + +use std::path::Path; +use std::process::Command; +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::SCHEMA_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}"))?; + let source = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../data") + .join("app.tablepro.TablePro.gschema.xml"); + std::fs::copy(&source, schemas.join("app.tablepro.TablePro.gschema.xml")) + .map_err(|error| format!("could not stage the schema: {error}"))?; + let output = Command::new("glib-compile-schemas") + .arg(&schemas) + .output() + .map_err(|error| format!("could not run glib-compile-schemas: {error}"))?; + if !output.status.success() { + return Err(format!( + "glib-compile-schemas failed: {}", + String::from_utf8_lossy(&output.stderr) + )); + } + AppSettings::with_backend( + tablepro_app::config::SCHEMA_ID, + &schemas, + &gio::memory_settings_backend_new(), + ) + .map_err(|error| format!("could not open the settings: {error}")) +} diff --git a/linux/crates/app/tests/desktop_entry.rs b/linux/crates/app/tests/desktop_entry.rs index 6a1e7f94c8..ca052de5be 100644 --- a/linux/crates/app/tests/desktop_entry.rs +++ b/linux/crates/app/tests/desktop_entry.rs @@ -56,3 +56,17 @@ fn new_window_is_a_desktop_action() { 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/core/src/column/classify.rs b/linux/crates/core/src/column/classify.rs index ab12bd8ed1..66d31332f0 100644 --- a/linux/crates/core/src/column/classify.rs +++ b/linux/crates/core/src/column/classify.rs @@ -325,9 +325,15 @@ mod tests { } } + fn assert_classified(cases: Vec<(&str, ColumnKind)>) { + for (name, expected) in cases { + assert_eq!(classify_type_name(name), expected, "{name:?}"); + } + } + #[test] - fn classify_type_name_table() { - let cases = [ + fn classifies_integers_booleans_and_floats() { + assert_classified(vec![ ("bigint", int(IntegerWidth::W64, true)), ("int8", int(IntegerWidth::W64, true)), ("BIGSERIAL", int(IntegerWidth::W64, true)), @@ -339,6 +345,12 @@ mod tests { ("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 { @@ -375,6 +387,12 @@ mod tests { }, ), ("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)), @@ -401,6 +419,12 @@ mod tests { length: TextLength::Fixed(16), }, ), + ]); + } + + #[test] + fn classifies_dates_times_and_timestamps() { + assert_classified(vec![ ("date", ColumnKind::Date), ( "time(3)", @@ -430,6 +454,12 @@ mod tests { 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 }), @@ -437,6 +467,12 @@ mod tests { ("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 { @@ -458,6 +494,12 @@ mod tests { ("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)))), @@ -466,11 +508,7 @@ mod tests { ("hstore", ColumnKind::Other), ("tsvector", ColumnKind::Other), ("", ColumnKind::Other), - ]; - - for (name, expected) in cases { - assert_eq!(classify_type_name(name), expected, "{name:?}"); - } + ]); } #[test] diff --git a/linux/crates/core/src/tls_config.rs b/linux/crates/core/src/tls_config.rs index 0198695764..756b24584d 100644 --- a/linux/crates/core/src/tls_config.rs +++ b/linux/crates/core/src/tls_config.rs @@ -145,7 +145,7 @@ mod tests { } #[test] - fn validate_table() { + fn every_mode_an_engine_offers_validates_on_every_route() { for caps in [POSTGRES, MYSQL, MSSQL] { for mode in ALL_MODES { for route in ROUTES { @@ -157,8 +157,25 @@ mod tests { } } } + } + + type Case = ( + &'static str, + TlsConfig, + TlsCapabilities, + TransportClass, + Result<(), ConfigError>, + ); - let cases = [ + fn assert_validates(cases: Vec) { + for (name, config, caps, route, expected) in cases { + assert_eq!(config.validate(&caps, route), expected, "{name}"); + } + } + + #[test] + fn modes_and_server_names_follow_the_capabilities_and_route() { + assert_validates(vec![ ( "mode outside the capabilities", TlsConfig { @@ -201,6 +218,12 @@ mod tests { route: TransportClass::SshForward, }), ), + ]); + } + + #[test] + fn client_identities_follow_the_capabilities() { + assert_validates(vec![ ( "mssql client identity", with_identity("/etc/tls/client.pem", "/etc/tls/client.key"), @@ -239,6 +262,12 @@ mod tests { TransportClass::DirectTcp, Err(ConfigError::RelativePath), ), + ]); + } + + #[test] + fn ca_files_follow_the_capabilities() { + assert_validates(vec![ ( "ca file with tls disabled", with_ca_file(TlsMode::Disable, "/etc/tls/ca.pem"), @@ -267,10 +296,7 @@ mod tests { TransportClass::SshForward, Ok(()), ), - ]; - for (name, config, caps, route, expected) in cases { - assert_eq!(config.validate(&caps, route), expected, "{name}"); - } + ]); } #[test] diff --git a/linux/crates/core/src/write_ledger.rs b/linux/crates/core/src/write_ledger.rs index 094649b8df..adefbfbad5 100644 --- a/linux/crates/core/src/write_ledger.rs +++ b/linux/crates/core/src/write_ledger.rs @@ -298,118 +298,27 @@ mod tests { } } - #[test] - fn outcome_table() { - use Outcome::{Partial, RolledBack, Unknown}; - use Phase::*; - use StatementOutcome::{Failed, Unknown as InDoubt}; - const TX: WriteAtomicity = WriteAtomicity::Transactional; - const STEP: WriteAtomicity = WriteAtomicity::StatementByStatement; + type Row = (Phase, bool, [Outcome; 3]); + + fn partial(applied: usize, at: usize, failed: StatementOutcome) -> Outcome { + Outcome::Partial { applied, at, failed } + } + + fn unknown(with_token: bool) -> Outcome { + Outcome::Unknown { + token: with_token.then(token), + } + } + + /// Each row is a phase and whether a token was attached, with the + /// outcome for each fate in the order of `fates`. + fn assert_outcomes(atomicity: WriteAtomicity, rows: Vec) { let fates = [ SessionFate::RolledBack, SessionFate::Discarded, SessionFate::RollbackIncomplete { executed: 2 }, ]; - let partial = |applied, at, failed| Partial { applied, at, failed }; - let unknown = |with_token: bool| Unknown { - token: with_token.then(token), - }; - - // One row per atomicity, phase and token, with the outcome for - // each fate in the order above. A statement-by-statement engine - // has no COMMIT, so it has no commit phase. - let table = [ - ( - TX, - NothingSent, - false, - [RolledBack { at: 0 }, RolledBack { at: 0 }, partial(2, 0, Failed)], - ), - ( - TX, - NothingSent, - true, - [RolledBack { at: 0 }, RolledBack { at: 0 }, partial(2, 0, Failed)], - ), - ( - TX, - FirstInFlight, - false, - [RolledBack { at: 0 }, RolledBack { at: 0 }, partial(2, 0, Failed)], - ), - ( - TX, - FirstInFlight, - true, - [RolledBack { at: 0 }, RolledBack { at: 0 }, partial(2, 0, Failed)], - ), - ( - TX, - TwoConfirmed, - false, - [RolledBack { at: 2 }, RolledBack { at: 2 }, partial(2, 2, Failed)], - ), - ( - TX, - TwoConfirmed, - true, - [RolledBack { at: 2 }, RolledBack { at: 2 }, partial(2, 2, Failed)], - ), - ( - TX, - ThirdInFlight, - false, - [RolledBack { at: 2 }, RolledBack { at: 2 }, partial(2, 2, Failed)], - ), - ( - TX, - ThirdInFlight, - true, - [RolledBack { at: 2 }, RolledBack { at: 2 }, partial(2, 2, Failed)], - ), - (TX, CommitSent, false, [unknown(false), unknown(false), unknown(false)]), - (TX, CommitSent, true, [unknown(true), unknown(true), unknown(true)]), - ( - STEP, - NothingSent, - false, - [RolledBack { at: 0 }, RolledBack { at: 0 }, RolledBack { at: 0 }], - ), - ( - STEP, - NothingSent, - true, - [RolledBack { at: 0 }, RolledBack { at: 0 }, RolledBack { at: 0 }], - ), - ( - STEP, - FirstInFlight, - false, - [unknown(false), unknown(false), unknown(false)], - ), - (STEP, FirstInFlight, true, [unknown(true), unknown(true), unknown(true)]), - ( - STEP, - TwoConfirmed, - false, - [partial(2, 2, Failed), partial(2, 2, Failed), partial(2, 2, Failed)], - ), - ( - STEP, - TwoConfirmed, - true, - [partial(2, 2, Failed), partial(2, 2, Failed), partial(2, 2, Failed)], - ), - ( - STEP, - ThirdInFlight, - false, - [partial(2, 2, InDoubt), partial(2, 2, InDoubt), partial(2, 2, InDoubt)], - ), - (STEP, ThirdInFlight, true, [unknown(true), unknown(true), unknown(true)]), - ]; - - for (atomicity, phase, with_token, expected) in table { + for (phase, with_token, expected) in rows { for (fate, expected) in fates.iter().zip(expected) { let ledger = WriteLedger::new(atomicity); phase.replay(&ledger); @@ -428,6 +337,102 @@ mod tests { } } + #[test] + fn a_lost_transaction_rolls_back_until_its_commit_is_sent() { + use Outcome::RolledBack; + use Phase::*; + use StatementOutcome::Failed; + assert_outcomes( + WriteAtomicity::Transactional, + vec![ + ( + NothingSent, + false, + [RolledBack { at: 0 }, RolledBack { at: 0 }, partial(2, 0, Failed)], + ), + ( + NothingSent, + true, + [RolledBack { at: 0 }, RolledBack { at: 0 }, partial(2, 0, Failed)], + ), + ( + FirstInFlight, + false, + [RolledBack { at: 0 }, RolledBack { at: 0 }, partial(2, 0, Failed)], + ), + ( + FirstInFlight, + true, + [RolledBack { at: 0 }, RolledBack { at: 0 }, partial(2, 0, Failed)], + ), + ( + TwoConfirmed, + false, + [RolledBack { at: 2 }, RolledBack { at: 2 }, partial(2, 2, Failed)], + ), + ( + TwoConfirmed, + true, + [RolledBack { at: 2 }, RolledBack { at: 2 }, partial(2, 2, Failed)], + ), + ( + ThirdInFlight, + false, + [RolledBack { at: 2 }, RolledBack { at: 2 }, partial(2, 2, Failed)], + ), + ( + ThirdInFlight, + true, + [RolledBack { at: 2 }, RolledBack { at: 2 }, partial(2, 2, Failed)], + ), + (CommitSent, false, [unknown(false), unknown(false), unknown(false)]), + (CommitSent, true, [unknown(true), unknown(true), unknown(true)]), + ], + ); + } + + /// A statement-by-statement engine has no COMMIT, so it has no + /// commit phase. + #[test] + fn a_lost_statement_by_statement_write_keeps_what_was_confirmed() { + use Outcome::RolledBack; + use Phase::*; + use StatementOutcome::{Failed, Unknown as InDoubt}; + assert_outcomes( + WriteAtomicity::StatementByStatement, + vec![ + ( + NothingSent, + false, + [RolledBack { at: 0 }, RolledBack { at: 0 }, RolledBack { at: 0 }], + ), + ( + NothingSent, + true, + [RolledBack { at: 0 }, RolledBack { at: 0 }, RolledBack { at: 0 }], + ), + (FirstInFlight, false, [unknown(false), unknown(false), unknown(false)]), + (FirstInFlight, true, [unknown(true), unknown(true), unknown(true)]), + ( + TwoConfirmed, + false, + [partial(2, 2, Failed), partial(2, 2, Failed), partial(2, 2, Failed)], + ), + ( + TwoConfirmed, + true, + [partial(2, 2, Failed), partial(2, 2, Failed), partial(2, 2, Failed)], + ), + ( + ThirdInFlight, + false, + [partial(2, 2, InDoubt), partial(2, 2, InDoubt), partial(2, 2, InDoubt)], + ), + (ThirdInFlight, true, [unknown(true), unknown(true), unknown(true)]), + ], + ); + } + #[test] fn a_transaction_lost_before_the_commit_left_nothing_behind() { let ledger = WriteLedger::new(WriteAtomicity::Transactional); diff --git a/linux/crates/drivers/clickhouse/src/editor_run.rs b/linux/crates/drivers/clickhouse/src/editor_run.rs index 84b46fdc97..a319898b4e 100644 --- a/linux/crates/drivers/clickhouse/src/editor_run.rs +++ b/linux/crates/drivers/clickhouse/src/editor_run.rs @@ -61,39 +61,16 @@ impl EditorRun { let mut stop = Stop::new(options.cancel, self.shutdown.clone(), deadline, self.grace); let previous = self.lingering.take(); if let Some(previous) = previous { - // The wait is the run's own, under its stop and its deadline, - // so it is as long as the server takes and no longer than the - // user lets it. A run with no deadline gives up at the cancel - // grace instead, so a stopped query that never ends cannot - // hold it for good; a session still locked then refuses it - // with 373. + // A run with no deadline gives up at the cancel grace, so a + // stopped query that never ends cannot hold it for good; a + // session still locked then refuses it with 373. let give_up = deadline.is_none().then(|| started + self.grace); - tokio::select! { - interrupt = stop.triggered() => { - self.lingering.left(previous); - return Ok(summary(started, unsent(interrupt))); - } - ended = wait_until_ended(&self.control, previous) => { - if let Err(error) = ended { - tracing::debug!(%error, "waiting for a stopped editor query to end failed"); - } - } - () = reached(give_up) => { - tracing::debug!("a stopped editor query did not end within the cancel grace"); - } + if let Some(interrupt) = self.wait_for_stopped(previous, &stop, give_up).await { + return Ok(summary(started, unsent(interrupt))); } } let query_id = Uuid::new_v4(); - let mut query = self - .session - .query_raw(sql.as_str()) - .with_setting("query_id", query_id.to_string()); - if bound.is_some() { - query = query - .with_setting("max_execution_time", whole_seconds(bound).to_string()) - .with_setting("timeout_overflow_mode", "throw"); - } - let mut reader = match ResultReader::open(query) { + let mut reader = match self.open(&sql, query_id, bound) { Ok(reader) => reader, Err(error) => return Ok(summary(started, RunEnd::Failed(error))), }; @@ -174,6 +151,42 @@ impl EditorRun { Ok(summary(started, end)) } + /// Wait for the query an earlier run stopped to end. The wait is the + /// run's own, under its stop and its deadline, so it is as long as + /// the server takes and no longer than the user lets it. `Some` when + /// the run is stopped first. + async fn wait_for_stopped(&self, previous: Uuid, stop: &Stop, give_up: Option) -> Option { + tokio::select! { + interrupt = stop.triggered() => { + self.lingering.left(previous); + return Some(interrupt); + } + ended = wait_until_ended(&self.control, previous) => { + if let Err(error) = ended { + tracing::debug!(%error, "waiting for a stopped editor query to end failed"); + } + } + () = reached(give_up) => { + tracing::debug!("a stopped editor query did not end within the cancel grace"); + } + } + None + } + + /// Send `sql` as `query_id`, which the server ends at `bound`. + fn open(&self, sql: &UserSql, query_id: Uuid, bound: Option) -> Result { + let mut query = self + .session + .query_raw(sql.as_str()) + .with_setting("query_id", query_id.to_string()); + if bound.is_some() { + query = query + .with_setting("max_execution_time", whole_seconds(bound).to_string()) + .with_setting("timeout_overflow_mode", "throw"); + } + ResultReader::open(query) + } + /// The server may still be ending a query told to stop when the run /// gives up its response, so the next run waits for it unless the /// response reached its end. diff --git a/linux/crates/drivers/clickhouse/tests/integration/writes.rs b/linux/crates/drivers/clickhouse/tests/integration/writes.rs index e8356df450..2ab2d45114 100644 --- a/linux/crates/drivers/clickhouse/tests/integration/writes.rs +++ b/linux/crates/drivers/clickhouse/tests/integration/writes.rs @@ -480,7 +480,7 @@ async fn resolve_syntax_error_insert_not_applied() { #[tokio::test] #[ignore = "requires docker"] -async fn read_only_insert_164() { +async fn f001_read_only_insert_164() { let fixture = start().await; let owner = connect(&fixture, false).await; let qualified = quoted(&unique("read_only")); diff --git a/linux/crates/drivers/mssql/src/ddl.rs b/linux/crates/drivers/mssql/src/ddl.rs index 596b74a5af..329147470a 100644 --- a/linux/crates/drivers/mssql/src/ddl.rs +++ b/linux/crates/drivers/mssql/src/ddl.rs @@ -1,9 +1,9 @@ use tablepro_core::column::{ColumnDefault, ColumnKind}; use tablepro_core::ddl::ddl_sql::{column_list, create_index_sql, default_sql, foreign_key_sql, parenthesized}; use tablepro_core::ddl::{ - BuildDdlError, ColumnAttributes, ColumnCapabilities, ColumnDefinition, DdlCapabilities, DdlDialect, DdlOperation, - DdlPlan, DraftColumn, DraftType, GeneratedStorage, IdentityStyle, Permission, ReferentialAction, RefusalReason, - ResolvedType, TableChanges, TableDefinition, TableDraft, + BuildDdlError, ColumnAlteration, ColumnAttributes, ColumnCapabilities, ColumnDefinition, DdlCapabilities, + DdlDialect, DdlOperation, DdlPlan, DraftColumn, DraftType, GeneratedStorage, IdentityStyle, Permission, + ReferentialAction, RefusalReason, ResolvedType, TableChanges, TableDefinition, TableDraft, }; use tablepro_core::dialect::SqlDialect; use tablepro_core::meta::TableRef; @@ -35,6 +35,10 @@ static CAPABILITIES: DdlCapabilities = DdlCapabilities { drop_table_cascade: false, }; +fn alter_table(dialect: &MssqlDialect, table: &TableRef, action: &str) -> String { + format!("ALTER TABLE {} {action}", dialect.qualify(table)) +} + /// T-SQL has no RESTRICT; the engine spells that behaviour NO ACTION. const REFERENTIAL_ACTIONS: &[ReferentialAction] = &[ ReferentialAction::NoAction, @@ -157,7 +161,7 @@ impl DdlDialect for MssqlDialect { } let table = changes.target(&original.table); let qualified = self.qualify(&table); - let alter = |action: String| format!("ALTER TABLE {qualified} {action}"); + let alter = |action: String| alter_table(self, &table, &action); for key in &changes.dropped_foreign_keys { statements.push(alter(format!("DROP CONSTRAINT {}", self.quote_identifier(&key.name)))); @@ -176,31 +180,7 @@ impl DdlDialect for MssqlDialect { statements.extend(self.drop_default(&table, column)); statements.push(alter(format!("DROP COLUMN {}", self.quote_identifier(column.name())))); } - // A column bound to a default cannot change type, so its default - // goes first and comes back under the same name once the column - // and the columns added beside it are in place. - let mut defaults = Vec::new(); - for alteration in &changes.altered_columns { - let changed = alteration.changed; - let bound = alteration.original.attributes.default_constraint_name.as_deref(); - let dropped = bound.filter(|_| changed.column_type || changed.default); - statements.extend(dropped.and_then(|_| self.drop_default(&table, &alteration.original))); - if !(changed.default || dropped.is_some()) { - continue; - } - let draft = &alteration.draft; - let column_type = draft.column_type.column_type(self, &draft.name)?; - if let Some(default) = default_sql(self, &draft.name, &draft.default, &column_type)? { - let constraint = bound - .map(|name| format!("CONSTRAINT {} ", self.quote_identifier(name))) - .unwrap_or_default(); - defaults.push(alter(format!( - "ADD {constraint}DEFAULT {} FOR {}", - default_clause(&draft.default, &default), - self.quote_identifier(&draft.name) - ))); - } - } + let defaults = self.rebind_defaults(&table, &changes.altered_columns, &mut statements)?; for rename in changes.ordered_renames(original) { let object = format!("{}.{}", qualified, self.quote_identifier(&rename.from)); statements.push(format!( @@ -210,47 +190,7 @@ impl DdlDialect for MssqlDialect { )); } for alteration in &changes.altered_columns { - let draft = &alteration.draft; - let name = self.quote_identifier(&draft.name); - let changed = alteration.changed; - // ALTER COLUMN carries nothing forward: an omitted collation is - // the database's, an omitted SPARSE is gone, and an omitted - // NULL or NOT NULL is NULL. So every one is restated. - if changed.column_type || changed.nullability { - let resolved = draft.column_type.resolve(self, &draft.name)?; - let attributes = &alteration.original.attributes; - let operation = match changed.column_type { - true => DdlOperation::AlterColumnType, - false => DdlOperation::AlterColumnNullability, - }; - if changed.column_type { - kept_on_new_type(&resolved, attributes).map_err(|attribute| BuildDdlError::Refused { - column: alteration.original.name().to_owned(), - operation, - reason: RefusalReason::ConflictingAttribute(attribute), - })?; - } - let mut parts = vec![format!("ALTER COLUMN {name}"), resolved.sql.clone()]; - parts.extend(collate_clause(draft, &resolved, attributes, operation)?); - if attributes.sparse { - parts.push("SPARSE".to_owned()); - } - parts.push(if draft.nullable { "NULL" } else { "NOT NULL" }.to_owned()); - statements.push(alter(parts.join(" "))); - // ALTER COLUMN takes a mask only in a statement of its own. - if let Some(function) = &attributes.masking_function { - statements.push(alter(format!("ALTER COLUMN {name} ADD {}", masked_with(function)))); - } - } - if changed.comment { - let had_comment = alteration - .original - .info - .comment - .as_deref() - .is_some_and(|text| !text.is_empty()); - statements.push(column_comment(&table, &draft.name, had_comment, draft.comment_text())); - } + self.alter_column(&table, alteration, &mut statements)?; } for column in &changes.added_columns { statements.push(alter(format!("ADD {}", self.column_definition(column)?))); @@ -282,6 +222,99 @@ impl DdlDialect for MssqlDialect { } impl MssqlDialect { + /// A column bound to a default cannot change type, so its default + /// goes into `statements` first, and the statements returned add it + /// back under the same name once the column and the columns added + /// beside it are in place. + fn rebind_defaults( + &self, + table: &TableRef, + altered: &[ColumnAlteration], + statements: &mut Vec, + ) -> Result, BuildDdlError> { + let mut defaults = Vec::new(); + for alteration in altered { + let changed = alteration.changed; + let bound = alteration.original.attributes.default_constraint_name.as_deref(); + let dropped = bound.filter(|_| changed.column_type || changed.default); + statements.extend(dropped.and_then(|_| self.drop_default(table, &alteration.original))); + if !(changed.default || dropped.is_some()) { + continue; + } + let draft = &alteration.draft; + let column_type = draft.column_type.column_type(self, &draft.name)?; + if let Some(default) = default_sql(self, &draft.name, &draft.default, &column_type)? { + let constraint = bound + .map(|name| format!("CONSTRAINT {} ", self.quote_identifier(name))) + .unwrap_or_default(); + defaults.push(alter_table( + self, + table, + &format!( + "ADD {constraint}DEFAULT {} FOR {}", + default_clause(&draft.default, &default), + self.quote_identifier(&draft.name) + ), + )); + } + } + Ok(defaults) + } + + fn alter_column( + &self, + table: &TableRef, + alteration: &ColumnAlteration, + statements: &mut Vec, + ) -> Result<(), BuildDdlError> { + let draft = &alteration.draft; + let name = self.quote_identifier(&draft.name); + let changed = alteration.changed; + // ALTER COLUMN carries nothing forward: an omitted collation is + // the database's, an omitted SPARSE is gone, and an omitted + // NULL or NOT NULL is NULL. So every one is restated. + if changed.column_type || changed.nullability { + let resolved = draft.column_type.resolve(self, &draft.name)?; + let attributes = &alteration.original.attributes; + let operation = match changed.column_type { + true => DdlOperation::AlterColumnType, + false => DdlOperation::AlterColumnNullability, + }; + if changed.column_type { + kept_on_new_type(&resolved, attributes).map_err(|attribute| BuildDdlError::Refused { + column: alteration.original.name().to_owned(), + operation, + reason: RefusalReason::ConflictingAttribute(attribute), + })?; + } + let mut parts = vec![format!("ALTER COLUMN {name}"), resolved.sql.clone()]; + parts.extend(collate_clause(draft, &resolved, attributes, operation)?); + if attributes.sparse { + parts.push("SPARSE".to_owned()); + } + parts.push(if draft.nullable { "NULL" } else { "NOT NULL" }.to_owned()); + statements.push(alter_table(self, table, &parts.join(" "))); + // ALTER COLUMN takes a mask only in a statement of its own. + if let Some(function) = &attributes.masking_function { + statements.push(alter_table( + self, + table, + &format!("ALTER COLUMN {name} ADD {}", masked_with(function)), + )); + } + } + if changed.comment { + let had_comment = alteration + .original + .info + .comment + .as_deref() + .is_some_and(|text| !text.is_empty()); + statements.push(column_comment(table, &draft.name, had_comment, draft.comment_text())); + } + Ok(()) + } + /// `name type [COLLATE c] [SPARSE] [MASKED WITH (..)] [IDENTITY(1,1)] /// NULL|NOT NULL [DEFAULT (..)]`, or a computed column read from the /// catalogue as `name AS (..)`. diff --git a/linux/crates/drivers/mysql/tests/integration/decode.rs b/linux/crates/drivers/mysql/tests/integration/decode.rs index 9e3135690f..234e9c0410 100644 --- a/linux/crates/drivers/mysql/tests/integration/decode.rs +++ b/linux/crates/drivers/mysql/tests/integration/decode.rs @@ -126,7 +126,7 @@ async fn int_unsigned_max_id_row_edits_through_plan_save() { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[ignore = "requires docker"] -async fn bigint_unsigned_max_id_edit_affects_one_row() { +async fn f006_bigint_unsigned_max_id_edit_affects_one_row() { for flavour in FLAVOURS { let fixture = start(flavour).await; let connection = connect(&fixture, false).await; diff --git a/linux/crates/drivers/mysql/tests/integration/flavour_suite.rs b/linux/crates/drivers/mysql/tests/integration/flavour_suite.rs index 0f58ee5daf..377e846481 100644 --- a/linux/crates/drivers/mysql/tests/integration/flavour_suite.rs +++ b/linux/crates/drivers/mysql/tests/integration/flavour_suite.rs @@ -213,7 +213,7 @@ async fn sleep_30_cancelled_thread_idle() { /// is refused by the server with 1792, for a row change and DDL alike. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[ignore = "requires docker"] -async fn read_only_insert_and_create_1792() { +async fn f001_read_only_insert_and_create_1792() { for flavour in FLAVOURS { let fixture = start(flavour).await; let owner = connect(&fixture, false).await; diff --git a/linux/crates/drivers/postgres/Cargo.toml b/linux/crates/drivers/postgres/Cargo.toml index 9fd02cb8b9..156d2fec3b 100644 --- a/linux/crates/drivers/postgres/Cargo.toml +++ b/linux/crates/drivers/postgres/Cargo.toml @@ -20,7 +20,6 @@ fallible-iterator.workspace = true futures-util.workspace = true postgres-protocol.workspace = true postgres-types.workspace = true -rustls.workspace = true secrecy.workspace = true tokio = { workspace = true, features = ["macros", "rt", "sync", "time"] } tokio-postgres.workspace = true diff --git a/linux/crates/drivers/postgres/src/ddl.rs b/linux/crates/drivers/postgres/src/ddl.rs index 4ee9e239a0..fee93d94c4 100644 --- a/linux/crates/drivers/postgres/src/ddl.rs +++ b/linux/crates/drivers/postgres/src/ddl.rs @@ -1,9 +1,9 @@ use tablepro_core::column::{ColumnKind, ColumnType, IntegerKind, IntegerWidth, NetworkFamily}; use tablepro_core::ddl::ddl_sql::{column_list, create_index_sql, default_sql, foreign_key_sql}; use tablepro_core::ddl::{ - BuildDdlError, CollationName, ColumnAttributes, ColumnCapabilities, ColumnCompression, ColumnDefinition, - ColumnStorage, DdlCapabilities, DdlDialect, DdlOperation, DdlPlan, DraftColumn, GeneratedStorage, IdentityStyle, - Permission, RefusalReason, ResolvedType, TableChanges, TableDefinition, TableDraft, + BuildDdlError, CollationName, ColumnAlteration, ColumnAttributes, ColumnCapabilities, ColumnCompression, + ColumnDefinition, ColumnStorage, DdlCapabilities, DdlDialect, DdlOperation, DdlPlan, DraftColumn, GeneratedStorage, + IdentityStyle, Permission, RefusalReason, ResolvedType, TableChanges, TableDefinition, TableDraft, }; use tablepro_core::dialect::SqlDialect; use tablepro_core::meta::TableRef; @@ -167,67 +167,7 @@ impl DdlDialect for PgDialect { }; let mut sequences = Vec::new(); for alteration in &changes.altered_columns { - let draft = &alteration.draft; - let name = self.quote_identifier(&draft.name); - let changed = alteration.changed; - let numbering = changed.auto_increment && draft.auto_increment; - if changed.column_type { - let resolved = draft.column_type.resolve(self, &draft.name)?; - // A new type takes its own default collation unless the - // change names the one the column had. - let collation = alteration - .original - .attributes - .collation - .as_ref() - .filter(|_| collatable(&resolved.column_type.kind)) - .map(|collation| format!(" COLLATE {}", self.collation(collation))) - .unwrap_or_default(); - actions.push(format!( - "ALTER COLUMN {name} TYPE {}{collation} USING {name}::{}", - resolved.sql, resolved.sql - )); - actions.extend(restated_storage(&name, &alteration.original.attributes, &resolved)); - } - // An identity column is NOT NULL whatever the switch said, and - // turning one on sets that below. - if changed.nullability && !numbering { - let action = if draft.nullable { - "DROP NOT NULL" - } else { - "SET NOT NULL" - }; - actions.push(format!("ALTER COLUMN {name} {action}")); - } - if changed.default { - let column_type = draft.column_type.column_type(self, &draft.name)?; - actions.push(match default_sql(self, &draft.name, &draft.default, &column_type)? { - Some(default) => format!("ALTER COLUMN {name} SET DEFAULT {default}"), - None => format!("ALTER COLUMN {name} DROP DEFAULT"), - }); - } - if numbering { - self.check_identity(draft)?; - actions.push(format!("ALTER COLUMN {name} SET NOT NULL")); - actions.push(format!("ALTER COLUMN {name} ADD GENERATED BY DEFAULT AS IDENTITY")); - sequences.push(self.identity_restart(&table, &draft.name)?); - } else if changed.auto_increment { - actions.push(match alteration.original.attributes.identity { - IdentityStyle::GeneratedAlways | IdentityStyle::GeneratedByDefault => { - format!("ALTER COLUMN {name} DROP IDENTITY") - } - // A serial column numbers itself through its default; - // the sequence stays, owned by the column. - IdentityStyle::SequenceDefault => format!("ALTER COLUMN {name} DROP DEFAULT"), - _ => { - return Err(BuildDdlError::Refused { - column: alteration.original.name().to_owned(), - operation: DdlOperation::AlterAutoIncrement, - reason: RefusalReason::UnreadableDefinition, - }); - } - }); - } + self.alter_column(&table, alteration, &mut actions, &mut sequences)?; } for column in &changes.added_columns { actions.push(format!("ADD COLUMN {}", self.column_definition(column)?)); @@ -266,6 +206,79 @@ impl DdlDialect for PgDialect { } impl PgDialect { + /// The ALTER TABLE actions that change one column, and the sequence + /// restart an identity it gains needs after them. + fn alter_column( + &self, + table: &TableRef, + alteration: &ColumnAlteration, + actions: &mut Vec, + sequences: &mut Vec, + ) -> Result<(), BuildDdlError> { + let draft = &alteration.draft; + let name = self.quote_identifier(&draft.name); + let changed = alteration.changed; + let numbering = changed.auto_increment && draft.auto_increment; + if changed.column_type { + let resolved = draft.column_type.resolve(self, &draft.name)?; + // A new type takes its own default collation unless the + // change names the one the column had. + let collation = alteration + .original + .attributes + .collation + .as_ref() + .filter(|_| collatable(&resolved.column_type.kind)) + .map(|collation| format!(" COLLATE {}", self.collation(collation))) + .unwrap_or_default(); + actions.push(format!( + "ALTER COLUMN {name} TYPE {}{collation} USING {name}::{}", + resolved.sql, resolved.sql + )); + actions.extend(restated_storage(&name, &alteration.original.attributes, &resolved)); + } + // An identity column is NOT NULL whatever the switch said, and + // turning one on sets that below. + if changed.nullability && !numbering { + let action = if draft.nullable { + "DROP NOT NULL" + } else { + "SET NOT NULL" + }; + actions.push(format!("ALTER COLUMN {name} {action}")); + } + if changed.default { + let column_type = draft.column_type.column_type(self, &draft.name)?; + actions.push(match default_sql(self, &draft.name, &draft.default, &column_type)? { + Some(default) => format!("ALTER COLUMN {name} SET DEFAULT {default}"), + None => format!("ALTER COLUMN {name} DROP DEFAULT"), + }); + } + if numbering { + self.check_identity(draft)?; + actions.push(format!("ALTER COLUMN {name} SET NOT NULL")); + actions.push(format!("ALTER COLUMN {name} ADD GENERATED BY DEFAULT AS IDENTITY")); + sequences.push(self.identity_restart(table, &draft.name)?); + } else if changed.auto_increment { + actions.push(match alteration.original.attributes.identity { + IdentityStyle::GeneratedAlways | IdentityStyle::GeneratedByDefault => { + format!("ALTER COLUMN {name} DROP IDENTITY") + } + // A serial column numbers itself through its default; + // the sequence stays, owned by the column. + IdentityStyle::SequenceDefault => format!("ALTER COLUMN {name} DROP DEFAULT"), + _ => { + return Err(BuildDdlError::Refused { + column: alteration.original.name().to_owned(), + operation: DdlOperation::AlterAutoIncrement, + reason: RefusalReason::UnreadableDefinition, + }); + } + }); + } + Ok(()) + } + /// The column as the save leaves it has to take an identity: an /// integer, with no default of its own. fn check_identity(&self, draft: &DraftColumn) -> Result<(), BuildDdlError> { diff --git a/linux/crates/drivers/postgres/tests/integration/decode.rs b/linux/crates/drivers/postgres/tests/integration/decode.rs index 7bdf268041..59dd97d33e 100644 --- a/linux/crates/drivers/postgres/tests/integration/decode.rs +++ b/linux/crates/drivers/postgres/tests/integration/decode.rs @@ -123,7 +123,7 @@ async fn save(connection: &dyn Connection, table: &TableRef, changes: &ChangeSet #[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[ignore = "requires docker"] -async fn round_trip_matrix() { +async fn f006_round_trip_matrix() { let fixture = start().await; let connection = connect(&fixture, false).await; setup(connection.as_ref(), &[MATRIX]).await; diff --git a/linux/crates/drivers/postgres/tests/integration/metadata.rs b/linux/crates/drivers/postgres/tests/integration/metadata.rs index f1cf5ac91a..929795f29e 100644 --- a/linux/crates/drivers/postgres/tests/integration/metadata.rs +++ b/linux/crates/drivers/postgres/tests/integration/metadata.rs @@ -379,8 +379,39 @@ async fn catalog_matches_information_schema(version: PostgresVersion) { assert_eq!(ours, theirs, "PostgreSQL {}", version.major()); let column = |name: &str| columns.iter().find(|column| column.name == name).expect("a column"); + for (name, kind, read_form) in typed_kinds() { + let info = column(name); + assert_eq!(info.column_type.kind, kind, "{name} on PostgreSQL {}", version.major()); + assert_eq!( + info.column_type.read_form, + read_form, + "{name} on PostgreSQL {}", + version.major() + ); + } + assert!(column("id").primary_key && column("id").is_auto_increment); + assert_eq!(column("id").default, ColumnDefault::None); + assert_eq!(column("small").default, ColumnDefault::Literal(Value::Int(7))); + assert_eq!( + column("name").default, + ColumnDefault::Literal(Value::Text("it's".to_owned())) + ); + assert!(column("at").default.needs_server_evaluation()); + assert_eq!( + column("count") + .column_type + .catalog + .as_ref() + .map(|catalog| catalog.cast_target.as_sql().to_owned()), + Some("positive".to_owned()) + ); +} + +/// What each column of `CATALOG_TABLE` reads as, and how its values are +/// read. +fn typed_kinds() -> Vec<(&'static str, ColumnKind, ReadForm)> { let int = |width| ColumnKind::Integer(IntegerKind { width, signed: true }); - let cases: Vec<(&str, ColumnKind, ReadForm)> = vec![ + vec![ ("id", int(IntegerWidth::W32), ReadForm::Native), ("small", int(IntegerWidth::W16), ReadForm::Native), ( @@ -448,33 +479,7 @@ async fn catalog_matches_information_schema(version: PostgresVersion) { ("tsv", ColumnKind::Other, ReadForm::ServerText), ("count", int(IntegerWidth::W32), ReadForm::Native), ("ratio", ColumnKind::Float(FloatWidth::F32), ReadForm::Native), - ]; - for (name, kind, read_form) in cases { - let info = column(name); - assert_eq!(info.column_type.kind, kind, "{name} on PostgreSQL {}", version.major()); - assert_eq!( - info.column_type.read_form, - read_form, - "{name} on PostgreSQL {}", - version.major() - ); - } - assert!(column("id").primary_key && column("id").is_auto_increment); - assert_eq!(column("id").default, ColumnDefault::None); - assert_eq!(column("small").default, ColumnDefault::Literal(Value::Int(7))); - assert_eq!( - column("name").default, - ColumnDefault::Literal(Value::Text("it's".to_owned())) - ); - assert!(column("at").default.needs_server_evaluation()); - assert_eq!( - column("count") - .column_type - .catalog - .as_ref() - .map(|catalog| catalog.cast_target.as_sql().to_owned()), - Some("positive".to_owned()) - ); + ] } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] diff --git a/linux/crates/drivers/postgres/tests/integration/read_only.rs b/linux/crates/drivers/postgres/tests/integration/read_only.rs index 6fccc5e06c..6fb8cc2d11 100644 --- a/linux/crates/drivers/postgres/tests/integration/read_only.rs +++ b/linux/crates/drivers/postgres/tests/integration/read_only.rs @@ -19,7 +19,7 @@ fn refused_by_server(end: &RunEnd) -> bool { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[ignore = "requires docker"] -async fn editor_idle_delete_fails_25006() { +async fn f001_editor_idle_delete_fails_25006() { let fixture = start().await; let owner = connect(&fixture, false).await; setup(owner.as_ref(), &["CREATE TABLE t (id int)", "INSERT INTO t VALUES (1)"]).await; diff --git a/linux/crates/drivers/sqlite/src/connection.rs b/linux/crates/drivers/sqlite/src/connection.rs index 5d9f05ab95..88e7ab08f1 100644 --- a/linux/crates/drivers/sqlite/src/connection.rs +++ b/linux/crates/drivers/sqlite/src/connection.rs @@ -171,15 +171,18 @@ impl SqliteConnection { .await } - /// With the workspace connection busy, the file is asked on a - /// connection of the probe's own, which needs no lock to answer. + /// With the workspace connection busy, the file is opened on a + /// connection of the probe's own and nothing more. Opening runs only + /// pragmas that set flags on the connection, so it needs no lock. A + /// SELECT would: SQLite 3.51 reads the schema before it compiles a + /// connection's first SELECT (3.46 waited for a table name), and so + /// waits out the lock the busy calls are waiting on. async fn probe_standalone(&self, options: &CallOptions) -> Result<(), DriverError> { let options_for_probe = self.options(true); let probing = async { - let mut connection = SqlxConnection::connect_with(&options_for_probe) + let connection = SqlxConnection::connect_with(&options_for_probe) .await .map_err(|error| map_open_error(error, &self.path))?; - connection.execute("SELECT 1").await.map_err(map_error)?; connection.close().await.map_err(map_error) }; tokio::select! { @@ -210,11 +213,15 @@ impl SqliteConnection { /// Read the file once, so a file that is not a database fails the /// connect rather than the first browse, and refuse a library older /// than the catalogue queries need. +/// +/// Every statement here is part of the open, because which one reads +/// the file first depends on the library: SQLite 3.51 reads the schema +/// to compile `sqlite_version()`, 3.46 only for `sqlite_schema`. async fn check_file(pool: &SqlitePool, path: &Path) -> Result { let version: String = sqlx::query_scalar("SELECT sqlite_version()") .fetch_one(pool) .await - .map_err(map_error)?; + .map_err(|error| map_open_error(error, path))?; let version = SqliteVersion::parse(&version) .ok_or_else(|| DriverError::Protocol(format!("SQLite reported version {version:?}")))?; check_runtime_version(&version)?; diff --git a/linux/crates/drivers/sqlite/tests/integration/decode.rs b/linux/crates/drivers/sqlite/tests/integration/decode.rs index 700d5916df..235d0eefb6 100644 --- a/linux/crates/drivers/sqlite/tests/integration/decode.rs +++ b/linux/crates/drivers/sqlite/tests/integration/decode.rs @@ -16,7 +16,7 @@ fn integer() -> ColumnKind { } #[tokio::test] -async fn count_sum_and_expression_columns_are_typed() { +async fn f006_count_sum_and_expression_columns_are_typed() { let file = TestFile::new().await; let connection = file.open().await; apply( diff --git a/linux/crates/drivers/sqlite/tests/integration/open_semantics.rs b/linux/crates/drivers/sqlite/tests/integration/open_semantics.rs index 80f989801e..a4c4116481 100644 --- a/linux/crates/drivers/sqlite/tests/integration/open_semantics.rs +++ b/linux/crates/drivers/sqlite/tests/integration/open_semantics.rs @@ -120,7 +120,7 @@ async fn path_with_question_mark_and_percent41_opens_literal_file() { } #[tokio::test] -async fn read_only_insert_is_read_only() { +async fn f001_read_only_insert_is_read_only() { let file = TestFile::new().await; let owner = file.open().await; apply(owner.as_ref(), &["CREATE TABLE t (a)"]).await.unwrap(); diff --git a/linux/crates/session/src/connect/connect_attempts.rs b/linux/crates/session/src/connect/connect_attempts.rs index d1f2113ffe..75474c6854 100644 --- a/linux/crates/session/src/connect/connect_attempts.rs +++ b/linux/crates/session/src/connect/connect_attempts.rs @@ -64,7 +64,7 @@ mod tests { } #[test] - fn accepts_only_latest_generation() { + fn f002_accepts_only_latest_generation() { let attempts = ConnectAttempts::new(); let first = attempts.begin(); let second = attempts.begin(); diff --git a/linux/crates/session/src/health/health_monitor.rs b/linux/crates/session/src/health/health_monitor.rs index 4ecf3a4510..9edaca04f3 100644 --- a/linux/crates/session/src/health/health_monitor.rs +++ b/linux/crates/session/src/health/health_monitor.rs @@ -61,7 +61,7 @@ pub(crate) async fn run_monitor(ctx: MonitorContext) { () = ctx.cancel.cancelled() => return, signals = ctx.mailbox.next() => Wakeup::Signals(signals), () = sleep_until(policy.next_deadline()) => Wakeup::Deadline, - generation = lease_lost(watched) => Wakeup::LeaseLost(generation), + generation = lease_lost(watched) => after_loss(&ctx.mailbox, generation), }; let now = Instant::now(); let (mut action, mut user_asked) = match wakeup { @@ -99,6 +99,18 @@ pub(crate) async fn run_monitor(ctx: MonitorContext) { } } +/// A signal posted after the select polled the mailbox, and before it +/// saw the loss, is still waiting. It is read first, as when both are +/// ready together, so the loss is judged with it: an unreachable answer +/// waits offline instead of redialling. The loss holds for the next +/// turn. +fn after_loss(mailbox: &MonitorMailbox, generation: LinkGeneration) -> Wakeup { + match mailbox.try_next() { + Some(signals) => Wakeup::Signals(signals), + None => Wakeup::LeaseLost(generation), + } +} + async fn sleep_until(deadline: Option) { match deadline { Some(deadline) => tokio::time::sleep_until(deadline).await, @@ -371,6 +383,21 @@ mod tests { } } + #[test] + fn a_loss_seen_with_a_signal_waiting_reads_the_signal_first() { + let mailbox = MonitorMailbox::new(); + mailbox.post(MonitorSignal::Reachability(Reachability::Unreachable)); + + let first = after_loss(&mailbox, LinkGeneration::FIRST); + let then = after_loss(&mailbox, LinkGeneration::FIRST); + + assert!( + matches!(&first, Wakeup::Signals(signals) if signals.reachability == Some(Reachability::Unreachable)), + "the loss was judged before the reachability answer that came with it" + ); + assert!(matches!(then, Wakeup::LeaseLost(generation) if generation == LinkGeneration::FIRST)); + } + #[tokio::test(start_paused = true)] async fn reconnects_on_connection_lost_with_exact_backoff() { let connector = FakeConnector::new([ @@ -732,7 +759,7 @@ mod tests { } #[tokio::test(start_paused = true)] - async fn late_reconnect_after_cancel_is_discarded_and_closed() { + async fn f002_late_reconnect_after_cancel_is_discarded_and_closed() { let connector = FakeConnector::new([FakeEstablish::Late { after: Duration::from_secs(10), script: healthy(), diff --git a/linux/crates/session/src/health/monitor_mailbox.rs b/linux/crates/session/src/health/monitor_mailbox.rs index 0a700e04b5..ce48f84c6e 100644 --- a/linux/crates/session/src/health/monitor_mailbox.rs +++ b/linux/crates/session/src/health/monitor_mailbox.rs @@ -35,13 +35,18 @@ impl MonitorMailbox { /// are none yet. pub async fn next(&self) -> PendingSignals { loop { - let taken = self.pending.lock().unwrap_or_else(PoisonError::into_inner).take(); - if !taken.is_empty() { + if let Some(taken) = self.try_next() { return taken; } self.notify.notified().await; } } + + /// The signals that have arrived, or `None` without waiting. + pub fn try_next(&self) -> Option { + let taken = self.pending.lock().unwrap_or_else(PoisonError::into_inner).take(); + (!taken.is_empty()).then_some(taken) + } } #[cfg(test)] diff --git a/linux/crates/ssh/src/session.rs b/linux/crates/ssh/src/session.rs index 8f89851f7c..be1a5debd9 100644 --- a/linux/crates/ssh/src/session.rs +++ b/linux/crates/ssh/src/session.rs @@ -22,7 +22,7 @@ use crate::hop_config; use crate::host_key_checking::HostKeyChecking; use crate::known_hosts::spawn_failure; use crate::password_owner::password_owner; -use crate::route::{JumpHop, lookup, trace_route}; +use crate::route::{JumpHop, Route, lookup, trace_route}; use crate::stderr_classify::{ClassifyContext, classify}; use crate::supervisor::{ForwardCancel, Supervisor, TASKS, run_control}; use crate::{SshAuth, SshConfig, SshServices, lock}; @@ -53,27 +53,31 @@ enum Handshake { Failed(TransportError), } -impl SshSession { - pub async fn connect( +/// A master that finished its handshake, and where it keeps its sockets. +struct Master { + child: Child, + master_dir: PathBuf, + control: PathBuf, + stderr_tail: Arc>, +} + +/// What ssh's own configuration says about the destination, read before +/// the master starts. +struct MasterPlan { + /// The `user@host` in the destination's password prompt. + password_owner: Option, + host_keys: Option, + route: Route, + master_config: Option, +} + +impl MasterPlan { + async fn resolve( config: &SshConfig, services: &SshServices, - liveness: &LivenessPolicy, - interaction: CredentialInteraction, - cancel: CancellationToken, - ) -> Result, TransportError> { - let runtime = services.runtime.get().await?; - let master_dir = tokio::task::spawn_blocking(move || runtime.create_master_dir()) - .await - .map_err(|error| protocol(&format!("the ssh setup task failed: {error}")))??; - let control = master_dir.join("control"); - - let (listener, owner_uid) = match prepare(&master_dir, &control).await { - Ok(prepared) => prepared, - Err(error) => { - remove_dir(&master_dir).await; - return Err(error); - } - }; + master_dir: &Path, + cancel: &CancellationToken, + ) -> Result { let config_file = services.config_file.as_deref(); let resolved = lookup( &services.ssh_program, @@ -89,25 +93,34 @@ impl SshSession { let host_keys = resolved.as_deref().and_then(HostKeyChecking::from_ssh_g); let route = tokio::select! { route = trace_route(&services.ssh_program, config_file, resolved.as_deref()) => route, - () = cancel.cancelled() => { - remove_dir(&master_dir).await; - return Err(SshFailure::InteractionCancelled.into()); - } - }; - let master_config = match pin_jump_hosts(&route.hops, config_file, &master_dir).await { - Ok(written) => written.or_else(|| config_file.map(Path::to_path_buf)), - Err(error) => { - remove_dir(&master_dir).await; - return Err(error); - } + () = cancel.cancelled() => return Err(SshFailure::InteractionCancelled.into()), }; - let spawned = Command::new(&services.ssh_program) + let master_config = pin_jump_hosts(&route.hops, config_file, master_dir) + .await? + .or_else(|| config_file.map(Path::to_path_buf)); + Ok(Self { + password_owner, + host_keys, + route, + master_config, + }) + } + + fn spawn_master( + &self, + config: &SshConfig, + services: &SshServices, + liveness: &LivenessPolicy, + control: &Path, + master_dir: &Path, + ) -> std::io::Result { + Command::new(&services.ssh_program) .args(master_args( config, - &control, + control, liveness, - host_keys, - master_config.as_deref(), + self.host_keys, + self.master_config.as_deref(), )) .env("SSH_ASKPASS", &services.askpass_program) .env("SSH_ASKPASS_REQUIRE", "force") @@ -117,8 +130,39 @@ impl SshSession { .stdout(Stdio::piped()) .stderr(Stdio::piped()) .kill_on_drop(true) - .spawn(); - let mut child = match spawned { + .spawn() + } +} + +impl SshSession { + pub async fn connect( + config: &SshConfig, + services: &SshServices, + liveness: &LivenessPolicy, + interaction: CredentialInteraction, + cancel: CancellationToken, + ) -> Result, TransportError> { + let runtime = services.runtime.get().await?; + let master_dir = tokio::task::spawn_blocking(move || runtime.create_master_dir()) + .await + .map_err(|error| protocol(&format!("the ssh setup task failed: {error}")))??; + let control = master_dir.join("control"); + + let (listener, owner_uid) = match prepare(&master_dir, &control).await { + Ok(prepared) => prepared, + Err(error) => { + remove_dir(&master_dir).await; + return Err(error); + } + }; + let plan = match MasterPlan::resolve(config, services, &master_dir, &cancel).await { + Ok(plan) => plan, + Err(error) => { + remove_dir(&master_dir).await; + return Err(error); + } + }; + let mut child = match plan.spawn_master(config, services, liveness, &control, &master_dir) { Ok(child) => child, Err(error) => { remove_dir(&master_dir).await; @@ -136,7 +180,7 @@ impl SshSession { config.destination.to_string(), interaction, &config.auth, - password_owner, + plan.password_owner, ); let outcome = handshake( &mut child, @@ -176,6 +220,36 @@ impl SshSession { } } + let master = Master { + child, + master_dir, + control, + stderr_tail, + }; + Ok(Self::supervise( + config, + services, + liveness, + master, + plan.route.first_hop, + )) + } + + /// The session over a master that finished its handshake, with the + /// supervisor that stops it. + fn supervise( + config: &SshConfig, + services: &SshServices, + liveness: &LivenessPolicy, + master: Master, + first_hop: Option, + ) -> Arc { + let Master { + child, + master_dir, + control, + stderr_tail, + } = master; let closed = CancellationToken::new(); let shutdown = CancellationToken::new(); let exit_status = Arc::new(Mutex::new(None)); @@ -187,7 +261,7 @@ impl SshSession { master_dir: master_dir.clone(), control: control.clone(), master_pid: child.id(), - first_hop: route.first_hop, + first_hop, closed: closed.clone(), shutdown: shutdown.clone(), exit_status: exit_status.clone(), @@ -209,7 +283,7 @@ impl SshSession { } .run(), ); - Ok(session) + session } pub fn closed(&self) -> WaitForCancellationFutureOwned { diff --git a/linux/crates/storage/src/connection_group_name.rs b/linux/crates/storage/src/connection_group_name.rs new file mode 100644 index 0000000000..95bf74d660 --- /dev/null +++ b/linux/crates/storage/src/connection_group_name.rs @@ -0,0 +1,95 @@ +use serde::{Deserialize, Serialize}; + +use crate::group_name_error::GroupNameError; + +/// The group a saved connection is filed under, as the user typed it +/// without the spaces around it. +/// +/// Read from disk through the same check as from the dialog, so a name +/// a menu or a section title could not show never reaches either. +#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(try_from = "String", into = "String")] +pub struct ConnectionGroupName(String); + +impl ConnectionGroupName { + /// Long enough for any real folder name, short enough for a menu. + pub const MAX_CHARS: usize = 64; + + pub fn new(value: &str) -> Result { + let name = value.trim(); + if name.is_empty() { + return Err(GroupNameError::Empty); + } + if name.chars().count() > Self::MAX_CHARS { + return Err(GroupNameError::TooLong { max: Self::MAX_CHARS }); + } + if name.chars().any(char::is_control) { + return Err(GroupNameError::ControlCharacter); + } + Ok(Self(name.to_owned())) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl TryFrom for ConnectionGroupName { + type Error = GroupNameError; + + fn try_from(value: String) -> Result { + Self::new(&value) + } +} + +impl From for String { + fn from(name: ConnectionGroupName) -> Self { + name.0 + } +} + +impl std::fmt::Display for ConnectionGroupName { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.0) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn group_name_rejects_blank_long_and_control_characters() { + assert_eq!(ConnectionGroupName::new(""), Err(GroupNameError::Empty)); + assert_eq!(ConnectionGroupName::new(" \t "), Err(GroupNameError::Empty)); + assert_eq!( + ConnectionGroupName::new(&"a".repeat(ConnectionGroupName::MAX_CHARS + 1)), + Err(GroupNameError::TooLong { + max: ConnectionGroupName::MAX_CHARS + }) + ); + assert_eq!( + ConnectionGroupName::new("Work\nHome"), + Err(GroupNameError::ControlCharacter) + ); + assert_eq!( + ConnectionGroupName::new("Wo\u{7}rk"), + Err(GroupNameError::ControlCharacter) + ); + } + + #[test] + fn the_limit_counts_characters_not_bytes() { + let name = "é".repeat(ConnectionGroupName::MAX_CHARS); + + assert_eq!(ConnectionGroupName::new(&name).map(String::from), Ok(name)); + } + + #[test] + fn group_name_try_from_and_into_string_round_trip() { + let name = ConnectionGroupName::try_from(String::from(" Prod ")).expect("a valid name"); + + assert_eq!(name.as_str(), "Prod"); + assert_eq!(String::from(name), "Prod"); + } +} diff --git a/linux/crates/storage/src/connections.rs b/linux/crates/storage/src/connections.rs index 918c5ad9b3..968c0e0f12 100644 --- a/linux/crates/storage/src/connections.rs +++ b/linux/crates/storage/src/connections.rs @@ -41,11 +41,10 @@ pub struct SavedConnection { /// apart from staging at a glance. `None` until they pick one. #[serde(default, skip_serializing_if = "Option::is_none")] pub color: Option, - /// The folder the user filed this connection under, as they typed - /// it. `None` means it sits on its own, which is where every - /// connection starts. + /// The folder the user filed this connection under. `None` means it + /// sits on its own, which is where every connection starts. #[serde(default, skip_serializing_if = "Option::is_none")] - pub group: Option, + pub group: Option, } impl SavedConnection { @@ -288,6 +287,31 @@ impl ConnectionStore { Ok(RemoveOutcome::Removed) } + /// Change the entry `id` as the list holds it at the moment of the + /// write, so a stamp or an edit from another window in between is + /// kept. Returns the entry as it now is, or `None` when the list has + /// no such entry. A change that changes nothing writes nothing. + pub fn update_blocking( + &self, + id: Uuid, + change: impl FnOnce(&mut SavedConnection), + ) -> Result, StorageError> { + let mut state = self.lock(); + let mut list = self.ensure_loaded(&mut state)?.to_vec(); + let Some(entry) = list.iter_mut().find(|saved| saved.id == id) else { + return Ok(None); + }; + let before = entry.clone(); + change(entry); + // The keyring files the entry's secrets under its id. + entry.id = id; + let updated = entry.clone(); + if updated != before { + self.commit(&mut state, list)?; + } + Ok(Some(updated)) + } + /// Stamp the last-opened time so the welcome view can sort /// recency-first. An id that is not in the list writes nothing: /// a connection opened without saving has nothing to update. @@ -470,36 +494,14 @@ fn read_document(path: &Path) -> Result, DocumentProblem> { crate::document::decode_document::(path, &bytes).map(|document| document.connections) } -/// A copy of `source` under a name no other connection holds. -/// -/// The copy is a new connection, not a second reference to the same -/// one: it takes a fresh id, so its secrets are its own and deleting -/// either leaves the other alone. -pub fn duplicate(source: &SavedConnection, taken: &[SavedConnection]) -> SavedConnection { - SavedConnection { - id: Uuid::new_v4(), - name: suffixed_name( - &source.name, - "copy", - &taken.iter().map(|c| c.name.as_str()).collect::>(), - ), - // A copy has never been opened, whatever the original did, so - // it sorts by name rather than claiming the original's recency. - // The colour and the group do carry: a copy points at the same - // server, so it belongs with the original. - last_opened_at: None, - ..source.clone() - } -} - -/// `name (copy)`, then `name (copy 2)` and on, until one is free. +/// `name (suffix)`, then `name (suffix 2)` and on, until one is free. fn suffixed_name(name: &str, suffix: &str, taken: &[&str]) -> String { let first = format!("{name} ({suffix})"); if !taken.contains(&first.as_str()) { return first; } // A name is short and the list is a person's own connections, so - // counting up from two is bounded by how many copies they made. + // counting up from two is bounded by how many imports took it. (2..) .map(|n| format!("{name} ({suffix} {n})")) .find(|candidate| !taken.contains(&candidate.as_str())) @@ -529,60 +531,6 @@ mod tests { } } - #[test] - fn a_copy_keeps_everything_that_says_where_to_connect() { - let source = saved("production"); - - let copy = duplicate(&source, std::slice::from_ref(&source)); - - assert_eq!(copy.host, source.host); - assert_eq!(copy.port, source.port); - assert_eq!(copy.database, source.database); - assert_eq!(copy.username, source.username); - assert_eq!(copy.tls, source.tls); - assert_eq!(copy.driver_id, source.driver_id); - } - - #[test] - fn a_copy_keeps_the_colour_because_it_points_at_the_same_server() { - let mut source = saved("production"); - source.color = Some(crate::ConnectionColor::Red); - - let copy = duplicate(&source, std::slice::from_ref(&source)); - - assert_eq!(copy.color, Some(crate::ConnectionColor::Red)); - } - - #[test] - fn a_copy_is_its_own_connection_not_a_second_name_for_one() { - let source = saved("production"); - - let copy = duplicate(&source, std::slice::from_ref(&source)); - - assert_ne!(copy.id, source.id, "the copy shares the original's secrets"); - assert_eq!(copy.last_opened_at, None, "the copy claimed the original's recency"); - } - - #[test] - fn copies_count_up_rather_than_colliding() { - let first = saved("production"); - let mut taken = vec![first.clone()]; - - for expected in ["production (copy)", "production (copy 2)", "production (copy 3)"] { - let copy = duplicate(&first, &taken); - assert_eq!(copy.name, expected); - taken.push(copy); - } - } - - #[test] - fn a_free_numbered_name_is_taken_before_counting_past_it() { - let source = saved("db"); - let taken = vec![source.clone(), saved("db (copy)"), saved("db (copy 3)")]; - - assert_eq!(duplicate(&source, &taken).name, "db (copy 2)"); - } - use tempfile::TempDir; use super::*; @@ -652,7 +600,7 @@ mod tests { } #[test] - fn truncated_file_is_unavailable_and_upsert_leaves_bytes_identical() { + fn f003_truncated_file_is_unavailable_and_upsert_leaves_bytes_identical() { let root = TempDir::new().expect("tempdir"); let path = seed(&root, r#"{"version": 1, "connections": [{"id":"#); let before = std::fs::read(&path).expect("read"); @@ -669,7 +617,7 @@ mod tests { } #[test] - fn version_2_is_newer_and_never_written() { + fn f003_version_2_is_newer_and_never_written() { let root = TempDir::new().expect("tempdir"); let path = seed(&root, r#"{"version": 2, "connections": []}"#); let before = std::fs::read(&path).expect("read"); @@ -1235,4 +1183,100 @@ mod tests { }; assert!(problem.is_newer_version(), "{problem:?}"); } + + fn connections_json(group: Option<&str>) -> String { + let group = group.map_or_else(String::new, |group| format!(r#","group":{group}"#)); + format!( + r#"{{"version": 1, "connections": [{{"id":"550e8400-e29b-41d4-a716-446655440000","name":"n","driver_id":"postgres","host":"h","port":5432,"database":"d","username":"u","tls":{{"mode":"disable"}}{group}}}]}}"# + ) + } + + #[test] + fn group_round_trips_through_connections_document() { + let root = TempDir::new().expect("tempdir"); + seed(&root, &connections_json(None)); + assert_eq!(store(&root).load_blocking().expect("load")[0].group, None); + + let root = TempDir::new().expect("tempdir"); + seed(&root, &connections_json(Some(r#"" Work ""#))); + let opened = store(&root); + let list = opened.load_blocking().expect("load"); + assert_eq!( + list[0].group.as_ref().map(crate::ConnectionGroupName::as_str), + Some("Work") + ); + opened.upsert_blocking(list[0].clone()).expect("rewrite"); + let raw: serde_json::Value = + serde_json::from_slice(&std::fs::read(opened.path()).expect("read")).expect("json"); + assert_eq!(raw["connections"][0]["group"], "Work"); + + for blank in [r#""""#, r#"" ""#] { + let root = TempDir::new().expect("tempdir"); + seed(&root, &connections_json(Some(blank))); + + let error = store(&root).load_blocking().expect_err("a blank group"); + + let StorageError::DocumentUnavailable(problem) = error else { + panic!("expected a document problem, got {error:?}"); + }; + assert!( + problem.to_string().contains(&crate::GroupNameError::Empty.to_string()), + "{blank}: {problem}" + ); + } + } + + #[test] + fn an_update_keeps_what_another_writer_changed_meanwhile() { + let root = TempDir::new().expect("tempdir"); + let store = store(&root); + let connection = sample_connection(); + store.upsert_blocking(connection.clone()).expect("upsert"); + store.touch_last_opened_blocking(connection.id).expect("touch"); + let work = crate::ConnectionGroupName::new("Work").expect("a name"); + + let updated = store + .update_blocking(connection.id, |entry| entry.group = Some(work.clone())) + .expect("update") + .expect("the entry"); + + assert_eq!(updated.group, Some(work)); + assert!(updated.last_opened_at.is_some(), "the update undid the stamp"); + assert_eq!(store.load_blocking().expect("load")[0], updated); + } + + #[test] + fn an_update_never_moves_the_entry_to_another_id() { + let root = TempDir::new().expect("tempdir"); + let store = store(&root); + let connection = sample_connection(); + store.upsert_blocking(connection.clone()).expect("upsert"); + + let updated = store + .update_blocking(connection.id, |entry| { + entry.id = Uuid::new_v4(); + entry.name = "Renamed".to_owned(); + }) + .expect("update") + .expect("the entry"); + + assert_eq!(updated.id, connection.id); + assert_eq!(store.load_blocking().expect("load")[0].name, "Renamed"); + } + + #[test] + fn an_update_that_changes_nothing_or_finds_nothing_writes_nothing() { + let root = TempDir::new().expect("tempdir"); + let store = store(&root); + let connection = sample_connection(); + store.upsert_blocking(connection.clone()).expect("upsert"); + let revision = store.snapshot().revision; + + assert_eq!( + store.update_blocking(connection.id, |_| {}).expect("update"), + Some(connection) + ); + assert_eq!(store.update_blocking(Uuid::new_v4(), |_| {}).expect("update"), None); + assert_eq!(store.snapshot().revision, revision); + } } diff --git a/linux/crates/storage/src/group_name_error.rs b/linux/crates/storage/src/group_name_error.rs new file mode 100644 index 0000000000..bcdf0c050b --- /dev/null +++ b/linux/crates/storage/src/group_name_error.rs @@ -0,0 +1,11 @@ +/// Why a group name cannot be used. The messages are also what a +/// connection list with such a name on disk is refused with. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum GroupNameError { + #[error("a group name cannot be empty")] + Empty, + #[error("a group name can be at most {max} characters long")] + TooLong { max: usize }, + #[error("a group name cannot contain control characters")] + ControlCharacter, +} diff --git a/linux/crates/storage/src/lib.rs b/linux/crates/storage/src/lib.rs index 830d147e97..b0c2e9c8e2 100644 --- a/linux/crates/storage/src/lib.rs +++ b/linux/crates/storage/src/lib.rs @@ -1,4 +1,5 @@ mod connection_color; +mod connection_group_name; mod connections; mod credentials; pub mod document; @@ -6,6 +7,7 @@ mod document_problem; pub mod drafts; mod error; pub mod fs; +mod group_name_error; mod paths; pub mod query_history; pub mod saved_queries; @@ -14,9 +16,10 @@ pub mod settings; mod unix_time; pub use connection_color::ConnectionColor; +pub use connection_group_name::ConnectionGroupName; pub use connections::{ ConnectionListSnapshot, ConnectionListState, ConnectionStore, ImportReport, RemoveOutcome, SavedConnection, - SavedSshAuth, SavedSshConfig, duplicate, + SavedSshAuth, SavedSshConfig, }; pub use credentials::{ CredentialDemand, CredentialError, KeyringCredentials, KeyringError, ResolvedSecret, SecretLabeler, @@ -25,6 +28,7 @@ pub use credentials::{ pub use document_problem::{DocumentProblem, DocumentProblemKind}; pub use drafts::{DraftId, DraftScope, DraftStore}; pub use error::StorageError; +pub use group_name_error::GroupNameError; pub use paths::StoragePaths; pub use query_history::QueryHistory; pub use saved_queries::SavedQueries; diff --git a/linux/crates/test-support/src/conformance/cases/ddl.rs b/linux/crates/test-support/src/conformance/cases/ddl.rs index 1b9155d4d9..d1670d708a 100644 --- a/linux/crates/test-support/src/conformance/cases/ddl.rs +++ b/linux/crates/test-support/src/conformance/cases/ddl.rs @@ -33,7 +33,6 @@ pub async fn ddl_plans_apply_and_read_back(engine: E) - let connection = connect(case, &engine, writable()).await?; let dialect = connection.dialect(); let ddl = ddl_of(case, dialect.as_ref())?; - let capabilities = *ddl.capabilities(); let parent = engine.scratch_table(&unique("ddl_parent")); let child = engine.scratch_table(&unique("ddl_child")); @@ -59,88 +58,9 @@ pub async fn ddl_plans_apply_and_read_back(engine: E) - connection: connection.as_ref(), ddl, }; - let note = DraftColumn::new("note", DraftType::Typed(engine.text_type().to_owned())); - step.apply( - &mut table, - "add column", - table_permission(capabilities.add_column), - |draft| draft.columns.push(note), - ) - .await?; - - let permission = column_permission(ddl, &table, "note", |permissions| permissions.rename); - step.apply(&mut table, "rename column", permission, |draft| { - rename(draft, "note", "remark") - }) - .await?; - let remark = if table.column("remark").is_some() { - "remark" - } else { - "note" - }; - - let permission = column_permission(ddl, &table, remark, |permissions| permissions.alter_nullability); - step.apply(&mut table, "change nullability", permission, |draft| { - edit_column(draft, remark, |column| column.nullable = false) - }) - .await?; - - let permission = column_permission(ddl, &table, remark, |permissions| permissions.alter_default); - step.apply(&mut table, "change default", permission, |draft| { - edit_column(draft, remark, |column| { - column.default = ColumnDefault::Literal(Value::Text("none".to_owned())); - }) - }) - .await?; - - let index = IndexInfo { - name: format!("{}_label", parent.name), - keys: vec![IndexKey::Column { - name: "label".to_owned(), - descending: false, - }], - unique: false, - primary: false, - predicate: None, - }; - step.apply( - &mut table, - "create index", - table_permission(capabilities.create_index), - |draft| draft.indexes.push(index), - ) - .await?; - - let mut child_table = definition(connection.as_ref(), &child).await?; - let key = ForeignKeyInfo { - name: format!("{}_parent_fk", child.name), - columns: vec!["parent_id".to_owned()], - ref_schema: parent.schema.clone(), - ref_table: parent.name.clone(), - ref_columns: vec!["id".to_owned()], - on_delete: None, - on_update: None, - }; - let key_name = key.name.clone(); - let added = step - .apply( - &mut child_table, - "add foreign key", - table_permission(capabilities.add_foreign_key), - |draft| draft.foreign_keys.push(key), - ) - .await?; - if added { - table = definition(connection.as_ref(), &parent).await?; - let permission = column_permission(ddl, &table, "label", |permissions| permissions.alter_primary_key); - let names_the_key = matches!( - &permission, - Permission::Refused(RefusalReason::ReferencedByForeignKeys(keys)) if keys.contains(&key_name) - ); - expect(case, names_the_key, || { - format!("the referenced primary key reads as {permission:?}, not held by {key_name}") - })?; - } + let remark = step.change_columns(&mut table, engine.text_type()).await?; + step.index_label(&mut table, &parent).await?; + step.reference_the_key(&mut table, &parent, &child).await?; let permission = column_permission(ddl, &table, remark, |permissions| permissions.drop); step.apply(&mut table, "drop column", permission, |draft| { @@ -148,57 +68,8 @@ pub async fn ddl_plans_apply_and_read_back(engine: E) - }) .await?; - let renamed = TableRef::new(parent.schema.clone(), unique("ddl_renamed")); - let new_name = renamed.name.clone(); - let moved = step - .apply( - &mut table, - "rename table", - table_permission(capabilities.rename_table), - |draft| draft.table.name = new_name, - ) - .await?; - // A name the server resolved against the session's default schema - // would have moved the table out of its own. - let parent = if moved { - let scope = renamed - .schema - .clone() - .map_or(ObjectScope::DefaultSchema, ObjectScope::Schema); - let objects = connection.list_objects(&scope, within(CALL_DEADLINE)).await?; - let listed = objects - .iter() - .any(|object| object.kind == ObjectKind::Table && object.name == renamed.name); - expect(case, listed, || { - format!("{} is not listed in {scope:?} after the rename", renamed.name) - })?; - renamed - } else { - parent - }; - - let drop = DropTableOptions { - if_exists: false, - cascade: capabilities.drop_table_cascade, - }; - for dropped in [&child, &parent] { - let plan = ddl - .plan_drop_table(dropped, drop) - .map_err(|error| planned(case, "drop table", error))?; - run(connection.as_ref(), dropped, plan).await?; - } - let objects = connection - .list_objects(&ObjectScope::DefaultSchema, within(CALL_DEADLINE)) - .await?; - let left: Vec<&str> = objects - .iter() - .filter(|object| object.kind == ObjectKind::Table) - .filter(|object| object.name == parent.name || object.name == child.name) - .map(|object| object.name.as_str()) - .collect(); - expect(case, left.is_empty(), || { - format!("{left:?} are still listed after the drop") - })?; + let parent = step.rename_table(&mut table, parent).await?; + step.drop_tables(&child, &parent).await?; connection.close().await?; Ok(()) } @@ -591,6 +462,176 @@ impl Step<'_> { )), } } + + /// Add a column, then rename it, make it NOT NULL and give it a + /// default, as far as the engine allows. The column's name after. + async fn change_columns( + &self, + table: &mut TableDefinition, + text_type: &str, + ) -> Result<&'static str, ConformanceFailure> { + let ddl = self.ddl; + let note = DraftColumn::new("note", DraftType::Typed(text_type.to_owned())); + self.apply( + table, + "add column", + table_permission(ddl.capabilities().add_column), + |draft| draft.columns.push(note), + ) + .await?; + + let permission = column_permission(ddl, table, "note", |permissions| permissions.rename); + self.apply(table, "rename column", permission, |draft| { + rename(draft, "note", "remark") + }) + .await?; + let remark = if table.column("remark").is_some() { + "remark" + } else { + "note" + }; + + let permission = column_permission(ddl, table, remark, |permissions| permissions.alter_nullability); + self.apply(table, "change nullability", permission, |draft| { + edit_column(draft, remark, |column| column.nullable = false) + }) + .await?; + + let permission = column_permission(ddl, table, remark, |permissions| permissions.alter_default); + self.apply(table, "change default", permission, |draft| { + edit_column(draft, remark, |column| { + column.default = ColumnDefault::Literal(Value::Text("none".to_owned())); + }) + }) + .await?; + Ok(remark) + } + + async fn index_label(&self, table: &mut TableDefinition, parent: &TableRef) -> Result<(), ConformanceFailure> { + let index = IndexInfo { + name: format!("{}_label", parent.name), + keys: vec![IndexKey::Column { + name: "label".to_owned(), + descending: false, + }], + unique: false, + primary: false, + predicate: None, + }; + self.apply( + table, + "create index", + table_permission(self.ddl.capabilities().create_index), + |draft| draft.indexes.push(index), + ) + .await?; + Ok(()) + } + + /// Point a foreign key of `child` at `parent`'s key, whose + /// permissions then name the foreign key that holds it. `table` is + /// `parent` read again. + async fn reference_the_key( + &self, + table: &mut TableDefinition, + parent: &TableRef, + child: &TableRef, + ) -> Result<(), ConformanceFailure> { + let mut child_table = definition(self.connection, child).await?; + let key = ForeignKeyInfo { + name: format!("{}_parent_fk", child.name), + columns: vec!["parent_id".to_owned()], + ref_schema: parent.schema.clone(), + ref_table: parent.name.clone(), + ref_columns: vec!["id".to_owned()], + on_delete: None, + on_update: None, + }; + let key_name = key.name.clone(); + let added = self + .apply( + &mut child_table, + "add foreign key", + table_permission(self.ddl.capabilities().add_foreign_key), + |draft| draft.foreign_keys.push(key), + ) + .await?; + if !added { + return Ok(()); + } + *table = definition(self.connection, parent).await?; + let permission = column_permission(self.ddl, table, "label", |permissions| permissions.alter_primary_key); + let names_the_key = matches!( + &permission, + Permission::Refused(RefusalReason::ReferencedByForeignKeys(keys)) if keys.contains(&key_name) + ); + expect(self.case, names_the_key, || { + format!("the referenced primary key reads as {permission:?}, not held by {key_name}") + }) + } + + /// The table under its new name, or `parent` where the engine can't + /// rename a table. + async fn rename_table( + &self, + table: &mut TableDefinition, + parent: TableRef, + ) -> Result { + let renamed = TableRef::new(parent.schema.clone(), unique("ddl_renamed")); + let new_name = renamed.name.clone(); + let moved = self + .apply( + table, + "rename table", + table_permission(self.ddl.capabilities().rename_table), + |draft| draft.table.name = new_name, + ) + .await?; + if !moved { + return Ok(parent); + } + // A name the server resolved against the session's default schema + // would have moved the table out of its own. + let scope = renamed + .schema + .clone() + .map_or(ObjectScope::DefaultSchema, ObjectScope::Schema); + let objects = self.connection.list_objects(&scope, within(CALL_DEADLINE)).await?; + let listed = objects + .iter() + .any(|object| object.kind == ObjectKind::Table && object.name == renamed.name); + expect(self.case, listed, || { + format!("{} is not listed in {scope:?} after the rename", renamed.name) + })?; + Ok(renamed) + } + + async fn drop_tables(&self, child: &TableRef, parent: &TableRef) -> Result<(), ConformanceFailure> { + let drop = DropTableOptions { + if_exists: false, + cascade: self.ddl.capabilities().drop_table_cascade, + }; + for dropped in [child, parent] { + let plan = self + .ddl + .plan_drop_table(dropped, drop) + .map_err(|error| planned(self.case, "drop table", error))?; + run(self.connection, dropped, plan).await?; + } + let objects = self + .connection + .list_objects(&ObjectScope::DefaultSchema, within(CALL_DEADLINE)) + .await?; + let left: Vec<&str> = objects + .iter() + .filter(|object| object.kind == ObjectKind::Table) + .filter(|object| object.name == parent.name || object.name == child.name) + .map(|object| object.name.as_str()) + .collect(); + expect(self.case, left.is_empty(), || { + format!("{left:?} are still listed after the drop") + }) + } } /// Names and order, nullability and defaults of the columns, the keys of diff --git a/linux/crates/test-support/src/conformance/cases/read_only.rs b/linux/crates/test-support/src/conformance/cases/read_only.rs index b0f1b38c10..559800d609 100644 --- a/linux/crates/test-support/src/conformance/cases/read_only.rs +++ b/linux/crates/test-support/src/conformance/cases/read_only.rs @@ -6,11 +6,13 @@ use crate::conformance::support::{ }; use crate::conformance::{ConformanceEngine, ConformanceFailure}; -const CASE: &str = "read_only_connection_refuses_writes"; +const CASE: &str = "f001_read_only_connection_refuses_writes"; /// Case 7: a read-only connection refuses a save, and its editor refuses /// both a row change and a schema change. -pub async fn read_only_connection_refuses_writes(engine: E) -> Result<(), ConformanceFailure> { +pub async fn f001_read_only_connection_refuses_writes( + engine: E, +) -> Result<(), ConformanceFailure> { let owner = connect(CASE, &engine, writable()).await?; let table = create_scratch_table(&engine, owner.as_ref(), "read_only").await?; let reader = connect(CASE, &engine, WorkspaceSettings::workspace(true)).await?; diff --git a/linux/crates/test-support/src/conformance/cases/type_fallback.rs b/linux/crates/test-support/src/conformance/cases/type_fallback.rs index af981881e0..2dc3daa671 100644 --- a/linux/crates/test-support/src/conformance/cases/type_fallback.rs +++ b/linux/crates/test-support/src/conformance/cases/type_fallback.rs @@ -5,11 +5,11 @@ use crate::conformance::support::{ }; use crate::conformance::{ConformanceFailure, TypedConformanceEngine}; -const CASE: &str = "unsupported_types_never_decode_as_null"; +const CASE: &str = "f006_unsupported_types_never_decode_as_null"; /// Case 13: a value of a type the driver has no decoder for still reads /// back as something, never as a NULL that would hide it. -pub async fn unsupported_types_never_decode_as_null( +pub async fn f006_unsupported_types_never_decode_as_null( engine: E, ) -> Result<(), ConformanceFailure> { let cases = engine.unsupported_type_cases(); diff --git a/linux/crates/test-support/src/conformance/connection_suite_macro.rs b/linux/crates/test-support/src/conformance/connection_suite_macro.rs index 86512cf1cc..1bf4021fa5 100644 --- a/linux/crates/test-support/src/conformance/connection_suite_macro.rs +++ b/linux/crates/test-support/src/conformance/connection_suite_macro.rs @@ -178,8 +178,8 @@ macro_rules! connection_conformance_suite { $crate::__conformance_case! { $docker, $engine as ConformanceEngine, - read_only_connection_refuses_writes, - $crate::conformance::cases::read_only::read_only_connection_refuses_writes::<$engine> + f001_read_only_connection_refuses_writes, + $crate::conformance::cases::read_only::f001_read_only_connection_refuses_writes::<$engine> } $crate::__conformance_case! { $docker, @@ -363,8 +363,8 @@ macro_rules! __conformance_types_case { $crate::__conformance_case! { $docker, $engine as ConformanceEngine, - unsupported_types_never_decode_as_null, - $crate::conformance::cases::type_fallback::unsupported_types_never_decode_as_null::<$engine> + f006_unsupported_types_never_decode_as_null, + $crate::conformance::cases::type_fallback::f006_unsupported_types_never_decode_as_null::<$engine> } }; (false, $docker:tt, $engine:ty) => {}; diff --git a/linux/crates/test-support/src/fake_reply.rs b/linux/crates/test-support/src/fake_reply.rs index f9c3fbbd80..4f1c8230bf 100644 --- a/linux/crates/test-support/src/fake_reply.rs +++ b/linux/crates/test-support/src/fake_reply.rs @@ -25,6 +25,13 @@ pub enum FakeReply { after: Duration, outcome: Result, }, + /// Answer once the test cancels `release`, unless the call's + /// deadline or cancel comes first, so a case can act while the call + /// is still running however slowly the machine runs. + UntilReleased { + release: CancellationToken, + outcome: Result, + }, /// Never answer on its own: only the call's deadline or cancel ends /// it, the way a statement the server never finishes behaves. UntilStopped, @@ -56,6 +63,10 @@ impl FakeReply { tokio::time::sleep(after).await; outcome } + Self::UntilReleased { release, outcome } => { + stop.until(release.cancelled()).await?; + outcome + } Self::UntilStopped => { stop.wait(None).await?; Err(DriverError::Protocol("an unbounded fake call finished".to_owned())) @@ -83,12 +94,15 @@ impl Stop<'_> { /// Wait `after`, or forever when it is `None`, giving up with the /// error a conforming driver reports when the call is stopped first. pub(crate) async fn wait(&self, after: Option) -> Result<(), DriverError> { - let work = async { - match after { - Some(after) => tokio::time::sleep(after).await, - None => std::future::pending().await, - } - }; + match after { + Some(after) => self.until(tokio::time::sleep(after)).await, + None => self.until(std::future::pending()).await, + } + } + + /// Wait for `work`, giving up the same way when the call is stopped + /// first. + pub(crate) async fn until(&self, work: impl Future) -> Result<(), DriverError> { let deadline = async { match self.deadline { Some(deadline) => tokio::time::sleep_until(deadline).await, @@ -106,3 +120,68 @@ impl Stop<'_> { } } } + +#[cfg(test)] +mod tests { + use std::pin::pin; + + use super::*; + + fn stop<'a>(deadline: Option, cancel: &'a CancellationToken, closed: &'a CancellationToken) -> Stop<'a> { + Stop { + deadline, + cancel, + closed, + phase: TimeoutPhase::Statement, + } + } + + #[tokio::test(start_paused = true)] + async fn an_until_released_reply_answers_only_once_released() { + let (release, cancel, closed) = ( + CancellationToken::new(), + CancellationToken::new(), + CancellationToken::new(), + ); + let stop = stop(None, &cancel, &closed); + let reply = FakeReply::UntilReleased { + release: release.clone(), + outcome: Ok(7), + }; + let mut answering = pin!(reply.resolve(&stop)); + + let early = tokio::time::timeout(Duration::from_secs(3600), &mut answering).await; + assert!(early.is_err(), "answered before the release: {early:?}"); + release.cancel(); + + assert_eq!(answering.await, Ok(7)); + } + + #[tokio::test(start_paused = true)] + async fn an_until_released_reply_gives_way_to_the_deadline_and_the_cancel() { + let (release, cancel, closed) = ( + CancellationToken::new(), + CancellationToken::new(), + CancellationToken::new(), + ); + let reply = || FakeReply::UntilReleased { + release: release.clone(), + outcome: Ok(7), + }; + + let timed = reply() + .resolve(&stop(Some(Instant::now() + Duration::from_secs(1)), &cancel, &closed)) + .await; + cancel.cancel(); + let cancelled = reply().resolve(&stop(None, &cancel, &closed)).await; + + assert_eq!( + timed, + Err(DriverError::Timeout { + phase: TimeoutPhase::Statement, + server_cancelled: true, + }) + ); + assert_eq!(cancelled, Err(DriverError::Cancelled)); + } +} diff --git a/linux/data/app.tablepro.TablePro.desktop.in.in b/linux/data/app.tablepro.TablePro.desktop.in.in index fa1c3937ea..d0684f79cd 100644 --- a/linux/data/app.tablepro.TablePro.desktop.in.in +++ b/linux/data/app.tablepro.TablePro.desktop.in.in @@ -12,6 +12,7 @@ Categories=Development;Database; MimeType=application/vnd.sqlite3; DBusActivatable=true StartupNotify=true +X-GNOME-UsesNotifications=true Actions=new-window; [Desktop Action new-window] diff --git a/linux/data/app.tablepro.TablePro.metainfo.xml.in.in b/linux/data/app.tablepro.TablePro.metainfo.xml.in.in index 91117c30a9..495e5d9ca0 100644 --- a/linux/data/app.tablepro.TablePro.metainfo.xml.in.in +++ b/linux/data/app.tablepro.TablePro.metainfo.xml.in.in @@ -29,13 +29,31 @@ https://github.com/TableProApp/TablePro/issues https://github.com/TableProApp/TablePro https://docs.tablepro.app - https://github.com/TableProApp/TablePro/tree/main/linux/po - https://github.com/TableProApp/TablePro/blob/main/linux/CONTRIBUTING.md + https://github.com/TableProApp/TablePro/blob/linux-v@version@/linux/po/README.md + https://github.com/TableProApp/TablePro/blob/linux-v@version@/linux/CONTRIBUTING.md @gettext-package@ #99c1f1 #1a5fb4 + + + @screenshots-base-url@/01-table.png + Browse and edit table rows + + + @screenshots-base-url@/02-editor.png + Run SQL in the editor + + + @screenshots-base-url@/03-structure.png + Change a table's columns and indexes + + + @screenshots-base-url@/04-connect.png + Connect through an SSH tunnel + + Development Database diff --git a/linux/data/meson.build b/linux/data/meson.build index 4bdc80dec8..4c93ce687c 100644 --- a/linux/data/meson.build +++ b/linux/data/meson.build @@ -2,6 +2,8 @@ substitutions = configuration_data() substitutions.set('app-id', application_id) substitutions.set('bindir', bindir) substitutions.set('gettext-package', meson.project_name()) +substitutions.set('version', meson.project_version()) +substitutions.set('screenshots-base-url', screenshots_base_url) desktop_file_in = configure_file( input: 'app.tablepro.TablePro.desktop.in.in', @@ -73,6 +75,8 @@ install_data( install_dir: get_option('datadir') / 'licenses' / application_id, ) +install_man('tablepro.1') + if get_option('flatpak') and get_option('kerberos') install_data( meson.project_source_root() / 'build-aux' / 'flatpak' / 'krb5.conf', @@ -108,4 +112,25 @@ if appstreamcli.found() args: ['validate', '--no-net', '--explain', metainfo_file], suite: 'data', ) + + # Meson pins the links and screenshot URLs to the project version's + # tag, and a plain cargo build to the newest release, which is listed + # first, so the two must agree. + newest_release = 'none' + release_tags = fs.read('app.tablepro.TablePro.metainfo.xml.in.in').replace('\n', ' ').replace('\t', ' ').split(' 1 + release_attributes = ' ' + release_tags[1].split('>')[0] + foreach quote : ['"', '\''] + release_version = release_attributes.split(' version=' + quote) + if newest_release == 'none' and release_version.length() > 1 + newest_release = release_version[1].split(quote)[0] + endif + endforeach + endif + test( + 'metainfo-release-version', + appstreamcli, + args: ['vercmp', newest_release, 'eq', meson.project_version()], + suite: 'data', + ) endif diff --git a/linux/data/resources/resources.gresource.xml b/linux/data/resources/resources.gresource.xml index d725974352..3e1a917a9f 100644 --- a/linux/data/resources/resources.gresource.xml +++ b/linux/data/resources/resources.gresource.xml @@ -14,11 +14,19 @@ ui/connect-failed-page.ui ui/connecting-page.ui ui/export-operations-button.ui + ui/filter-rule-row.ui + ui/filter-strip.ui ui/find-bar.ui ui/history-dialog.ui ui/history-row.ui + ui/pager-bar.ui + ui/pending-changes-bar.ui ui/picker-page.ui + ui/preferences-data-browser-group.ui ui/preferences-dialog.ui + ui/preferences-editor-page.ui + ui/preferences-general-page.ui + ui/preferences-history-group.ui ui/results-pane.ui ui/session-window.ui ui/sql-editor.ui diff --git a/linux/data/resources/shortcuts-dialog.ui b/linux/data/resources/shortcuts-dialog.ui index de2b28b7bf..6960765c73 100644 --- a/linux/data/resources/shortcuts-dialog.ui +++ b/linux/data/resources/shortcuts-dialog.ui @@ -11,7 +11,7 @@ General - New window + New Window app.new-window @@ -23,25 +23,25 @@ - Query history + Query History win.show-history - Show or hide the sidebar + Show or Hide the Sidebar win.show-sidebar - Keyboard shortcuts + Keyboard Shortcuts app.shortcuts - Close a dialog + Close a Dialog Escape @@ -58,43 +58,43 @@ Tabs - New SQL editor tab + New SQL Editor Tab win.new-editor-tab - Close the tab, or the window when no tab is open + Close the Tab or the Empty Window win.close-current - Reopen the last closed tab + Reopen the Last Closed Tab win.reopen-closed-tab - Next tab + Next Tab <Primary>Tab <Primary>Page_Down - Previous tab + Previous Tab <Primary><Shift>Tab <Primary>Page_Up - Move the tab right + Move the Tab Right <Primary><Shift>Page_Down - Move the tab left + Move the Tab Left <Primary><Shift>Page_Up @@ -105,79 +105,79 @@ Browse Table - Filter rows + Filter Rows <Primary>f - Read the table again + Read the Table Again F5 - Save pending changes + Save Pending Changes <Primary>s - Undo the last change + Undo the Last Change <Primary>z - Redo the last change + Redo the Last Change <Primary><Shift>z <Primary>y - Previous page + Previous Page <Alt>Page_Up - Next page + Next Page <Alt>Page_Down - Select all rows + Select All Rows <Primary>a - Clear the selection + Clear the Selection <Primary><Shift>a - First row of the page + First Row of the Page <Primary>Home - Last row of the page + Last Row of the Page <Primary>End - Extend the selection to the clicked row + Extend the Selection to the Clicked Row <Shift>Pointer_Button1 - Add or remove the clicked row + Add or Remove the Clicked Row <Primary>Pointer_Button1 @@ -188,85 +188,85 @@ Data Grid - Move between cells + Move Between Cells Up Down Left Right - Edit cell, or toggle a check box + Edit a Cell or Toggle a Check Box F2 Return - Toggle a check box + Toggle a Check Box space - Show the cell menu + Show the Cell Menu Menu <Shift>F10 - Set cell to NULL + Set Cell to NULL <Primary>BackSpace - Finish editing + Finish Editing Return - Cancel editing + Cancel Editing Escape - Finish editing and edit the next cell + Finish Editing and Edit the Next Cell Tab - Finish editing and edit the previous cell + Finish Editing and Edit the Previous Cell <Shift>Tab - Save a multi-line value + Save a Multi-Line Value <Primary>Return - Insert row + Insert Row <Primary>n - Mark rows for deletion + Mark Rows for Deletion Delete - Copy selected rows as TSV + Copy Selected Rows as TSV <Primary>c - Clear the multi-row selection + Clear the Multi-Row Selection Escape @@ -277,19 +277,19 @@ SQL Editor - Run the query, or the selected text + Run the Query or the Selected Text <Primary>Return - Run the statement at the cursor + Run the Statement at the Cursor <Primary><Shift>Return - Cancel the running query + Cancel the Running Query Escape @@ -301,7 +301,7 @@ - Toggle line comment + Toggle Line Comment <Primary>slash @@ -313,25 +313,25 @@ - Find and replace + Find and Replace <Primary>h - Next match + Next Match <Primary>g - Previous match + Previous Match <Primary><Shift>g - Save the query under a name + Save the Query Under a Name win.save-query @@ -342,7 +342,7 @@ Table Structure - Save pending changes + Save Pending Changes <Primary>s diff --git a/linux/data/resources/ui/browse-tab.ui b/linux/data/resources/ui/browse-tab.ui index cea219143f..6807b0e071 100644 --- a/linux/data/resources/ui/browse-tab.ui +++ b/linux/data/resources/ui/browse-tab.ui @@ -100,70 +100,7 @@ - - - - - - go-first-symbolic - false - - - - - go-previous-symbolic - false - - - - - go-next-symbolic - false - - - - - go-last-symbolic - false - - - - - - - status - - - - - - - false - - - - 6 - - - false - - - - - Count Rows - - - - - + @@ -189,7 +126,7 @@ filtering rows, and the search icons mean Ctrl+F's other use. --> - Filter rows + Filter Rows browse.filter - - - - - false - Saving… - - - - - Save - Save pending edits - - - - - - Discard - Discard all pending edits - - - - - _Stop Saving - true - false - - - - - - + diff --git a/linux/data/resources/ui/connect-dialog.ui b/linux/data/resources/ui/connect-dialog.ui index 8f0e96b0cc..07a1f29234 100644 --- a/linux/data/resources/ui/connect-dialog.ui +++ b/linux/data/resources/ui/connect-dialog.ui @@ -14,7 +14,7 @@ Test - Connect once to check the settings, then disconnect + Connect Once to Check the Settings @@ -34,6 +34,14 @@ Connection + + + + Name + true + + Driver @@ -98,7 +106,7 @@ - Remember password + Remember Password Keep it in the keyring; otherwise it is asked for at each connect true @@ -116,7 +124,7 @@ Options - Read-only mode + Read-Only Mode diff --git a/linux/data/resources/ui/connect-failed-page.ui b/linux/data/resources/ui/connect-failed-page.ui index e6e67482f4..1dded9256b 100644 --- a/linux/data/resources/ui/connect-failed-page.ui +++ b/linux/data/resources/ui/connect-failed-page.ui @@ -12,7 +12,6 @@ - dialog-error-symbolic Could Not Connect @@ -58,11 +57,14 @@ - + + Choose Connection win.cancel-connect diff --git a/linux/data/resources/ui/connecting-page.ui b/linux/data/resources/ui/connecting-page.ui index ae90ec95fa..4c0bef0c58 100644 --- a/linux/data/resources/ui/connecting-page.ui +++ b/linux/data/resources/ui/connecting-page.ui @@ -20,7 +20,7 @@ - + Cancel win.cancel-connect center diff --git a/linux/data/resources/ui/filter-rule-row.ui b/linux/data/resources/ui/filter-rule-row.ui new file mode 100644 index 0000000000..f43cd41101 --- /dev/null +++ b/linux/data/resources/ui/filter-rule-row.ui @@ -0,0 +1,33 @@ + + + + + + diff --git a/linux/data/resources/ui/filter-strip.ui b/linux/data/resources/ui/filter-strip.ui new file mode 100644 index 0000000000..2a1315e6bd --- /dev/null +++ b/linux/data/resources/ui/filter-strip.ui @@ -0,0 +1,178 @@ + + + + + + diff --git a/linux/data/resources/ui/find-bar.ui b/linux/data/resources/ui/find-bar.ui index de98427cec..2372f4aaf3 100644 --- a/linux/data/resources/ui/find-bar.ui +++ b/linux/data/resources/ui/find-bar.ui @@ -33,6 +33,9 @@ true 150 Find + + Find + @@ -86,6 +89,9 @@ true Replace + + Replace with + diff --git a/linux/data/resources/ui/history-dialog.ui b/linux/data/resources/ui/history-dialog.ui index 1bb1e4a7c7..c393b99992 100644 --- a/linux/data/resources/ui/history-dialog.ui +++ b/linux/data/resources/ui/history-dialog.ui @@ -42,13 +42,17 @@ + - true Search queries… 150 true + + Search queries + diff --git a/linux/data/resources/ui/pager-bar.ui b/linux/data/resources/ui/pager-bar.ui new file mode 100644 index 0000000000..dde0f75540 --- /dev/null +++ b/linux/data/resources/ui/pager-bar.ui @@ -0,0 +1,76 @@ + + + + + + diff --git a/linux/data/resources/ui/pending-changes-bar.ui b/linux/data/resources/ui/pending-changes-bar.ui new file mode 100644 index 0000000000..631e7223d2 --- /dev/null +++ b/linux/data/resources/ui/pending-changes-bar.ui @@ -0,0 +1,57 @@ + + + + + + diff --git a/linux/data/resources/ui/preferences-data-browser-group.ui b/linux/data/resources/ui/preferences-data-browser-group.ui new file mode 100644 index 0000000000..133f28539e --- /dev/null +++ b/linux/data/resources/ui/preferences-data-browser-group.ui @@ -0,0 +1,29 @@ + + + + + + diff --git a/linux/data/resources/ui/preferences-dialog.ui b/linux/data/resources/ui/preferences-dialog.ui index e6e458c827..3e1c6aa2ad 100644 --- a/linux/data/resources/ui/preferences-dialog.ui +++ b/linux/data/resources/ui/preferences-dialog.ui @@ -6,167 +6,10 @@ Preferences true - - General - preferences-system-symbolic - - - Startup - - - Restore session - Reopen the connections that were open when TablePro quit - - - - - - - Data browser - Tunes the row paginator and destructive-action confirmation. - - - Default page size - Rows fetched per request when browsing a table - - - 100 - 100000 - 100 - 1000 - - - - - - - Confirm before deleting rows - Show a confirmation dialog before each destructive action - - - - - - - Query history - - - Save query history - Keep a searchable record of the SQL you run. Statements that contain passwords or connection secrets are never saved. - - - - - Retention (days) - 0 keeps history forever; pinned entries are never pruned. - - - 0 - 365 - 1 - 30 - - - - - - - Clear query history - Removes every saved query, including pinned ones. - - - - Clear… - center - - - - - - - - Storage location - - - Show in Files - center - - - - - - - - + - - Editor - text-editor-symbolic - - - Font - - - Use the system monospace font - Follows the font you picked in the desktop settings - - - - - Editor font - - - center - - - - - - - - - - - - Style scheme - - - none - true - 12 - 12 - 3 - - - - - - - Running queries - - - Query timeout (seconds) - Cancel long-running queries automatically. Set to 0 to disable. - - - 0 - 3600 - 5 - 60 - - - - - - - + diff --git a/linux/data/resources/ui/preferences-editor-page.ui b/linux/data/resources/ui/preferences-editor-page.ui new file mode 100644 index 0000000000..bc7db020e2 --- /dev/null +++ b/linux/data/resources/ui/preferences-editor-page.ui @@ -0,0 +1,66 @@ + + + + + + diff --git a/linux/data/resources/ui/preferences-general-page.ui b/linux/data/resources/ui/preferences-general-page.ui new file mode 100644 index 0000000000..8b550547f9 --- /dev/null +++ b/linux/data/resources/ui/preferences-general-page.ui @@ -0,0 +1,26 @@ + + + + + + diff --git a/linux/data/resources/ui/preferences-history-group.ui b/linux/data/resources/ui/preferences-history-group.ui new file mode 100644 index 0000000000..80cca2738f --- /dev/null +++ b/linux/data/resources/ui/preferences-history-group.ui @@ -0,0 +1,60 @@ + + + + + + diff --git a/linux/data/resources/ui/results-pane.ui b/linux/data/resources/ui/results-pane.ui index ab1729ea5e..3326f98cf9 100644 --- a/linux/data/resources/ui/results-pane.ui +++ b/linux/data/resources/ui/results-pane.ui @@ -24,13 +24,13 @@ 6 - Show results list + Show Results List false - Show results list + Show Results List diff --git a/linux/data/resources/ui/sql-editor.ui b/linux/data/resources/ui/sql-editor.ui index b6b3b9b433..efcf70feb4 100644 --- a/linux/data/resources/ui/sql-editor.ui +++ b/linux/data/resources/ui/sql-editor.ui @@ -55,20 +55,20 @@ - Editor options + Editor Options editor_menu - Editor options + Editor Options Stop - Stop the running query + Stop the Running Query false diff --git a/linux/data/resources/ui/sqlite-file-row.ui b/linux/data/resources/ui/sqlite-file-row.ui index 39077b431a..50120b30a0 100644 --- a/linux/data/resources/ui/sqlite-file-row.ui +++ b/linux/data/resources/ui/sqlite-file-row.ui @@ -9,14 +9,14 @@ Choose… - Open an existing database file + Open an Existing Database File center New Database… - Create a database file when you connect + Create a Database File When You Connect center diff --git a/linux/data/resources/ui/ssh-section.ui b/linux/data/resources/ui/ssh-section.ui index 257181d29d..b0d1175a02 100644 --- a/linux/data/resources/ui/ssh-section.ui +++ b/linux/data/resources/ui/ssh-section.ui @@ -6,7 +6,7 @@ SSH Tunnel - Use SSH tunnel + Use SSH Tunnel Empty rows are filled from your SSH configuration true false @@ -55,13 +55,13 @@ true - Choose a private key file + Choose a Private Key File center - Choose private key + Choose Private Key diff --git a/linux/data/resources/ui/structure-tab.ui b/linux/data/resources/ui/structure-tab.ui index dc4d004375..5ad071e313 100644 --- a/linux/data/resources/ui/structure-tab.ui +++ b/linux/data/resources/ui/structure-tab.ui @@ -297,7 +297,7 @@ dialog-error-symbolic - Couldn't load structure + Couldn't Load Structure Try Again diff --git a/linux/data/resources/ui/tls-section.ui b/linux/data/resources/ui/tls-section.ui index 5d7fc1b5d8..380174315c 100644 --- a/linux/data/resources/ui/tls-section.ui +++ b/linux/data/resources/ui/tls-section.ui @@ -27,7 +27,7 @@ - Clear CA certificate + Clear CA Certificate @@ -36,7 +36,7 @@ Choose… center - Choose CA certificate + Choose CA Certificate @@ -53,7 +53,7 @@ - Clear client certificate + Clear Client Certificate @@ -62,7 +62,7 @@ Choose… center - Choose client certificate + Choose Client Certificate @@ -79,7 +79,7 @@ - Clear client key + Clear Client Key @@ -88,7 +88,7 @@ Choose… center - Choose client key + Choose Client Key diff --git a/linux/data/resources/ui/workspace.ui b/linux/data/resources/ui/workspace.ui index d5aeffd8ec..00f5c86400 100644 --- a/linux/data/resources/ui/workspace.ui +++ b/linux/data/resources/ui/workspace.ui @@ -53,12 +53,16 @@ + - true Filter tables… true + + Filter tables + diff --git a/linux/data/screenshots/01-table.png b/linux/data/screenshots/01-table.png new file mode 100644 index 0000000000..558ae7defe Binary files /dev/null and b/linux/data/screenshots/01-table.png differ diff --git a/linux/data/screenshots/02-editor.png b/linux/data/screenshots/02-editor.png new file mode 100644 index 0000000000..8982b29d8b Binary files /dev/null and b/linux/data/screenshots/02-editor.png differ diff --git a/linux/data/screenshots/03-structure.png b/linux/data/screenshots/03-structure.png new file mode 100644 index 0000000000..f730bad0ac Binary files /dev/null and b/linux/data/screenshots/03-structure.png differ diff --git a/linux/data/screenshots/04-connect.png b/linux/data/screenshots/04-connect.png new file mode 100644 index 0000000000..be509488a7 Binary files /dev/null and b/linux/data/screenshots/04-connect.png differ diff --git a/linux/data/tablepro.1 b/linux/data/tablepro.1 new file mode 100644 index 0000000000..f25c235540 --- /dev/null +++ b/linux/data/tablepro.1 @@ -0,0 +1,40 @@ +.TH TABLEPRO 1 "" "TablePro" "User Commands" +.SH NAME +tablepro \- work with your databases +.SH SYNOPSIS +.B tablepro +.RI [ OPTION ...] +.RI [ FILE ...] +.SH DESCRIPTION +TablePro connects to PostgreSQL, MySQL, SQLite, SQL Server and ClickHouse. +It browses tables, edits rows in place, runs SQL with syntax highlighting, +and reaches servers behind an SSH bastion through the system OpenSSH client. +.PP +Each +.I FILE +is a SQLite database, opened in its own window. +A running TablePro opens the files itself and the new launch exits. +.SH OPTIONS +.TP +.BR \-n ", " \-\-new\-window +Open a new window, in the running TablePro when there is one. +.TP +.BR \-h ", " \-\-help +Show the options and exit. +.TP +.B \-\-help\-all +Show every option, including the GApplication ones, and exit. +.SH ENVIRONMENT +.TP +.B RUST_LOG +Which messages are logged to standard error, as a tracing filter such as +.BR info " or " tablepro=debug . +.TP +.B TABLEPRO_LOG_FORMAT +.B json +logs one JSON object per line. +.SH SEE ALSO +.BR ssh (1), +.BR ssh_config (5) +.PP +https://docs.tablepro.app diff --git a/linux/deny.toml b/linux/deny.toml index 71f500a2e0..088f9fddfc 100644 --- a/linux/deny.toml +++ b/linux/deny.toml @@ -8,7 +8,7 @@ version = 2 yanked = "deny" unmaintained = "workspace" ignore = [ - { id = "RUSTSEC-2023-0071", reason = "Stage A until the OpenSSH transport replaces russh: rsa signs SSH RSA identities in process through russh, one blinded signature per authentication. sqlx-mysql uses rsa only to encrypt a password with the server public key. See docs/decisions/0009-crypto-provider-and-sources.md." }, + { id = "RUSTSEC-2023-0071", reason = "rsa is reached only through sqlx-mysql, which encrypts the MySQL password with the server's public key; no private-key operation runs in process, and OpenSSH signs SSH identities. The rsa ban below holds that scope. See docs/decisions/0009-crypto-provider-and-sources.md." }, ] [licenses] @@ -49,7 +49,8 @@ deny = [ { crate = "rust_decimal", reason = "decimals use tablepro_core::value::SqlDecimal; rust_decimal stops at 28 digits and has no NaN or infinity" }, { crate = "backoff", reason = "unmaintained" }, { crate = "instant", reason = "unmaintained" }, - { crate = "tablepro-test-fixtures", wrappers = ["tablepro-driver-clickhouse", "tablepro-driver-mssql", "tablepro-driver-mysql", "tablepro-driver-postgres"], reason = "container fixtures are a dev-only leaf; extend the wrappers with each new dependent" }, + { crate = "rsa", wrappers = ["sqlx-mysql"], reason = "RUSTSEC-2023-0071 is accepted only for sqlx-mysql's public-key encryption; any other user of rsa needs a new decision in ADR 0009" }, + { crate = "tablepro-test-fixtures", wrappers = ["tablepro", "tablepro-driver-clickhouse", "tablepro-driver-mssql", "tablepro-driver-mysql", "tablepro-driver-postgres", "tablepro-session"], reason = "container fixtures are a dev-only leaf; extend the wrappers with each new dependent" }, { crate = "libsqlite3-sys", deny-multiple-versions = true, reason = "two SQLite builds in one binary conflict" }, ] diff --git a/linux/docs/README.md b/linux/docs/README.md new file mode 100644 index 0000000000..5d36fa5214 --- /dev/null +++ b/linux/docs/README.md @@ -0,0 +1,39 @@ +# Linux docs + +The reference for working on the Linux app. Start with [ARCHITECTURE.md](../ARCHITECTURE.md) for the crate graph and [CONTRIBUTING.md](../CONTRIBUTING.md) for the build; [ROADMAP.md](../ROADMAP.md) says what is built and what is not. + +## Guides + +| Guide | What it covers | +|---|---| +| [adding-drivers.md](adding-drivers.md) | Adding an engine as a crate: the client library, capabilities, `Connection`, writes and editor sessions | +| [error-handling.md](error-handling.md) | The error types each layer returns, their categories, and the text and logs the app makes of them | +| [state-management.md](state-management.md) | Where the application, window, workspace and tab state lives, and the rules every view follows | +| [storage.md](storage.md) | Which store holds which data: files, GSettings, the Secret Service and SQLite | +| [testing.md](testing.md) | The test suites, the conformance and Docker tests, and the CI jobs | +| [regression-tests.md](regression-tests.md) | The tests that guard each critical audit finding | +| [po/README.md](../po/README.md) | Marking strings for translation and keeping the template current | + +## Drivers + +| Engine | Page | +|---|---| +| PostgreSQL | [drivers/postgresql.md](drivers/postgresql.md) | +| MySQL and MariaDB | [drivers/mysql.md](drivers/mysql.md) | +| SQL Server | [drivers/sql-server.md](drivers/sql-server.md) | +| ClickHouse | [drivers/clickhouse.md](drivers/clickhouse.md) | +| SQLite | [drivers/sqlite.md](drivers/sqlite.md) | + +## Decisions + +The architecture decision records, with a summary of each, are indexed in [decisions/README.md](decisions/README.md). + +## Dated audits and plans + +Snapshots, kept as they were written. The code has moved on since each date. + +| Date | Document | +|---|---| +| 2026-04-26 | [production-audit.md](production-audit.md): the production-readiness audit the roadmap's release criteria come from | +| 2026-09-14 | [plan-2026-09-14-audit-remediation.md](plan-2026-09-14-audit-remediation.md): the plan that fixed the 2026-09-13 audit's findings, with its [details](plan-2026-09-14-audit-remediation-details.md) | +| 2026-09-14 | [dependency-review-2026-09-14.md](dependency-review-2026-09-14.md): the dependency review whose amendments override that plan | diff --git a/linux/docs/decisions/0002-rust-gtk4-libadwaita.md b/linux/docs/decisions/0002-rust-gtk4-libadwaita.md index aad90a44af..71c7d15b86 100644 --- a/linux/docs/decisions/0002-rust-gtk4-libadwaita.md +++ b/linux/docs/decisions/0002-rust-gtk4-libadwaita.md @@ -1,6 +1,6 @@ # 0002 — Rust + GTK4 + libadwaita -- **Status**: Accepted +- **Status**: Accepted; version floor superseded by [0015](0015-gnome-50-platform-floor.md) - **Date**: 2026-04-26 ## Context diff --git a/linux/docs/decisions/0008-flatpak-sandbox-permissions.md b/linux/docs/decisions/0008-flatpak-sandbox-permissions.md new file mode 100644 index 0000000000..e3dc28e443 --- /dev/null +++ b/linux/docs/decisions/0008-flatpak-sandbox-permissions.md @@ -0,0 +1,45 @@ +# 0008: Flatpak sandbox permissions + +- **Status**: Accepted +- **Date**: 2026-10-14 + +## Context + +The Flatpak build runs in a sandbox that starts with no access to the user's files, sockets or session services. TablePro opens SQLite databases in place, runs the system OpenSSH client for tunnels ([ADR 0013](0013-openssh-client-transport.md)), signs in to SQL Server with Kerberos, and keeps passwords in the Secret Service ([ADR 0004](0004-libsecret-secret-storage.md)). Flathub's linter refuses broad grants unless Flathub has granted the app an exception for each one. + +The first manifest also granted `--filesystem=/etc/krb5.conf:ro` and `--talk-name=org.freedesktop.secrets`. + +## Decision + +`build-aux/flatpak/app.tablepro.TablePro.Devel.json` grants the network, the display, the home folder and the mount points of removable drives, the SSH agent socket and the Kerberos KCM socket, and nothing else. Everything a portal covers goes through the portal. + +| Grant | Why | +|---|---| +| `--share=network` | Database servers and SSH bastions. | +| `--share=ipc`, `--socket=wayland`, `--socket=fallback-x11`, `--device=dri` | The GTK display and GPU rendering. | +| `--filesystem=home`, `--filesystem=/media`, `--filesystem=/run/media`, `--filesystem=/mnt` | SQLite databases, and `~/.ssh` for ssh. | +| `--socket=ssh-auth` | Keys held by the user's SSH agent, including FIDO keys. | +| `--filesystem=/run/.heim_org.h5l.kcm-socket`, `--env=KRB5_CONFIG=/app/etc/krb5.conf` | Kerberos tickets in the KCM cache. | + +## Rationale + +- **SQLite needs the folder, not the file.** SQLite opens a database read-write and creates `-journal`, `-wal` and `-shm` files beside it. The document portal exports only the file the user picked: a file created next to it stays inside the portal and never reaches the real folder, so another program never sees the WAL, and a crash can leave a journal that the next open cannot find to roll back. The FileChooser portal hands the app the real path when the app can already reach the file, so a file picked or opened with Open With under home or a removable drive opens in place. A database from a folder the grants do not cover still arrives through the portal, and the app asks whether to open it read-only, as it does for one on a network share. +- **ssh reads `~/.ssh`.** The OpenSSH client reads `~/.ssh/config`, identity files and `known_hosts`, and writes new host keys there. The home grant covers it; a separate `~/.ssh` grant would add nothing. +- **Agent keys sign on the host.** `--socket=ssh-auth` forwards `SSH_AUTH_SOCK`. A FIDO key kept in a file cannot sign inside the sandbox, since the runtime has no `ssh-sk-helper`; the same key loaded into the host agent can. +- **Kerberos uses the app's own configuration.** `/etc` is reserved for the runtime, so the host's `/etc/krb5.conf` cannot be granted. The app installs `build-aux/flatpak/krb5.conf` to `/app/etc/krb5.conf`, which uses the KCM cache and DNS realm and KDC lookup, and `KRB5_CONFIG` selects it. +- **Portals cover the rest.** oo7 uses the Secret portal inside a sandbox, so `org.freedesktop.secrets` is not needed and the app cannot read other apps' secrets. Export destinations and CA files outside home go through the FileChooser portal, Reveal in Files and links through OpenURI, completion alerts through the Notification portal, and offline detection through the NetworkMonitor portal. + +## Consequences + +- Two lint findings are expected: `finish-args-home-filesystem-access` and `finish-args-has-socket-ssh-auth`. `build-aux/flatpak/lint-exceptions.json` lists them for `app.tablepro.TablePro` and for the Devel ID, and every lint passes it with `--exceptions --user-exceptions`, so any other finding fails. +- Before the first Flathub submission, maintainers ask Flathub for the same two exceptions with the reasons above. Flathub's builders apply only the exceptions Flathub granted. +- The removed grants stay removed: `/etc/krb5.conf:ro` is a reserved path that Flatpak never granted, and the Secret Service talk-name exposed the whole keyring. +- A `ProxyCommand` or `Match exec` that runs a host program, the host's `/etc/ssh` and FIDO keys kept in files do not work in the Flatpak build ([ADR 0013](0013-openssh-client-transport.md)). Distribution packages have none of these limits. + +## Alternatives considered + +- **Document portal only**: one file per grant, with no journal or WAL beside it. +- **`--filesystem=host`**: wider than home and the media mount points, with no use the narrower grants miss. +- **`--filesystem=~/.ssh:ro`** instead of home: ssh works, but no SQLite file opens in place. +- **`--filesystem=host-etc:ro` with `KRB5_CONFIG=/run/host/etc/krb5.conf`**: MIT Kerberos refuses a configuration whose `includedir` is missing, and many hosts' files have one. +- **Keeping `--talk-name=org.freedesktop.secrets`**: unused once oo7 talks to the portal. diff --git a/linux/docs/decisions/0009-crypto-provider-and-sources.md b/linux/docs/decisions/0009-crypto-provider-and-sources.md index d256cd878a..3dd7cc530d 100644 --- a/linux/docs/decisions/0009-crypto-provider-and-sources.md +++ b/linux/docs/decisions/0009-crypto-provider-and-sources.md @@ -17,8 +17,9 @@ aws-lc-rs is the only crypto provider, and `deny.toml` enforces providers, sourc - **Sources**: crates.io plus exactly one git source, `tiberius-rs/tiberius`, until tiberius 0.13 is published. No forks. - **Bans**: ring, openssl-sys, native-tls, rustls before 0.23, rustls-pemfile, bigdecimal, backoff and instant are denied with a reason each, and libsqlite3-sys may appear only once. - **RUSTSEC-2023-0071** (Marvin attack on rsa): - - Stage A, until the OpenSSH transport replaces russh: rsa signs SSH RSA identities in process, one blinded signature per authentication. Host-key verification and agent signing do not use it. sqlx-mysql only encrypts a password with the server's public key. - - Stage B, written by the commit that removes russh: rsa is reached only through sqlx-mysql's public-key encryption. + - Stage A, until the OpenSSH transport replaced russh: rsa signed SSH RSA identities in process, one blinded signature per authentication. Host-key verification and agent signing did not use it. + - Stage B, since ADR 0013 removed russh: rsa is reached only through sqlx-mysql, which encrypts the MySQL password with the server's public key when the connection has no TLS. The advisory is about private-key timing, and no private-key operation runs in process: the OpenSSH client or the agent signs SSH identities in its own process. `deny.toml` bans rsa under any other parent, so a dependency that brings an in-process RSA signer fails `cargo deny check bans` instead of passing under the ignore. + - RSA identity files stay supported with no hint on the key row. Recommending Ed25519 or the agent was the Stage A mitigation; under Stage B it would warn about a working key with no exposure behind it. ## Consequences diff --git a/linux/docs/decisions/0014-lossless-values-and-dialects.md b/linux/docs/decisions/0014-lossless-values-and-dialects.md new file mode 100644 index 0000000000..f5e38a5b39 --- /dev/null +++ b/linux/docs/decisions/0014-lossless-values-and-dialects.md @@ -0,0 +1,50 @@ +# 0014: Lossless values and engine dialects + +- **Status**: Accepted +- **Date**: 2026-10-15 + +## Context + +The first `Value` had one integer type, one float type and a `rust_decimal` decimal, and a value the driver could not decode became NULL. Core built the app's SQL, matching on driver ids for each engine's spelling. The 2026-09-14 audit traced lost and wrong data to this. A decode failure read as NULL, so a save keyed on it wrote `WHERE id IS NULL` and matched no row (F-006). The grid filled its editors from display text, so text cut short for display and floats rounded by a spin button went back to the database (F-005). Columns were typed from their type names, and SQL was spelled for the wrong engine (F-018, F-021, F-022). + +## Decision + +A value goes from the server to the user and back without losing anything, and every piece of SQL the app writes is spelled by the dialect of the connection that runs it. + +## Rationale + +**Values that keep what the server sent.** `tablepro_core::Value` has a variant for each shape a value can take: `Int(i64)`, `UInt(u64)` and `WideInt` for integers past 64 bits; `Float32` and `Float64`, with NaN and the infinities kept; `Decimal(SqlDecimal)`, a validated decimal string that holds NaN, the infinities and every digit PostgreSQL's numeric allows; `Date`, `Timestamp` and `TimestampTz` wrapped in `Temporal`, which carries `infinity`; `SqlTime` past 24 hours and `TimeTz`; `OffsetTimestamp`, which keeps the offset the server sent; `Json(JsonText)`, the server's text, validated, in its own key order; and `Interval`, `Bits`, `Uuid` and `Array`. Two variants are not NULL and never become it: `Other` is a value the app has no type for but can show, and `Undecodable` is one the driver could not read. `BoundParam::new` refuses `Undecodable`, and a write whose row key holds one is refused as `UndecodableKey`, so it never reaches a `WHERE` clause. `Value` and the column types are in-memory types with no serde derive: nothing persists them. + +**Kinds from the catalog.** Each driver gives every column a `ColumnKind` from its engine's catalog, with a `ColumnType` that also says how the column is read and bound: PostgreSQL from `pg_type`, its enums and its domains; SQL Server from `sys.types`; ClickHouse from its own type grammar; SQLite from its affinity rules and a table's STRICT flag; MySQL from `COLUMN_TYPE`, read by a scanner of its own because sqlparser has no ZEROFILL. `column::classify_type_name` in core is the fallback for a type only a name describes. sqlparser 0.63 reads a catalogue default through `parse_expression`, and `SqlDialect::parse_type` and `parse_expression` check the types and defaults a user types before they reach DDL. + +**The dialect belongs to the connection.** `Connection::dialect()` returns the `SqlDialect` the driver built from the `ServerInfo` of the server it connected to, such as the version and, for MySQL, the flavour, and never from session state ([adding-drivers.md](../adding-drivers.md#7-dialects)). A reconnect builds it again, so SQL is never spelled for the server before. It quotes names, renders placeholders and literals, escapes LIKE patterns and writes the LIKE predicate, projects each column for reading, writes row windows, keyset predicates and engine row ids, writes UPDATE, DELETE and the key probe, classifies SQL for the history, and plans DDL ([ADR 0010](0010-ddl-plans-and-table-definitions.md)). Core holds the structure of a statement and no engine's spelling: `browse::build_page`, `plan_save` and the export builders ask the dialect for every engine-specific part, and `Connection` has no `fetch_rows`. + +**One encoding decision.** `SqlDialect::placeholder` takes the value and the column it is bound to and returns a `Placeholder`: the SQL that names the parameter and, unless the engine has no parameters, a `BoundParam`. `BoundParam::new` is the only way to build one, and it checks the value against the `ParamType` the dialect chose, so the driver binds what the marker decided and a value that does not fit fails before anything is sent. ClickHouse names its parameters `{p:}` from the same decision. + +**PostgreSQL reads by read form.** No statement is prepared under a name, so the driver works behind PgBouncer with `max_prepared_statements = 0` ([ADR 0016](0016-driver-contract-v2.md)). tokio-postgres would look a type it has no decoder for up with a named statement, so the catalogue marks each column's `ReadForm`: `Native` for a type with an exact binary decoder, `ServerText` for any other, projected as `::text`, and `ServerNumeric` for money, projected as `::numeric`. Every result column is then a built-in type, and every value still arrives exact. SQL Server reads money the same way in a table tab, cast to a decimal of its own scale. + +**Edits from exact text.** `edit_text` fills an editor from the value, never from the grid's display text. `parse_edit_text` turns what the user typed back into a value for the column, and what an empty field means depends on the `EditContext`: an inserted row's empty field can take the column's default, an updated row's cannot. `is_unchanged` compares the result with the value it came from, so opening and closing an editor records no edit. + +**Saves prove their rows.** `plan_save` gives every row a save writes a `RowGuard` from how the row is identified: `AffectedExactly` for a unique key on an engine that counts affected rows exactly, or for the engine's own row id, and `ProbeExactly`, a count read first in the same transaction, otherwise. A guard that fails stops the save: an engine with transactions takes it all back, and one without reports how far it got ([ADR 0016](0016-driver-contract-v2.md)). A table with no key that identifies a row takes inserts and refuses row changes. + +**Literals that read the same everywhere.** A value copied as SQL or exported as INSERT goes through `SqlDialect::literal`, which writes it so the server reads it the same under any session setting: PostgreSQL through `postgres_protocol`'s escaping, MySQL as a hex string literal once the text holds a backslash or a control character, so `NO_BACKSLASH_ESCAPES` cannot change it. A value no literal can spell is exported as NULL with a comment that says so, and copying it is refused. The app's own MySQL workspace sessions pin `sql_mode` and the time zone, because generated DDL has literals with no hex form ([drivers/mysql.md](../drivers/mysql.md)). + +## Consequences + +- A new engine brings a dialect, a decoder and a catalogue classifier in its driver crate ([adding-drivers.md](../adding-drivers.md)), and core gains no branch on a driver id. +- A column of a type the app does not model still shows its value, as `Other` or the server's text, and never as NULL. +- An editor tab's PostgreSQL result columns arrive as text: the simple protocol gives them no type, until rust-postgres #1358 ships ([drivers/postgresql.md](../drivers/postgresql.md)). +- MySQL's `tinyint(1)` is a boolean stored as an integer: it edits as a toggle, and a stored value other than 0 or 1 shows the toggle as inconsistent, with the exact value in its tooltip. +- CSV formula neutralisation follows the value's variant: free text, JSON and `Other` are neutralised, and a number, date or UUID is written as it is. +- JSON keeps its key order end to end: `clippy.toml` bans `serde_json::Map`, which sorts keys. +- ClickHouse binds every value but NULL as a typed server-side parameter, `{p:}`, rather than writing a literal into the statement. +- The `f006_` tests and the conformance case `f006_unsupported_types_never_decode_as_null` guard the decode rule on every engine ([regression-tests.md](../regression-tests.md)). + +## Alternatives considered + +- **Text for every value the app cannot type.** Binding text into a PostgreSQL enum or domain fails, and the app could not tell editable text from bytes it could not read. +- **One `Unknown` variant.** The same problem: a value the app can show and edit as text and one it could not read at all need different handling. +- **rust_decimal or bigdecimal.** rust_decimal stops at 28 digits and neither has NaN, which PostgreSQL's numeric holds. +- **serde_json::Value for JSON.** Its map sorts keys, so a document would come back reordered. +- **Typing PostgreSQL columns with a prepared statement.** A named statement is what a transaction pooler forbids. +- **One SQL builder in core, keyed by driver id.** The design the audit found spelling SQL for the wrong engine; it also cannot follow a server's version. diff --git a/linux/docs/decisions/0017-distribution-channels.md b/linux/docs/decisions/0017-distribution-channels.md new file mode 100644 index 0000000000..629e7b2b27 --- /dev/null +++ b/linux/docs/decisions/0017-distribution-channels.md @@ -0,0 +1,111 @@ +# 0017. Distribution channels + +Date: 2026-10-14 + +## Status + +Accepted. + +## Context + +Flathub is the primary channel. Users of Ubuntu and Fedora also expect a +system package, with the OpenSSH client, Kerberos and the system trust +store of the host rather than of a sandbox runtime. The GNOME 50 floor +(ADR 0015) means only Ubuntu 26.04 and Fedora 44 ship the libraries the +app links. + +The workspace needs Rust 1.98. Fedora 44 ships it; Ubuntu 26.04 ships +rustc 1.93. + +## Decision + +Three channels, all built from one `meson install` and one lock file: + +| Channel | Built by | Published to | +|---|---|---| +| Flatpak | the Flathub manifest | Flathub | +| .deb for Ubuntu 26.04 | `dpkg-buildpackage -b` with the committed `build-aux/debian`, Rust from rustup | GitHub Releases | +| .rpm for Fedora 44 | `rpmbuild` with the committed `build-aux/rpm/tablepro.spec` and Fedora's Rust | COPR | + +A `linux-vX.Y.Z` tag runs `release-linux.yml`. It checks that the tag, +`meson.build`, the newest metainfo release (dated the day of the tagged +commit), the spec and `CHANGELOG.md` name one version, then publishes: + +- `tablepro-X.Y.Z.tar.xz` from `meson dist`; +- `tablepro-X.Y.Z-vendor.tar.xz`: `vendor/` from `cargo vendor --locked + --versioned-dirs`, with the printed source replacement as + `vendor/cargo-config.toml`. Both packages build offline from it, the + .deb through the `cargo_config` Meson option and the .rpm through + `%cargo_prep -v vendor`; +- the .deb for amd64 and arm64 with its `-dbgsym` package, and + `SHA256SUMS`; +- the source RPM to COPR for `fedora-44-x86_64` and `fedora-44-aarch64`. + +Both packages run every Meson suite during the build, the gtk suite under +`xvfb-run` and `dbus-run-session`. `linux-packages.yml` builds both on a +pull request that touches the packaging, each in a fresh container with +only the declared build dependencies, and installs each in another fresh +container with only its runtime dependencies. There it runs +`tablepro --help`, which opens the installed GSettings schema first, +validates the desktop entry, metainfo and schema, and starts a window +under Xvfb. `build-aux/packaging/build-packages.sh` runs the same steps +locally. + +`debian/copyright` and the spec's `License` tag come from +`data/licenses/third-party.json`, the cargo-about output the About dialog +and the license notices use, through +`build-aux/packaging/license_metadata.py`. +`build-aux/update-third-party-licenses.sh` rewrites all of them, and a +Meson test fails when they drift. + +## Rationale + +- `meson install` is the only build that installs the desktop entry, the + D-Bus service, the metainfo, the schema, the translations and the + askpass helper where the compiled-in paths expect them (ADR 0006). +- The .deb cannot enter a PPA or the Ubuntu archive while the archive's + rustc is older than the workspace's `rust-version`, so it is a release + asset built with rustup, and binary-only. +- COPR builds with Fedora's own Rust in mock, so the .rpm is a normal + Fedora build and its users get updates through dnf. +- `cargo-about` templates cannot indent a license text into a deb822 + field, so the Debian file is rendered by a script from the same JSON + rather than by a template. + +## Consequences + +- Each release commit bumps `meson.build`, the newest metainfo + ``, the spec's `Version` and `%changelog` (`rpmdev-bumpspec`), + and moves `[Unreleased]` in `CHANGELOG.md` under the version. The + `release-metadata` Meson test keeps the first three in step between + releases. +- The first translation in `po/LINGUAS` needs `%find_lang %{name}` and + `-f %{name}.lang` in the spec; the same test fails until it is there. +- A pre-release version such as `0.2.0-rc.1` cannot be packaged: RPM + forbids `-` in `Version` and git forbids `~` in a tag. +- Publishing needs the `COPR_CONFIG` secret and the `LINUX_COPR_PROJECT` + variable; without them the COPR job fails and the GitHub release still + goes out. +- The vendor tarball holds every crate in the lock file, Windows-only and + test-only ones included, because `cargo vendor` cannot filter. +- The release profile's fat LTO needs gigabytes per binary. The .deb + keeps it with line-table debug info, which links within 8 GB; the .rpm + links with thin LTO, because Fedora's rustc runs out of 8 GB on the fat + link. Both packages build their test suites without LTO. + +## Alternatives considered + +- **AppImage**: dropped. It has to be built on the oldest base it + supports, no base older than Ubuntu 26.04 or Fedora 44 carries GLib + 2.88 and GTK 4.22, and the format cannot honour the compiled-in locale, + schema and D-Bus service paths. +- **Snap**: skipped, as before; Flathub covers the cross-distribution + case. +- **cargo-deb, cargo-generate-rpm, nfpm**: they package Cargo's output + and skip Meson's data files and translations. +- **dh-cargo and Fedora's crate-by-crate packaging**: dh-cargo needs the + archive's Rust; packaging each dependency as its own Fedora crate is + far more work than vendoring for an application. +- **A source package with a second orig tarball for vendor/**: the + archive could not build it with its own rustc, so it would add a format + nobody consumes. diff --git a/linux/docs/decisions/README.md b/linux/docs/decisions/README.md index c7e28eb3c5..6fa52064a2 100644 --- a/linux/docs/decisions/README.md +++ b/linux/docs/decisions/README.md @@ -40,18 +40,23 @@ ADRs are short on purpose. If yours is more than a page, you are probably arguin | # | Title | Status | Summary | |---|---|---|---| | [0001](0001-no-plugin-system.md) | No plugin system; drivers are static | Accepted | Drivers are crates, registered at compile time. No `.tableplugin`-equivalent on Linux. | -| [0002](0002-rust-gtk4-libadwaita.md) | Rust + GTK4 + libadwaita | Accepted | Validated by spike. Only stack with a production virtualized data grid (GtkColumnView). | +| [0002](0002-rust-gtk4-libadwaita.md) | Rust + GTK4 + libadwaita | Accepted; version floor superseded by 0015 | Validated by spike. Only stack with a production virtualized data grid (GtkColumnView). | | [0003](0003-relm4-architecture.md) | Relm4 for app architecture | Accepted; superseded in part by 0012 | Elm-style components scale to TablePro's view count. | | [0004](0004-libsecret-secret-storage.md) | libsecret via oo7 for password storage | Accepted | Secret Service API is the universal Linux secret backend. | | [0005](0005-supply-chain-policy.md) | Supply-chain policy | Accepted | Maintained crates first; custom code needs recorded evidence and a removal trigger. | | [0006](0006-meson-cargo-build.md) | Meson drives Cargo | Accepted | Meson owns install, data files and the build-time config; Cargo stays the Rust compiler. | | [0007](0007-gsettings-preferences.md) | Preferences and window geometry in GSettings | Accepted | A schema with ranges and enums replaces preferences.json and window.json; keyed data stays in files. | +| [0008](0008-flatpak-sandbox-permissions.md) | Flatpak sandbox permissions | Accepted | Network, display, home and removable drives for SQLite, the SSH agent and the Kerberos KCM socket; portals for the rest; two Flathub lint exceptions. | | [0009](0009-crypto-provider-and-sources.md) | One crypto provider, upstream sources and dependency bans | Accepted | aws-lc-rs only; deny.toml enforces sources, licenses, bans and advisory ignores. | | [0010](0010-ddl-plans-and-table-definitions.md) | DDL plans against table definitions | Accepted | Each engine plans structure changes through its `DdlDialect` against the table's definition; the UI offers only what the plan accepts. | | [0012](0012-window-per-session.md) | One window per connection session, built from gtk-rs subclasses | Accepted | One `TableProApplication` per profile through D-Bus uniqueness; a window holds at most one session and returns to the picker on disconnect; the application, the window and its workspace are subclasses, not Relm4 components. | | [0013](0013-openssh-client-transport.md) | SSH through the system OpenSSH client | Accepted | ControlMaster per connection, Unix-socket forwards, prompts through an askpass helper. | +| [0014](0014-lossless-values-and-dialects.md) | Lossless values and engine dialects | Accepted | A value reaches the user and goes back without loss; every statement is spelled by the dialect of the connection that runs it. | | [0015](0015-gnome-50-platform-floor.md) | GNOME 50 platform floor | Accepted | GTK 4.22, libadwaita 1.9, GtkSourceView 5.18, GLib 2.88; AdwShortcutsDialog replaces the shortcuts window. | | [0016](0016-driver-contract-v2.md) | Driver contract v2 | Accepted | Owned calls with engine stops, phase-aware write outcomes, server-side row limits; PostgreSQL on tokio-postgres; no forks. | +| [0017](0017-distribution-channels.md) | Distribution channels | Accepted | Flathub, a .deb for Ubuntu 26.04 on GitHub Releases built with rustup, and an .rpm for Fedora 44 on COPR, all from `meson install` and one vendor tarball; no AppImage. | + +0011 is not used. The 2026-09-14 remediation plan reserved it for the DDL decision, which was recorded as 0010, and docs cite ADRs by number, so the later ones keep theirs. ## Adding an ADR diff --git a/linux/docs/dependency-review-2026-09-14.md b/linux/docs/dependency-review-2026-09-14.md index 116035da15..ad2b3d44f5 100644 --- a/linux/docs/dependency-review-2026-09-14.md +++ b/linux/docs/dependency-review-2026-09-14.md @@ -655,7 +655,7 @@ Fact-check corrections: Verdict challenges: -- hyper-util (HttpConnector as the DirectTcpTransport dialer, FC-D6 amendment): keep, and adopt HttpConnector as the generic database dialer to keep only as a transitive dependency of clickhouse; avoid it as a database dialer. Use tokio::net::TcpStream::connect plus socket2::SockRef for keepalive and TCP_USER_TIMEOUT, and use tokio-postgres's own Config for direct PostgreSQL TCP.. No Rust database driver dials through hyper's HTTP connector. It lives in client::legacy and needs a fabricated http://host:port Uri (with its own IPv6 bracket and scheme handling). It resolves through GaiResolver, implements RFC 6555 only, and splits set_connect_timeout evenly across addresses (http.rs:343-347). The standard pattern is the one tokio-postgres 0.7 uses internally: connect_socket.rs:4,34,40 calls SockRef::set_tcp_user_timeout and set_tcp_keepalive, and Config offers connect_timeout, keepalives_idle/interval/retries, tcp_user_timeout, hostaddr and load_balance_hosts (config.rs:413-557). For tiberius, which takes a caller-supplied stream, the same pattern is about 10 lines on socket2, a rust-lang org crate with feature "all" (unix.rs:2544-2552, lib.rs:558-580). Drop the Happy Eyeballs requirement rather than borrowing an HTTP-client component to meet it. +- hyper-util (HttpConnector as the DirectTcpTransport dialer, FC-D6 amendment): keep, and adopt HttpConnector as the generic database dialer to keep only as a transitive dependency of clickhouse; avoid it as a database dialer. Use tokio::net::TcpStream::connect plus socket2::SockRef for keepalive and TCP_USER_TIMEOUT, and use tokio-postgres's own Config for direct PostgreSQL TCP.. No Rust database driver dials through hyper's HTTP connector. It lives in client::legacy and needs a fabricated `http://host:port` Uri (with its own IPv6 bracket and scheme handling). It resolves through GaiResolver, implements RFC 6555 only, and splits set_connect_timeout evenly across addresses (http.rs:343-347). The standard pattern is the one tokio-postgres 0.7 uses internally: connect_socket.rs:4,34,40 calls SockRef::set_tcp_user_timeout and set_tcp_keepalive, and Config offers connect_timeout, keepalives_idle/interval/retries, tcp_user_timeout, hostaddr and load_balance_hosts (config.rs:413-557). For tiberius, which takes a caller-supplied stream, the same pattern is about 10 lines on socket2, a rust-lang org crate with feature "all" (unix.rs:2544-2552, lib.rs:558-580). Drop the Happy Eyeballs requirement rather than borrowing an HTTP-client component to meet it. - pem: add to avoid. Prefer pem-rfc7468 1.0.0 if an encoder is still needed; better, remove the need by contributing to transact-rs/sqlx #4044/#4045.. pem has a single owner (jcreekmore, personal repo, 58 stars), and 4.0.0 is a new major from 2026-07-28. pem-rfc7468 1.0.0 (2025-11-08) is RustCrypto-org owned (tarcieri plus the github:rustcrypto:formats team), has 317,199,017 all-time and 76,488,129 recent downloads, and exports encode_string. It is already in the lock file at 0.7.0. The PEM bundle is also weak on its own merits: sqlx adds any CA PEM on top of webpki roots (tls_rustls.rs:140-150), so it never yields platform-only trust. The least-debt fix is upstream platform-verifier support in sqlx (issues #4044/#4045, open since 2025-09-26). - rustls-native-certs (narrowed to the sqlx-mysql PEM bundle): keep (direct, to build the MySQL PEM bundle) to keep as a transitive dependency of rustls-platform-verifier only. For MySQL, accept sqlx's built-in roots plus the user CA file and record the gap in the ADR. Then contribute to sqlx #4044/#4045 instead of keeping a bundle builder.. The review drops the webpki-roots union for the other engines because it widens trust. The MySQL bundle recreates the same union (webpki plus system plus CA), because sqlx-core 0.9 seeds webpki roots under tls-rustls-aws-lc-rs and adds the CA to them. A direct native-certs loader plus a PEM encoder is custom glue that exists only to work around sqlx. The implicit sqlx-core 'rustls-native-certs' feature would do the same without glue, but sqlx-core is 'Not intended to be used directly'. The upstream issue is the non-debt exit. If system-store corporate CAs must work for MySQL before then, keep the bundle as an explicitly temporary item tied to #4044. - OpenSSH client (system ssh, ssh-keygen): add (the only option meeting the full requirement set, including ProxyCommand and Match exec) to add, but state the Flatpak limits in the ADR and do not count ProxyCommand, Match exec, host /etc/ssh or FIDO file keys as delivered in the Flatpak build. The core argument is sound and verified: readpass.c:130-133 (force sets use_askpass and allow_askpass), sshconnect.c:615 and 1646, sshconnect2.c:1051, 1547 and 2124, readconf.c:2880, russh known_hosts.rs:99 plus exact or |1| matching only, russh-config with no Include or Match (#699 open), and ssh-key without a matcher. But in the sandbox, ProxyCommand and Match exec run in the runtime, where host tools such as cloudflared, aws ssm or tsh are absent. Host /etc/ssh is hidden. FIDO sk keys from files lose ssh-sk-helper (vm-only). flatpak-spawn --host would need org.freedesktop.Flatpak, a sandbox escape. The .deb and .rpm builds get the full benefit. The custom code that remains is also not trivial: askpass helper and socket bridge, stderr classification, master lifecycle, and a Unix-socket connector for ClickHouse HTTP. It is still far less debt than re-implementing sshconnect2.c and known_hosts on russh. diff --git a/linux/docs/drivers/sql-server.md b/linux/docs/drivers/sql-server.md index 86cab8ab3a..3e6a16bdca 100644 --- a/linux/docs/drivers/sql-server.md +++ b/linux/docs/drivers/sql-server.md @@ -49,6 +49,7 @@ The sandbox has no `/etc/krb5.conf` of the host, and its `/tmp` is private, so a - A build with `-Dflatpak=true` and Kerberos installs `build-aux/flatpak/krb5.conf` into the app's `sysconfdir`. It uses the KCM credential cache and finds the realm and the KDC through DNS. - The development manifest grants the KCM socket, `/run/.heim_org.h5l.kcm-socket`, so a host that runs `sssd-kcm` shares its tickets with the app. - A FILE cache works only when `KRB5CCNAME` points at a file the sandbox can read, such as one under the home folder. +- A KEYRING cache does not work: Flatpak blocks the kernel keyring calls. Running from source remains the supported path for Kerberos today. diff --git a/linux/docs/drivers/sqlite.md b/linux/docs/drivers/sqlite.md index b98f6e158b..e4b27a70ea 100644 --- a/linux/docs/drivers/sqlite.md +++ b/linux/docs/drivers/sqlite.md @@ -16,7 +16,7 @@ Every connection to the file opens with the same options: - The statement cache is off, because sqlx keeps a statement's columns from its first prepare and SQLite prepares it again with more columns after an `ALTER TABLE`. - sqlx's statement logging is off. -The connect reads `sqlite_version()` from the file once, so a file that is not a database fails the connect (`NotADatabase`) rather than the first page. SQLite only says it could not open a file; the driver tells a moved file (`FileNotFound`) from one the user may not read (`FileAccessDenied`) by looking at the path. +The connect reads `sqlite_version()` and counts `sqlite_schema` once, so a file that is not a database fails the connect (`NotADatabase`) rather than the first page. Either statement can be the one that finds out: SQLite 3.51 reads the schema before it compiles a connection's first SELECT, while 3.46 waits for a statement that names a table. SQLite only says it could not open a file; the driver tells a moved file (`FileNotFound`) from one the user may not read (`FileAccessDenied`) by looking at the path. ### New databases @@ -28,7 +28,7 @@ A reconnect uses `FileEndpoint::for_reopen`, which is always `OpenExisting`, so SQLite relies on POSIX file locks and, for a database in WAL mode, on shared memory in the `-shm` file beside it. Both work on a local file system. On NFS or SMB, locks can fail without an error, and two writers can then corrupt the file. A FUSE mount may not implement the locks at all. A file chosen through the Flatpak document portal sits on the portal's own FUSE file system, which exposes the chosen file rather than its folder, so the journal and WAL files SQLite keeps beside it may not work there. -The driver does not detect any of these. Open such a file read-only, or copy it to a local folder first. Under the development Flatpak, which grants `--filesystem=home`, a file under the home folder is opened at its real path, not through the portal. +The driver does not detect any of these. Open such a file read-only, or copy it to a local folder first. Under the development Flatpak, which grants the home folder, `/media`, `/run/media` and `/mnt`, a file under any of them is opened at its real path, not through the portal. ## Connections @@ -40,6 +40,8 @@ The driver does not detect any of these. Open such a file read-only, or copy it One workspace connection is enough because SQLite takes one writer at a time anyway, and it keeps a temporary table or an attached database where the next call can see it. Calls wait for it for up to the pool wait (5 s). +The health probe runs `SELECT 1` on the workspace connection when it is free. When it is busy, the probe opens a read-only connection of its own and closes it again without running a statement: opening runs only pragmas that set connection flags, which need no lock, whereas its first SELECT would read the schema on SQLite 3.51 and so wait for any lock another connection holds, often the one the busy call is waiting for too. + ## Stops and timeouts SQLite has no statement timeout and no out-of-band cancel. Each call installs a fresh flag, and a progress handler checks it every 1,000 virtual-machine instructions; when the flag is up the statement ends with `SQLITE_INTERRUPT`. The flag names the call, not the connection, so a stop that arrives late cannot reach the next call: `StopDelivery::OutOfBand(StopScope::Statement)`. `sqlite3_interrupt` is not used, because sqlx's handle to the connection waits for the worker thread to finish its current command, which is the statement the stop was meant for. diff --git a/linux/docs/plan-2026-09-14-audit-remediation.md b/linux/docs/plan-2026-09-14-audit-remediation.md index 170bbd901e..e4ba380371 100644 --- a/linux/docs/plan-2026-09-14-audit-remediation.md +++ b/linux/docs/plan-2026-09-14-audit-remediation.md @@ -1,7 +1,7 @@ # TablePro Linux remediation plan **Date**: 2026-09-14 -**Fixes**: every finding in [audit-2026-09-13-full.md](audit-2026-09-13-full.md) (F-001 to F-138) +**Fixes**: every finding of the 2026-09-13 full audit (F-001 to F-138) **Size**: 172 commit-sized tasks in 66 landing steps across 9 workstreams **Details**: contracts, decisions and full task specifications are in [plan-2026-09-14-audit-remediation-details.md](plan-2026-09-14-audit-remediation-details.md) diff --git a/linux/docs/regression-tests.md b/linux/docs/regression-tests.md new file mode 100644 index 0000000000..fbe5c22230 --- /dev/null +++ b/linux/docs/regression-tests.md @@ -0,0 +1,76 @@ +# Regression tests + +The 2026-09-13 audit rated seven findings critical. Each one has tests that fail if it comes back, and every such test carries the finding in its name: a guard for F-001 starts with `f001_`. The audit itself is not in the tree; the [remediation plan](plan-2026-09-14-audit-remediation.md#finding-to-task-index) lists the tasks that fixed each finding. + +```bash +cargo test --workspace --exclude tablepro -- f001_ +xvfb-run -a dbus-run-session -- cargo test -p tablepro --lib -- f001_ +cargo nextest run --workspace --exclude tablepro --run-ignored only -E 'test(/f001_/)' +``` + +The first two run the guards for F-001 that need no server: the app's are GTK tests, so they need a display and a session bus. The third runs the ones that need Docker, as the integration jobs do. The app runs with `--lib` because its [application tests](testing.md#application-tests) have no test harness to filter, and `startup` needs Meson. + +## Findings + +| Finding | Failure the guards catch | +|---|---| +| F-001 | A connection marked read-only still wrote: a save, an editor statement or a schema change reached the database. | +| F-002 | A reply meant for an earlier connect attempt or a closed session reached the current one: a late connection was adopted or left open, or a late result changed a workspace that was gone. | +| F-003 | An unreadable or newer saved-connection file was overwritten by the next change, losing every saved connection. | +| F-004 | After a draft row was inserted or removed, an edit, a Set NULL or a Copy as INSERT went to the row at the old position. | +| F-005 | The grid wrote back what it displayed: text cut short for display, or a float rounded by the editor, replaced the stored value. | +| F-006 | A value the driver could not decode read as NULL, so a save keyed on it matched no row or the wrong one. | +| F-007 | The release Flatpak manifest was never built: it carried placeholder sources, and nothing built or linted it. | + +## Guards + +Kinds: `unit` runs in the Meson unit suite, `gtk` in the gtk suite, `docker` in the integration jobs (ignored with `requires docker`), and `conformance` is a case that `connection_conformance_suite!` emits into every driver's integration target, which for SQLite runs in the unit suite. `ci-job` is a job in the Linux workflow. + +| Finding | Kind | Guard | File | +|---|---|---|---| +| F-001 | conformance | `f001_read_only_connection_refuses_writes` | [crates/test-support/src/conformance/cases/read_only.rs](../crates/test-support/src/conformance/cases/read_only.rs) | +| F-001 | docker | `f001_editor_idle_delete_fails_25006` | [crates/drivers/postgres/tests/integration/read_only.rs](../crates/drivers/postgres/tests/integration/read_only.rs) | +| F-001 | docker | `f001_read_only_insert_and_create_1792` | [crates/drivers/mysql/tests/integration/flavour_suite.rs](../crates/drivers/mysql/tests/integration/flavour_suite.rs) | +| F-001 | unit | `f001_read_only_insert_is_read_only` | [crates/drivers/sqlite/tests/integration/open_semantics.rs](../crates/drivers/sqlite/tests/integration/open_semantics.rs) | +| F-001 | docker | `f001_read_only_insert_164` | [crates/drivers/clickhouse/tests/integration/writes.rs](../crates/drivers/clickhouse/tests/integration/writes.rs) | +| F-002 | unit | `f002_accepts_only_latest_generation` | [crates/session/src/connect/connect_attempts.rs](../crates/session/src/connect/connect_attempts.rs) | +| F-002 | unit | `f002_late_reconnect_after_cancel_is_discarded_and_closed` | [crates/session/src/health/health_monitor.rs](../crates/session/src/health/health_monitor.rs) | +| F-002 | gtk | `f002_cancel_connect_discards_late_attempt` | [crates/app/src/ui/session_window/tests.rs](../crates/app/src/ui/session_window/tests.rs) | +| F-002 | gtk | `f002_late_show_create_reply_after_disconnect_is_dropped` | [crates/app/src/ui/session_window/tests.rs](../crates/app/src/ui/session_window/tests.rs) | +| F-002 | gtk | `f002_a_late_catalog_commit_after_the_watch_drops_is_not_delivered` | [crates/app/src/ui/workspace/sidebar/catalog_watch.rs](../crates/app/src/ui/workspace/sidebar/catalog_watch.rs) | +| F-003 | unit | `f003_truncated_file_is_unavailable_and_upsert_leaves_bytes_identical` | [crates/storage/src/connections.rs](../crates/storage/src/connections.rs) | +| F-003 | unit | `f003_version_2_is_newer_and_never_written` | [crates/storage/src/connections.rs](../crates/storage/src/connections.rs) | +| F-004 | gtk | `f004_edit_after_draft_insert_targets_edited_row` | [crates/app/src/ui/grid/column_factory.rs](../crates/app/src/ui/grid/column_factory.rs) | +| F-004 | gtk | `f004_menu_set_null_after_draft_removal_targets_clicked_row` | [crates/app/src/ui/grid/column_factory.rs](../crates/app/src/ui/grid/column_factory.rs) | +| F-004 | gtk | `f004_copy_row_as_insert_uses_row_below_draft` | [crates/app/src/ui/browse_tab/tests.rs](../crates/app/src/ui/browse_tab/tests.rs) | +| F-005 | gtk | `f005_unchanged_40kb_multiline_text_records_no_edit` | [crates/app/src/ui/browse_tab/tests.rs](../crates/app/src/ui/browse_tab/tests.rs) | +| F-005 | gtk | `f005_pasted_newlines_in_text_cell_are_kept` | [crates/app/src/ui/browse_tab/tests.rs](../crates/app/src/ui/browse_tab/tests.rs) | +| F-005 | gtk | `f005_pressing_enter_on_float_keeps_full_precision` | [crates/app/src/ui/browse_tab/tests.rs](../crates/app/src/ui/browse_tab/tests.rs) | +| F-006 | conformance | `f006_unsupported_types_never_decode_as_null` | [crates/test-support/src/conformance/cases/type_fallback.rs](../crates/test-support/src/conformance/cases/type_fallback.rs) | +| F-006 | docker | `f006_round_trip_matrix` | [crates/drivers/postgres/tests/integration/decode.rs](../crates/drivers/postgres/tests/integration/decode.rs) | +| F-006 | unit | `f006_count_sum_and_expression_columns_are_typed` | [crates/drivers/sqlite/tests/integration/decode.rs](../crates/drivers/sqlite/tests/integration/decode.rs) | +| F-006 | docker | `f006_bigint_unsigned_max_id_edit_affects_one_row` | [crates/drivers/mysql/tests/integration/decode.rs](../crates/drivers/mysql/tests/integration/decode.rs) | +| F-007 | ci-job | `flatpak-devel` | [.github/workflows/build-linux.yml](../../.github/workflows/build-linux.yml) | +| F-007 | ci-job | `flatpak-flathub` | [.github/workflows/build-linux.yml](../../.github/workflows/build-linux.yml) | + +The read-only case runs on every engine. SQL Server leaves read-only to the login's privileges, so there it checks only the app's own refusal of a save. The type case runs on every network engine: every SQLite value is one of five storage classes, and the driver decodes all five. + +## Retired helpers + +A helper an audit named that no longer exists, and the tests that hold its behaviour now. + +| Helper | Behaviour | Guard | File | +|---|---|---|---| +| `dec_close_after_save` | A tab or window closes only after every save started for it has settled. | `when_idle_resolves_after_last_settle` | [crates/app/src/workspace/save_ledger.rs](../crates/app/src/workspace/save_ledger.rs) | +| `dec_close_after_save` | A tab or window closes only after every save started for it has settled. | `in_flight_save_waiting_dialog_force_closes_on_settle` | [crates/app/src/workspace/close_guard.rs](../crates/app/src/workspace/close_guard.rs) | + +## Keeping the map true + +[test_regression_names.py](../build-aux/tests/test_regression_names.py) runs in the Meson `packaging` suite and fails when: + +- a guard on this page is not a function in the file it links; +- a function whose name starts with a finding id, anywhere under `crates/`, is not on this page with its file; +- a finding has no guard, or a guard's name carries another finding than its row; +- a CI job on this page is not in the workflow. Only a checkout has the workflow, so the Flatpak and package builds skip this one. + +lychee checks that every linked file exists. Rename or move a guard, and this page changes in the same commit. diff --git a/linux/docs/state-management.md b/linux/docs/state-management.md index 483a15fb3a..5b50b65a15 100644 --- a/linux/docs/state-management.md +++ b/linux/docs/state-management.md @@ -55,7 +55,7 @@ Windows share nothing but `AppServices`. CI fails on a `thread_local!` under `cr - Actions, signal handlers and background work send it a message, and `pump_inputs` hands the messages to the widget one at a time on the GTK thread, never inside the signal or action that sent one. - Shutdown aborts the pump, which closes the queue. A queued message that owns something, such as a connection that finished connecting, goes to the pump's leftover handler (`pump_inputs_with_leftovers`) and is closed rather than dropped. - Window actions go on the window's own action map with `add_action_entries`, which GTK exports as `win`; CI fails on a group inserted as `win`. The workspace removes the actions it added when it shuts down. -- The workspace reports to the window through signals (`title-changed`, `dirty-changed`, `saves-in-flight-changed`), and the window turns each into one of its own inputs. +- The workspace reports to the window through signals (`title-changed`, `dirty-changed`, `saves-in-flight-changed`, `work-completed`, `tabs-changed`), and the window turns each into one of its own inputs. A background read follows this shape, trimmed from `SessionWindow::on_restore`, which looks up the saved connection `id` names: @@ -146,6 +146,7 @@ The components that remain follow these conventions until a rewrite turns them i ## Naming - `Input`: every message a view handles, as an enum (`SessionWindowInput`, `WorkspaceInput`, `BrowseTabInput`). +- A view with many inputs groups them by the state each one changes, one enum per group (`BrowseTabInput::Paging(PagingInput)`, `StructureTabInput::Save(SaveInput)`), and handles each group in its own `*_handlers` module. Senders pass the group's enum; `send` takes anything that converts into the view's input. - `Output`: only the messages a parent should react to. - `Cmd`: a Relm4 command's result. - `Init`: what the parent hands a view when it builds it (`SessionWindowInit`, `WorkspaceInit`). diff --git a/linux/docs/storage.md b/linux/docs/storage.md index 87bce815a3..05dec64b8e 100644 --- a/linux/docs/storage.md +++ b/linux/docs/storage.md @@ -17,11 +17,13 @@ The app's data goes to the store its shape calls for. The `storage` crate owns t `ConnectionStore` holds the saved-connection list, one store per storage root: -- `load_blocking`, `upsert_blocking`, `remove_blocking`, `touch_last_opened_blocking`, `import_blocking` and `export_blocking` run off the GTK thread. A change holds the store's lock across read, change and write, so two writers never lose an entry. +- `load_blocking`, `upsert_blocking`, `update_blocking`, `remove_blocking`, `touch_last_opened_blocking`, `import_blocking` and `export_blocking` run off the GTK thread. A change holds the store's lock across read, change and write, so two writers never lose an entry. `update_blocking` changes one entry as the list holds it at the write, so a last-opened stamp from another window in between is kept. - A write goes through `write_private_blocking` first and the cached list changes only once it landed, so a failed write leaves both as they were. - `subscribe` hands out a watch of `ConnectionListSnapshot`, so every picker sees every writer's change. - A file that cannot be read, or that another version wrote, leaves the store `Unavailable`, and it refuses every write until the user picks Reset (`reset_unreadable_blocking`), which moves the file aside. -- A `SavedConnection` does **not** carry a password. Secrets live in the keyring, keyed by the connection's UUID. +- A `SavedConnection` does **not** carry a password. Secrets live in the keyring, keyed by the connection's UUID, so a rename or a new host keeps them. +- `group` is a `ConnectionGroupName`: trimmed, 1 to 64 characters, no control characters. A file with any other group is refused like any other unreadable list. +- An edit, a copy or a move to another group goes through the app's `SavedConnectionEditor`, which writes the keyring first. A refused secret stops before the list changes, and the secrets of a copy whose entry did not land are deleted again. ### SSH settings diff --git a/linux/docs/testing.md b/linux/docs/testing.md index faf3f2a664..dfa8a4fcfe 100644 --- a/linux/docs/testing.md +++ b/linux/docs/testing.md @@ -36,9 +36,14 @@ meson setup _build -Dprofile=development dbus-run-session -- meson test -C _build --print-errorlogs ``` -Three suites: `unit` is `cargo test --workspace --exclude tablepro`, `gtk` +Four suites: `unit` is `cargo test --workspace --exclude tablepro`, `gtk` is `cargo test -p tablepro` with `GSK_RENDERER=cairo` and `GTK_A11Y=test`, -and `data` runs `desktop-file-validate`, `glib-compile-schemas --strict` and `appstreamcli validate`. +`data` runs `desktop-file-validate`, `glib-compile-schemas --strict` and `appstreamcli validate`, +and `packaging` runs the unit tests in `build-aux/tests` and checks that +`debian/copyright`, the RPM spec and the metainfo agree with `data/licenses` +and the version Meson builds. In a `-Dprofile=default` build, which the +packages run, the unit and gtk suites build with `--release` but without +its fat LTO, which would cost gigabytes and minutes per test binary. Tests that need Docker or a Secret Service are ignored. Without a display (SSH, containers), prefix with xvfb the way the CI fast job does: @@ -211,7 +216,8 @@ A test that panics hard can still leave a container behind. `podman container pr ### Regression names -A test that guards a critical audit finding starts with the finding id, `f001_` to `f006_`. `cargo test --workspace -- f001_` runs the guards for that finding. +A test that guards a critical audit finding starts with the finding id, `f001_` to `f006_`. `cargo test --workspace --exclude tablepro -- f001_` runs the guards for that finding that need no server, and `cargo test -p tablepro --lib -- f001_` the app's, under a display. +[regression-tests.md](regression-tests.md) lists every guard with its file, and `build-aux/tests/test_regression_names.py`, in the `packaging` suite, fails when the list and the code disagree. ## App / UI tests @@ -225,6 +231,7 @@ Helpers live in `crates/app/src/test_support`, compiled only for tests: - `MemorySettings` compiles the schema into a `TempDir` behind the memory backend, so a test never touches the developer's dconf database. - `FakeSession` is a session over a fake connection; `FakeClocks` and `FakeProbe` stand in for the suspend clocks and the reachability check. - `KeyInjector` presses real keys through XTEST, so a test meets every capture, class and bubble handler in the order GTK runs them. It needs an X11 display, which is why the gtk suite sets `GDK_BACKEND=x11`; `focus_only` first closes any window an earlier test left open. Such a test goes in a `real_keys` module, which nextest runs with no other test beside it, since a window another test presents takes the keyboard focus. +- `FocusThief` takes the focus from a test's window with a window of its own, as another application does, then hands it back without running the main loop, so the focus-in reaches the window before its next frame. It needs the same X11 display. - `shortcut_keys` reads the keys a controller or a widget class binds, for the accelerator table's collision tests. - `assert_labelled(widget)` checks that a screen reader can name a control: - an `adw::PreferencesRow` needs a non-empty title; @@ -280,6 +287,7 @@ The tests in `crates/app/tests` that start TablePro run without the test harness | `application_lifecycle` | Quit waits for a session's close within the close timeout and gives up on a close that hangs, a cancelled close keeps the app running, and an editor session's close holds the process | | `application_session_restore` | the windows open at quit open again at the next launch, and a window closed first does not | | `open_files` | a file handed over D-Bus opens a window on it, the same file brings that window forward, and another file opens a second window | +| `accessibility` | every control a screen reader reaches has a name, on the picker, connecting, connect failed and workspace pages and in the connect and preferences dialogs | | `startup`, `i18n_catalog`, `logging` | the command-line options, a missing schema, the translation catalogue and the log setup | They need a display and a session bus, so run them the way CI does: @@ -290,6 +298,8 @@ xvfb-run -a dbus-run-session -- meson test -C _build --suite gtk --print-errorlo or one at a time, `xvfb-run -a dbus-run-session -- meson devenv -C _build cargo test -p tablepro --test application_lifecycle`. `startup` needs the build Meson configures, so it fails under plain Cargo. cargo-nextest asks each test binary to list its tests, which a harness-free binary does not do, so run these with `cargo test`. +`accessibility` starts one TablePro per scenario in its own process instead, on the main context, and needs `GTK_A11Y=test`; pass scenario names after `--` to run only those. It leaves to libadwaita the parts libadwaita builds and names with a tooltip alone: a tab's close button, a password row's peek button and the preferences dialog's search button. + Policy: - Pure logic: extract to `app::services::` and write plain unit tests. @@ -328,12 +338,19 @@ The tests in `crates/app/src/ui/editor/script_runner.rs` drive `run_batches` und ## CI -GitHub Actions (`.github/workflows/build-linux.yml`), Ubuntu runner: +GitHub Actions (`.github/workflows/build-linux.yml`), Ubuntu runners. Every action is pinned by commit SHA and every tool by version. + +1. **Fast checks**: `meson setup`, `cargo fmt --all -- --check`, the source greps (no `thread_local!` in services, no blocking file I/O under `ui`, no em dash in user-facing text), clippy with and without default features, `meson compile`, `xvfb-run -a dbus-run-session -- meson test --print-errorlogs`, a Secret Service step under `gnome-keyring-daemon`, then `meson dist`. Runs in an `ubuntu:26.04` container, which ships GLib 2.88 plus the meson, appstream and desktop-file-utils packages. A failed run keeps `_build/meson-logs/testlog.txt` as the `meson-testlog` artifact. The job ends by rendering the release-shaped Flatpak manifest from the dist tarball (below). [CONTRIBUTING.md](../CONTRIBUTING.md#fast-job-commands) lists the same commands. +2. **Docker tests**: runs after fast checks pass. One matrix entry per package with docker tests (the PostgreSQL driver with PgBouncer, the MySQL driver with MariaDB, the SQL Server and ClickHouse drivers, all four with their TLS suites, `tablepro-ssh` against an OpenSSH server container, and `tablepro-test-fixtures` for the fixtures themselves) runs that package's ignored docker tests through cargo-nextest on the host runner's Docker, one test at a time per package. `tablepro-session` builds storage, which needs GLib 2.88, so its job runs in the fast job's `ubuntu:26.04` image with the host's Docker socket mounted. The app's docker tests (`crates/app/tests/tracker_save_postgres.rs`) run the same way, since the app links GTK; that job names its test targets, because the app's `startup` test needs Meson. The SQLite driver needs no container: its suites run in the fast job. +3. **Supply chain**: `cargo deny check` against `deny.toml` (advisories, bans, licenses and sources, with every feature and the dev-dependencies), `cargo shear` for unused or misplaced dependencies, `build-aux/update-third-party-licenses.sh` with a diff of the files it writes, and `zizmor` over the Linux workflows. +4. **Translations**: in `fedora:44`, whose gettext reads Rust. `po/POTFILES.in` must match `git ls-files`, `meson setup --fatal-meson-warnings` must pass, and `po/tablepro.pot` must match a fresh `meson compile -C _build tablepro-pot` in everything but its creation date. A failed run keeps the template it wrote as the `tablepro-pot` artifact. +5. **Documentation links**: lychee, offline, over every Markdown file under `linux/`, with heading anchors checked. +6. **Flatpak Devel**: the Devel manifest built in the `gnome-50` Flatpak image for x86_64 and aarch64, with every Meson suite run inside the build sandbox from a tree with no `.git`. +7. **Flatpak release shape**: the manifest the fast job rendered, which is the Devel manifest with the Flathub app ID, `-Dprofile=default`, the dist tarball as its source and a `cargo-sources.json` that `flatpak-cargo-generator` wrote from `Cargo.lock`, built offline with the screenshots mirrored, then `flatpak-builder-lint` over the manifest and the repository with `build-aux/flatpak/lint-exceptions.json`. -1. **Fast checks**: `meson setup`, `cargo fmt --all -- --check`, clippy with and without default features, `meson compile`, `xvfb-run -a dbus-run-session -- meson test --print-errorlogs`, a Secret Service step under `gnome-keyring-daemon`, then `meson dist`. Runs in an `ubuntu:26.04` container, which ships GLib 2.88 plus the meson, appstream and desktop-file-utils packages. [CONTRIBUTING.md](../CONTRIBUTING.md#fast-job-commands) lists the same commands. -2. **Docker tests**: runs after fast checks pass. One matrix entry per package with docker tests (the PostgreSQL, MySQL, SQL Server and ClickHouse drivers, `tablepro-ssh` against an OpenSSH server container, and `tablepro-test-fixtures` for the fixtures themselves) runs that package's ignored docker tests through cargo-nextest on the host runner's Docker. `tablepro-session` builds storage, which needs GLib 2.88, so its job runs in the fast job's `ubuntu:26.04` image with the host's Docker socket mounted. The app's docker tests (`crates/app/tests/tracker_save_postgres.rs`) run the same way, since the app links GTK; that job names its test targets, because the app's `startup` test needs Meson. +Every job first checks that `linux/meson.build` exists, so the same file can sit on `main` before `linux/` does. GitHub runs a schedule only from the default branch: while `main` has no `linux/`, the Monday run there starts this workflow on the branch the `LINUX_BRANCH` repository variable names, `linux` when it is unset. PRs only merge when every job is green. -A Translations job also checks `po/POTFILES.in` and extracts the POT template. PRs only merge when every job is green. +`.github/workflows/linux-packages.yml` builds the .deb in `ubuntu:26.04` and the .rpm in `fedora:44` from the release source and vendor tarballs, running every Meson suite inside the package build, then installs each package in a fresh container and starts it. It runs on a pull request that touches the packaging or the Meson files; `build-aux/packaging/build-packages.sh` runs the same steps locally. ## Coverage diff --git a/linux/meson.build b/linux/meson.build index 0470eef9b8..2b3a3ac613 100644 --- a/linux/meson.build +++ b/linux/meson.build @@ -7,6 +7,7 @@ project( license_files: 'LICENSE', ) +fs = import('fs') i18n = import('i18n') gnome = import('gnome') @@ -33,6 +34,20 @@ endif version = meson.project_version() + version_suffix +# Flathub mirrors the screenshots from these URLs at build time, so the +# default names the release tag, which never moves. A build of a commit +# that is not tagged yet passes that commit's URL instead. +screenshots_base_url = get_option('screenshots_base_url') +if screenshots_base_url == '' + screenshots_base_url = 'https://raw.githubusercontent.com/TableProApp/TablePro/linux-v@0@/linux/data/screenshots'.format( + meson.project_version(), + ) +endif +if not screenshots_base_url.startswith('https://') + error('screenshots_base_url must be an https URL: ' + screenshots_base_url) +endif +screenshots_base_url = screenshots_base_url.strip('/') + # GNOME 50 floor; see docs/decisions/0015-gnome-50-platform-floor.md. dependency('glib-2.0', version: '>= 2.88') dependency('gio-2.0', version: '>= 2.88') @@ -68,6 +83,7 @@ cargo_env.set('TABLEPRO_LIBEXECDIR', libexecdir) subdir('data') subdir('po') subdir('crates/app') +subdir('build-aux') # `meson devenv` runs cargo against the build tree, where the askpass # helper sits next to the binary rather than under libexecdir. diff --git a/linux/meson_options.txt b/linux/meson_options.txt index 31cdf21770..0556e3ec11 100644 --- a/linux/meson_options.txt +++ b/linux/meson_options.txt @@ -19,3 +19,17 @@ option( value: false, description: 'Install the files a Flatpak build needs, such as the Kerberos configuration.', ) + +option( + 'screenshots_base_url', + type: 'string', + value: '', + description: 'The https URL the metainfo screenshots are under. Empty uses data/screenshots at the release tag.', +) + +option( + 'cargo_config', + type: 'string', + value: '', + description: 'A Cargo configuration file, relative to the source root, for every cargo call. Packages point it at their vendored crates.', +) diff --git a/linux/po/POTFILES.in b/linux/po/POTFILES.in index 8185f0a0ce..921db33cc1 100644 --- a/linux/po/POTFILES.in +++ b/linux/po/POTFILES.in @@ -8,6 +8,7 @@ crates/app/src/application/app_window_host.rs crates/app/src/application/debug_info.rs crates/app/src/application/imp.rs crates/app/src/application/legal_section.rs +crates/app/src/application/metainfo_release.rs crates/app/src/application/mod.rs crates/app/src/application/open_files.rs crates/app/src/application/quit_sequence.rs @@ -61,6 +62,7 @@ crates/app/src/services/column_relayout.rs crates/app/src/services/connection_deletion.rs crates/app/src/services/deletion_error.rs crates/app/src/services/deletion_report.rs +crates/app/src/services/duplicate_name.rs crates/app/src/services/file_location.rs crates/app/src/services/file_location_kind.rs crates/app/src/services/file_resolution.rs @@ -70,10 +72,14 @@ crates/app/src/services/history_service.rs crates/app/src/services/mod.rs crates/app/src/services/pending_structure.rs crates/app/src/services/save_batch.rs +crates/app/src/services/saved_connection_editor.rs +crates/app/src/services/saved_connection_error.rs crates/app/src/services/saved_connection_list.rs crates/app/src/services/saved_connection_object.rs crates/app/src/services/saved_connection_object_imp.rs crates/app/src/services/saved_connection_order.rs +crates/app/src/services/secret_change.rs +crates/app/src/services/secret_changes.rs crates/app/src/services/secret_labels.rs crates/app/src/services/secret_save_report.rs crates/app/src/services/session_context.rs @@ -94,6 +100,7 @@ crates/app/src/test_support/alert_response.rs crates/app/src/test_support/fake_clocks.rs crates/app/src/test_support/fake_reachability.rs crates/app/src/test_support/fake_session.rs +crates/app/src/test_support/focus_thief.rs crates/app/src/test_support/icon_buttons.rs crates/app/src/test_support/key_injection_error.rs crates/app/src/test_support/key_injector.rs @@ -113,20 +120,39 @@ crates/app/src/ui/browse_tab/browse_actions.rs crates/app/src/ui/browse_tab/browse_command.rs crates/app/src/ui/browse_tab/browse_pager.rs crates/app/src/ui/browse_tab/browse_request_kind.rs +crates/app/src/ui/browse_tab/browse_tab_input.rs +crates/app/src/ui/browse_tab/browse_tab_widgets.rs +crates/app/src/ui/browse_tab/clipboard_handlers.rs +crates/app/src/ui/browse_tab/clipboard_input.rs crates/app/src/ui/browse_tab/column_recheck.rs crates/app/src/ui/browse_tab/columns_fetch.rs +crates/app/src/ui/browse_tab/command_handlers.rs crates/app/src/ui/browse_tab/confirming_delete.rs crates/app/src/ui/browse_tab/count_failure_text.rs crates/app/src/ui/browse_tab/count_policy.rs +crates/app/src/ui/browse_tab/data_handlers.rs +crates/app/src/ui/browse_tab/data_input.rs +crates/app/src/ui/browse_tab/edit_handlers.rs +crates/app/src/ui/browse_tab/edit_input.rs crates/app/src/ui/browse_tab/fetch.rs crates/app/src/ui/browse_tab/imp.rs +crates/app/src/ui/browse_tab/layout.rs crates/app/src/ui/browse_tab/load_tests.rs crates/app/src/ui/browse_tab/mod.rs +crates/app/src/ui/browse_tab/page_label.rs crates/app/src/ui/browse_tab/page_reply.rs crates/app/src/ui/browse_tab/page_state.rs +crates/app/src/ui/browse_tab/pager_bar.rs +crates/app/src/ui/browse_tab/paging_handlers.rs +crates/app/src/ui/browse_tab/paging_input.rs +crates/app/src/ui/browse_tab/pending_changes_bar.rs crates/app/src/ui/browse_tab/row_count.rs crates/app/src/ui/browse_tab/row_deletion.rs crates/app/src/ui/browse_tab/row_total.rs +crates/app/src/ui/browse_tab/save_handlers.rs +crates/app/src/ui/browse_tab/save_input.rs +crates/app/src/ui/browse_tab/selection_handlers.rs +crates/app/src/ui/browse_tab/selection_input.rs crates/app/src/ui/browse_tab/selection_snapshot.rs crates/app/src/ui/browse_tab/tests.rs crates/app/src/ui/browse_tab/total_tests.rs @@ -137,6 +163,8 @@ crates/app/src/ui/clipboard/clipboard_writer.rs crates/app/src/ui/clipboard/copy_renderer.rs crates/app/src/ui/clipboard/mod.rs crates/app/src/ui/clipboard/tests.rs +crates/app/src/ui/combo_value.rs +crates/app/src/ui/connect_dialog/connect_dialog_mode.rs crates/app/src/ui/connect_dialog/connect_form.rs crates/app/src/ui/connect_dialog/connect_submission.rs crates/app/src/ui/connect_dialog/form_errors.rs @@ -193,6 +221,7 @@ crates/app/src/ui/error_text/driver.rs crates/app/src/ui/error_text/edit.rs crates/app/src/ui/error_text/mod.rs crates/app/src/ui/error_text/operation.rs +crates/app/src/ui/error_text/saved_connection.rs crates/app/src/ui/error_text/script.rs crates/app/src/ui/error_text/secret.rs crates/app/src/ui/error_text/ssh.rs @@ -226,7 +255,13 @@ crates/app/src/ui/file_reveal/mod.rs crates/app/src/ui/file_reveal/recording_revealer.rs crates/app/src/ui/file_reveal/reveal_failure_toast.rs crates/app/src/ui/file_write.rs -crates/app/src/ui/filter_strip.rs +crates/app/src/ui/filter_strip/filter_rule_object.rs +crates/app/src/ui/filter_strip/imp.rs +crates/app/src/ui/filter_strip/mod.rs +crates/app/src/ui/filter_strip/rule_operators.rs +crates/app/src/ui/filter_strip/rule_row.rs +crates/app/src/ui/filter_strip/rule_value_editor.rs +crates/app/src/ui/filter_strip/tests.rs crates/app/src/ui/format/mod.rs crates/app/src/ui/format/thousands.rs crates/app/src/ui/grid/calendar_editor.rs @@ -241,6 +276,7 @@ crates/app/src/ui/grid/cell_preset.rs crates/app/src/ui/grid/cell_tooltip.rs crates/app/src/ui/grid/column_drawing.rs crates/app/src/ui/grid/column_factory.rs +crates/app/src/ui/grid/column_figures.rs crates/app/src/ui/grid/column_width_binding.rs crates/app/src/ui/grid/context_menu.rs crates/app/src/ui/grid/delete_availability.rs @@ -298,9 +334,14 @@ crates/app/src/ui/mod.rs crates/app/src/ui/picker/connection_menu.rs crates/app/src/ui/picker/connection_row.rs crates/app/src/ui/picker/mod.rs +crates/app/src/ui/picker/new_group_dialog.rs crates/app/src/ui/picker/picker_actions.rs crates/app/src/ui/picker/tests.rs crates/app/src/ui/plain_toast.rs +crates/app/src/ui/preferences_dialog/data_browser_group.rs +crates/app/src/ui/preferences_dialog/editor_page.rs +crates/app/src/ui/preferences_dialog/general_page.rs +crates/app/src/ui/preferences_dialog/history_group.rs crates/app/src/ui/preferences_dialog/imp.rs crates/app/src/ui/preferences_dialog/mod.rs crates/app/src/ui/result_pane/mod.rs @@ -308,13 +349,19 @@ crates/app/src/ui/result_pane/result_limit_banner.rs crates/app/src/ui/result_pane/result_set_view.rs crates/app/src/ui/result_pane/result_summary.rs crates/app/src/ui/saved_queries_dialog.rs +crates/app/src/ui/session_window/application_notification_sink.rs crates/app/src/ui/session_window/connect_failed_page.rs +crates/app/src/ui/session_window/connect_failure_view.rs crates/app/src/ui/session_window/connect_flow.rs crates/app/src/ui/session_window/connecting_page.rs crates/app/src/ui/session_window/connections.rs crates/app/src/ui/session_window/imp.rs crates/app/src/ui/session_window/locate_file.rs crates/app/src/ui/session_window/mod.rs +crates/app/src/ui/session_window/notification_request.rs +crates/app/src/ui/session_window/notification_sink.rs +crates/app/src/ui/session_window/notification_tests.rs +crates/app/src/ui/session_window/notifications.rs crates/app/src/ui/session_window/open_file_tests.rs crates/app/src/ui/session_window/picker_page.rs crates/app/src/ui/session_window/resolve_file_identity.rs @@ -327,31 +374,49 @@ crates/app/src/ui/session_window/session_window_input.rs crates/app/src/ui/session_window/sqlite_location_dialog.rs crates/app/src/ui/session_window/tests.rs crates/app/src/ui/session_window/window_actions.rs +crates/app/src/ui/session_window/work_notifier.rs crates/app/src/ui/shell/mod.rs crates/app/src/ui/shell/primary_menu_button.rs crates/app/src/ui/ssh_inputs.rs crates/app/src/ui/ssh_section.rs +crates/app/src/ui/structure_tab/column_handlers.rs crates/app/src/ui/structure_tab/columns.rs +crates/app/src/ui/structure_tab/columns_input.rs crates/app/src/ui/structure_tab/context.rs crates/app/src/ui/structure_tab/draft_column_object.rs crates/app/src/ui/structure_tab/draft_column_object_imp.rs crates/app/src/ui/structure_tab/fks.rs crates/app/src/ui/structure_tab/foreign_key_dialog.rs crates/app/src/ui/structure_tab/foreign_key_draft.rs +crates/app/src/ui/structure_tab/foreign_key_handlers.rs crates/app/src/ui/structure_tab/foreign_key_object.rs +crates/app/src/ui/structure_tab/foreign_keys_input.rs crates/app/src/ui/structure_tab/form_dialog.rs crates/app/src/ui/structure_tab/imp.rs crates/app/src/ui/structure_tab/index_dialog.rs crates/app/src/ui/structure_tab/index_draft.rs +crates/app/src/ui/structure_tab/index_handlers.rs crates/app/src/ui/structure_tab/index_object.rs crates/app/src/ui/structure_tab/indexes.rs +crates/app/src/ui/structure_tab/indexes_input.rs +crates/app/src/ui/structure_tab/layout.rs +crates/app/src/ui/structure_tab/load_handlers.rs +crates/app/src/ui/structure_tab/load_input.rs crates/app/src/ui/structure_tab/mod.rs crates/app/src/ui/structure_tab/referential_action_label.rs crates/app/src/ui/structure_tab/review_dialog.rs +crates/app/src/ui/structure_tab/save_gate.rs +crates/app/src/ui/structure_tab/save_handlers.rs +crates/app/src/ui/structure_tab/save_input.rs +crates/app/src/ui/structure_tab/sql_preview.rs crates/app/src/ui/structure_tab/structure_actions.rs crates/app/src/ui/structure_tab/structure_command.rs crates/app/src/ui/structure_tab/structure_fetch.rs +crates/app/src/ui/structure_tab/structure_tab_input.rs +crates/app/src/ui/structure_tab/structure_tab_widgets.rs crates/app/src/ui/structure_tab/table_choice_object.rs +crates/app/src/ui/structure_tab/table_handlers.rs +crates/app/src/ui/structure_tab/table_input.rs crates/app/src/ui/structure_tab/tests.rs crates/app/src/ui/style_scheme_grid.rs crates/app/src/ui/style_scheme_resolver.rs @@ -364,12 +429,14 @@ crates/app/src/ui/welcome_view.rs crates/app/src/ui/window_geometry.rs crates/app/src/ui/workspace/close_flow.rs crates/app/src/ui/workspace/editor_label.rs +crates/app/src/ui/workspace/finished_work.rs crates/app/src/ui/workspace/health_banner.rs crates/app/src/ui/workspace/imp.rs crates/app/src/ui/workspace/import.rs crates/app/src/ui/workspace/mod.rs crates/app/src/ui/workspace/row_ops.rs crates/app/src/ui/workspace/saves.rs +crates/app/src/ui/workspace/session_watches.rs crates/app/src/ui/workspace/shell.rs crates/app/src/ui/workspace/sidebar/catalog_watch.rs crates/app/src/ui/workspace/sidebar/mod.rs @@ -409,6 +476,7 @@ crates/app/src/workspace/close_guards.rs crates/app/src/workspace/close_reason.rs crates/app/src/workspace/close_scope.rs crates/app/src/workspace/close_step.rs +crates/app/src/workspace/completed_work.rs crates/app/src/workspace/dialog_response.rs crates/app/src/workspace/dirty_kind.rs crates/app/src/workspace/dirty_tab.rs @@ -448,6 +516,8 @@ crates/app/src/workspace/unknown_cause.rs crates/app/src/workspace/unknown_outcome_dialog.rs crates/app/src/workspace/waiting_dialog.rs crates/app/src/workspace/window_inhibitor.rs +crates/app/src/workspace/work_kind.rs +crates/app/src/workspace/work_outcome.rs crates/app/src/workspace/workspace_event.rs crates/app/src/workspace/workspace_file_name.rs crates/app/src/workspace/workspace_handle.rs @@ -469,11 +539,19 @@ data/resources/ui/connect-dialog.ui data/resources/ui/connect-failed-page.ui data/resources/ui/connecting-page.ui data/resources/ui/export-operations-button.ui +data/resources/ui/filter-rule-row.ui +data/resources/ui/filter-strip.ui data/resources/ui/find-bar.ui data/resources/ui/history-dialog.ui data/resources/ui/history-row.ui +data/resources/ui/pager-bar.ui +data/resources/ui/pending-changes-bar.ui data/resources/ui/picker-page.ui +data/resources/ui/preferences-data-browser-group.ui data/resources/ui/preferences-dialog.ui +data/resources/ui/preferences-editor-page.ui +data/resources/ui/preferences-general-page.ui +data/resources/ui/preferences-history-group.ui data/resources/ui/results-pane.ui data/resources/ui/session-window.ui data/resources/ui/sql-editor.ui diff --git a/linux/po/README.md b/linux/po/README.md index 247edb27a3..130f668c18 100644 --- a/linux/po/README.md +++ b/linux/po/README.md @@ -58,18 +58,24 @@ silently drop out of translation. ## Regenerating tablepro.pot -`tablepro.pot` is the master template, regenerated during release -preparation rather than on every change: +`tablepro.pot` is the master template. A change that adds, edits or +removes a user-facing string regenerates it in the same commit: ```sh meson compile -C _build tablepro-pot ``` +The template names the file each string is in but not the line, so moving +code leaves it alone. The Translations CI job regenerates it and fails when +the committed file differs in anything but its creation date. It then +uploads the template it wrote as the `tablepro-pot` artifact, which can be +committed as it is. + This needs **gettext 0.24 or newer**, which is the first release whose -xgettext reads Rust. An older xgettext falls back to its C lexer, which -misreads lifetimes such as `'static` as character constants and produces a -template you should not commit. `meson setup` warns when the xgettext it -found is too old. +xgettext reads Rust. Fedora 44 ships 0.26; Ubuntu 26.04 ships 0.23.2. An +older xgettext falls back to its C lexer, which misreads lifetimes such as +`'static` as character constants and produces a template you should not +commit. `meson setup` warns when the xgettext it found is too old. Use `msgmerge` to fold new strings into existing translations: diff --git a/linux/po/meson.build b/linux/po/meson.build index 7c69f8e145..45b46838be 100644 --- a/linux/po/meson.build +++ b/linux/po/meson.build @@ -24,6 +24,9 @@ i18n.gettext( '--keyword=ngettext_f:1,2', '--keyword=pgettext_f:1c,2', '--from-code=UTF-8', + # CI fails when the committed template differs from a fresh one, and + # line numbers would make every edit above a string count as a change. + '--add-location=file', '--add-comments=Translators:', '--package-name=' + meson.project_name(), '--package-version=' + meson.project_version(), diff --git a/linux/po/tablepro.pot b/linux/po/tablepro.pot index aecef8b7cd..6641dc0860 100644 --- a/linux/po/tablepro.pot +++ b/linux/po/tablepro.pot @@ -1,5 +1,5 @@ # SOME DESCRIPTIVE TITLE. -# Copyright (C) YEAR TablePro Authors +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER # This file is distributed under the same license as the tablepro package. # FIRST AUTHOR , YEAR. # @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: tablepro 0.1.0\n" "Report-Msgid-Bugs-To: https://github.com/TableProApp/TablePro/issues\n" -"POT-Creation-Date: 2026-04-26 15:36+0000\n" +"POT-Creation-Date: 2026-10-15 06:52+0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -16,911 +16,1863 @@ msgstr "" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=INTEGER; plural=EXPRESSION;\n" -#: crates/app/src/ui/app.rs:200 crates/app/src/ui/app.rs:1549 crates/app/src/ui/app.rs:1584 crates/app/src/ui/app.rs:1626 -msgid "New connection" +#. Translators: your names, one per line, as "Name " or "Name https://site". +#: crates/app/src/application/about_dialog.rs +msgid "translator-credits" msgstr "" -#: crates/app/src/ui/app.rs:207 -msgid "Open saved connection" +#: crates/app/src/i18n.rs crates/app/src/ui/export/export_scope.rs +#: crates/app/src/ui/history_dialog/history_formatting.rs +#: crates/app/src/ui/result_pane/mod.rs +#, rust-format +msgid "{n} row" +msgid_plural "{n} rows" +msgstr[0] "" +msgstr[1] "" + +#: crates/app/src/ui/browse_tab/browse_actions.rs +#: data/resources/shortcuts-dialog.ui +msgid "Insert Row" msgstr "" -#: crates/app/src/ui/app.rs:218 -msgid "Read-only" +#: crates/app/src/ui/browse_tab/browse_actions.rs +msgid "Delete Selected Rows" msgstr "" -#: crates/app/src/ui/app.rs:235 crates/app/src/ui/edit_dialog.rs:178 -msgid "Saving…" +#: crates/app/src/ui/browse_tab/browse_actions.rs +msgid "Export Rows…" msgstr "" -#: crates/app/src/ui/app.rs:241 crates/app/src/ui/app.rs:2301 crates/app/src/ui/preferences.rs:124 -msgid "SQL editor" +#: crates/app/src/ui/browse_tab/pager_bar.rs +msgid "Last Page" msgstr "" -#: crates/app/src/ui/app.rs:250 -msgid "Main menu" +#: crates/app/src/ui/browse_tab/pager_bar.rs +msgid "Count rows to go to the last page" msgstr "" -#: crates/app/src/ui/app.rs:274 -msgid "Filter tables…" +#: crates/app/src/ui/browse_tab/pager_bar.rs +msgid "Counting…" msgstr "" -#: crates/app/src/ui/app.rs:296 -msgid "Retry" +#: crates/app/src/ui/browse_tab/pager_bar.rs data/resources/ui/browse-tab.ui +#: data/resources/ui/structure-tab.ui +msgid "Try Again" msgstr "" -#: crates/app/src/ui/app.rs:302 crates/app/src/ui/app.rs:1545 -msgid "Connect to a database" +#: crates/app/src/ui/browse_tab/pager_bar.rs data/resources/ui/pager-bar.ui +msgid "Count Rows" msgstr "" -#: crates/app/src/ui/app.rs:303 -msgid "Click the server icon for a new connection or the folder icon to open a saved one." +#: crates/app/src/ui/browse_tab/pager_bar.rs +msgid "First Page" msgstr "" -#: crates/app/src/ui/app.rs:437 -msgid "Saved Connections" +#: crates/app/src/ui/browse_tab/pager_bar.rs data/resources/shortcuts-dialog.ui +msgid "Previous Page" +msgstr "" + +#: crates/app/src/ui/browse_tab/pager_bar.rs data/resources/shortcuts-dialog.ui +msgid "Next Page" msgstr "" -#: crates/app/src/ui/app.rs:456 -msgid "Previous page" +#: crates/app/src/ui/clipboard/clipboard_copy.rs +msgid "Copied to clipboard" msgstr "" -#: crates/app/src/ui/app.rs:461 -msgid "Next page" +#: crates/app/src/ui/clipboard/clipboard_copy.rs +#, rust-format +msgid "Copied {n} row" +msgid_plural "Copied {n} rows" +msgstr[0] "" +msgstr[1] "" + +#: crates/app/src/ui/clipboard/clipboard_copy.rs +#, rust-format +msgid "Copied {n} value" +msgid_plural "Copied {n} values" +msgstr[0] "" +msgstr[1] "" + +#: crates/app/src/ui/clipboard/clipboard_copy.rs +msgid "Couldn't copy the selection" msgstr "" -#: crates/app/src/ui/app.rs:485 -msgid "Rows per page" +#: crates/app/src/ui/clipboard/clipboard_copy.rs +#, rust-format +msgid "{n} NULL or binary values left out of the IN clause" msgstr "" -#: crates/app/src/ui/app.rs:501 -msgid "Insert row" +#: crates/app/src/ui/clipboard/clipboard_copy.rs +#, rust-format +msgid "" +"Couldn't copy: the column “{column}” holds a value TablePro couldn't read" msgstr "" -#: crates/app/src/ui/app.rs:506 -msgid "Edit selected row" +#: crates/app/src/ui/clipboard/clipboard_copy.rs +msgid "This row has a value that can't be written as SQL" msgstr "" -#: crates/app/src/ui/app.rs:511 -msgid "Delete selected row" +#: crates/app/src/ui/clipboard/clipboard_copy.rs +msgid "This row has no value to put in an INSERT" msgstr "" -#: crates/app/src/ui/app.rs:533 -msgid "Export as CSV…" +#: crates/app/src/ui/clipboard/clipboard_copy.rs +msgid "This column has a value that can't be written as SQL" msgstr "" -#: crates/app/src/ui/app.rs:534 -msgid "Export as JSON…" +#: crates/app/src/ui/clipboard/clipboard_copy.rs +msgid "Nothing to copy: an IN clause can't carry NULL or binary values" msgstr "" -#: crates/app/src/ui/app.rs:537 -msgid "Export results" +#: crates/app/src/ui/editor/tests.rs +msgid "query-results" msgstr "" -#: crates/app/src/ui/app.rs:558 crates/app/src/ui/app.rs:2287 -msgid "Find in results" +#: crates/app/src/ui/error_text/edit.rs +msgid "This column needs a value" msgstr "" -#: crates/app/src/ui/app.rs:692 -msgid "Rows updated" +#: crates/app/src/ui/error_text/edit.rs +msgid "Enter a whole number" msgstr "" -#: crates/app/src/ui/app.rs:703 -msgid "Undoing…" +#: crates/app/src/ui/error_text/edit.rs +#, rust-format +msgid "Enter a whole number from {min} to {max}" msgstr "" -#: crates/app/src/ui/app.rs:784 crates/app/src/ui/app.rs:1425 -msgid "Select a table" +#: crates/app/src/ui/error_text/edit.rs +msgid "Enter a number" msgstr "" -#: crates/app/src/ui/app.rs:785 -msgid "Connected to {driver}. Pick a table from the left to load up to 100,000 rows." +#: crates/app/src/ui/error_text/edit.rs +msgid "Enter a decimal number, such as 12.50" msgstr "" -#: crates/app/src/ui/app.rs:832 -msgid "Loading…" +#: crates/app/src/ui/error_text/edit.rs +#, rust-format +msgid "This column keeps at most {n} digit" +msgid_plural "This column keeps at most {n} digits" +msgstr[0] "" +msgstr[1] "" + +#: crates/app/src/ui/error_text/edit.rs +#, rust-format +msgid "This column keeps at most {n} decimal place" +msgid_plural "This column keeps at most {n} decimal places" +msgstr[0] "" +msgstr[1] "" + +#: crates/app/src/ui/error_text/edit.rs +msgid "Enter true or false" +msgstr "" + +#: crates/app/src/ui/error_text/edit.rs +msgid "Enter a UUID" +msgstr "" + +#: crates/app/src/ui/error_text/edit.rs +#, rust-format +msgid "Enter valid JSON: {detail}" +msgstr "" + +#: crates/app/src/ui/error_text/edit.rs +msgid "Enter a date as YYYY-MM-DD" +msgstr "" + +#: crates/app/src/ui/error_text/edit.rs +msgid "Enter a time as HH:MM:SS" +msgstr "" + +#: crates/app/src/ui/error_text/edit.rs +msgid "This time is outside the range the column can hold" +msgstr "" + +#: crates/app/src/ui/error_text/edit.rs +msgid "Enter a date and time as YYYY-MM-DD HH:MM:SS" +msgstr "" + +#: crates/app/src/ui/error_text/edit.rs +msgid "Add a time zone offset, such as +02:00" +msgstr "" + +#: crates/app/src/ui/error_text/edit.rs +msgid "Enter an interval, such as 1 day 02:00:00" +msgstr "" + +#: crates/app/src/ui/error_text/edit.rs +msgid "Enter bits as 0 and 1" +msgstr "" + +#: crates/app/src/ui/error_text/edit.rs +#, rust-format +msgid "Enter exactly {n} bit" +msgid_plural "Enter exactly {n} bits" +msgstr[0] "" +msgstr[1] "" + +#: crates/app/src/ui/error_text/edit.rs +msgid "Enter one of the values this column allows" +msgstr "" + +#: crates/app/src/ui/error_text/edit.rs +msgid "Binary values can't be edited here" +msgstr "" + +#: crates/app/src/ui/error_text/edit.rs +msgid "Spatial values can't be edited here" +msgstr "" + +#: crates/app/src/ui/error_text/edit.rs +msgid "This value couldn't be read, so it can't be edited" +msgstr "" + +#: crates/app/src/ui/error_text/edit.rs +msgid "Arrays and composite values can't be edited here" +msgstr "" + +#: crates/app/src/ui/error_text/edit.rs +msgid "Values whose type changes by row can't be edited here" +msgstr "" + +#: crates/app/src/ui/export/export_failure_text.rs +#, rust-format +msgid "Exported {rows} row to {name}" +msgid_plural "Exported {rows} rows to {name}" +msgstr[0] "" +msgstr[1] "" + +#: crates/app/src/ui/export/export_failure_text.rs +msgid "Export stopped" +msgstr "" + +#: crates/app/src/ui/export/export_failure_text.rs +#, rust-format +msgid "Export stopped. {name} is incomplete." +msgstr "" + +#: crates/app/src/ui/export/export_failure_text.rs +#, rust-format +msgid "{rows} of {total} row" +msgid_plural "{rows} of {total} rows" +msgstr[0] "" +msgstr[1] "" + +#: crates/app/src/ui/export/export_failure_text.rs +#, rust-format +msgid "{rows} of about {total} row" +msgid_plural "{rows} of about {total} rows" +msgstr[0] "" +msgstr[1] "" + +#: crates/app/src/ui/export/export_failure_text.rs +#, rust-format +msgid "{rows} row" +msgid_plural "{rows} rows" +msgstr[0] "" +msgstr[1] "" + +#: crates/app/src/ui/export/export_failure_text.rs +#, rust-format +msgid "" +"The column “{column}” holds a value TablePro couldn't read, so it can't be " +"written to the file." +msgstr "" + +#: crates/app/src/ui/export/export_failure_text.rs +msgid "" +"This statement returned more than one result, so it can't be exported as one " +"file." +msgstr "" + +#: crates/app/src/ui/export/export_failure_text.rs +msgid "This statement returned no rows to export." +msgstr "" + +#: crates/app/src/ui/export/export_failure_text.rs +#, rust-format +msgid "" +"An Excel sheet holds {limit} rows, and this result has more. Export it as " +"CSV instead." +msgstr "" + +#: crates/app/src/ui/export/export_failure_text.rs +#, rust-format +msgid "Writing {name} failed: {error}" +msgstr "" + +#: crates/app/src/ui/export/export_failure_text.rs +#, rust-format +msgid "" +"An export to {name} is already running. Wait for it to finish, or export to " +"another file." +msgstr "" + +#: crates/app/src/ui/export/export_failure_text.rs +msgid "The export stopped unexpectedly." +msgstr "" + +#: crates/app/src/ui/export/export_failure_text.rs +#, rust-format +msgid "{name} is incomplete." +msgstr "" + +#: crates/app/src/ui/export/export_failure_text.rs +msgid "" +"Export All only runs statements that read data. This statement changes data." +msgstr "" + +#: crates/app/src/ui/export/export_failure_text.rs +msgid "" +"Export All runs the statement again on a separate connection, so temporary " +"tables, session settings and uncommitted changes from this editor aren't " +"available." +msgstr "" + +#: crates/app/src/ui/export/export_failure_text.rs +#, rust-format +msgid "An Excel sheet holds {limit} rows, and this result has {got}." +msgstr "" + +#: crates/app/src/ui/export/export_failure_text.rs +#, rust-format +msgid "An Excel sheet holds {limit} columns, and this result has {got}." +msgstr "" + +#: crates/app/src/ui/export/export_failure_text.rs +#, rust-format +msgid "The file couldn't be built: {error}" +msgstr "" + +#: crates/app/src/ui/export/export_failure_text.rs +#: data/resources/ui/history-dialog.ui +#: data/resources/ui/preferences-history-group.ui +msgid "Show in Files" +msgstr "" + +#: crates/app/src/ui/export/export_failure_text.rs +#: crates/app/src/ui/history_dialog/history_export.rs +msgid "Couldn't Export" +msgstr "" + +#: crates/app/src/ui/export/export_failure_text.rs +#: crates/app/src/ui/history_dialog/history_export.rs +msgid "_Close" +msgstr "" + +#: crates/app/src/ui/export/export_ledger.rs +msgid "Stop Exports?" +msgstr "" + +#: crates/app/src/ui/export/export_ledger.rs +#, rust-format +msgid "{n} export is still running. Stopping it leaves the file unfinished." +msgid_plural "" +"{n} exports are still running. Stopping them leaves the files unfinished." +msgstr[0] "" +msgstr[1] "" + +#: crates/app/src/ui/export/export_ledger.rs +msgid "_Keep Exporting" +msgstr "" + +#: crates/app/src/ui/export/export_ledger.rs +msgid "_Stop Exports" +msgstr "" + +#: crates/app/src/ui/export/export_operations_button.rs +msgid "Stop Export" +msgstr "" + +#: crates/app/src/ui/export/export_scope.rs +msgid "filters apply" +msgstr "" + +#: crates/app/src/ui/export/export_scope.rs +msgid "unsaved changes aren't included" +msgstr "" + +#: crates/app/src/ui/export/export_scope.rs +msgid "Current Page" +msgstr "" + +#: crates/app/src/ui/export/export_scope.rs +msgid "Entire Table" +msgstr "" + +#: crates/app/src/ui/export/export_scope.rs +msgctxt "list separator" +msgid ", " +msgstr "" + +#: crates/app/src/ui/export/export_scope.rs +msgid "Shown Rows" +msgstr "" + +#: crates/app/src/ui/export/export_scope.rs +msgid "All Rows" +msgstr "" + +#: crates/app/src/ui/export/export_scope.rs +msgid "" +"Runs the statement again on a separate read-only connection, without this " +"editor's USE or SET changes" +msgstr "" + +#: crates/app/src/ui/export/export_scope.rs +#, rust-format +msgid "About {n} row" +msgid_plural "About {n} rows" +msgstr[0] "" +msgstr[1] "" + +#: crates/app/src/ui/export/export_scope.rs +msgid "Every row" +msgstr "" + +#: crates/app/src/ui/export_dialog.rs +#, rust-format +msgid "" +"An Excel sheet holds {limit} rows, and this export has {rows}. Export it as " +"CSV instead." +msgstr "" + +#: crates/app/src/ui/file_reveal/reveal_failure_toast.rs +msgid "Could not show the file in Files" +msgstr "" + +#: crates/app/src/ui/grid/row_json_dialog.rs +#, rust-format +msgid "… ({count} more byte)" +msgid_plural "… ({count} more bytes)" +msgstr[0] "" +msgstr[1] "" + +#: crates/app/src/ui/history_dialog/connection_filter.rs +msgid "All Connections" +msgstr "" + +#: crates/app/src/ui/history_dialog/history_export.rs +msgid "SQL files" +msgstr "" + +#: crates/app/src/ui/history_dialog/history_export.rs +msgid "CSV files" +msgstr "" + +#: crates/app/src/ui/history_dialog/history_export.rs +msgid "Export Query History" +msgstr "" + +#: crates/app/src/ui/history_dialog/history_export.rs +msgid "The selected entries couldn't be written as CSV." msgstr "" -#: crates/app/src/ui/app.rs:833 -msgid "Fetching rows from {table}" +#: crates/app/src/ui/history_dialog/history_export.rs +msgid "The selected entries couldn't be read from the query history." msgstr "" -#: crates/app/src/ui/app.rs:946 -msgid "No rows at offset {n}" +#: crates/app/src/ui/history_dialog/history_filters.rs +#: data/resources/ui/connect-dialog.ui +msgid "Connection" msgstr "" -#: crates/app/src/ui/app.rs:952 -msgid "Rows {start} – {end} of {total}" +#: crates/app/src/ui/history_dialog/history_filters.rs +msgid "All" msgstr "" -#: crates/app/src/ui/app.rs:956 -msgid "Rows {start} – {end}" +#: crates/app/src/ui/history_dialog/history_filters.rs +#: crates/app/src/ui/history_dialog/history_formatting.rs +msgid "Succeeded" msgstr "" -#: crates/app/src/ui/app.rs:1016 crates/app/src/ui/history_dialog.rs:113 +#: crates/app/src/ui/history_dialog/history_filters.rs +#: crates/app/src/ui/history_dialog/history_formatting.rs msgid "Failed" msgstr "" -#: crates/app/src/ui/app.rs:1051 -msgid "Row inserted" +#: crates/app/src/ui/history_dialog/history_filters.rs +#: crates/app/src/ui/history_dialog/history_formatting.rs +msgid "Cancelled" +msgstr "" + +#: crates/app/src/ui/history_dialog/history_filters.rs +msgid "Status" +msgstr "" + +#: crates/app/src/ui/history_dialog/history_filters.rs +msgid "Any Time" +msgstr "" + +#: crates/app/src/ui/history_dialog/history_filters.rs +msgid "Today" +msgstr "" + +#: crates/app/src/ui/history_dialog/history_filters.rs +msgid "Last 7 Days" +msgstr "" + +#: crates/app/src/ui/history_dialog/history_filters.rs +msgid "Last 30 Days" +msgstr "" + +#: crates/app/src/ui/history_dialog/history_filters.rs +msgid "Time window" +msgstr "" + +#: crates/app/src/ui/history_dialog/history_formatting.rs +msgid "Empty Query" +msgstr "" + +#: crates/app/src/ui/history_dialog/history_formatting.rs +msgid "cancelled" +msgstr "" + +#: crates/app/src/ui/history_dialog/history_formatting.rs +msgid "just now" +msgstr "" + +#: crates/app/src/ui/history_dialog/history_formatting.rs +#, rust-format +msgid "{n} minute ago" +msgid_plural "{n} minutes ago" +msgstr[0] "" +msgstr[1] "" + +#: crates/app/src/ui/history_dialog/history_formatting.rs +#, rust-format +msgid "{n} hour ago" +msgid_plural "{n} hours ago" +msgstr[0] "" +msgstr[1] "" + +#: crates/app/src/ui/history_dialog/history_formatting.rs +#, rust-format +msgid "{n} day ago" +msgid_plural "{n} days ago" +msgstr[0] "" +msgstr[1] "" + +#: crates/app/src/ui/history_dialog/history_row.rs +#: crates/app/src/ui/history_dialog/mod.rs +msgid "Unpin" +msgstr "" + +#: crates/app/src/ui/history_dialog/history_row.rs +#: crates/app/src/ui/history_dialog/mod.rs +msgid "Pin" +msgstr "" + +#: crates/app/src/ui/history_dialog/mod.rs +#, rust-format +msgid "{n} selected" +msgid_plural "{n} selected" +msgstr[0] "" +msgstr[1] "" + +#: crates/app/src/ui/history_dialog/mod.rs +msgid "The query could not be pinned." +msgstr "" + +#: crates/app/src/ui/history_dialog/mod.rs +msgid "The query could not be unpinned." +msgstr "" + +#: crates/app/src/ui/history_dialog/mod.rs +msgid "The queries could not be deleted." +msgstr "" + +#: crates/app/src/ui/history_dialog/mod.rs +#, rust-format +msgid "{count} query deleted" +msgid_plural "{count} queries deleted" +msgstr[0] "" +msgstr[1] "" + +#: crates/app/src/ui/history_dialog/mod.rs +msgid "_Undo" +msgstr "" + +#: crates/app/src/ui/history_dialog/mod.rs +msgid "The queries could not be restored." +msgstr "" + +#: crates/app/src/ui/history_dialog/mod.rs +msgid "Clear All Query History?" +msgstr "" + +#: crates/app/src/ui/history_dialog/mod.rs +msgid "This permanently deletes every saved query, including pinned ones." +msgstr "" + +#: crates/app/src/ui/history_dialog/mod.rs data/resources/ui/connecting-page.ui +msgid "Cancel" +msgstr "" + +#: crates/app/src/ui/history_dialog/mod.rs data/resources/ui/tls-section.ui +msgid "Clear" +msgstr "" + +#: crates/app/src/ui/history_dialog/mod.rs +msgid "The query history could not be cleared." +msgstr "" + +#: crates/app/src/ui/history_dialog/mod.rs +msgid "The query history could not be read." +msgstr "" + +#: crates/app/src/ui/history_dialog/mod.rs +msgid "No Matches" +msgstr "" + +#: crates/app/src/ui/history_dialog/mod.rs +msgid "Try a different search term or change the filters." msgstr "" -#: crates/app/src/ui/app.rs:1068 -msgid "Cannot edit" +#: crates/app/src/ui/history_dialog/mod.rs data/resources/ui/history-dialog.ui +msgid "No Queries Yet" msgstr "" -#: crates/app/src/ui/app.rs:1069 -msgid "Select exactly one row to edit." +#: crates/app/src/ui/history_dialog/mod.rs data/resources/ui/history-dialog.ui +msgid "Run a query in the SQL editor and it will appear here." +msgstr "" + +#: crates/app/src/ui/history_dialog/mod.rs +msgid "History Unavailable" +msgstr "" + +#: crates/app/src/ui/history_dialog/mod.rs +msgid "Pinned" msgstr "" -#: crates/app/src/ui/app.rs:1095 -msgid "Row updated" +#: crates/app/src/ui/history_dialog/mod.rs +msgid "All Queries" msgstr "" -#: crates/app/src/ui/app.rs:1122 crates/app/src/ui/app.rs:2405 -msgid "Cannot delete" +#: crates/app/src/ui/history_dialog/mod.rs +msgid "Open in New Tab" msgstr "" -#: crates/app/src/ui/app.rs:1131 crates/app/src/ui/app.rs:2415 -msgid "Delete row where {pk}?" +#: crates/app/src/ui/history_dialog/mod.rs +msgid "Replace Current Tab" msgstr "" -#: crates/app/src/ui/app.rs:1133 crates/app/src/ui/app.rs:1858 -msgid "Delete {n} rows?" +#: crates/app/src/ui/history_dialog/mod.rs +msgid "Copy SQL" msgstr "" -#: crates/app/src/ui/app.rs:1136 crates/app/src/ui/app.rs:2426 crates/app/src/ui/history_dialog.rs:333 crates/app/src/ui/history_dialog.rs:726 crates/app/src/ui/history_dialog.rs:753 +#: crates/app/src/ui/history_dialog/mod.rs data/resources/ui/history-dialog.ui +#: data/resources/ui/history-row.ui msgid "Delete" msgstr "" -#: crates/app/src/ui/app.rs:1138 -msgid "Delete {n}" +#: crates/app/src/ui/host_key_review.rs +msgid "Replace Key and Connect" msgstr "" -#: crates/app/src/ui/app.rs:1192 -msgid "Cannot update cell" +#: crates/app/src/ui/result_pane/result_limit_banner.rs +msgid "Export All…" msgstr "" -#: crates/app/src/ui/app.rs:1208 -msgid "Cell updated" +#: crates/app/src/ui/result_pane/result_summary.rs +#, rust-format +msgid "Showing {shown} of {total} row" +msgid_plural "Showing {shown} of {total} rows" +msgstr[0] "" +msgstr[1] "" + +#: crates/app/src/ui/result_pane/result_summary.rs +#, rust-format +msgid "Showing the first {shown} row" +msgid_plural "Showing the first {shown} rows" +msgstr[0] "" +msgstr[1] "" + +#: crates/app/src/ui/result_pane/result_summary.rs +#, rust-format +msgid "{rows}+" msgstr "" -#: crates/app/src/ui/app.rs:1244 -msgid "No connection" +#: crates/app/src/ui/result_pane/result_summary.rs +#, rust-format +msgid "Result {n} ({rows})" msgstr "" -#: crates/app/src/ui/app.rs:1245 -msgid "Connect to a database first to run SQL." +#: crates/app/src/ui/structure_tab/review_dialog.rs +msgid "" +"Rebuilds the table: copies its rows into a new table and drops the old one." msgstr "" -#: crates/app/src/ui/app.rs:1269 -msgid "View open tabs" +#: crates/app/src/ui/structure_tab/sql_preview.rs +msgid "-- Connect to a database to see the SQL." msgstr "" -#: crates/app/src/ui/app.rs:1276 -msgid "New tab" +#: crates/app/src/ui/structure_tab/sql_preview.rs +msgid "-- This database's structure can't be edited here." msgstr "" -#: crates/app/src/ui/app.rs:1385 -msgid "Query {n}" +#: crates/app/src/ui/structure_tab/sql_preview.rs +msgid "-- Fix the highlighted fields to see the SQL." msgstr "" -#: crates/app/src/ui/app.rs:1426 -msgid "Pick a table from the left to load rows." +#: crates/app/src/ui/structure_tab/sql_preview.rs +msgid "-- No pending changes." msgstr "" -#: crates/app/src/ui/app.rs:1449 crates/app/src/ui/editor.rs:573 crates/app/src/ui/history_dialog.rs:917 -msgid "Empty query" +#. Translators: TablePro is the product name and is not translated. +#: data/app.tablepro.TablePro.desktop.in.in data/resources/ui/picker-page.ui +#: data/resources/ui/session-window.ui +msgid "TablePro" msgstr "" -#: crates/app/src/ui/app.rs:1508 crates/app/src/ui/connect_dialog.rs:281 -msgid "Connecting…" +#: data/app.tablepro.TablePro.desktop.in.in +msgid "Database Client" msgstr "" -#: crates/app/src/ui/app.rs:1509 -msgid "Opening {name}" +#: data/app.tablepro.TablePro.desktop.in.in +msgid "" +"Browse and edit PostgreSQL, MySQL, SQLite, SQL Server and ClickHouse " +"databases" msgstr "" -#: crates/app/src/ui/app.rs:1546 -msgid "Add a connection to get started." +#: data/app.tablepro.TablePro.desktop.in.in +msgid "SQL;Database;PostgreSQL;MySQL;SQLite;SQL Server;ClickHouse;" msgstr "" -#: crates/app/src/ui/app.rs:1580 -msgid "Saved connections" +#: data/app.tablepro.TablePro.desktop.in.in data/resources/gtk/menus.ui +#: data/resources/shortcuts-dialog.ui +msgid "New Window" msgstr "" -#: crates/app/src/ui/app.rs:1607 crates/app/src/ui/connection_row.rs:44 -msgid "Remove connection" +#: data/app.tablepro.TablePro.gschema.xml +msgid "Window width" msgstr "" -#: crates/app/src/ui/app.rs:1761 -msgid "Connected" +#: data/app.tablepro.TablePro.gschema.xml +msgid "Width of the main window in logical pixels." msgstr "" -#: crates/app/src/ui/app.rs:1767 -msgid "Reconnecting · attempt {n} · retrying" +#: data/app.tablepro.TablePro.gschema.xml +msgid "Window height" msgstr "" -#: crates/app/src/ui/app.rs:1775 -msgid "Connection lost — reconnecting (attempt {n}, will keep retrying)" +#: data/app.tablepro.TablePro.gschema.xml +msgid "Height of the main window in logical pixels." msgstr "" -#: crates/app/src/ui/app.rs:1836 -msgid "1 row deleted" +#: data/app.tablepro.TablePro.gschema.xml +msgid "Window is maximized" msgstr "" -#: crates/app/src/ui/app.rs:1838 -msgid "{n} rows deleted" +#: data/app.tablepro.TablePro.gschema.xml +msgid "Whether the main window was maximized when it last closed." msgstr "" -#: crates/app/src/ui/app.rs:1856 crates/app/src/ui/app.rs:2417 -msgid "Delete row?" +#: data/app.tablepro.TablePro.gschema.xml +msgid "Rows per page" msgstr "" -#: crates/app/src/ui/app.rs:1861 crates/app/src/ui/editor.rs:96 crates/app/src/ui/history_dialog.rs:492 crates/app/src/ui/preferences.rs:70 -msgid "Cancel" +#: data/app.tablepro.TablePro.gschema.xml +msgid "How many rows a table page fetches at a time." msgstr "" -#: crates/app/src/ui/app.rs:1903 -msgid "Undo" +#: data/app.tablepro.TablePro.gschema.xml +msgid "Confirm destructive actions" msgstr "" -#: crates/app/src/ui/app.rs:2151 -msgid "Disconnect" +#: data/app.tablepro.TablePro.gschema.xml +msgid "" +"Ask before dropping a table, deleting rows, or discarding pending edits." +msgstr "" + +#: data/app.tablepro.TablePro.gschema.xml +msgid "Save query history" +msgstr "" + +#: data/app.tablepro.TablePro.gschema.xml +msgid "" +"Whether the SQL you run is kept in the query history. Statements that " +"contain passwords or connection secrets are never saved. Turning this off " +"keeps the entries already saved." +msgstr "" + +#: data/app.tablepro.TablePro.gschema.xml +msgid "Query history retention in days" +msgstr "" + +#: data/app.tablepro.TablePro.gschema.xml +msgid "" +"Unpinned history entries older than this are pruned at startup. Zero keeps " +"everything." +msgstr "" + +#: data/app.tablepro.TablePro.gschema.xml +msgid "Query timeout in seconds" +msgstr "" + +#: data/app.tablepro.TablePro.gschema.xml +msgid "" +"How long the editor waits before cancelling a running query. Zero disables " +"the timeout." +msgstr "" + +#: data/app.tablepro.TablePro.gschema.xml +msgid "Use the system monospace font" +msgstr "" + +#: data/app.tablepro.TablePro.gschema.xml +msgid "" +"Whether the SQL editor follows the desktop monospace font instead of a " +"custom one." +msgstr "" + +#: data/app.tablepro.TablePro.gschema.xml +msgid "Custom editor font" +msgstr "" + +#: data/app.tablepro.TablePro.gschema.xml +msgid "" +"Pango font description the SQL editor uses when the system font is turned " +"off." +msgstr "" + +#: data/app.tablepro.TablePro.gschema.xml +msgid "Editor style scheme" +msgstr "" + +#: data/app.tablepro.TablePro.gschema.xml +msgid "GtkSourceView style scheme id for the SQL editor." +msgstr "" + +#: data/app.tablepro.TablePro.gschema.xml +msgid "Restore session" msgstr "" -#: crates/app/src/ui/app.rs:2156 crates/app/src/ui/history_dialog.rs:83 crates/app/src/ui/history_dialog.rs:92 +#: data/app.tablepro.TablePro.gschema.xml +msgid "" +"Whether the connections open when TablePro quit are opened again at the next " +"launch." +msgstr "" + +#: data/app.tablepro.TablePro.gschema.xml +msgid "Driver the connect dialog opens with" +msgstr "" + +#: data/app.tablepro.TablePro.gschema.xml +msgid "" +"Id of the driver last connected to, so the dialog opens on it with its own " +"default port." +msgstr "" + +#: data/app.tablepro.TablePro.gschema.xml +msgid "Write NULL as an empty field" +msgstr "" + +#: data/app.tablepro.TablePro.gschema.xml +msgid "" +"Whether a CSV export writes NULL as an empty field rather than the text NULL." +msgstr "" + +#: data/app.tablepro.TablePro.gschema.xml +msgid "Replace line breaks with spaces" +msgstr "" + +#: data/app.tablepro.TablePro.gschema.xml +msgid "" +"Whether a CSV export flattens line breaks inside a value into single spaces." +msgstr "" + +#: data/app.tablepro.TablePro.gschema.xml +msgid "Write a header row" +msgstr "" + +#: data/app.tablepro.TablePro.gschema.xml +msgid "Whether a CSV export starts with the column names." +msgstr "" + +#: data/app.tablepro.TablePro.gschema.xml +msgid "Neutralise spreadsheet formulas" +msgstr "" + +#: data/app.tablepro.TablePro.gschema.xml +msgid "" +"Whether a CSV export prefixes values starting with =, +, - or @ so a " +"spreadsheet does not run them." +msgstr "" + +#: data/app.tablepro.TablePro.gschema.xml +msgid "CSV field delimiter" +msgstr "" + +#: data/app.tablepro.TablePro.gschema.xml +msgid "Character that separates fields in a CSV export." +msgstr "" + +#: data/app.tablepro.TablePro.gschema.xml +msgid "CSV quoting" +msgstr "" + +#: data/app.tablepro.TablePro.gschema.xml +msgid "When a CSV export wraps a field in quotes." +msgstr "" + +#: data/app.tablepro.TablePro.gschema.xml +msgid "CSV line ending" +msgstr "" + +#: data/app.tablepro.TablePro.gschema.xml +msgid "Line ending a CSV export writes between records." +msgstr "" + +#: data/app.tablepro.TablePro.gschema.xml +msgid "CSV decimal separator" +msgstr "" + +#: data/app.tablepro.TablePro.gschema.xml +msgid "" +"Character a CSV export uses between the whole and fractional parts of a " +"number." +msgstr "" + +#: data/app.tablepro.TablePro.metainfo.xml.in.in +msgid "Work with your databases" +msgstr "" + +#: data/app.tablepro.TablePro.metainfo.xml.in.in +msgid "" +"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." +msgstr "" + +#: data/app.tablepro.TablePro.metainfo.xml.in.in +msgid "It can:" +msgstr "" + +#: data/app.tablepro.TablePro.metainfo.xml.in.in +msgid "Edit cells in place, with primary-key safety and an undo history" +msgstr "" + +#: data/app.tablepro.TablePro.metainfo.xml.in.in +msgid "Run SQL scripts statement by statement and cancel a slow query" +msgstr "" + +#: data/app.tablepro.TablePro.metainfo.xml.in.in +msgid "Tunnel through an SSH jump host using your own OpenSSH client" +msgstr "" + +#: data/app.tablepro.TablePro.metainfo.xml.in.in +msgid "Verify TLS, keep connections read-only, and reconnect on drop" +msgstr "" + +#: data/app.tablepro.TablePro.metainfo.xml.in.in +msgid "Export results to CSV, TSV, JSON and SQL" +msgstr "" + +#: data/app.tablepro.TablePro.metainfo.xml.in.in +msgid "Browse and edit table rows" +msgstr "" + +#: data/app.tablepro.TablePro.metainfo.xml.in.in +msgid "Run SQL in the editor" +msgstr "" + +#: data/app.tablepro.TablePro.metainfo.xml.in.in +msgid "Change a table's columns and indexes" +msgstr "" + +#: data/app.tablepro.TablePro.metainfo.xml.in.in +msgid "Connect through an SSH tunnel" +msgstr "" + +#: data/app.tablepro.TablePro.metainfo.xml.in.in +msgid "sql" +msgstr "" + +#: data/app.tablepro.TablePro.metainfo.xml.in.in +msgid "postgres" +msgstr "" + +#: data/app.tablepro.TablePro.metainfo.xml.in.in +msgid "mysql" +msgstr "" + +#: data/app.tablepro.TablePro.metainfo.xml.in.in +msgid "sqlite" +msgstr "" + +#: data/app.tablepro.TablePro.metainfo.xml.in.in +msgid "database" +msgstr "" + +#: data/app.tablepro.TablePro.metainfo.xml.in.in +msgid "Initial pre-release tracking the production-hardening pass." +msgstr "" + +#: data/resources/gtk/menus.ui data/resources/shortcuts-dialog.ui +#: data/resources/ui/history-dialog.ui +#: data/resources/ui/preferences-history-group.ui msgid "Query History" msgstr "" -#: crates/app/src/ui/app.rs:2159 crates/app/src/ui/preferences.rs:9 +#: data/resources/gtk/menus.ui +msgid "Saved Queries" +msgstr "" + +#: data/resources/gtk/menus.ui +msgid "Disconnect" +msgstr "" + +#: data/resources/gtk/menus.ui +msgid "Import Connections…" +msgstr "" + +#: data/resources/gtk/menus.ui +msgid "Export Connections…" +msgstr "" + +#: data/resources/gtk/menus.ui data/resources/shortcuts-dialog.ui +#: data/resources/ui/preferences-dialog.ui msgid "Preferences" msgstr "" -#: crates/app/src/ui/app.rs:2162 +#: data/resources/gtk/menus.ui data/resources/shortcuts-dialog.ui msgid "Keyboard Shortcuts" msgstr "" -#: crates/app/src/ui/app.rs:2163 +#: data/resources/gtk/menus.ui msgid "About TablePro" msgstr "" -#: crates/app/src/ui/app.rs:2164 crates/app/src/ui/app.rs:2295 -msgid "Quit" +#: data/resources/gtk/menus.ui +msgid "Reopen Closed Tab" msgstr "" -#: crates/app/src/ui/app.rs:2285 crates/app/src/ui/preferences.rs:13 +#: data/resources/shortcuts-dialog.ui +#: data/resources/ui/preferences-general-page.ui msgid "General" msgstr "" -#: crates/app/src/ui/app.rs:2286 -msgid "Open SQL editor" +#: data/resources/shortcuts-dialog.ui +msgid "Show or Hide the Sidebar" msgstr "" -#: crates/app/src/ui/app.rs:2288 -msgid "Refresh table" +#: data/resources/shortcuts-dialog.ui +msgid "Close a Dialog" msgstr "" -#: crates/app/src/ui/app.rs:2289 -msgid "Open Preferences" +#: data/resources/shortcuts-dialog.ui +msgid "Quit" msgstr "" -#: crates/app/src/ui/app.rs:2290 -msgid "Open Query History" +#: data/resources/shortcuts-dialog.ui +msgid "Tabs" msgstr "" -#: crates/app/src/ui/app.rs:2293 -msgid "Show keyboard shortcuts" +#: data/resources/shortcuts-dialog.ui +msgid "New SQL Editor Tab" msgstr "" -#: crates/app/src/ui/app.rs:2302 -msgid "Run query" +#: data/resources/shortcuts-dialog.ui +msgid "Close the Tab or the Empty Window" msgstr "" -#: crates/app/src/ui/app.rs:2303 -msgid "Cancel running query" +#: data/resources/shortcuts-dialog.ui +msgid "Reopen the Last Closed Tab" msgstr "" -#: crates/app/src/ui/app.rs:2304 -msgid "New editor tab" +#: data/resources/shortcuts-dialog.ui +msgid "Next Tab" msgstr "" -#: crates/app/src/ui/app.rs:2307 -msgid "Close current tab or window" +#: data/resources/shortcuts-dialog.ui +msgid "Previous Tab" msgstr "" -#: crates/app/src/ui/app.rs:2309 -msgid "Next editor tab" +#: data/resources/shortcuts-dialog.ui +msgid "Move the Tab Right" msgstr "" -#: crates/app/src/ui/app.rs:2312 -msgid "Previous editor tab" +#: data/resources/shortcuts-dialog.ui +msgid "Move the Tab Left" msgstr "" -#: crates/app/src/ui/app.rs:2316 -msgid "Dialogs" +#: data/resources/shortcuts-dialog.ui +msgid "Browse Table" msgstr "" -#: crates/app/src/ui/app.rs:2317 -msgid "Close dialog" +#: data/resources/shortcuts-dialog.ui data/resources/ui/browse-tab.ui +msgid "Filter Rows" msgstr "" -#: crates/app/src/ui/app.rs:2338 -msgid "Copied to clipboard" +#: data/resources/shortcuts-dialog.ui +msgid "Read the Table Again" msgstr "" -#: crates/app/src/ui/app.rs:2366 -msgid "Cannot set NULL" +#: data/resources/shortcuts-dialog.ui +msgid "Save Pending Changes" msgstr "" -#: crates/app/src/ui/app.rs:2382 -msgid "Cell cleared" +#: data/resources/shortcuts-dialog.ui +msgid "Undo the Last Change" msgstr "" -#: crates/app/src/ui/app.rs:2454 -msgid "INSERT statement copied" +#: data/resources/shortcuts-dialog.ui +msgid "Redo the Last Change" msgstr "" -#: crates/app/src/ui/app.rs:2459 -msgid "Nothing to export" +#: data/resources/shortcuts-dialog.ui +msgid "Select All Rows" msgstr "" -#: crates/app/src/ui/app.rs:2469 crates/app/src/ui/history_dialog.rs:860 -msgid "CSV files" +#: data/resources/shortcuts-dialog.ui +msgid "Clear the Selection" msgstr "" -#: crates/app/src/ui/app.rs:2474 -msgid "JSON files" +#: data/resources/shortcuts-dialog.ui +msgid "First Row of the Page" msgstr "" -#: crates/app/src/ui/app.rs:2483 crates/app/src/ui/history_dialog.rs:326 -msgid "Export as CSV" +#: data/resources/shortcuts-dialog.ui +msgid "Last Row of the Page" msgstr "" -#: crates/app/src/ui/app.rs:2484 -msgid "Export as JSON" +#: data/resources/shortcuts-dialog.ui +msgid "Extend the Selection to the Clicked Row" msgstr "" -#: crates/app/src/ui/app.rs:2502 -msgid "Exported to {path}" +#: data/resources/shortcuts-dialog.ui +msgid "Add or Remove the Clicked Row" msgstr "" -#: crates/app/src/ui/app.rs:2505 -msgid "Export failed: {error}" +#: data/resources/shortcuts-dialog.ui +msgid "Data Grid" msgstr "" -#: crates/app/src/ui/app.rs:2550 -msgid "TablePro" +#: data/resources/shortcuts-dialog.ui +msgid "Move Between Cells" msgstr "" -#: crates/app/src/ui/app.rs:2552 -msgid "TablePro Authors" +#: data/resources/shortcuts-dialog.ui +msgid "Edit a Cell or Toggle a Check Box" msgstr "" -#: crates/app/src/ui/app.rs:2557 -msgid "© 2025–2026 TablePro Authors" +#: data/resources/shortcuts-dialog.ui +msgid "Toggle a Check Box" msgstr "" -#: crates/app/src/ui/app.rs:2560 -msgid "A native Linux database client built with GTK4 + libadwaita." +#: data/resources/shortcuts-dialog.ui +msgid "Show the Cell Menu" msgstr "" -#: crates/app/src/ui/app.rs:2564 -msgid "translator-credits" +#: data/resources/shortcuts-dialog.ui +msgid "Set Cell to NULL" +msgstr "" + +#: data/resources/shortcuts-dialog.ui +msgid "Finish Editing" +msgstr "" + +#: data/resources/shortcuts-dialog.ui +msgid "Cancel Editing" +msgstr "" + +#: data/resources/shortcuts-dialog.ui +msgid "Finish Editing and Edit the Next Cell" +msgstr "" + +#: data/resources/shortcuts-dialog.ui +msgid "Finish Editing and Edit the Previous Cell" +msgstr "" + +#: data/resources/shortcuts-dialog.ui +msgid "Save a Multi-Line Value" +msgstr "" + +#: data/resources/shortcuts-dialog.ui +msgid "Mark Rows for Deletion" +msgstr "" + +#: data/resources/shortcuts-dialog.ui +msgid "Copy Selected Rows as TSV" +msgstr "" + +#: data/resources/shortcuts-dialog.ui +msgid "Clear the Multi-Row Selection" +msgstr "" + +#: data/resources/shortcuts-dialog.ui +msgid "SQL Editor" +msgstr "" + +#: data/resources/shortcuts-dialog.ui +msgid "Run the Query or the Selected Text" +msgstr "" + +#: data/resources/shortcuts-dialog.ui +msgid "Run the Statement at the Cursor" +msgstr "" + +#: data/resources/shortcuts-dialog.ui +msgid "Cancel the Running Query" +msgstr "" + +#: data/resources/shortcuts-dialog.ui +msgid "Format SQL" +msgstr "" + +#: data/resources/shortcuts-dialog.ui +msgid "Toggle Line Comment" +msgstr "" + +#: data/resources/shortcuts-dialog.ui data/resources/ui/find-bar.ui +msgid "Find" +msgstr "" + +#: data/resources/shortcuts-dialog.ui +msgid "Find and Replace" +msgstr "" + +#: data/resources/shortcuts-dialog.ui +msgid "Next Match" +msgstr "" + +#: data/resources/shortcuts-dialog.ui +msgid "Previous Match" +msgstr "" + +#: data/resources/shortcuts-dialog.ui +msgid "Save the Query Under a Name" +msgstr "" + +#: data/resources/shortcuts-dialog.ui +msgid "Table Structure" +msgstr "" + +#: data/resources/ui/browse-tab.ui +msgid "Couldn't refresh rows. Showing the last loaded page." +msgstr "" + +#: data/resources/ui/browse-tab.ui +msgid "Read-only connection. Editing disabled." +msgstr "" + +#: data/resources/ui/browse-tab.ui +msgid "This table has no primary key. Use the SQL editor to modify rows." +msgstr "" + +#: data/resources/ui/browse-tab.ui +msgid "Loading…" +msgstr "" + +#: data/resources/ui/browse-tab.ui +msgid "Couldn't Load Rows" +msgstr "" + +#: data/resources/ui/browse-tab.ui +msgid "Filter" +msgstr "" + +#: data/resources/ui/browse-tab.ui +msgid "Rows:" msgstr "" -#: crates/app/src/ui/connect_dialog.rs:78 crates/app/src/ui/connect_dialog.rs:212 +#: data/resources/ui/connect-dialog.ui msgid "Connect" msgstr "" -#: crates/app/src/ui/connect_dialog.rs:155 +#: data/resources/ui/connect-dialog.ui +msgid "Test" +msgstr "" + +#: data/resources/ui/connect-dialog.ui +msgid "Connect Once to Check the Settings" +msgstr "" + +#: data/resources/ui/connect-dialog.ui data/resources/ui/structure-tab.ui +msgid "Name" +msgstr "" + +#: data/resources/ui/connect-dialog.ui msgid "Driver" msgstr "" -#: crates/app/src/ui/connect_dialog.rs:164 +#: data/resources/ui/connect-dialog.ui msgid "Host" msgstr "" -#: crates/app/src/ui/connect_dialog.rs:167 +#: data/resources/ui/connect-dialog.ui data/resources/ui/ssh-section.ui msgid "Port" msgstr "" -#: crates/app/src/ui/connect_dialog.rs:169 crates/app/src/ui/connect_dialog.rs:481 +#: data/resources/ui/connect-dialog.ui msgid "Database" msgstr "" -#: crates/app/src/ui/connect_dialog.rs:173 +#: data/resources/ui/connect-dialog.ui data/resources/ui/ssh-section.ui +msgid "Authentication" +msgstr "" + +#: data/resources/ui/connect-dialog.ui +msgid "Method" +msgstr "" + +#: data/resources/ui/connect-dialog.ui data/resources/ui/ssh-section.ui msgid "Username" msgstr "" -#: crates/app/src/ui/connect_dialog.rs:176 crates/app/src/ui/ssh_section.rs:58 +#: data/resources/ui/connect-dialog.ui data/resources/ui/ssh-section.ui msgid "Password" msgstr "" -#: crates/app/src/ui/connect_dialog.rs:178 -msgid "Use TLS" +#: data/resources/ui/connect-dialog.ui +msgid "Remember Password" msgstr "" -#: crates/app/src/ui/connect_dialog.rs:179 -msgid "Require encrypted connection" +#: data/resources/ui/connect-dialog.ui +msgid "Keep it in the keyring; otherwise it is asked for at each connect" msgstr "" -#: crates/app/src/ui/connect_dialog.rs:183 -msgid "Read-only mode" +#: data/resources/ui/connect-dialog.ui +msgid "Options" msgstr "" -#: crates/app/src/ui/connect_dialog.rs:184 -msgid "Block INSERT, UPDATE, DELETE, and DDL on this connection" +#: data/resources/ui/connect-dialog.ui +msgid "Read-Only Mode" msgstr "" -#: crates/app/src/ui/connect_dialog.rs:205 -msgid "Test" +#: data/resources/ui/connect-failed-page.ui +msgid "Could Not Connect" msgstr "" -#: crates/app/src/ui/connect_dialog.rs:246 crates/app/src/ui/connect_dialog.rs:262 -msgid "Connect to {name}" +#: data/resources/ui/connect-failed-page.ui +msgid "Enter Password…" msgstr "" -#: crates/app/src/ui/connect_dialog.rs:285 crates/app/src/ui/connect_dialog.rs:348 -msgid "no driver selected" +#: data/resources/ui/connect-failed-page.ui data/resources/ui/workspace.ui +msgid "Retry" msgstr "" -#: crates/app/src/ui/connect_dialog.rs:294 crates/app/src/ui/connect_dialog.rs:355 -msgid "driver {id} not registered" +#: data/resources/ui/connect-failed-page.ui +msgid "Edit Connection…" msgstr "" -#: crates/app/src/ui/connect_dialog.rs:344 -msgid "Testing…" +#: data/resources/ui/connect-failed-page.ui +msgid "Choose Connection" msgstr "" -#: crates/app/src/ui/connect_dialog.rs:423 -msgid "Connection ok · {n} table(s) visible" +#: data/resources/ui/export-operations-button.ui +msgid "Exports" msgstr "" -#: crates/app/src/ui/connect_dialog.rs:428 -msgid "Test failed: {error}" +#: data/resources/ui/export-operations-button.ui +msgid "Running exports" msgstr "" -#: crates/app/src/ui/connect_dialog.rs:479 -msgid "File path" +#: data/resources/ui/filter-strip.ui +msgid "Match" msgstr "" -#: crates/app/src/ui/edit_dialog.rs:98 -msgid "{name} (read-only · primary key)" +#: data/resources/ui/filter-strip.ui +msgctxt "filter operator" +msgid "all" msgstr "" -#: crates/app/src/ui/edit_dialog.rs:102 crates/app/src/ui/insert_dialog.rs:98 -msgid "{name} (required)" +#: data/resources/ui/filter-strip.ui +msgctxt "filter operator" +msgid "any" msgstr "" -#: crates/app/src/ui/edit_dialog.rs:113 -msgid "Save" +#: data/resources/ui/filter-strip.ui +msgid "of these rules" msgstr "" -#: crates/app/src/ui/edit_dialog.rs:123 -msgid "Edit row in {table}" +#: data/resources/ui/filter-strip.ui +msgid "Clear All" msgstr "" -#: crates/app/src/ui/edit_dialog.rs:145 crates/app/src/ui/editor.rs:257 crates/app/src/ui/insert_dialog.rs:135 -msgid "no active connection" +#: data/resources/ui/filter-strip.ui +msgid "Apply" msgstr "" -#: crates/app/src/ui/editor.rs:104 -msgid "Run" +#: data/resources/ui/filter-strip.ui +msgid "e.g. created_at > now() - interval '1 day'" msgstr "" -#: crates/app/src/ui/editor.rs:250 -msgid "empty query" +#: data/resources/ui/filter-strip.ui +msgid "Or use the rule editor" msgstr "" -#: crates/app/src/ui/editor.rs:271 -msgid "Running…" +#: data/resources/ui/filter-strip.ui +msgid "Add Rule" msgstr "" -#: crates/app/src/ui/editor.rs:328 -msgid "{n} row(s) in {ms} ms (truncated)" +#: data/resources/ui/find-bar.ui +msgid "Match Case" msgstr "" -#: crates/app/src/ui/editor.rs:332 -msgid "{n} row(s) in {ms} ms" +#: data/resources/ui/find-bar.ui +msgid "Match Whole Word" msgstr "" -#: crates/app/src/ui/editor.rs:340 -msgid "No rows" +#: data/resources/ui/find-bar.ui +msgid "Regular Expressions" msgstr "" -#: crates/app/src/ui/editor.rs:341 -msgid "Query returned no rows." +#: data/resources/ui/find-bar.ui +msgid "Replace" msgstr "" -#: crates/app/src/ui/editor.rs:370 -msgid "error" +#: data/resources/ui/find-bar.ui +msgid "Replace with" msgstr "" -#: crates/app/src/ui/editor.rs:373 -msgid "Query failed" +#: data/resources/ui/find-bar.ui +msgid "Replace All" msgstr "" -#: crates/app/src/ui/editor.rs:393 crates/app/src/ui/history_dialog.rs:930 -msgid "cancelled" +#: data/resources/ui/history-dialog.ui +msgid "Select" msgstr "" -#: crates/app/src/ui/editor.rs:396 -msgid "Query cancelled" +#: data/resources/ui/history-dialog.ui +msgid "Select Queries" msgstr "" -#: crates/app/src/ui/editor.rs:397 -msgid "The running query was stopped." +#: data/resources/ui/history-dialog.ui +msgid "More Actions" msgstr "" -#: crates/app/src/ui/error_text.rs:7 -msgid "This table has no primary key. Use the modal Edit dialog instead." +#: data/resources/ui/history-dialog.ui +msgid "Search" msgstr "" -#: crates/app/src/ui/error_text.rs:8 -msgid "No changes to save." +#: data/resources/ui/history-dialog.ui +msgid "Search queries…" msgstr "" -#: crates/app/src/ui/error_text.rs:10 -msgid "Internal column count mismatch (expected {expected}, got {got})." +#: data/resources/ui/history-dialog.ui +msgid "Search queries" msgstr "" -#: crates/app/src/ui/error_text.rs:20 -msgid "Could not reach the database. Is it running?" +#: data/resources/ui/history-dialog.ui +msgid "Queries" msgstr "" -#: crates/app/src/ui/error_text.rs:21 -msgid "Username or password is wrong." +#: data/resources/ui/history-dialog.ui +msgid "Export" msgstr "" -#: crates/app/src/ui/error_text.rs:22 -msgid "TLS handshake failed: {detail}" +#: data/resources/ui/history-dialog.ui +msgid "Clear All History…" msgstr "" -#: crates/app/src/ui/error_text.rs:26 -msgid "Query failed (SQLSTATE {sqlstate}): {message}" +#: data/resources/ui/history-dialog.ui +msgid "Export as SQL" msgstr "" -#: crates/app/src/ui/error_text.rs:29 -msgid "Query failed: {message}" +#: data/resources/ui/history-dialog.ui +msgid "Export as CSV" msgstr "" -#: crates/app/src/ui/error_text.rs:30 -msgid "The connection was closed. Try reconnecting." +#: data/resources/ui/history-row.ui +msgid "Select Query" msgstr "" -#: crates/app/src/ui/error_text.rs:32 -msgid "This connection is read-only. Reopen it without read-only mode to make changes." +#: data/resources/ui/pending-changes-bar.ui data/resources/ui/structure-tab.ui +#: data/resources/ui/workspace.ui +msgid "Saving…" msgstr "" -#: crates/app/src/ui/error_text.rs:34 -msgid "Internal driver error: {detail}" +#: data/resources/ui/pending-changes-bar.ui data/resources/ui/structure-tab.ui +msgid "Save" msgstr "" -#: crates/app/src/ui/grid.rs:272 -msgid "Copy value" +#: data/resources/ui/pending-changes-bar.ui +msgid "Save Changes" msgstr "" -#: crates/app/src/ui/grid.rs:273 -msgid "Copy row as INSERT" +#: data/resources/ui/pending-changes-bar.ui data/resources/ui/structure-tab.ui +msgid "Discard" msgstr "" -#: crates/app/src/ui/grid.rs:276 -msgid "Set to NULL" +#: data/resources/ui/pending-changes-bar.ui +msgid "Discard All Changes" msgstr "" -#: crates/app/src/ui/grid.rs:277 -msgid "Delete row" +#: data/resources/ui/pending-changes-bar.ui data/resources/ui/structure-tab.ui +msgid "_Stop Saving" msgstr "" -#: crates/app/src/ui/history_dialog.rs:96 -msgid "Filter" +#: data/resources/ui/preferences-data-browser-group.ui +msgid "Data Browser" msgstr "" -#: crates/app/src/ui/history_dialog.rs:98 -msgid "Filter history" +#: data/resources/ui/preferences-data-browser-group.ui +msgid "Tunes the row paginator and destructive-action confirmation." msgstr "" -#: crates/app/src/ui/history_dialog.rs:104 -msgid "All connections" +#: data/resources/ui/preferences-data-browser-group.ui +msgid "Default page size" msgstr "" -#: crates/app/src/ui/history_dialog.rs:111 -msgid "Any" +#: data/resources/ui/preferences-data-browser-group.ui +msgid "Rows fetched per request when browsing a table" msgstr "" -#: crates/app/src/ui/history_dialog.rs:112 -msgid "Successful" +#: data/resources/ui/preferences-data-browser-group.ui +msgid "Confirm Before Deleting Rows" msgstr "" -#: crates/app/src/ui/history_dialog.rs:114 -msgid "Cancelled" +#: data/resources/ui/preferences-data-browser-group.ui +msgid "Show a confirmation dialog before each destructive action" msgstr "" -#: crates/app/src/ui/history_dialog.rs:120 -msgid "Any time" +#: data/resources/ui/preferences-editor-page.ui +msgid "Editor" msgstr "" -#: crates/app/src/ui/history_dialog.rs:121 -msgid "Last 24 hours" +#: data/resources/ui/preferences-editor-page.ui +msgid "Font" msgstr "" -#: crates/app/src/ui/history_dialog.rs:122 -msgid "Last 7 days" +#: data/resources/ui/preferences-editor-page.ui +msgid "Use the System Monospace Font" msgstr "" -#: crates/app/src/ui/history_dialog.rs:123 -msgid "Last 30 days" +#: data/resources/ui/preferences-editor-page.ui +msgid "Follows the font you picked in the desktop settings" msgstr "" -#: crates/app/src/ui/history_dialog.rs:128 -msgid "Reset" +#: data/resources/ui/preferences-editor-page.ui +msgid "Editor font" msgstr "" -#: crates/app/src/ui/history_dialog.rs:148 -msgid "Connection" +#: data/resources/ui/preferences-editor-page.ui +msgid "Style Scheme" msgstr "" -#: crates/app/src/ui/history_dialog.rs:151 -msgid "Status" +#: data/resources/ui/preferences-editor-page.ui +msgid "Running Queries" msgstr "" -#: crates/app/src/ui/history_dialog.rs:153 -msgid "Time window" +#: data/resources/ui/preferences-editor-page.ui +msgid "Query timeout (seconds)" msgstr "" -#: crates/app/src/ui/history_dialog.rs:176 -msgid "Select" +#: data/resources/ui/preferences-editor-page.ui +msgid "Cancel long-running queries automatically. Set to 0 to disable." msgstr "" -#: crates/app/src/ui/history_dialog.rs:177 -msgid "Toggle multi-select" +#: data/resources/ui/preferences-general-page.ui +msgid "Startup" msgstr "" -#: crates/app/src/ui/history_dialog.rs:187 -msgid "Show storage location" +#: data/resources/ui/preferences-general-page.ui +msgid "Restore Session" msgstr "" -#: crates/app/src/ui/history_dialog.rs:190 -msgid "Clear all history…" +#: data/resources/ui/preferences-general-page.ui +msgid "Reopen the connections that were open when TablePro quit" msgstr "" -#: crates/app/src/ui/history_dialog.rs:195 -msgid "More actions" +#: data/resources/ui/preferences-history-group.ui +msgid "Save Query History" msgstr "" -#: crates/app/src/ui/history_dialog.rs:223 -msgid "Search queries…" +#: data/resources/ui/preferences-history-group.ui +msgid "" +"Keep a searchable record of the SQL you run. Statements that contain " +"passwords or connection secrets are never saved." msgstr "" -#: crates/app/src/ui/history_dialog.rs:238 -msgid "Search" +#: data/resources/ui/preferences-history-group.ui +msgid "Retention (days)" msgstr "" -#: crates/app/src/ui/history_dialog.rs:254 -msgid "Pinned" +#: data/resources/ui/preferences-history-group.ui +msgid "0 keeps history forever; pinned entries are never pruned." msgstr "" -#: crates/app/src/ui/history_dialog.rs:274 -msgid "All queries" +#: data/resources/ui/preferences-history-group.ui +msgid "Clear query history" msgstr "" -#: crates/app/src/ui/history_dialog.rs:305 crates/app/src/ui/history_dialog.rs:633 -msgid "No queries yet" +#: data/resources/ui/preferences-history-group.ui +msgid "Removes every saved query, including pinned ones." msgstr "" -#: crates/app/src/ui/history_dialog.rs:306 crates/app/src/ui/history_dialog.rs:635 -msgid "Run a query in the SQL editor and it will appear here." +#: data/resources/ui/preferences-history-group.ui +msgid "Clear…" msgstr "" -#: crates/app/src/ui/history_dialog.rs:325 -msgid "Export as SQL" +#: data/resources/ui/preferences-history-group.ui +msgid "Storage location" msgstr "" -#: crates/app/src/ui/history_dialog.rs:328 -msgid "Export" +#: data/resources/ui/results-pane.ui +msgid "Show Results List" msgstr "" -#: crates/app/src/ui/history_dialog.rs:487 crates/app/src/ui/preferences.rs:65 -msgid "Clear all query history?" +#: data/resources/ui/results-pane.ui +msgid "Results" msgstr "" -#: crates/app/src/ui/history_dialog.rs:489 crates/app/src/ui/preferences.rs:67 -msgid "This permanently deletes every saved query, including pinned ones." +#: data/resources/ui/results-pane.ui +msgid "No Results Yet" msgstr "" -#: crates/app/src/ui/history_dialog.rs:493 crates/app/src/ui/preferences.rs:71 -msgid "Clear" +#: data/resources/ui/results-pane.ui +msgid "Run a query to see what it returns." msgstr "" -#: crates/app/src/ui/history_dialog.rs:628 -msgid "No matches" +#: data/resources/ui/sql-editor.ui +msgid "Open SQL File…" msgstr "" -#: crates/app/src/ui/history_dialog.rs:630 -msgid "Try a different search term or change the filters." +#: data/resources/ui/sql-editor.ui +msgid "Save Query…" msgstr "" -#: crates/app/src/ui/history_dialog.rs:709 crates/app/src/ui/history_dialog.rs:744 -msgid "Unpin" +#: data/resources/ui/sql-editor.ui +msgid "Format" msgstr "" -#: crates/app/src/ui/history_dialog.rs:711 crates/app/src/ui/history_dialog.rs:746 -msgid "Pin" +#: data/resources/ui/sql-editor.ui +msgid "Find…" msgstr "" -#: crates/app/src/ui/history_dialog.rs:738 -msgid "Open in new tab" +#: data/resources/ui/sql-editor.ui +msgid "Find and Replace…" msgstr "" -#: crates/app/src/ui/history_dialog.rs:739 -msgid "Replace current tab" +#: data/resources/ui/sql-editor.ui +msgid "Editor Options" msgstr "" -#: crates/app/src/ui/history_dialog.rs:750 -msgid "Copy SQL" +#: data/resources/ui/sql-editor.ui +msgid "Stop" msgstr "" -#: crates/app/src/ui/history_dialog.rs:822 -msgid "{n} selected" +#: data/resources/ui/sql-editor.ui +msgid "Stop the Running Query" msgstr "" -#: crates/app/src/ui/history_dialog.rs:856 -msgid "SQL files" +#: data/resources/ui/sql-editor.ui +msgid "Run" msgstr "" -#: crates/app/src/ui/history_dialog.rs:867 -msgid "Export query history" +#: data/resources/ui/sql-editor.ui +msgid "Run the Script" msgstr "" -#: crates/app/src/ui/history_dialog.rs:939 -msgid "{n} row(s)" +#: data/resources/ui/sql-editor.ui +msgid "SQL editor" msgstr "" -#: crates/app/src/ui/history_dialog.rs:949 -msgid "just now" +#: data/resources/ui/sqlite-file-row.ui +msgid "Database file" msgstr "" -#: crates/app/src/ui/history_dialog.rs:952 -msgid "{n} min ago" +#: data/resources/ui/sqlite-file-row.ui +msgid "No file chosen" msgstr "" -#: crates/app/src/ui/history_dialog.rs:955 -msgid "{n} h ago" +#: data/resources/ui/sqlite-file-row.ui data/resources/ui/tls-section.ui +msgid "Choose…" msgstr "" -#: crates/app/src/ui/history_dialog.rs:958 -msgid "{n} d ago" +#: data/resources/ui/sqlite-file-row.ui +msgid "Open an Existing Database File" msgstr "" -#: crates/app/src/ui/insert_dialog.rs:104 -msgid "Insert" +#: data/resources/ui/sqlite-file-row.ui +msgid "New Database…" msgstr "" -#: crates/app/src/ui/insert_dialog.rs:114 -msgid "Insert into {table}" +#: data/resources/ui/sqlite-file-row.ui +msgid "Create a Database File When You Connect" msgstr "" -#: crates/app/src/ui/insert_dialog.rs:167 -msgid "Inserting…" +#: data/resources/ui/ssh-section.ui +msgid "SSH Tunnel" msgstr "" -#: crates/app/src/ui/preferences.rs:18 -msgid "Data browser" +#: data/resources/ui/ssh-section.ui +msgid "Use SSH Tunnel" msgstr "" -#: crates/app/src/ui/preferences.rs:20 -msgid "Tunes the row paginator and destructive-action confirmation." +#: data/resources/ui/ssh-section.ui +msgid "Empty rows are filled from your SSH configuration" msgstr "" -#: crates/app/src/ui/preferences.rs:27 -msgid "Default page size" +#: data/resources/ui/ssh-section.ui +msgid "Host or alias" msgstr "" -#: crates/app/src/ui/preferences.rs:28 -msgid "Rows fetched per request when browsing a table" +#: data/resources/ui/ssh-section.ui +msgid "Jump hosts" msgstr "" -#: crates/app/src/ui/preferences.rs:32 -msgid "Confirm before deleting rows" +#: data/resources/ui/ssh-section.ui +msgid "Private key" msgstr "" -#: crates/app/src/ui/preferences.rs:33 -msgid "Show a confirmation dialog before each destructive action" +#: data/resources/ui/ssh-section.ui +msgid "Choose a Private Key File" msgstr "" -#: crates/app/src/ui/preferences.rs:42 -msgid "Query history" +#: data/resources/ui/ssh-section.ui +msgid "Choose Private Key" msgstr "" -#: crates/app/src/ui/preferences.rs:43 -msgid "Persistent record of every SQL query you run." +#: data/resources/ui/ssh-section.ui +msgid "Passphrase" msgstr "" -#: crates/app/src/ui/preferences.rs:47 -msgid "Retention (days)" +#: data/resources/ui/structure-tab.ui +msgid "Couldn't reload structure. Showing the last loaded version." msgstr "" -#: crates/app/src/ui/preferences.rs:48 -msgid "0 keeps history forever; pinned entries are never pruned." +#: data/resources/ui/structure-tab.ui +msgid "Loading structure…" msgstr "" -#: crates/app/src/ui/preferences.rs:53 -msgid "Clear now" +#: data/resources/ui/structure-tab.ui +msgid "Reading columns, indexes, and foreign keys…" msgstr "" -#: crates/app/src/ui/preferences.rs:58 -msgid "Clear history now" +#: data/resources/ui/structure-tab.ui +msgid "Add a name and at least one column to save." msgstr "" -#: crates/app/src/ui/preferences.rs:59 -msgid "Removes every saved query, including pinned ones." +#: data/resources/ui/structure-tab.ui +msgid "Columns" msgstr "" -#: crates/app/src/ui/preferences.rs:90 -msgid "Show in Files" +#: data/resources/ui/structure-tab.ui +msgid "Add Column" msgstr "" -#: crates/app/src/ui/preferences.rs:98 -msgid "Storage location" +#: data/resources/ui/structure-tab.ui +msgid "Indexes" msgstr "" -#: crates/app/src/ui/preferences.rs:120 -msgid "Editor" +#: data/resources/ui/structure-tab.ui +msgid "Add Index…" msgstr "" -#: crates/app/src/ui/preferences.rs:127 -msgid "Editor font size" +#: data/resources/ui/structure-tab.ui +msgid "Foreign Keys" msgstr "" -#: crates/app/src/ui/ssh_section.rs:42 -msgid "Use SSH tunnel" +#: data/resources/ui/structure-tab.ui +msgid "Add Foreign Key…" msgstr "" -#: crates/app/src/ui/ssh_section.rs:43 -msgid "Reach the database through a bastion host" +#: data/resources/ui/structure-tab.ui +msgid "SQL Preview" msgstr "" -#: crates/app/src/ui/ssh_section.rs:48 -msgid "SSH tunnel" +#: data/resources/ui/structure-tab.ui +msgid "Couldn't Load Structure" msgstr "" -#: crates/app/src/ui/ssh_section.rs:51 -msgid "SSH host" +#: data/resources/ui/structure-tab.ui +msgid "Drop Table…" msgstr "" -#: crates/app/src/ui/ssh_section.rs:53 -msgid "SSH port" +#: data/resources/ui/tls-section.ui +msgid "Security" msgstr "" -#: crates/app/src/ui/ssh_section.rs:56 -msgid "SSH user" +#: data/resources/ui/tls-section.ui +msgid "Encryption" msgstr "" -#: crates/app/src/ui/ssh_section.rs:59 -msgid "Private key" +#: data/resources/ui/tls-section.ui +msgid "Certificates" +msgstr "" + +#: data/resources/ui/tls-section.ui +msgid "For a server whose certificate this computer does not trust" +msgstr "" + +#: data/resources/ui/tls-section.ui +msgid "CA certificate" +msgstr "" + +#: data/resources/ui/tls-section.ui +msgid "Clear CA Certificate" +msgstr "" + +#: data/resources/ui/tls-section.ui +msgid "Choose CA Certificate" +msgstr "" + +#: data/resources/ui/tls-section.ui +msgid "Client certificate" +msgstr "" + +#: data/resources/ui/tls-section.ui +msgid "Clear Client Certificate" +msgstr "" + +#: data/resources/ui/tls-section.ui +msgid "Choose Client Certificate" msgstr "" -#: crates/app/src/ui/ssh_section.rs:62 -msgid "SSH auth" +#: data/resources/ui/tls-section.ui +msgid "Client key" msgstr "" -#: crates/app/src/ui/ssh_section.rs:68 -msgid "SSH password" +#: data/resources/ui/tls-section.ui +msgid "Clear Client Key" +msgstr "" + +#: data/resources/ui/tls-section.ui +msgid "Choose Client Key" +msgstr "" + +#: data/resources/ui/tls-section.ui +msgid "TLS server name" +msgstr "" + +#: data/resources/ui/welcome-view.ui +msgid "Saved Connections" +msgstr "" + +#: data/resources/ui/welcome-view.ui +msgid "No Connections" +msgstr "" + +#: data/resources/ui/welcome-view.ui +msgid "Add a database connection to get started." +msgstr "" + +#: data/resources/ui/welcome-view.ui +msgid "Add Connection" +msgstr "" + +#: data/resources/ui/workspace.ui +msgid "Filter tables…" msgstr "" -#: crates/app/src/ui/ssh_section.rs:71 -msgid "Private key path" +#: data/resources/ui/workspace.ui +msgid "Filter tables" msgstr "" -#: crates/app/src/ui/ssh_section.rs:76 -msgid "Key passphrase" +#: data/resources/ui/workspace.ui +msgid "Select a Table" msgstr "" -#: crates/app/src/ui/ssh_section.rs:209 -msgid "Browse for private key" +#: data/resources/ui/workspace.ui +msgid "Choose a table in the sidebar or open the SQL editor." msgstr "" -#: crates/app/src/ui/ssh_section.rs:216 -msgid "Select SSH private key" +#: data/resources/ui/workspace.ui +msgid "Open SQL Editor" msgstr "" diff --git a/scripts/check-banned-words.sh b/scripts/check-banned-words.sh index 711c401981..55863cb4ae 100755 --- a/scripts/check-banned-words.sh +++ b/scripts/check-banned-words.sh @@ -10,14 +10,15 @@ # A term matches case-insensitively at the start of a word, so a capitalized or suffixed form is a # hit and a word that only ends in a term is not. A term that starts with punctuation, the em dash, # matches anywhere. Each hit prints as file:line: term. Exits 1 on a hit, 0 when clean and 2 on a -# usage error. A diff skips linux/data/licenses/, which reproduces third-party license text verbatim. +# usage error. A diff skips linux/data/licenses/ and linux/build-aux/debian/copyright, which reproduce +# third-party license text verbatim. set -uo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)" LIST="$SCRIPT_DIR/banned-words.txt" LIST_PATH_IN_REPO="scripts/banned-words.txt" -VERBATIM_DIR_IN_REPO="linux/data/licenses/" +VERBATIM_IN_REPO="linux/data/licenses/ linux/build-aux/debian/copyright" usage() { sed -n "5,8s/^# //p" "${BASH_SOURCE[0]}" @@ -71,12 +72,23 @@ function check(text, location, lowered, i) { ' DIFF_READER=' +BEGIN { verbatim_count = split(verbatim, verbatim_paths, " ") } + +function is_verbatim(path, i) { + for (i = 1; i <= verbatim_count; i++) { + if (index(path, verbatim_paths[i]) == 1) { + return 1 + } + } + return 0 +} + /^diff --git / { in_header = 1; file = ""; next } in_header && /^\+\+\+ / { file = substr($0, 5) sub(/^b\//, "", file) sub(/\t$/, "", file) - if (file == "/dev/null" || file == skip || index(file, verbatim) == 1) { + if (file == "/dev/null" || file == skip || is_verbatim(file)) { file = "" } next @@ -111,7 +123,7 @@ check_diff() { } git -c core.quotePath=false diff -U0 --no-color --no-ext-diff --diff-filter=ACMR \ --src-prefix=a/ --dst-prefix=b/ "$@" | - LC_ALL=C awk -v skip="$LIST_PATH_IN_REPO" -v verbatim="$VERBATIM_DIR_IN_REPO" "$MATCHER$DIFF_READER" + LC_ALL=C awk -v skip="$LIST_PATH_IN_REPO" -v verbatim="$VERBATIM_IN_REPO" "$MATCHER$DIFF_READER" } check_files() {