├── .editorconfig ├── .github └── workflows │ ├── ci.yml │ └── publish.yml ├── .gitignore ├── .prettierrc ├── .vscode └── extensions.json ├── CHANGELOG.md ├── DESIGN.md ├── LICENSE ├── README.md ├── TODO.md ├── index.html ├── package-lock.json ├── package.json ├── public └── icon.ico ├── src-tauri ├── .gitignore ├── Cargo.lock ├── Cargo.toml ├── build.rs ├── capabilities │ └── main.json ├── gen │ └── schemas │ │ ├── acl-manifests.json │ │ ├── capabilities.json │ │ ├── desktop-schema.json │ │ ├── linux-schema.json │ │ ├── macOS-schema.json │ │ ├── plugin-manifests.json │ │ └── windows-schema.json ├── icons │ ├── 128x128.png │ ├── 128x128@2x.png │ ├── 24x24.png │ ├── 32x32.png │ ├── Square107x107Logo.png │ ├── Square142x142Logo.png │ ├── Square150x150Logo.png │ ├── Square284x284Logo.png │ ├── Square30x30Logo.png │ ├── Square310x310Logo.png │ ├── Square44x44Logo.png │ ├── Square71x71Logo.png │ ├── Square89x89Logo.png │ ├── StoreLogo.png │ ├── icon.icns │ ├── icon.ico │ └── icon.png ├── resources │ ├── hallucination.png │ ├── screenshot.png │ └── test-repo.zip ├── src │ ├── callbacks.rs │ ├── config │ │ ├── gg.toml │ │ └── mod.rs │ ├── handler.rs │ ├── main.rs │ ├── menu.rs │ ├── messages │ │ ├── mod.rs │ │ ├── mutations.rs │ │ └── queries.rs │ ├── windows.rs │ └── worker │ │ ├── gui_util.rs │ │ ├── mod.rs │ │ ├── mutations.rs │ │ ├── queries.rs │ │ ├── session.rs │ │ └── tests │ │ ├── mod.rs │ │ ├── mutations.rs │ │ ├── queries.rs │ │ └── session.rs └── tauri.conf.json ├── src ├── App.svelte ├── GraphLine.svelte ├── GraphLog.svelte ├── GraphNode.svelte ├── LogPane.svelte ├── RevisionPane.svelte ├── controls │ ├── ActionWidget.svelte │ ├── AuthorSpan.svelte │ ├── BoundQuery.svelte │ ├── BranchSpan.svelte │ ├── CheckWidget.svelte │ ├── Chip.svelte │ ├── Icon.svelte │ ├── IdSpan.svelte │ ├── ListWidget.svelte │ └── SelectWidget.svelte ├── global.css ├── ipc.ts ├── main.ts ├── messages │ ├── AbandonRevisions.ts │ ├── BackoutRevisions.ts │ ├── ChangeHunk.ts │ ├── ChangeId.ts │ ├── ChangeKind.ts │ ├── CheckoutRevision.ts │ ├── CommitId.ts │ ├── CopyChanges.ts │ ├── CreateRef.ts │ ├── CreateRevision.ts │ ├── DeleteRef.ts │ ├── DescribeRevision.ts │ ├── DisplayPath.ts │ ├── DuplicateRevisions.ts │ ├── FileRange.ts │ ├── GitFetch.ts │ ├── GitPush.ts │ ├── HunkLocation.ts │ ├── InputField.ts │ ├── InputRequest.ts │ ├── InputResponse.ts │ ├── InsertRevision.ts │ ├── LogCoordinates.ts │ ├── LogLine.ts │ ├── LogPage.ts │ ├── LogRow.ts │ ├── MoveChanges.ts │ ├── MoveRef.ts │ ├── MoveRevision.ts │ ├── MoveSource.ts │ ├── MultilineString.ts │ ├── MutationResult.ts │ ├── Operand.ts │ ├── RenameBranch.ts │ ├── RepoConfig.ts │ ├── RepoStatus.ts │ ├── RevAuthor.ts │ ├── RevChange.ts │ ├── RevConflict.ts │ ├── RevHeader.ts │ ├── RevId.ts │ ├── RevResult.ts │ ├── StoreRef.ts │ ├── TrackBranch.ts │ ├── TreePath.ts │ ├── UndoOperation.ts │ └── UntrackBranch.ts ├── mutators │ ├── BinaryMutator.ts │ ├── ChangeMutator.ts │ ├── RefMutator.ts │ └── RevisionMutator.ts ├── objects │ ├── BranchObject.svelte │ ├── ChangeObject.svelte │ ├── Object.svelte │ ├── RevisionObject.svelte │ ├── TagObject.svelte │ └── Zone.svelte ├── shell │ ├── ErrorDialog.svelte │ ├── InputDialog.svelte │ ├── ModalDialog.svelte │ ├── ModalOverlay.svelte │ ├── Pane.svelte │ ├── Settings.ts │ └── StatusBar.svelte ├── stores.ts └── vite-env.d.ts ├── svelte.config.js ├── tsconfig.json ├── tsconfig.node.json └── vite.config.ts /.editorconfig: -------------------------------------------------------------------------------- 1 | [*] 2 | end_of_line = lf -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: 'ci' 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | jobs: 9 | svelte-check: 10 | runs-on: "ubuntu-22.04" 11 | steps: 12 | - uses: actions/checkout@v4 13 | 14 | - uses: actions/setup-node@v4 15 | with: 16 | node-version: '20' 17 | cache: 'npm' 18 | 19 | - name: install packages 20 | run: npm install --package-lock=false 21 | 22 | - name: run check 23 | run: npm run check 24 | 25 | cargo-test: 26 | runs-on: "ubuntu-22.04" 27 | env: 28 | RUST_BACKTRACE: "1" 29 | steps: 30 | - uses: actions/checkout@v4 31 | 32 | - uses: awalsh128/cache-apt-pkgs-action@v1 33 | with: 34 | packages: libgtk-3-dev libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf 35 | version: 1.0 36 | 37 | - uses: dtolnay/rust-toolchain@stable 38 | 39 | - uses: Swatinem/rust-cache@v2 40 | with: 41 | workspaces: "src-tauri" 42 | 43 | - name: run tests 44 | run: cd src-tauri && cargo test 45 | 46 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: 'publish' 2 | 3 | on: 4 | push: 5 | branches: 6 | - release 7 | 8 | jobs: 9 | publish-tauri: 10 | permissions: 11 | contents: write 12 | strategy: 13 | fail-fast: false 14 | matrix: 15 | platform: [macos-latest, ubuntu-22.04, windows-latest] 16 | 17 | runs-on: ${{ matrix.platform }} 18 | steps: 19 | - uses: actions/checkout@v4 20 | 21 | - name: setup node 22 | uses: actions/setup-node@v4 23 | with: 24 | node-version: 20 25 | 26 | - name: install Rust stable 27 | uses: dtolnay/rust-toolchain@stable 28 | 29 | - name: install dependencies (ubuntu) 30 | if: matrix.platform == 'ubuntu-22.04' 31 | run: | 32 | sudo apt-get update 33 | sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf 34 | 35 | - name: install dependencies (macos) 36 | if: matrix.platform == 'macos-latest' 37 | run: | 38 | rustup target add aarch64-apple-darwin 39 | rustup target add x86_64-apple-darwin 40 | 41 | - name: install signtool (windows) 42 | if: matrix.platform == 'windows-latest' 43 | run: cargo install trusted-signing-cli@0.3.0 44 | 45 | - name: install frontend dependencies 46 | run: npm install --package-lock=false 47 | 48 | - uses: tauri-apps/tauri-action@v0 49 | env: 50 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 51 | APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} 52 | APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} 53 | APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} 54 | APPLE_ID: ${{ secrets.APPLE_ID }} 55 | APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} 56 | APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }} 57 | AZURE_TENANT_ID: ${{ matrix.platform == 'windows-latest' && secrets.AZURE_TENANT_ID }} 58 | AZURE_CLIENT_ID: ${{ matrix.platform == 'windows-latest' && secrets.AZURE_CLIENT_ID }} 59 | AZURE_CLIENT_SECRET: ${{ matrix.platform == 'windows-latest' && secrets.AZURE_CLIENT_SECRET }} 60 | with: 61 | args: ${{ matrix.platform == 'macos-latest' && '--target universal-apple-darwin' || ''}} 62 | tagName: v__VERSION__ 63 | releaseName: 'GG __VERSION__' 64 | releaseBody: 'See the assets to download this version and install.' 65 | releaseDraft: true 66 | prerelease: true 67 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "tabWidth": 4, 3 | "bracketSameLine": true, 4 | "svelteBracketNewLine": false, 5 | "printWidth": 120 6 | } -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "rust-lang.rust-analyzer", 4 | "svelte.svelte-vscode", 5 | "tauri-apps.tauri-vscode", 6 | ] 7 | } -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # GG Changelog 2 | 3 | ## [0.27.0](releases/tag/v0.27.0) 4 | This version is based on Jujutsu 0.27. 5 | 6 | ### Added 7 | - Cmd/Ctrl-enter shortcut to save revision descriptions. 8 | 9 | ### Fixed 10 | - Suppress MacOS auto-capitalisation of branch/remote names. 11 | 12 | ## [0.23.0](releases/tag/v0.23.0) 13 | This version is based on Jujutsu 0.23 and the recently-released Tauri 2.0. 14 | 15 | ### Changed 16 | - Branches have been renamed to bookmarks. The setting `gg.ui.mark-unpushed-branches` has changed to `mark-unpushed-bookmarks`, but the old one will still work as well. 17 | 18 | ## [0.20.0](releases/tag/v0.20.0) 19 | This version is based on Jujutsu 0.20. 20 | 21 | ### Fixed 22 | - `gg.queries.log-page-size` setting was not being respected. 23 | - Removed <CR> character which rendered as a circle in the author display on some Linux systems. 24 | - Improved button/control font display on Linux. 25 | - Fixed a panic attempting to display delete/delete conflicts in the right pane. 26 | 27 | ## [0.18.0](releases/tag/v0.18.0) 28 | This version is based on Jujutsu 0.18. 29 | 30 | ## [0.17.0](releases/tag/v0.17.0) 31 | This version is compatible with Jujutsu 0.17. 32 | 33 | ## [0.16.0](releases/tag/v0.16.0) 34 | This version is compatible with Jujutsu 0.16. 35 | 36 | ### Added 37 | - File diffs displayed in the revision pane; also, the file list is now keyboard-selectable. 38 | - Backout command, which creates the changes necessary to undo a revision in the working copy. 39 | - Consistent author/timestamp formatting, with tooltips for more detail. 40 | 41 | ### Fixed 42 | - Right-pane scrollbar wasn't responding to clicks. 43 | - Various design improvements. 44 | 45 | ## [0.15.3](releases/tag/v0.15.3) 46 | 47 | ### Added 48 | - Relatively comprehensive branch management - create, delete, rename, forget, push and fetch. 49 | - Display Git remotes in the status bar, with commands to push or fetch all their branches. 50 | - Display Git tags (readonly; they aren't really a Jujutsu concept). 51 | - Display edges to commits that aren't in the queried revset, by drawing a line to nowhere. 52 | - Detect changes made by other Jujutsu clients and merge the operation log automatically. 53 | - Improved keyboard support and focus behaviour. 54 | - Window title includes the workspace path (when one is open). 55 | - On Windows, the taskbar icon has a jump list with links to recent workspaces. 56 | - New config options: 57 | * `gg.queries.log-page-size` for tuning performance on large repositories. 58 | * `gg.ui.mark-unpushed-branches` to control whether local-only branches are called out. 59 | 60 | ### Fixed 61 | - GG now understands divergent changes, and can act on commits that have a shared change id. 62 | Note that if you do anything to such commits other than abandoning them, you're likely to 63 | create even more divergent commits! 64 | - The AppImage build wasn't picking up the working directory correctly. This is fixed, and 65 | you can also specify a workspace to open on the commandline as an alternative. 66 | - Watchman support (core.fsmonitor) was not enabled. 67 | - Various design improvements. 68 | 69 | ## [0.15.2](releases/tag/v0.15.2) 70 | 71 | ### Fixed 72 | - Right click -> Abandon revision... again. 73 | 74 | ## [0.15.1](releases/tag/v0.15.1) 75 | 76 | ### Fixed 77 | - Several buttons had stopped working due to IPC changes: 78 | * The Squash/Restore buttons on the right pane. 79 | * Right click -> Abandon revision. 80 | * Right click -> Squash into parent. 81 | * Right click -> Restore from parent. 82 | 83 | ## [0.15.0](releases/tag/v0.15.0) 84 | Initial experimental release. This version is compatible with Jujutsu 0.15. 85 | 86 | ### Added 87 | - Open, reload and snapshot repositories. 88 | - Graph-based log displaying summaries, author and status. 89 | - Log queries in Jujutsu's [revset language](https://martinvonz.github.io/jj/latest/revsets/). 90 | - Revision view with file-level change details and editing commands. 91 | - Drag and drop to move, remove and recombine revisions/files/branches. 92 | - Context menus for common operations. 93 | - Transactional operations with single-level undo. 94 | - Light and dark themes. 95 | - Codesigned binaries for MacOS and Windows. 96 | - Completely untested binaries for Linux. 97 | -------------------------------------------------------------------------------- /DESIGN.md: -------------------------------------------------------------------------------- 1 | Design Principles 2 | ----------------- 3 | The primary metaphor is *direct manipulation*. GG aims to present a view of the repository's 4 | conceptual contents - revisions, changes to files, synced refs and maybe more - which can be 5 | modified, using right-click and drag-drop, to 'edit' the repo as a whole. 6 | 7 | Jujutsu CLI commands sometimes have a lot of options (`rebase`) or are partially redundant 8 | for convenience (`move`, `squash`). This is good for scripting, but some use cases demand 9 | interactivity - reordering multiple commits, for example. Hopefully, `gg` can complement `jj` 10 | by providing decomposed means to achieve some of the same tasks, with immediate visual feedback. 11 | 12 | The UI uses a couple of key conventions for discoverability: 13 | - An *actionable object* is represented by an icon followed by a line of text. These are 14 | drag sources, drop targets and context menu hosts. 15 | - Chrome and labels are greyscale; anything interactable uses specific colours to indicate 16 | categories of widget or object states. 17 | 18 | Architectural Choices 19 | --------------------- 20 | In order to create a quality desktop app, a pure webapp is out of scope. However, significant 21 | portions of the code could be reused in a client server app, and we won't introduce *needless* 22 | coupling. `mod worker` and `ipc.ts` are key abstraction boundaries which keep Tauri-specific 23 | code in its own glue layers. 24 | 25 | Each window has a worker thread which owns `Session` data. A session can be in multiple states, 26 | including: 27 | - `WorkerSession` - Opening/reopening a workspace 28 | - `WorkspaceSession` - Workspace open, able to execute mutations 29 | - `QuerySession` - Paged query in progress, able to fetch efficiently 30 | 31 | IPC is divided into four categories, which is probably one too many: 32 | - Client->Server **triggers** cause the backend to perform native UI actions. 33 | - Client->Server **queries** request information from the session without affecting state. 34 | - Client->Server **mutations** modify session state in a structured fashion. 35 | - Server->Client and Client->Client **events** are broadcast to push information to the UI. 36 | 37 | Drag & drop capabilities are implemented by `objects/Object.svelte`, a draggable item, and 38 | `objects/Zone.svelte`, a droppable region. Policy is centralised in `mutators/BinaryMutator.ts`. 39 | 40 | Branch Objects 41 | -------------- 42 | The representation of branches, in JJ and GG, is a bit complicated; there are multiple state axes. 43 | A repository can have zero or more **remotes**. 44 | A **local branch** can track zero or more of the remotes. (Technically, remote *branches*.) 45 | A **remote branch** can be any of *tracked* (a flag on the ref), *synced* (if it points to the same 46 | commit as a local branch of the same name), and *absent* (if there's a local branch with *no* ref, 47 | in which case it will be deleted by the CLI on push. 48 | 49 | GG attempts to simplify the display of branches by combining refs in the UI. Taking advantage of 50 | Jujutsu's model, which guarantees that a branch name identifies the same branch across remotes, a 51 | local branch and the tracked remote branches with which it is currently synced are be combined into 52 | a single UI object. Remote branches are displayed separately if they're unsynced, untracked or absent. 53 | 54 | Consequently, the commands available for a branch as displayed in the UI have polymorphic effect: 55 | 1) "Track": Applies to any remote branch that is not already tracked. 56 | 2) "Untrack": 57 | - For a *tracking local/combined branch*, untracks all remotes. 58 | - For an *unsynced remote branch*, untracks one remote. 59 | 3) "Push": Applies to local branches tracking any remotes. 60 | 4) "Push to remote...": Applies to local branches when any remotes exist. 61 | 5) "Fetch": Downloads for a specific branch only. 62 | - For a *tracking local/combined branch*, fetches from all remotes. 63 | - For a *remote branch*, fetches from its remote. 64 | 6) "Fetch from remote...": Applies to local branches when any trackable remotes exist. 65 | 7) "Rename...": Renames a local branch, without affecting remote branches. 66 | - For a *nontracking local branch*, just renames. 67 | - For a *tracking/combined branch*, untracks first. 68 | 8) "Delete": Applies to a user-visible object, not combined objects. 69 | - For a *local/combined branch*, deletes the local ref. 70 | - For a *remote branch*, forgets the remote ref (which also clears pending deletes.) 71 | 72 | Multiple-dispatch commands: 73 | 1) "Move": Drop local branch onto revision. Sets the ref to a commit, potentially de- or re-syncing it. 74 | 2) "Track": Drop remote branch onto local of the same name. 75 | 3) "Delete": Drag almost any branch out, with polymorphic effect (see above). 76 | 77 | Displaying the branch state is a bit fuzzy. The idea is to convey the most useful bits of information at 78 | a glance, and leave the rest to tooltips or context menus. Most branches display in the 79 | "modify" state; "add" and "remove" are used only for *unsynced* branches, with unsynced locals being "add" 80 | and unsynced or absent remotes "remove". 81 | 82 | This is vaguely analogous to the more straightforward use of modify/add/remove for file changes, adapted to 83 | the fact that many branch states are "normal"; the mental shorthand is that add/green means that pushing will 84 | cause a remote to set this ref, and remove/red means the remote will no longer contain this ref (at this pointer). 85 | 86 | Additionally, a dashed border (like the dashed lines used for elided commits) has a special meaning, also 87 | fuzzy: this ref is "disconnected", either local-only or remote-only. Disconnected local branches are ones 88 | which have no remotes (in a repo that does have remotes); disconnected remote branches are ones which will 89 | be deleted on push (with an absent local ref). -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ![icon](src-tauri/icons/24x24.png) GG - Gui for JJ 2 | 3 | ![screenshot](src-tauri/resources/screenshot.png) 4 | 5 | GG is a GUI for the version control system [Jujutsu](https://github.com/martinvonz/jj). It takes advantage of Jujutsu's composable primitives to present an interactive view of your repository. Just imagine: what if you were always in the middle of an interactive rebase, but this was actually good? 6 | 7 | ## Installation 8 | GG is a desktop application with a keyboard & mouse interface, written in [Tauri](https://tauri.app/). Binaries are available for several platforms on the [releases page](https://github.com/gulbanana/gg/releases). Use the `.dmg` or `.app.tar.gz` on MacOS, and the `.msi` or `.exe` on Windows. 9 | 10 | To compile from source: 11 | 1. Install the system dependencies (on Debian-likes, `apt install libpango1.0-dev libatk1.0-dev libgdk-pixbuf2.0-dev libgtk-3-dev libjavascriptcoregtk-4.1-dev libwebkit2gtk-4.1-dev`). 12 | 2. Install the frontend dependencies: `npm install`. 13 | 3. Build the application: `npm run tauri build`. 14 | 15 | ### Setup 16 | Put `gg` on your path and run it from a Jujutsu workspace, pass the workspace directory as an argument or launch it separately and use the Repository->Open menu item. Tips: 17 | - On MacOS, try adding `/Applications/gg.app/Contents/MacOS/` to your PATH environment variable. On Windows, add `C:\Program Files\gg\`. 18 | - Using `gg &` on MacOS/Linux or `start gg` on Windows will run in the background without blocking your shell. 19 | - `gg --help` will display some possible command-line arguments. 20 | 21 | ### Configuration 22 | GG uses `jj config`; `revset-aliases.immutable_heads()` is particularly important, as it determines how much history you can edit. GG has some additional settings of its own, with defaults and documentation [here](src-tauri/src/config/gg.toml). 23 | 24 | ## Features 25 | GG doesn't require [JJ](https://martinvonz.github.io/jj/latest/install-and-setup/) to run, but you'll need it for tasks GG doesn't cover. What it *can* do: 26 | - Use the left pane to query and browse the log. Click to select revisions, double-click to edit (if mutable) or create a new child (if immutable). 27 | - Use the right pane to inspect and edit revisions - set descriptions, issue commands, view their parents and changes. 28 | - Drag revisions around to rebase them; move them into or out of a revision's parents to add merges and move entire subtrees. Or just abandon them entirely. 29 | - Drag files around to squash them into new revisions or throw away changes (restoring from parents). 30 | - Drag bookmarks around to set or delete them. 31 | - Right click on any of the above for more actions. 32 | - Push and fetch git changes using the bottom bar. 33 | - Undo anything with ⟲ in the bottom right corner. 34 | 35 | More detail is available in [the changelog](CHANGELOG.md). 36 | 37 | ### Future Features 38 | There's no roadmap as such, but items on [the to-do list](TODO.md) may or may not be implemented in future. Just about everything is subject to change for now, including the name. 39 | 40 | ### Known Issues 41 | GG is in early development and will have bugs. In theory it can't corrupt a repository thanks to the operation log, but it never hurts to make backups. 42 | 43 | If your repo is "too large" some features will be disabled for performance. See [the default config](src-tauri/src/config/gg.toml) for details. 44 | 45 | ## Development 46 | Recommended IDE setup: [VS Code](https://code.visualstudio.com/) + [rust-analyzer](https://marketplace.visualstudio.com/items?itemName=rust-lang.rust-analyzer) + [Svelte](https://marketplace.visualstudio.com/items?itemName=svelte.svelte-vscode) + [Tauri](https://marketplace.visualstudio.com/items?itemName=tauri-apps.tauri-vscode). 47 | 48 | Some useful commands: 49 | * `npm run test` - execute unit tests. 50 | * `npm run gen` - update the IPC message types in src/messages from src-tauri/messages.rs. 51 | * `npm run tauri dev` - launch a debug build with automatic reloading. 52 | * `npm run tauri build -- --target universal-apple-darwin` - create a fat binary for MacOS. 53 | * `npm run tauri dev -- -- -- --debug` - run locally with --debug. Yes, all three `--` are necessary. 54 | 55 | [DESIGN.md](DESIGN.md) has some basic information about how GG works. 56 | -------------------------------------------------------------------------------- /TODO.md: -------------------------------------------------------------------------------- 1 | Known Issues 2 | ------------ 3 | * "Open..." menu command sometimes opens multiple dialogues. 4 | * Mutations can fail due to ambiguity when there are other writers; this should update the UI. Maybe a special From impl for resolve_change. 5 | * Windows codesigning will break in August 2024; the CI needs a new approach. 6 | * Visual issues on Xubuntu 22.04: 7 | - menu leaves a white background when there's no repo loaded - no xdamage maybe? 8 | - there's a weird bullet (looks like an uncoloured rev icon) in the sig area 9 | - fonts are kind of awful 10 | 11 | Planned Features 12 | ---------------- 13 | > The best laid schemes o' mice an' men / Gang aft a-gley. 14 | 15 | * Hunk selection/operations. Maybe a change/hunk menu. 16 | * Alternate drag modes for copy/duplicate, perhaps rebase-all-descendants. 17 | * Optimise revdetail loads - we already have the header available. 18 | * Multiselection, viewing and operating on revsets or changesets. 19 | * Undo/redo stack, possibly with a menu of recent ops. 20 | * Some way to access the resolve (mergetool) workflow. Difftools too, although this is less useful. 21 | * More stuff in the log - timestamps, commit ids... this might have to be configurable. 22 | * Progress bar, particularly for git and snapshot operations. 23 | * Structured op descriptions - extracted ids etc, maybe via tags. This would benefit from being in JJ core. 24 | * "Onboarding" features - init/clone/colocate. 25 | * Relative timestamps should update on refocus. 26 | 27 | UI Expansion 28 | ------------ 29 | With some dynamic way to show extra panes, replace content, open new windows &c, more useful features would be possible: 30 | 31 | * View the repo at past ops. 32 | * View a revision at past evolutions (possibly this could be folded into the log). 33 | * Config UI, both for core stuff and gg's own settings. 34 | * Revision pinning for split/comparison workflows. 35 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | GG - Gui for JJ 9 | 10 | 11 | 12 |
13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "gg", 3 | "private": true, 4 | "version": "0.27.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "vite build", 9 | "preview": "vite preview", 10 | "check": "svelte-check --tsconfig ./tsconfig.json", 11 | "tauri": "tauri", 12 | "gen": "cd src-tauri && cargo test -F ts-rs", 13 | "test": "cd src-tauri && cargo test" 14 | }, 15 | "dependencies": { 16 | "@catppuccin/palette": "^1.0.3", 17 | "@tauri-apps/api": "^2.0.0-beta.0", 18 | "@tauri-apps/plugin-shell": "^2.0.0-beta.0", 19 | "feather-icons": "^4.29.1", 20 | "modern-normalize": "^2.0.0" 21 | }, 22 | "devDependencies": { 23 | "@sveltejs/vite-plugin-svelte": "^3.0.2", 24 | "@tauri-apps/cli": "^2.0.0-beta.0", 25 | "@tsconfig/svelte": "^5.0.2", 26 | "svelte": "^4.2.10", 27 | "svelte-check": "^3.6.3", 28 | "tslib": "^2.6.0", 29 | "typescript": "^5.0.2", 30 | "vite": "^5.0.0" 31 | } 32 | } -------------------------------------------------------------------------------- /public/icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gulbanana/gg/46ac46e3e8e3e7a3fdc3c2c31e63d91975072fb1/public/icon.ico -------------------------------------------------------------------------------- /src-tauri/.gitignore: -------------------------------------------------------------------------------- 1 | # Generated by Cargo 2 | # will have compiled files and executables 3 | /target/ 4 | 5 | -------------------------------------------------------------------------------- /src-tauri/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "gg" 3 | version = "0.27.0" 4 | description = "GG - Gui for JJ" 5 | authors = ["Thomas Castiglione"] 6 | edition = "2021" 7 | 8 | [build-dependencies] 9 | tauri-build = { version = "2.0.0", features = [] } 10 | 11 | [dev-dependencies] 12 | tempfile = "3.10.1" 13 | zip = "0.6" 14 | assert_matches = "1.5" 15 | 16 | [dependencies] 17 | tauri = { version = "2.0.0", features = [] } 18 | tauri-codegen = "2.0.0" 19 | tauri-macros = "2.0.0" 20 | tauri-plugin = "2.0.0" 21 | tauri-runtime = "2.0.0" 22 | tauri-runtime-wry = "2.0.0" 23 | tauri-utils = "2.0.0" 24 | tauri-plugin-dialog = "2.0.0" 25 | tauri-plugin-shell = "2.0.0" 26 | tauri-plugin-window-state = "2.0.0" 27 | tauri-plugin-log = "2.0.0" 28 | 29 | jj-lib = { version = "0.27.0", features = ["vendored-openssl", "watchman"] } 30 | jj-cli = { version = "0.27.0", default-features = false, features = [ 31 | "git", 32 | "vendored-openssl", 33 | ] } 34 | 35 | # deps shared with JJ, which we try to keep on the same version 36 | anyhow = "1.0.93" 37 | clap = { version = "4.5.20", features = [ 38 | "derive", 39 | "deprecated", 40 | "wrap_help", 41 | "string", 42 | ] } 43 | config = { version = "0.13.4", default-features = false, features = ["toml"] } 44 | dirs = "5.0.1" 45 | dunce = "1.0.5" 46 | itertools = "0.13.0" 47 | indexmap = "2.6.0" 48 | gix = { version = "0.70.0", default-features = false, features = [ 49 | "index", 50 | "max-performance-safe", 51 | "blob-diff", 52 | ] } 53 | pollster = "0.3.0" 54 | serde = { version = "1.0", features = ["derive"] } 55 | serde_json = "1.0.132" 56 | toml_edit = { version = "0.22.23", features = ["serde"] } 57 | thiserror = "1.0.68" 58 | 59 | # deps implicitly used by JJ, which need to be pinned to a version to fix errors 60 | gix-object = "0.47.0" 61 | 62 | # deps used by JJ but with different features 63 | chrono = { version = "0.4.38", features = ["serde"] } 64 | git2 = { version = "0.19.0", features = ["vendored-libgit2"] } 65 | 66 | # extra deps not used by JJ 67 | log = "0.4" 68 | futures-util = "0.3.30" 69 | ts-rs = { version = "7.1.1", features = ["chrono-impl"], optional = true } 70 | 71 | [target."cfg(windows)".dependencies] 72 | windows = { version = "0.54.0", features = [ 73 | "Win32_Foundation", 74 | "Win32_System_Com", 75 | "Win32_System_Com_StructuredStorage", 76 | "Win32_System_Console", 77 | "Win32_UI_Shell", 78 | "Win32_UI_Shell_Common", 79 | "Win32_UI_Shell_PropertiesSystem", 80 | ] } 81 | -------------------------------------------------------------------------------- /src-tauri/build.rs: -------------------------------------------------------------------------------- 1 | fn main() { 2 | tauri_build::build() 3 | } 4 | -------------------------------------------------------------------------------- /src-tauri/capabilities/main.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "./schemas/desktop-schema.json", 3 | "identifier": "main-capability", 4 | "description": "Capability for the main window", 5 | "windows": [ 6 | "main" 7 | ], 8 | "permissions": [ 9 | "core:path:default", 10 | "core:event:default", 11 | "core:window:default", 12 | "core:app:default", 13 | "core:resources:default", 14 | "core:menu:default", 15 | "core:tray:default", 16 | "shell:allow-open" 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /src-tauri/gen/schemas/capabilities.json: -------------------------------------------------------------------------------- 1 | {"main-capability":{"identifier":"main-capability","description":"Capability for the main window","local":true,"windows":["main"],"permissions":["core:path:default","core:event:default","core:window:default","core:app:default","core:resources:default","core:menu:default","core:tray:default","shell:allow-open"]}} -------------------------------------------------------------------------------- /src-tauri/icons/128x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gulbanana/gg/46ac46e3e8e3e7a3fdc3c2c31e63d91975072fb1/src-tauri/icons/128x128.png -------------------------------------------------------------------------------- /src-tauri/icons/128x128@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gulbanana/gg/46ac46e3e8e3e7a3fdc3c2c31e63d91975072fb1/src-tauri/icons/128x128@2x.png -------------------------------------------------------------------------------- /src-tauri/icons/24x24.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gulbanana/gg/46ac46e3e8e3e7a3fdc3c2c31e63d91975072fb1/src-tauri/icons/24x24.png -------------------------------------------------------------------------------- /src-tauri/icons/32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gulbanana/gg/46ac46e3e8e3e7a3fdc3c2c31e63d91975072fb1/src-tauri/icons/32x32.png -------------------------------------------------------------------------------- /src-tauri/icons/Square107x107Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gulbanana/gg/46ac46e3e8e3e7a3fdc3c2c31e63d91975072fb1/src-tauri/icons/Square107x107Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square142x142Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gulbanana/gg/46ac46e3e8e3e7a3fdc3c2c31e63d91975072fb1/src-tauri/icons/Square142x142Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square150x150Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gulbanana/gg/46ac46e3e8e3e7a3fdc3c2c31e63d91975072fb1/src-tauri/icons/Square150x150Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square284x284Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gulbanana/gg/46ac46e3e8e3e7a3fdc3c2c31e63d91975072fb1/src-tauri/icons/Square284x284Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square30x30Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gulbanana/gg/46ac46e3e8e3e7a3fdc3c2c31e63d91975072fb1/src-tauri/icons/Square30x30Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square310x310Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gulbanana/gg/46ac46e3e8e3e7a3fdc3c2c31e63d91975072fb1/src-tauri/icons/Square310x310Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square44x44Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gulbanana/gg/46ac46e3e8e3e7a3fdc3c2c31e63d91975072fb1/src-tauri/icons/Square44x44Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square71x71Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gulbanana/gg/46ac46e3e8e3e7a3fdc3c2c31e63d91975072fb1/src-tauri/icons/Square71x71Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square89x89Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gulbanana/gg/46ac46e3e8e3e7a3fdc3c2c31e63d91975072fb1/src-tauri/icons/Square89x89Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/StoreLogo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gulbanana/gg/46ac46e3e8e3e7a3fdc3c2c31e63d91975072fb1/src-tauri/icons/StoreLogo.png -------------------------------------------------------------------------------- /src-tauri/icons/icon.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gulbanana/gg/46ac46e3e8e3e7a3fdc3c2c31e63d91975072fb1/src-tauri/icons/icon.icns -------------------------------------------------------------------------------- /src-tauri/icons/icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gulbanana/gg/46ac46e3e8e3e7a3fdc3c2c31e63d91975072fb1/src-tauri/icons/icon.ico -------------------------------------------------------------------------------- /src-tauri/icons/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gulbanana/gg/46ac46e3e8e3e7a3fdc3c2c31e63d91975072fb1/src-tauri/icons/icon.png -------------------------------------------------------------------------------- /src-tauri/resources/hallucination.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gulbanana/gg/46ac46e3e8e3e7a3fdc3c2c31e63d91975072fb1/src-tauri/resources/hallucination.png -------------------------------------------------------------------------------- /src-tauri/resources/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gulbanana/gg/46ac46e3e8e3e7a3fdc3c2c31e63d91975072fb1/src-tauri/resources/screenshot.png -------------------------------------------------------------------------------- /src-tauri/resources/test-repo.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gulbanana/gg/46ac46e3e8e3e7a3fdc3c2c31e63d91975072fb1/src-tauri/resources/test-repo.zip -------------------------------------------------------------------------------- /src-tauri/src/callbacks.rs: -------------------------------------------------------------------------------- 1 | //! Sometimes callbacks are buried deep in library code, requiring user input. 2 | //! This module offers an overcomplicated and fragile solution. 3 | 4 | use std::{ 5 | collections::HashMap, 6 | path::{Path, PathBuf}, 7 | sync::mpsc::channel, 8 | }; 9 | 10 | use anyhow::Result; 11 | use jj_lib::{git::RemoteCallbacks, repo::MutableRepo}; 12 | use tauri::{Emitter, Manager, Window}; 13 | 14 | use crate::{ 15 | messages::{InputField, InputRequest}, 16 | worker::WorkerCallbacks, 17 | AppState, 18 | }; 19 | 20 | pub struct FrontendCallbacks(pub Window); 21 | 22 | impl WorkerCallbacks for FrontendCallbacks { 23 | fn with_git( 24 | &self, 25 | repo: &mut MutableRepo, 26 | f: &dyn Fn(&mut MutableRepo, RemoteCallbacks<'_>) -> Result<()>, 27 | ) -> Result<()> { 28 | let mut cb = RemoteCallbacks::default(); 29 | 30 | let get_ssh_keys = &mut get_ssh_keys; 31 | cb.get_ssh_keys = Some(get_ssh_keys); 32 | 33 | let get_password = &mut |url: &str, username: &str| { 34 | self.request_input( 35 | format!("Please enter a password for {} at {}", username, url), 36 | ["Password"], 37 | ) 38 | .and_then(|mut fields| fields.remove("Password")) 39 | }; 40 | cb.get_password = Some(get_password); 41 | 42 | let get_username_password = &mut |url: &str| { 43 | self.request_input( 44 | format!("Please enter a username and password for {}", url), 45 | ["Username", "Password"], 46 | ) 47 | .and_then(|mut fields| { 48 | fields.remove("Username").and_then(|username| { 49 | fields 50 | .remove("Password") 51 | .map(|password| (username, password)) 52 | }) 53 | }) 54 | }; 55 | cb.get_username_password = Some(get_username_password); 56 | 57 | f(repo, cb) 58 | } 59 | } 60 | 61 | impl FrontendCallbacks { 62 | fn request_input, U: Into>( 63 | &self, 64 | detail: String, 65 | fields: T, 66 | ) -> Option> { 67 | log::debug!("request input"); 68 | 69 | // initialise a channel to receive responses 70 | let (tx, rx) = channel(); 71 | self.0.state::().set_input(self.0.label(), tx); 72 | 73 | // send the request 74 | match self.0.emit( 75 | "gg://input", 76 | InputRequest { 77 | title: String::from("Git Login"), 78 | detail, 79 | fields: fields.into_iter().map(|field| field.into()).collect(), 80 | }, 81 | ) { 82 | Ok(_) => (), 83 | Err(err) => { 84 | log::error!("input request failed: emit failed: {err}"); 85 | return None; 86 | } 87 | } 88 | 89 | // wait for the response 90 | match rx.recv() { 91 | Ok(response) => { 92 | if response.cancel { 93 | log::error!("input request failed: input cancelled"); 94 | None 95 | } else { 96 | Some(response.fields) 97 | } 98 | } 99 | Err(err) => { 100 | log::error!("input request failed: {err}"); 101 | None 102 | } 103 | } 104 | } 105 | } 106 | 107 | // simplistic, but it's the same as the version in jj_cli::git_util 108 | fn get_ssh_keys(_username: &str) -> Vec { 109 | let mut paths = vec![]; 110 | if let Some(home_dir) = dirs::home_dir() { 111 | let ssh_dir = Path::new(&home_dir).join(".ssh"); 112 | for filename in ["id_ed25519_sk", "id_ed25519", "id_rsa"] { 113 | let key_path = ssh_dir.join(filename); 114 | if key_path.is_file() { 115 | log::info!("found ssh key {key_path:?}"); 116 | paths.push(key_path); 117 | } 118 | } 119 | } 120 | if paths.is_empty() { 121 | log::info!("no ssh key found"); 122 | } 123 | paths 124 | } 125 | -------------------------------------------------------------------------------- /src-tauri/src/config/gg.toml: -------------------------------------------------------------------------------- 1 | [gg.queries] 2 | # Number of commits to load per call 3 | log-page-size = 1000 4 | 5 | # Some query settings will default to false instead of true if a repo has this many commits. 6 | large-repo-heuristic = 100000 7 | 8 | # Take a snapshot when the window gains focus; slow in large checkouts. 9 | # When disabled, snapshots will still be created if you run commands. 10 | # auto-snapshot = 11 | 12 | [gg.ui] 13 | # Stores a list of recently opened directories for shell integration 14 | recent-workspaces = [] 15 | 16 | # When set, bookmarks that are local-only or remote-only will be visually indicated. 17 | mark-unpushed-bookmarks = true 18 | 19 | # "light" or "dark". If not set, your OS settings will be used. 20 | # theme-override = 21 | -------------------------------------------------------------------------------- /src-tauri/src/config/mod.rs: -------------------------------------------------------------------------------- 1 | use std::path::Path; 2 | 3 | use anyhow::{anyhow, Result}; 4 | use jj_cli::config::{config_from_environment, default_config_layers, ConfigEnv}; 5 | use jj_lib::{ 6 | config::{ConfigGetError, ConfigLayer, ConfigNamePathBuf, ConfigSource, StackedConfig}, 7 | revset::RevsetAliasesMap, 8 | settings::UserSettings, 9 | }; 10 | 11 | pub trait GGSettings { 12 | fn query_log_page_size(&self) -> usize; 13 | fn query_large_repo_heuristic(&self) -> i64; 14 | fn query_auto_snapshot(&self) -> Option; 15 | fn ui_theme_override(&self) -> Option; 16 | fn ui_mark_unpushed_bookmarks(&self) -> bool; 17 | #[allow(dead_code)] 18 | fn ui_recent_workspaces(&self) -> Vec; 19 | } 20 | 21 | impl GGSettings for UserSettings { 22 | fn query_log_page_size(&self) -> usize { 23 | self.get_int("gg.queries.log-page-size").unwrap_or(1000) as usize 24 | } 25 | 26 | fn query_large_repo_heuristic(&self) -> i64 { 27 | self.get_int("gg.queries.large-repo-heuristic") 28 | .unwrap_or(100000) 29 | } 30 | 31 | fn query_auto_snapshot(&self) -> Option { 32 | self.get_bool("gg.queries.auto-snapshot").ok() 33 | } 34 | 35 | fn ui_theme_override(&self) -> Option { 36 | self.get_string("gg.ui.theme-override").ok() 37 | } 38 | 39 | fn ui_mark_unpushed_bookmarks(&self) -> bool { 40 | self.get_bool("gg.ui.mark-unpushed-bookmarks").unwrap_or( 41 | self.get_bool("gg.ui.mark-unpushed-branches") 42 | .unwrap_or(true), 43 | ) 44 | } 45 | 46 | fn ui_recent_workspaces(&self) -> Vec { 47 | self.get_value("gg.ui.recent-workspaces") 48 | .ok() 49 | .and_then(|v| v.as_array().cloned()) 50 | .map(|values| { 51 | values 52 | .into_iter() 53 | .filter_map(|value| value.as_str().map(|s| s.to_string())) 54 | .collect() 55 | }) 56 | .unwrap_or_default() 57 | } 58 | } 59 | 60 | pub fn read_config(repo_path: &Path) -> Result<(UserSettings, RevsetAliasesMap)> { 61 | let mut default_layers = default_config_layers(); 62 | let gg_layer = ConfigLayer::parse(ConfigSource::Default, include_str!("../config/gg.toml"))?; 63 | default_layers.push(gg_layer); 64 | let mut raw_config = config_from_environment(default_layers); 65 | 66 | let mut config_env = ConfigEnv::from_environment()?; 67 | 68 | config_env.reload_user_config(&mut raw_config)?; 69 | 70 | config_env.reset_repo_path(repo_path); 71 | config_env.reload_repo_config(&mut raw_config)?; 72 | 73 | let config = config_env.resolve_config(&raw_config)?; 74 | 75 | let aliases_map = build_aliases_map(&config)?; 76 | let settings = UserSettings::from_config(config)?; 77 | 78 | Ok((settings, aliases_map)) 79 | } 80 | 81 | pub fn build_aliases_map(stacked_config: &StackedConfig) -> Result { 82 | let table_name = ConfigNamePathBuf::from_iter(["revset-aliases"]); 83 | let mut aliases_map = RevsetAliasesMap::new(); 84 | // Load from all config layers in order. 'f(x)' in default layer should be 85 | // overridden by 'f(a)' in user. 86 | for layer in stacked_config.layers() { 87 | let table = match layer.look_up_table(&table_name) { 88 | Ok(Some(table)) => table, 89 | Ok(None) => continue, 90 | Err(item) => { 91 | return Err(ConfigGetError::Type { 92 | name: table_name.to_string(), 93 | error: format!("Expected a table, but is {}", item.type_name()).into(), 94 | source_path: layer.path.clone(), 95 | } 96 | .into()); 97 | } 98 | }; 99 | for (decl, item) in table.iter() { 100 | let r = item 101 | .as_str() 102 | .ok_or_else(|| format!("Expected a string, but is {}", item.type_name())) 103 | .and_then(|v| aliases_map.insert(decl, v).map_err(|e| e.to_string())); 104 | if let Err(s) = r { 105 | return Err(anyhow!("Failed to load `{table_name}.{decl}`: {s}")); 106 | } 107 | } 108 | } 109 | Ok(aliases_map) 110 | } 111 | -------------------------------------------------------------------------------- /src-tauri/src/handler.rs: -------------------------------------------------------------------------------- 1 | macro_rules! fatal { 2 | ($result:expr) => { 3 | match $result { 4 | Ok(_) => (), 5 | Err(err) => { 6 | log::error!("{}: {:#}", stringify!($result), err); 7 | panic!("{}: {:#}", stringify!($result), err); 8 | } 9 | } 10 | }; 11 | } 12 | 13 | macro_rules! nonfatal { 14 | ($result:expr) => { 15 | match $result { 16 | Ok(x) => x, 17 | Err(err) => { 18 | log::error!("{}: {:#}", stringify!($result), err); 19 | return; 20 | } 21 | } 22 | }; 23 | } 24 | 25 | #[allow(dead_code, unused_macros)] 26 | macro_rules! optional { 27 | ($result:expr) => { 28 | match $result { 29 | Ok(_) => (), 30 | Err(err) => { 31 | log::warn!("{}: {:#}", stringify!($result), err); 32 | } 33 | } 34 | }; 35 | } 36 | 37 | pub(crate) use fatal; 38 | pub(crate) use nonfatal; 39 | #[allow(unused_imports)] 40 | pub(crate) use optional; 41 | -------------------------------------------------------------------------------- /src-tauri/src/messages/mod.rs: -------------------------------------------------------------------------------- 1 | //! Message types used to communicate between backend and frontend 2 | 3 | mod mutations; 4 | mod queries; 5 | 6 | pub use mutations::*; 7 | pub use queries::*; 8 | 9 | use std::{collections::HashMap, path::Path}; 10 | 11 | use anyhow::{anyhow, Result}; 12 | use serde::{Deserialize, Serialize}; 13 | #[cfg(feature = "ts-rs")] 14 | use ts_rs::TS; 15 | 16 | /// Utility type used to abstract crlf/
/etc 17 | #[derive(Serialize, Deserialize, Clone, Debug)] 18 | #[cfg_attr( 19 | feature = "ts-rs", 20 | derive(TS), 21 | ts(export, export_to = "../src/messages/") 22 | )] 23 | pub struct MultilineString { 24 | pub lines: Vec, 25 | } 26 | 27 | impl<'a, T> From for MultilineString 28 | where 29 | T: Into<&'a str>, 30 | { 31 | fn from(value: T) -> Self { 32 | MultilineString { 33 | lines: value.into().split("\n").map(|l| l.to_owned()).collect(), 34 | } 35 | } 36 | } 37 | 38 | /// Utility type used for platform-specific display 39 | #[derive(Serialize, Deserialize, Debug, Clone)] 40 | #[cfg_attr( 41 | feature = "ts-rs", 42 | derive(TS), 43 | ts(export, export_to = "../src/messages/") 44 | )] 45 | pub struct DisplayPath(pub String); 46 | 47 | impl> From for DisplayPath { 48 | fn from(value: T) -> Self { 49 | DisplayPath( 50 | dunce::simplified(value.as_ref()) 51 | .to_string_lossy() 52 | .to_string(), 53 | ) 54 | } 55 | } 56 | 57 | /// Utility type used for round-tripping 58 | #[derive(Serialize, Deserialize, Debug, Clone)] 59 | #[cfg_attr( 60 | feature = "ts-rs", 61 | derive(TS), 62 | ts(export, export_to = "../src/messages/") 63 | )] 64 | pub struct TreePath { 65 | pub repo_path: String, 66 | pub relative_path: DisplayPath, 67 | } 68 | 69 | #[derive(Serialize, Clone)] 70 | #[serde(tag = "type")] 71 | #[cfg_attr( 72 | feature = "ts-rs", 73 | derive(TS), 74 | ts(export, export_to = "../src/messages/") 75 | )] 76 | 77 | pub enum RepoConfig { 78 | #[allow(dead_code)] // used by frontend 79 | Initial, 80 | Workspace { 81 | absolute_path: DisplayPath, 82 | git_remotes: Vec, 83 | default_query: String, 84 | latest_query: String, 85 | status: RepoStatus, 86 | theme_override: Option, 87 | mark_unpushed_branches: bool, 88 | }, 89 | #[allow(dead_code)] // used by frontend 90 | TimeoutError, 91 | LoadError { 92 | absolute_path: DisplayPath, 93 | message: String, 94 | }, 95 | WorkerError { 96 | message: String, 97 | }, 98 | } 99 | 100 | #[derive(Serialize, Clone, Debug)] 101 | #[cfg_attr( 102 | feature = "ts-rs", 103 | derive(TS), 104 | ts(export, export_to = "../src/messages/") 105 | )] 106 | pub struct RepoStatus { 107 | pub operation_description: String, 108 | pub working_copy: CommitId, 109 | } 110 | 111 | /// Bookmark or tag name with metadata. 112 | #[derive(Serialize, Deserialize, Clone, Debug)] 113 | #[serde(tag = "type")] 114 | #[cfg_attr( 115 | feature = "ts-rs", 116 | derive(TS), 117 | ts(export, export_to = "../src/messages/") 118 | )] 119 | pub enum StoreRef { 120 | LocalBookmark { 121 | branch_name: String, 122 | has_conflict: bool, 123 | /// Synchronized with all tracking remotes 124 | is_synced: bool, 125 | /// Actual and potential remotes 126 | tracking_remotes: Vec, 127 | available_remotes: usize, 128 | potential_remotes: usize, 129 | }, 130 | RemoteBookmark { 131 | branch_name: String, 132 | remote_name: String, 133 | has_conflict: bool, 134 | /// Tracking remote ref is synchronized with local ref 135 | is_synced: bool, 136 | /// Has local ref 137 | is_tracked: bool, 138 | /// Local ref has been deleted 139 | is_absent: bool, 140 | }, 141 | Tag { 142 | tag_name: String, 143 | }, 144 | } 145 | 146 | impl StoreRef { 147 | pub fn as_branch(&self) -> Result<&str> { 148 | match self { 149 | StoreRef::LocalBookmark { branch_name, .. } => Ok(&branch_name), 150 | StoreRef::RemoteBookmark { branch_name, .. } => Ok(&branch_name), 151 | _ => Err(anyhow!("not a local bookmark")), 152 | } 153 | } 154 | } 155 | 156 | /// Refers to one of the repository's manipulatable objects 157 | #[derive(Serialize, Deserialize, Debug, Clone)] 158 | #[serde(tag = "type")] 159 | #[cfg_attr( 160 | feature = "ts-rs", 161 | derive(TS), 162 | ts(export, export_to = "../src/messages/") 163 | )] 164 | pub enum Operand { 165 | Repository, 166 | Revision { 167 | header: RevHeader, 168 | }, 169 | Merge { 170 | header: RevHeader, 171 | }, 172 | Parent { 173 | header: RevHeader, 174 | child: RevHeader, 175 | }, 176 | Change { 177 | header: RevHeader, 178 | path: TreePath, // someday: hunks 179 | }, 180 | Ref { 181 | header: RevHeader, 182 | r#ref: StoreRef, 183 | }, 184 | } 185 | 186 | #[derive(Serialize, Debug, Clone)] 187 | #[cfg_attr( 188 | feature = "ts-rs", 189 | derive(TS), 190 | ts(export, export_to = "../src/messages/") 191 | )] 192 | pub struct InputRequest { 193 | pub title: String, 194 | pub detail: String, 195 | pub fields: Vec, 196 | } 197 | 198 | #[derive(Deserialize, Debug)] 199 | #[cfg_attr( 200 | feature = "ts-rs", 201 | derive(TS), 202 | ts(export, export_to = "../src/messages/") 203 | )] 204 | pub struct InputResponse { 205 | pub cancel: bool, 206 | pub fields: HashMap, 207 | } 208 | 209 | #[derive(Serialize, Debug, Clone)] 210 | #[cfg_attr( 211 | feature = "ts-rs", 212 | derive(TS), 213 | ts(export, export_to = "../src/messages/") 214 | )] 215 | pub struct InputField { 216 | pub label: String, 217 | pub choices: Vec, 218 | } 219 | 220 | impl From<&str> for InputField { 221 | fn from(label: &str) -> Self { 222 | InputField { 223 | label: label.to_owned(), 224 | choices: vec![], 225 | } 226 | } 227 | } 228 | -------------------------------------------------------------------------------- /src-tauri/src/messages/mutations.rs: -------------------------------------------------------------------------------- 1 | use super::*; 2 | 3 | /// Common result type for mutating commands 4 | #[derive(Serialize, Clone, Debug)] 5 | #[serde(tag = "type")] 6 | #[cfg_attr( 7 | feature = "ts-rs", 8 | derive(TS), 9 | ts(export, export_to = "../src/messages/") 10 | )] 11 | pub enum MutationResult { 12 | Unchanged, 13 | Updated { 14 | new_status: RepoStatus, 15 | }, 16 | UpdatedSelection { 17 | new_status: RepoStatus, 18 | new_selection: RevHeader, 19 | }, 20 | PreconditionError { 21 | message: String, 22 | }, 23 | InternalError { 24 | message: MultilineString, 25 | }, 26 | } 27 | 28 | /// Makes a revision the working copy 29 | #[derive(Deserialize, Debug)] 30 | #[cfg_attr( 31 | feature = "ts-rs", 32 | derive(TS), 33 | ts(export, export_to = "../src/messages/") 34 | )] 35 | pub struct CheckoutRevision { 36 | pub id: RevId, 37 | } 38 | 39 | /// Creates a new revision and makes it the working copy 40 | #[derive(Deserialize, Debug)] 41 | #[cfg_attr( 42 | feature = "ts-rs", 43 | derive(TS), 44 | ts(export, export_to = "../src/messages/") 45 | )] 46 | pub struct CreateRevision { 47 | pub parent_ids: Vec, 48 | } 49 | 50 | #[derive(Deserialize, Debug)] 51 | #[cfg_attr( 52 | feature = "ts-rs", 53 | derive(TS), 54 | ts(export, export_to = "../src/messages/") 55 | )] 56 | pub struct InsertRevision { 57 | pub id: RevId, 58 | pub after_id: RevId, 59 | pub before_id: RevId, 60 | } 61 | 62 | #[derive(Deserialize, Debug)] 63 | #[cfg_attr( 64 | feature = "ts-rs", 65 | derive(TS), 66 | ts(export, export_to = "../src/messages/") 67 | )] 68 | pub struct MoveRevision { 69 | pub id: RevId, 70 | pub parent_ids: Vec, 71 | } 72 | 73 | #[derive(Deserialize, Debug)] 74 | #[cfg_attr( 75 | feature = "ts-rs", 76 | derive(TS), 77 | ts(export, export_to = "../src/messages/") 78 | )] 79 | pub struct MoveSource { 80 | pub id: RevId, 81 | pub parent_ids: Vec, 82 | } 83 | 84 | /// Updates a revision's description 85 | #[derive(Deserialize, Debug)] 86 | #[cfg_attr( 87 | feature = "ts-rs", 88 | derive(TS), 89 | ts(export, export_to = "../src/messages/") 90 | )] 91 | pub struct DescribeRevision { 92 | pub id: RevId, 93 | pub new_description: String, 94 | pub reset_author: bool, 95 | } 96 | 97 | /// Creates a copy of the selected revisions with the same parents and content 98 | #[derive(Deserialize, Debug)] 99 | #[cfg_attr( 100 | feature = "ts-rs", 101 | derive(TS), 102 | ts(export, export_to = "../src/messages/") 103 | )] 104 | pub struct DuplicateRevisions { 105 | pub ids: Vec, 106 | } 107 | 108 | #[derive(Deserialize, Debug)] 109 | #[cfg_attr( 110 | feature = "ts-rs", 111 | derive(TS), 112 | ts(export, export_to = "../src/messages/") 113 | )] 114 | pub struct AbandonRevisions { 115 | pub ids: Vec, 116 | } 117 | 118 | /// Adds changes to the working copy which reverse the effect of the selected revisions 119 | #[derive(Deserialize, Debug)] 120 | #[cfg_attr( 121 | feature = "ts-rs", 122 | derive(TS), 123 | ts(export, export_to = "../src/messages/") 124 | )] 125 | pub struct BackoutRevisions { 126 | pub ids: Vec, 127 | } 128 | 129 | #[derive(Deserialize, Debug)] 130 | #[cfg_attr( 131 | feature = "ts-rs", 132 | derive(TS), 133 | ts(export, export_to = "../src/messages/") 134 | )] 135 | pub struct MoveChanges { 136 | pub from_id: RevId, 137 | pub to_id: CommitId, // limitation: we don't know parent chids because they are more expensive to look up 138 | pub paths: Vec, 139 | } 140 | 141 | #[derive(Deserialize, Debug)] 142 | #[cfg_attr( 143 | feature = "ts-rs", 144 | derive(TS), 145 | ts(export, export_to = "../src/messages/") 146 | )] 147 | pub struct CopyChanges { 148 | pub from_id: CommitId, // limitation: we don't know parent chids because they are more expensive to look up 149 | pub to_id: RevId, 150 | pub paths: Vec, 151 | } 152 | 153 | #[derive(Deserialize, Debug)] 154 | #[cfg_attr( 155 | feature = "ts-rs", 156 | derive(TS), 157 | ts(export, export_to = "../src/messages/") 158 | )] 159 | pub struct TrackBranch { 160 | pub r#ref: StoreRef, 161 | } 162 | 163 | #[derive(Deserialize, Debug)] 164 | #[cfg_attr( 165 | feature = "ts-rs", 166 | derive(TS), 167 | ts(export, export_to = "../src/messages/") 168 | )] 169 | pub struct UntrackBranch { 170 | pub r#ref: StoreRef, 171 | } 172 | 173 | #[derive(Deserialize, Debug)] 174 | #[cfg_attr( 175 | feature = "ts-rs", 176 | derive(TS), 177 | ts(export, export_to = "../src/messages/") 178 | )] 179 | pub struct RenameBranch { 180 | pub r#ref: StoreRef, 181 | pub new_name: String, 182 | } 183 | 184 | #[derive(Deserialize, Debug)] 185 | #[cfg_attr( 186 | feature = "ts-rs", 187 | derive(TS), 188 | ts(export, export_to = "../src/messages/") 189 | )] 190 | pub struct CreateRef { 191 | pub id: RevId, 192 | pub r#ref: StoreRef, 193 | } 194 | 195 | #[derive(Deserialize, Debug)] 196 | #[cfg_attr( 197 | feature = "ts-rs", 198 | derive(TS), 199 | ts(export, export_to = "../src/messages/") 200 | )] 201 | pub struct DeleteRef { 202 | pub r#ref: StoreRef, 203 | } 204 | 205 | #[derive(Deserialize, Debug)] 206 | #[cfg_attr( 207 | feature = "ts-rs", 208 | derive(TS), 209 | ts(export, export_to = "../src/messages/") 210 | )] 211 | pub struct MoveRef { 212 | pub r#ref: StoreRef, 213 | pub to_id: RevId, 214 | } 215 | 216 | #[derive(Deserialize, Debug)] 217 | #[serde(tag = "type")] 218 | #[cfg_attr( 219 | feature = "ts-rs", 220 | derive(TS), 221 | ts(export, export_to = "../src/messages/") 222 | )] 223 | pub enum GitPush { 224 | AllBookmarks { 225 | remote_name: String, 226 | }, 227 | AllRemotes { 228 | branch_ref: StoreRef, 229 | }, 230 | RemoteBookmark { 231 | remote_name: String, 232 | branch_ref: StoreRef, 233 | }, 234 | } 235 | 236 | #[derive(Deserialize, Debug)] 237 | #[serde(tag = "type")] 238 | #[cfg_attr( 239 | feature = "ts-rs", 240 | derive(TS), 241 | ts(export, export_to = "../src/messages/") 242 | )] 243 | pub enum GitFetch { 244 | AllBookmarks { 245 | remote_name: String, 246 | }, 247 | AllRemotes { 248 | branch_ref: StoreRef, 249 | }, 250 | RemoteBookmark { 251 | remote_name: String, 252 | branch_ref: StoreRef, 253 | }, 254 | } 255 | 256 | #[derive(Deserialize, Debug)] 257 | #[cfg_attr( 258 | feature = "ts-rs", 259 | derive(TS), 260 | ts(export, export_to = "../src/messages/") 261 | )] 262 | pub struct UndoOperation; 263 | -------------------------------------------------------------------------------- /src-tauri/src/messages/queries.rs: -------------------------------------------------------------------------------- 1 | use chrono::{offset::LocalResult, DateTime, FixedOffset, Local, TimeZone, Utc}; 2 | use jj_lib::backend::{Signature, Timestamp}; 3 | 4 | use super::*; 5 | 6 | /// A change or commit id with a disambiguated prefix 7 | #[allow(dead_code)] // the frontend needs these structs kept in sync 8 | pub trait Id { 9 | fn hex(&self) -> &String; 10 | fn prefix(&self) -> &String; 11 | fn rest(&self) -> &String; 12 | } 13 | 14 | #[derive(Serialize, Deserialize, Clone, Debug)] 15 | #[serde(tag = "type")] 16 | #[cfg_attr( 17 | feature = "ts-rs", 18 | derive(TS), 19 | ts(export, export_to = "../src/messages/") 20 | )] 21 | pub struct CommitId { 22 | pub hex: String, 23 | pub prefix: String, 24 | pub rest: String, 25 | } 26 | 27 | impl Id for CommitId { 28 | fn hex(&self) -> &String { 29 | &self.hex 30 | } 31 | fn prefix(&self) -> &String { 32 | &self.prefix 33 | } 34 | fn rest(&self) -> &String { 35 | &self.rest 36 | } 37 | } 38 | 39 | #[derive(Serialize, Deserialize, Clone, Debug)] 40 | #[serde(tag = "type")] 41 | #[cfg_attr( 42 | feature = "ts-rs", 43 | derive(TS), 44 | ts(export, export_to = "../src/messages/") 45 | )] 46 | pub struct ChangeId { 47 | pub hex: String, 48 | pub prefix: String, 49 | pub rest: String, 50 | } 51 | 52 | impl Id for ChangeId { 53 | fn hex(&self) -> &String { 54 | &self.hex 55 | } 56 | fn prefix(&self) -> &String { 57 | &self.prefix 58 | } 59 | fn rest(&self) -> &String { 60 | &self.rest 61 | } 62 | } 63 | 64 | /// A pair of ids representing the ui's view of a revision. 65 | /// The worker may use one or both depending on policy. 66 | #[derive(Serialize, Deserialize, Clone, Debug)] 67 | #[cfg_attr( 68 | feature = "ts-rs", 69 | derive(TS), 70 | ts(export, export_to = "../src/messages/") 71 | )] 72 | pub struct RevId { 73 | pub change: ChangeId, 74 | pub commit: CommitId, 75 | } 76 | 77 | #[derive(Serialize, Deserialize, Clone, Debug)] 78 | #[cfg_attr( 79 | feature = "ts-rs", 80 | derive(TS), 81 | ts(export, export_to = "../src/messages/") 82 | )] 83 | pub struct RevHeader { 84 | pub id: RevId, 85 | pub description: MultilineString, 86 | pub author: RevAuthor, 87 | pub has_conflict: bool, 88 | pub is_working_copy: bool, 89 | pub is_immutable: bool, 90 | pub refs: Vec, 91 | pub parent_ids: Vec, 92 | } 93 | 94 | #[derive(Serialize, Deserialize, Clone, Debug)] 95 | #[cfg_attr( 96 | feature = "ts-rs", 97 | derive(TS), 98 | ts(export, export_to = "../src/messages/") 99 | )] 100 | pub struct RevAuthor { 101 | pub email: String, 102 | pub name: String, 103 | pub timestamp: chrono::DateTime, 104 | } 105 | 106 | impl TryFrom<&Signature> for RevAuthor { 107 | type Error = anyhow::Error; 108 | 109 | fn try_from(value: &Signature) -> Result { 110 | Ok(RevAuthor { 111 | name: value.name.clone(), 112 | email: value.email.clone(), 113 | timestamp: format_timestamp(&value.timestamp)?.with_timezone(&Local), 114 | }) 115 | } 116 | } 117 | 118 | #[derive(Serialize, Deserialize, Debug)] 119 | #[cfg_attr( 120 | feature = "ts-rs", 121 | derive(TS), 122 | ts(export, export_to = "../src/messages/") 123 | )] 124 | pub struct RevChange { 125 | pub kind: ChangeKind, 126 | pub path: TreePath, 127 | pub has_conflict: bool, 128 | pub hunks: Vec, 129 | } 130 | 131 | #[derive(Serialize, Deserialize, Debug)] 132 | #[cfg_attr( 133 | feature = "ts-rs", 134 | derive(TS), 135 | ts(export, export_to = "../src/messages/") 136 | )] 137 | pub struct RevConflict { 138 | pub path: TreePath, 139 | pub hunk: ChangeHunk, 140 | } 141 | 142 | #[derive(Serialize, Deserialize, Debug)] 143 | #[cfg_attr( 144 | feature = "ts-rs", 145 | derive(TS), 146 | ts(export, export_to = "../src/messages/") 147 | )] 148 | pub enum ChangeKind { 149 | None, 150 | Added, 151 | Deleted, 152 | Modified, 153 | } 154 | 155 | #[derive(Serialize, Deserialize, Debug)] 156 | #[cfg_attr( 157 | feature = "ts-rs", 158 | derive(TS), 159 | ts(export, export_to = "../src/messages/") 160 | )] 161 | pub struct ChangeHunk { 162 | pub location: HunkLocation, 163 | pub lines: MultilineString, 164 | } 165 | 166 | #[derive(Serialize, Deserialize, Debug)] 167 | #[cfg_attr( 168 | feature = "ts-rs", 169 | derive(TS), 170 | ts(export, export_to = "../src/messages/") 171 | )] 172 | pub struct HunkLocation { 173 | pub from_file: FileRange, 174 | pub to_file: FileRange, 175 | } 176 | 177 | #[derive(Serialize, Deserialize, Debug)] 178 | #[cfg_attr( 179 | feature = "ts-rs", 180 | derive(TS), 181 | ts(export, export_to = "../src/messages/") 182 | )] 183 | pub struct FileRange { 184 | pub start: usize, 185 | pub len: usize, 186 | } 187 | 188 | #[derive(Serialize, Debug)] 189 | #[serde(tag = "type")] 190 | #[cfg_attr( 191 | feature = "ts-rs", 192 | derive(TS), 193 | ts(export, export_to = "../src/messages/") 194 | )] 195 | pub enum RevResult { 196 | NotFound { 197 | id: RevId, 198 | }, 199 | Detail { 200 | header: RevHeader, 201 | parents: Vec, 202 | changes: Vec, 203 | conflicts: Vec, 204 | }, 205 | } 206 | 207 | #[derive(Serialize, Clone, Copy, Debug)] 208 | #[cfg_attr( 209 | feature = "ts-rs", 210 | derive(TS), 211 | ts(export, export_to = "../src/messages/") 212 | )] 213 | pub struct LogCoordinates(pub usize, pub usize); 214 | 215 | #[derive(Serialize, Debug)] 216 | #[serde(tag = "type")] 217 | #[cfg_attr( 218 | feature = "ts-rs", 219 | derive(TS), 220 | ts(export, export_to = "../src/messages/") 221 | )] 222 | pub enum LogLine { 223 | FromNode { 224 | source: LogCoordinates, 225 | target: LogCoordinates, 226 | indirect: bool, 227 | }, 228 | ToNode { 229 | source: LogCoordinates, 230 | target: LogCoordinates, 231 | indirect: bool, 232 | }, 233 | ToIntersection { 234 | source: LogCoordinates, 235 | target: LogCoordinates, 236 | indirect: bool, 237 | }, 238 | ToMissing { 239 | source: LogCoordinates, 240 | target: LogCoordinates, 241 | indirect: bool, 242 | }, 243 | } 244 | 245 | #[derive(Serialize, Debug)] 246 | #[cfg_attr( 247 | feature = "ts-rs", 248 | derive(TS), 249 | ts(export, export_to = "../src/messages/") 250 | )] 251 | pub struct LogRow { 252 | pub revision: RevHeader, 253 | pub location: LogCoordinates, 254 | pub padding: usize, 255 | pub lines: Vec, 256 | } 257 | 258 | #[derive(Serialize)] 259 | #[cfg_attr( 260 | feature = "ts-rs", 261 | derive(TS), 262 | ts(export, export_to = "../src/messages/") 263 | )] 264 | pub struct LogPage { 265 | pub rows: Vec, 266 | pub has_more: bool, 267 | } 268 | 269 | // similar to time_util::datetime_from_timestamp, which is not pub 270 | fn format_timestamp(context: &Timestamp) -> Result> { 271 | let utc = match Utc.timestamp_opt( 272 | context.timestamp.0.div_euclid(1000), 273 | (context.timestamp.0.rem_euclid(1000)) as u32 * 1000000, 274 | ) { 275 | LocalResult::None => { 276 | return Err(anyhow!("no UTC instant exists for timestamp")); 277 | } 278 | LocalResult::Single(x) => x, 279 | LocalResult::Ambiguous(y, _z) => y, 280 | }; 281 | 282 | let tz = FixedOffset::east_opt(context.tz_offset * 60) 283 | .or_else(|| FixedOffset::east_opt(0)) 284 | .ok_or(anyhow!("timezone offset out of bounds"))?; 285 | 286 | Ok(utc.with_timezone(&tz)) 287 | } 288 | -------------------------------------------------------------------------------- /src-tauri/src/windows.rs: -------------------------------------------------------------------------------- 1 | use std::path::Path; 2 | 3 | use anyhow::{anyhow, Result}; 4 | use windows::core::{w, Interface, BSTR, HSTRING, PROPVARIANT, PWSTR}; 5 | use windows::Win32::Foundation::MAX_PATH; 6 | use windows::Win32::System::Com::{CoCreateInstance, CLSCTX_INPROC_SERVER}; 7 | use windows::Win32::System::Console::{AttachConsole, ATTACH_PARENT_PROCESS}; 8 | use windows::Win32::UI::Shell::Common::{IObjectArray, IObjectCollection}; 9 | use windows::Win32::UI::Shell::PropertiesSystem::{ 10 | IPropertyStore, PSGetPropertyKeyFromName, PROPERTYKEY, 11 | }; 12 | use windows::Win32::UI::Shell::{ 13 | DestinationList, EnumerableObjectCollection, ICustomDestinationList, IShellLinkW, ShellLink, 14 | }; 15 | 16 | pub fn reattach_console() { 17 | // safety: FFI 18 | let _ = unsafe { AttachConsole(ATTACH_PARENT_PROCESS) }; 19 | } 20 | 21 | #[cfg_attr(not(windows), allow(dead_code))] 22 | pub fn update_jump_list(recent: &mut Vec, path: &String) -> Result<()> { 23 | // create a jump list 24 | // safety: FFI 25 | let jump_list: ICustomDestinationList = 26 | unsafe { CoCreateInstance(&DestinationList, None, CLSCTX_INPROC_SERVER)? }; 27 | 28 | // initialise the list and honour removals requested by the user 29 | let mut max_destinations = 0u32; 30 | let mut destination_path = vec![0u16; MAX_PATH as usize]; 31 | 32 | // safety: GetArguments() calls len() on the provided slice, and produces a null-terminated string for PWSTR::to_string() 33 | unsafe { 34 | let removed_destinations: IObjectArray = jump_list.BeginList(&mut max_destinations)?; 35 | for i in 0..removed_destinations.GetCount()? { 36 | let removed_link: IShellLinkW = removed_destinations.GetAt(i)?; // safety: i <= GetCount() 37 | removed_link.GetArguments(&mut destination_path)?; 38 | let removed_path_wstr = PWSTR::from_raw(destination_path.as_mut_ptr()); 39 | if !removed_path_wstr.is_null() { 40 | let removed_path = removed_path_wstr.to_string()?; 41 | recent.retain(|x| *x != removed_path); 42 | } 43 | } 44 | }; 45 | 46 | // add the new path as most-recent and trim to the configured max size 47 | recent.retain(|x| x != path); 48 | recent.insert(0, path.to_owned()); 49 | 50 | // safety: FFI 51 | let items: IObjectCollection = 52 | unsafe { CoCreateInstance(&EnumerableObjectCollection, None, CLSCTX_INPROC_SERVER)? }; 53 | 54 | // turn the paths into IShellLinks 55 | for path in recent { 56 | let path_wstr: HSTRING = (&*path).into(); 57 | let exe_wstr: HSTRING = std::env::current_exe()?.as_os_str().into(); 58 | let dir_wstr: HSTRING = Path::new(path) 59 | .file_name() 60 | .ok_or(anyhow!("repo path is not a directory"))? 61 | .into(); 62 | let dir_wstr = BSTR::from_wide(dir_wstr.as_wide())?; 63 | 64 | // safety: FFI 65 | unsafe { 66 | let link = create_directory_link(exe_wstr, path_wstr, dir_wstr)?; 67 | items.AddObject(&link)?; 68 | } 69 | } 70 | 71 | // add a custom category 72 | // safety: FFI 73 | unsafe { 74 | let array: IObjectArray = items.cast()?; 75 | jump_list.AppendCategory(w!("Recent"), &array)?; 76 | jump_list.CommitList()?; 77 | } 78 | 79 | Ok(()) 80 | } 81 | 82 | // safety: no invariants, it's all FFI 83 | unsafe fn create_directory_link(path: HSTRING, args: HSTRING, title: BSTR) -> Result { 84 | let link: IShellLinkW = CoCreateInstance(&ShellLink, None, CLSCTX_INPROC_SERVER)?; 85 | link.SetPath(&path)?; // launch ourselves... 86 | link.SetIconLocation(w!("%SystemRoot%\\System32\\shell32.dll"), 3)?; // ...with the icon for a directory... 87 | link.SetArguments(&args)?; // ...the directory as an argument... 88 | link.SetDescription(&args)?; // ...and a tooltip containing just the directory name 89 | 90 | // the actual display string must be set as a property because IShellLink is primarily for shortcuts 91 | let title_value = PROPVARIANT::from(title); 92 | let mut title_key = PROPERTYKEY::default(); 93 | PSGetPropertyKeyFromName(w!("System.Title"), &mut title_key)?; 94 | 95 | let store: IPropertyStore = link.cast()?; 96 | store.SetValue(&title_key, &title_value)?; 97 | store.Commit()?; 98 | 99 | Ok(link) 100 | } 101 | -------------------------------------------------------------------------------- /src-tauri/src/worker/mod.rs: -------------------------------------------------------------------------------- 1 | //! Worker per window, owning repo data (jj-lib is not thread-safe) 2 | //! The worker thread is a state machine, running different handle functions based on loaded data 3 | 4 | mod gui_util; 5 | mod mutations; 6 | mod queries; 7 | mod session; 8 | #[cfg(all(test, not(feature = "ts-rs")))] 9 | mod tests; 10 | 11 | use std::{ 12 | env::{self, VarError}, 13 | fmt::Debug, 14 | fs, 15 | path::PathBuf, 16 | }; 17 | 18 | use anyhow::{anyhow, Error, Result}; 19 | use jj_lib::{git::RemoteCallbacks, repo::MutableRepo}; 20 | 21 | use crate::messages; 22 | use gui_util::WorkspaceSession; 23 | pub use session::{Session, SessionEvent}; 24 | 25 | /// implemented by structured-change commands 26 | pub trait Mutation: Debug { 27 | fn describe(&self) -> String { 28 | std::any::type_name::().to_owned() 29 | } 30 | 31 | fn execute(self: Box, ws: &mut WorkspaceSession) -> Result; 32 | 33 | #[cfg(test)] 34 | fn execute_unboxed(self, ws: &mut WorkspaceSession) -> Result 35 | where 36 | Self: Sized, 37 | { 38 | Box::new(self).execute(ws) 39 | } 40 | } 41 | 42 | /// implemented by UI layers to request user input and receive progress 43 | pub trait WorkerCallbacks { 44 | fn with_git( 45 | &self, 46 | repo: &mut MutableRepo, 47 | f: &dyn Fn(&mut MutableRepo, RemoteCallbacks<'_>) -> Result<()>, 48 | ) -> Result<()>; 49 | } 50 | 51 | struct NoCallbacks; 52 | 53 | impl WorkerCallbacks for NoCallbacks { 54 | fn with_git( 55 | &self, 56 | repo: &mut MutableRepo, 57 | f: &dyn Fn(&mut MutableRepo, RemoteCallbacks<'_>) -> Result<()>, 58 | ) -> Result<()> { 59 | f(repo, RemoteCallbacks::default()) 60 | } 61 | } 62 | 63 | /// state that doesn't depend on jj-lib borrowings 64 | pub struct WorkerSession { 65 | pub force_log_page_size: Option, 66 | pub latest_query: Option, 67 | pub callbacks: Box, 68 | pub working_directory: Option, 69 | } 70 | 71 | impl WorkerSession { 72 | pub fn new(callbacks: T, workspace: Option) -> Self { 73 | WorkerSession { 74 | callbacks: Box::new(callbacks), 75 | working_directory: workspace, 76 | ..Default::default() 77 | } 78 | } 79 | 80 | // AppImage runs the executable from somewhere weird, but sets OWD=cwd() first. 81 | pub fn get_cwd(&self) -> Result { 82 | self.working_directory 83 | .as_ref() 84 | .map(|cwd| Ok(fs::canonicalize(cwd.clone())?)) 85 | .or_else(|| match env::var("OWD") { 86 | Ok(var) => Some(Ok(PathBuf::from(var))), 87 | Err(VarError::NotPresent) => None, 88 | Err(err) => Some(Err(anyhow!(err))), 89 | }) 90 | .unwrap_or_else(|| env::current_dir().map_err(Error::new)) 91 | } 92 | } 93 | 94 | impl Default for WorkerSession { 95 | fn default() -> Self { 96 | WorkerSession { 97 | force_log_page_size: None, 98 | latest_query: None, 99 | callbacks: Box::new(NoCallbacks), 100 | working_directory: None, 101 | } 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /src-tauri/src/worker/tests/mod.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | messages::{ChangeId, CommitId, RevId}, 3 | worker::WorkerSession, 4 | }; 5 | use anyhow::Result; 6 | use jj_lib::{backend::TreeValue, repo_path::RepoPath}; 7 | use std::{ 8 | fs::{self, File}, 9 | path::PathBuf, 10 | }; 11 | use tempfile::{tempdir, TempDir}; 12 | use zip::ZipArchive; 13 | 14 | mod mutations; 15 | mod queries; 16 | mod session; 17 | 18 | fn mkrepo() -> TempDir { 19 | let repo_dir = tempdir().unwrap(); 20 | let mut archive_path = PathBuf::from(env!("CARGO_MANIFEST_DIR")); 21 | archive_path.push("resources/test-repo.zip"); 22 | let archive_file = File::open(&archive_path).unwrap(); 23 | let mut archive = ZipArchive::new(archive_file).unwrap(); 24 | 25 | archive.extract(repo_dir.path()).unwrap(); 26 | 27 | repo_dir 28 | } 29 | 30 | fn mkid(xid: &str, cid: &str) -> RevId { 31 | RevId { 32 | change: ChangeId { 33 | hex: xid.to_owned(), 34 | prefix: xid.to_owned(), 35 | rest: "".to_owned(), 36 | }, 37 | commit: CommitId { 38 | hex: cid.to_owned(), 39 | prefix: cid.to_owned(), 40 | rest: "".to_owned(), 41 | }, 42 | } 43 | } 44 | 45 | mod revs { 46 | use crate::messages::RevId; 47 | 48 | use super::mkid; 49 | 50 | pub fn working_copy() -> RevId { 51 | mkid("nnloouly", "56018b94eb61a9acddc58ad7974aa51c3368eadd") 52 | } 53 | 54 | pub fn main_bookmark() -> RevId { 55 | mkid("mnkoropy", "87e9c6c03e1b727ff712d962c03b32fffb704bc0") 56 | } 57 | 58 | pub fn conflict_bookmark() -> RevId { 59 | mkid("nwrnuwyp", "880abeefdd3ac344e2a0901c5f486d02d34053da") 60 | } 61 | 62 | pub fn resolve_conflict() -> RevId { 63 | mkid("rrxroxys", "db297552443bcafc0f0715b7ace7fb4488d7954d") 64 | } 65 | } 66 | 67 | #[test] 68 | fn wc_path_is_visible() -> Result<()> { 69 | let repo = mkrepo(); 70 | 71 | let mut session = WorkerSession::default(); 72 | let ws = session.load_directory(repo.path())?; 73 | 74 | let commit = ws.get_commit(ws.wc_id())?; 75 | let value = commit 76 | .tree()? 77 | .path_value(RepoPath::from_internal_string("a.txt"))?; 78 | 79 | assert!(value.is_resolved()); 80 | assert!(value 81 | .first() 82 | .as_ref() 83 | .is_some_and(|x| matches!(x, TreeValue::File { .. }))); 84 | 85 | Ok(()) 86 | } 87 | 88 | #[test] 89 | fn snapshot_updates_wc_if_changed() -> Result<()> { 90 | let repo = mkrepo(); 91 | 92 | let mut session = WorkerSession::default(); 93 | let mut ws = session.load_directory(repo.path())?; 94 | let old_wc = ws.wc_id().clone(); 95 | 96 | assert!(!ws.import_and_snapshot(true)?); 97 | assert_eq!(&old_wc, ws.wc_id()); 98 | 99 | fs::write(repo.path().join("new.txt"), []).unwrap(); 100 | 101 | assert!(ws.import_and_snapshot(true)?); 102 | assert_ne!(&old_wc, ws.wc_id()); 103 | 104 | Ok(()) 105 | } 106 | 107 | #[test] 108 | fn transaction_updates_wc_if_snapshot() -> Result<()> { 109 | let repo = mkrepo(); 110 | 111 | let mut session = WorkerSession::default(); 112 | let mut ws = session.load_directory(repo.path())?; 113 | let old_wc = ws.wc_id().clone(); 114 | 115 | fs::write(repo.path().join("new.txt"), []).unwrap(); 116 | 117 | let tx = ws.start_transaction()?; 118 | ws.finish_transaction(tx, "do nothing")?; 119 | 120 | assert_ne!(&old_wc, ws.wc_id()); 121 | 122 | Ok(()) 123 | } 124 | 125 | #[test] 126 | fn transaction_snapshot_path_is_visible() -> Result<()> { 127 | let repo = mkrepo(); 128 | 129 | let mut session = WorkerSession::default(); 130 | let mut ws = session.load_directory(repo.path())?; 131 | 132 | fs::write(repo.path().join("new.txt"), []).unwrap(); 133 | 134 | let tx = ws.start_transaction()?; 135 | ws.finish_transaction(tx, "do nothing")?; 136 | 137 | let commit = ws.get_commit(ws.wc_id())?; 138 | let value = commit 139 | .tree()? 140 | .path_value(RepoPath::from_internal_string("new.txt"))?; 141 | 142 | assert!(value.is_resolved()); 143 | assert!(value 144 | .first() 145 | .as_ref() 146 | .is_some_and(|x| matches!(x, TreeValue::File { .. }))); 147 | 148 | Ok(()) 149 | } 150 | -------------------------------------------------------------------------------- /src-tauri/src/worker/tests/queries.rs: -------------------------------------------------------------------------------- 1 | use super::{mkrepo, revs}; 2 | use crate::messages::{RevHeader, RevResult, StoreRef}; 3 | use crate::worker::{queries, WorkerSession}; 4 | use anyhow::Result; 5 | use assert_matches::assert_matches; 6 | 7 | #[test] 8 | fn log_all() -> Result<()> { 9 | let repo = mkrepo(); 10 | 11 | let mut session = WorkerSession::default(); 12 | let ws = session.load_directory(repo.path())?; 13 | 14 | let all_rows = queries::query_log(&ws, "all()", 100)?; 15 | 16 | assert_eq!(12, all_rows.rows.len()); 17 | assert!(!all_rows.has_more); 18 | 19 | Ok(()) 20 | } 21 | 22 | #[test] 23 | fn log_paged() -> Result<()> { 24 | let repo = mkrepo(); 25 | 26 | let mut session = WorkerSession::default(); 27 | let ws = session.load_directory(repo.path())?; 28 | 29 | let page_rows = queries::query_log(&ws, "all()", 6)?; 30 | 31 | assert_eq!(6, page_rows.rows.len()); 32 | assert!(page_rows.has_more); 33 | 34 | Ok(()) 35 | } 36 | 37 | #[test] 38 | fn log_subset() -> Result<()> { 39 | let repo = mkrepo(); 40 | 41 | let mut session = WorkerSession::default(); 42 | let ws = session.load_directory(repo.path())?; 43 | 44 | let several_rows = queries::query_log(&ws, "bookmarks()", 100)?; 45 | 46 | assert_eq!(3, several_rows.rows.len()); 47 | 48 | Ok(()) 49 | } 50 | 51 | #[test] 52 | fn log_mutable() -> Result<()> { 53 | let repo = mkrepo(); 54 | 55 | let mut session = WorkerSession::default(); 56 | let ws = session.load_directory(repo.path())?; 57 | 58 | let single_row = queries::query_log(&ws, "mnkoropy", 100)? 59 | .rows 60 | .pop() 61 | .unwrap(); 62 | 63 | assert!(!single_row.revision.is_immutable); 64 | 65 | Ok(()) 66 | } 67 | 68 | #[test] 69 | fn log_immutable() -> Result<()> { 70 | let repo = mkrepo(); 71 | 72 | let mut session = WorkerSession::default(); 73 | let ws = session.load_directory(repo.path())?; 74 | 75 | let single_row = queries::query_log(&ws, "ummxkyyk", 100)? 76 | .rows 77 | .pop() 78 | .unwrap(); 79 | 80 | assert!(single_row.revision.is_immutable); 81 | 82 | Ok(()) 83 | } 84 | 85 | #[test] 86 | fn revision() -> Result<()> { 87 | let repo = mkrepo(); 88 | 89 | let mut session = WorkerSession::default(); 90 | let ws = session.load_directory(repo.path())?; 91 | 92 | let rev = queries::query_revision(&ws, revs::main_bookmark())?; 93 | 94 | assert_matches!( 95 | rev, 96 | RevResult::Detail { 97 | header: RevHeader { refs, .. }, 98 | .. 99 | } if matches!(refs.as_slice(), [StoreRef::LocalBookmark { branch_name, .. }] if branch_name == "main") 100 | ); 101 | 102 | Ok(()) 103 | } 104 | 105 | #[test] 106 | fn remotes_all() -> Result<()> { 107 | let repo = mkrepo(); 108 | 109 | let mut session = WorkerSession::default(); 110 | let ws = session.load_directory(repo.path())?; 111 | 112 | let remotes = queries::query_remotes(&ws, None)?; 113 | 114 | assert_eq!(2, remotes.len()); 115 | assert!(remotes.contains(&String::from("origin"))); 116 | assert!(remotes.contains(&String::from("second"))); 117 | 118 | Ok(()) 119 | } 120 | 121 | #[test] 122 | fn remotes_tracking_bookmark() -> Result<()> { 123 | let repo = mkrepo(); 124 | 125 | let mut session = WorkerSession::default(); 126 | let ws = session.load_directory(repo.path())?; 127 | 128 | let remotes = queries::query_remotes(&ws, Some(String::from("main")))?; 129 | 130 | assert_eq!(1, remotes.len()); 131 | assert!(remotes.contains(&String::from("origin"))); 132 | 133 | Ok(()) 134 | } 135 | -------------------------------------------------------------------------------- /src-tauri/tauri.conf.json: -------------------------------------------------------------------------------- 1 | { 2 | "productName": "gg", 3 | "version": "0.27.0", 4 | "identifier": "au.gulbanana.gg", 5 | "build": { 6 | "beforeDevCommand": "npm run dev", 7 | "devUrl": "http://localhost:1420", 8 | "beforeBuildCommand": "npm run build", 9 | "frontendDist": "../dist" 10 | }, 11 | "app": { 12 | "windows": [ 13 | { 14 | "label": "main", 15 | "title": "GG - Gui for JJ", 16 | "decorations": true, 17 | "resizable": true, 18 | "focus": true, 19 | "width": 1280, 20 | "height": 720, 21 | "visible": false, 22 | "dragDropEnabled": false 23 | } 24 | ], 25 | "security": { 26 | "csp": null 27 | } 28 | }, 29 | "bundle": { 30 | "active": true, 31 | "targets": "all", 32 | "icon": [ 33 | "icons/32x32.png", 34 | "icons/128x128.png", 35 | "icons/128x128@2x.png", 36 | "icons/icon.icns", 37 | "icons/icon.ico" 38 | ], 39 | "windows": { 40 | "signCommand": "trusted-signing-cli -e https://wus2.codesigning.azure.net/ -a agile-signing -c cloud-apps %1" 41 | } 42 | }, 43 | "plugins": {} 44 | } -------------------------------------------------------------------------------- /src/App.svelte: -------------------------------------------------------------------------------- 1 | 126 | 127 | 128 |
129 | {#if $repoConfigEvent.type == "Initial"} 130 | 131 |

Loading...

132 |
133 | 134 |
135 | 136 | 137 | {:else if $repoConfigEvent.type == "Workspace"} 138 | {#key $repoConfigEvent.absolute_path} 139 | 140 | {/key} 141 | 142 |
143 | 144 | 145 | {#if data.type == "Detail"} 146 | 147 | {:else} 148 | 149 |

Not Found

150 |

151 | Revision | does not exist. 152 |

153 |
154 | {/if} 155 | 156 |

Error

157 |

{message}

158 |
159 | 160 |

Loading...

161 |
162 |
163 | {:else if $repoConfigEvent.type == "LoadError"} 164 | 165 | 166 |

{$repoConfigEvent.message}.

167 |

Try opening a workspace from the Repository menu.

168 |
169 |
170 | {:else if $repoConfigEvent.type == "TimeoutError"} 171 | 172 | 173 |

Error communicating with backend: the operation is taking too long.

174 |

You may need to restart GG to continue.

175 |
176 |
177 | {:else} 178 | 179 | 180 |

Error communicating with backend: {$repoConfigEvent.message}.

181 |

You may need to restart GG to continue.

182 |
183 |
184 | {/if} 185 | 186 |
187 | 188 | 189 | 190 | {#if $currentInput} 191 | 192 | $currentInput?.callback(event.detail)} /> 197 | 198 | {:else if $currentMutation} 199 | 200 | {#if $currentMutation.type == "data" && ($currentMutation.value.type == "InternalError" || $currentMutation.value.type == "PreconditionError")} 201 | ($currentMutation = null)} severe> 202 | {#if $currentMutation.value.type == "InternalError"} 203 |

204 | {#each $currentMutation.value.message.lines as line} 205 | {line}
206 | {/each} 207 |

208 | {:else} 209 |

{$currentMutation.value.message}

210 | {/if} 211 |
212 | {:else if $currentMutation.type == "error"} 213 | ($currentMutation = null)} severe> 214 |

{$currentMutation.message}

215 |
216 | {/if} 217 |
218 | {/if} 219 |
220 | 221 | 222 | 245 | -------------------------------------------------------------------------------- /src/GraphLine.svelte: -------------------------------------------------------------------------------- 1 | 77 | 78 | {#if !line.indirect} 79 | 80 | 81 |
82 | 83 | 84 | {/if} 85 | 86 | 87 | 88 | 108 | -------------------------------------------------------------------------------- /src/GraphLog.svelte: -------------------------------------------------------------------------------- 1 | 2 | 3 | 12 | 13 | 86 | 87 | 88 | {#each visibleSlice.rows as row} 89 | {#key row} 90 | 91 | 96 | 97 | 98 | 99 | {#if row} 100 | 101 | {/if} 102 | 103 | {/key} 104 | {/each} 105 | 106 | {#each visibleSlice.rows as row} 107 | {#key row} 108 | {#each distinctLines(visibleSlice.keys, row) as line} 109 | 110 | {/each} 111 | {/key} 112 | {/each} 113 | 114 | 115 | 132 | -------------------------------------------------------------------------------- /src/GraphNode.svelte: -------------------------------------------------------------------------------- 1 | 10 | 11 | {#if header.is_immutable} 12 | 13 | {:else} 14 | 15 | {#if header.is_working_copy} 16 | 17 | {/if} 18 | {/if} 19 | 20 | 41 | -------------------------------------------------------------------------------- /src/LogPane.svelte: -------------------------------------------------------------------------------- 1 | 167 | 168 | 169 |
170 | 171 | {option.label} 172 | 173 | 174 |
175 | 176 | 184 | {#if graphRows} 185 | 191 | {#if row} 192 | 195 | {/if} 196 | 197 | {:else} 198 |
Loading changes...
199 | {/if} 200 |
201 |
202 | 203 | 216 | -------------------------------------------------------------------------------- /src/controls/ActionWidget.svelte: -------------------------------------------------------------------------------- 1 | 9 | 10 | {#if disabled || (!safe && $hasModal)} 11 | 14 | {:else} 15 | 23 | {/if} 24 | 25 | 82 | -------------------------------------------------------------------------------- /src/controls/AuthorSpan.svelte: -------------------------------------------------------------------------------- 1 | 23 | 24 | 25 | 26 | {#if includeTimestamp} 27 |
28 | {author.name} 29 |
, 30 |
31 | {relativeDate()} 32 |
33 | {:else} 34 |
35 | {author.name} 36 |
37 | {/if} 38 |
39 | 40 | 51 | -------------------------------------------------------------------------------- /src/controls/BoundQuery.svelte: -------------------------------------------------------------------------------- 1 | 5 | 6 | 19 | 20 | {#key query} 21 | {#if query.type == "wait"} 22 | 23 | {:else if query.type == "error"} 24 | 25 | {query.message} 26 | 27 | {:else} 28 | 29 | {/if} 30 | {/key} 31 | 32 | 37 | -------------------------------------------------------------------------------- /src/controls/BranchSpan.svelte: -------------------------------------------------------------------------------- 1 | 6 | 7 | 8 | {ref.branch_name}{#if ref.type == "RemoteBookmark"}@{ref.remote_name}{/if} 9 | 10 | 11 | 19 | -------------------------------------------------------------------------------- /src/controls/CheckWidget.svelte: -------------------------------------------------------------------------------- 1 | 7 | 8 | 12 | 13 | 27 | -------------------------------------------------------------------------------- /src/controls/Chip.svelte: -------------------------------------------------------------------------------- 1 | 8 | 9 |
10 | 11 |
12 | 13 | 51 | -------------------------------------------------------------------------------- /src/controls/Icon.svelte: -------------------------------------------------------------------------------- 1 | 6 | 7 | 13 | 14 | 15 | 16 | 40 | -------------------------------------------------------------------------------- /src/controls/IdSpan.svelte: -------------------------------------------------------------------------------- 1 | 10 | 11 | 12 | {id.prefix}{suffix} 13 | 14 | 15 | 41 | -------------------------------------------------------------------------------- /src/controls/ListWidget.svelte: -------------------------------------------------------------------------------- 1 | 9 | 10 | 118 | 119 |
    130 | 131 |
132 | 133 | 160 | -------------------------------------------------------------------------------- /src/controls/SelectWidget.svelte: -------------------------------------------------------------------------------- 1 | 19 | 20 |
21 | 28 | 29 |
30 | 31 | -------------------------------------------------------------------------------- /src/global.css: -------------------------------------------------------------------------------- 1 | @import "modern-normalize"; 2 | @import "@catppuccin/palette/css/catppuccin.css"; 3 | 4 | :root { 5 | overscroll-behavior: none; 6 | font-synthesis: none; 7 | text-rendering: optimizeLegibility; 8 | -webkit-font-smoothing: antialiased; 9 | -moz-osx-font-smoothing: grayscale; 10 | -webkit-text-size-adjust: 100%; 11 | 12 | /* system fonts */ 13 | --stack-code: ui-monospace, 14 | 'Cascadia Code', 15 | 'Source Code Pro', 16 | Menlo, 17 | Consolas, 18 | 'DejaVu Sans Mono', 19 | monospace; 20 | --stack-industrial: Bahnschrift, 21 | "DIN Alternate", 22 | "Franklin Gothic Medium", 23 | "Roboto", 24 | "Nimbus Sans", 25 | sans-serif-condensed, 26 | sans-serif; 27 | } 28 | 29 | #shell, 30 | #shell.light { 31 | /* catppuccin latte */ 32 | --ctp-crust: var(--ctp-latte-crust); 33 | --ctp-mantle: var(--ctp-latte-mantle); 34 | --ctp-base: var(--ctp-latte-base); 35 | --ctp-surface0: var(--ctp-latte-surface0); 36 | --ctp-surface1: var(--ctp-latte-surface1); 37 | --ctp-surface2: var(--ctp-latte-surface2); 38 | --ctp-overlay0: var(--ctp-latte-overlay0); 39 | --ctp-overlay0-rgb: var(--ctp-latte-overlay0-rgb); 40 | --ctp-overlay1: var(--ctp-latte-overlay1); 41 | --ctp-overlay1-rgb: var(--ctp-latte-overlay1-rgb); 42 | --ctp-overlay2: var(--ctp-latte-overlay2); 43 | --ctp-overlay2-rgb: var(--ctp-latte-overlay2-rgb); 44 | --ctp-subtext0: var(--ctp-latte-subtext0); 45 | --ctp-subtext1: var(--ctp-latte-subtext1); 46 | --ctp-text: var(--ctp-latte-text); 47 | --ctp-lavender: var(--ctp-latte-lavender); 48 | --ctp-blue: var(--ctp-latte-blue); 49 | --ctp-sapphire: var(--ctp-latte-sapphire); 50 | --ctp-sky: var(--ctp-latte-sky); 51 | --ctp-teal: var(--ctp-latte-teal); 52 | --ctp-green: var(--ctp-latte-green); 53 | --ctp-yellow: var(--ctp-latte-yellow); 54 | --ctp-peach: var(--ctp-latte-peach); 55 | --ctp-maroon: var(--ctp-latte-maroon); 56 | --ctp-red: var(--ctp-latte-red); 57 | --ctp-mauve: var(--ctp-latte-mauve); 58 | --ctp-pink: var(--ctp-latte-pink); 59 | --ctp-flamingo: var(--ctp-latte-flamingo); 60 | --ctp-rosewater: var(--ctp-latte-rosewater); 61 | } 62 | 63 | #shell.dark { 64 | --ctp-crust: var(--ctp-macchiato-crust); 65 | --ctp-mantle: var(--ctp-macchiato-mantle); 66 | --ctp-base: var(--ctp-macchiato-base); 67 | --ctp-surface0: var(--ctp-macchiato-surface0); 68 | --ctp-surface1: var(--ctp-macchiato-surface1); 69 | --ctp-surface2: var(--ctp-macchiato-surface2); 70 | --ctp-overlay0: var(--ctp-macchiato-overlay0); 71 | --ctp-overlay0-rgb: var(--ctp-latte-overlay0-rgb); 72 | --ctp-overlay1: var(--ctp-macchiato-overlay1); 73 | --ctp-overlay1-rgb: var(--ctp-latte-overlay1-rgb); 74 | --ctp-overlay2: var(--ctp-macchiato-overlay2); 75 | --ctp-overlay2-rgb: var(--ctp-latte-overlay2-rgb); 76 | --ctp-subtext0: var(--ctp-macchiato-subtext0); 77 | --ctp-subtext1: var(--ctp-macchiato-subtext1); 78 | --ctp-text: var(--ctp-macchiato-text); 79 | --ctp-lavender: var(--ctp-macchiato-lavender); 80 | --ctp-blue: var(--ctp-macchiato-blue); 81 | --ctp-sapphire: var(--ctp-macchiato-sapphire); 82 | --ctp-sky: var(--ctp-macchiato-sky); 83 | --ctp-teal: var(--ctp-macchiato-teal); 84 | --ctp-green: var(--ctp-macchiato-green); 85 | --ctp-yellow: var(--ctp-macchiato-yellow); 86 | --ctp-peach: var(--ctp-macchiato-peach); 87 | --ctp-maroon: var(--ctp-macchiato-maroon); 88 | --ctp-red: var(--ctp-macchiato-red); 89 | --ctp-mauve: var(--ctp-macchiato-mauve); 90 | --ctp-pink: var(--ctp-macchiato-pink); 91 | --ctp-flamingo: var(--ctp-macchiato-flamingo); 92 | --ctp-rosewater: var(--ctp-macchiato-rosewater); 93 | } 94 | 95 | @media (prefers-color-scheme: dark) { 96 | #shell { 97 | --ctp-crust: var(--ctp-macchiato-crust); 98 | --ctp-mantle: var(--ctp-macchiato-mantle); 99 | --ctp-base: var(--ctp-macchiato-base); 100 | --ctp-surface0: var(--ctp-macchiato-surface0); 101 | --ctp-surface1: var(--ctp-macchiato-surface1); 102 | --ctp-surface2: var(--ctp-macchiato-surface2); 103 | --ctp-overlay0: var(--ctp-macchiato-overlay0); 104 | --ctp-overlay0-rgb: var(--ctp-latte-overlay0-rgb); 105 | --ctp-overlay1: var(--ctp-macchiato-overlay1); 106 | --ctp-overlay1-rgb: var(--ctp-latte-overlay1-rgb); 107 | --ctp-overlay2: var(--ctp-macchiato-overlay2); 108 | --ctp-overlay2-rgb: var(--ctp-latte-overlay2-rgb); 109 | --ctp-subtext0: var(--ctp-macchiato-subtext0); 110 | --ctp-subtext1: var(--ctp-macchiato-subtext1); 111 | --ctp-text: var(--ctp-macchiato-text); 112 | --ctp-lavender: var(--ctp-macchiato-lavender); 113 | --ctp-blue: var(--ctp-macchiato-blue); 114 | --ctp-sapphire: var(--ctp-macchiato-sapphire); 115 | --ctp-sky: var(--ctp-macchiato-sky); 116 | --ctp-teal: var(--ctp-macchiato-teal); 117 | --ctp-green: var(--ctp-macchiato-green); 118 | --ctp-yellow: var(--ctp-macchiato-yellow); 119 | --ctp-peach: var(--ctp-macchiato-peach); 120 | --ctp-maroon: var(--ctp-macchiato-maroon); 121 | --ctp-red: var(--ctp-macchiato-red); 122 | --ctp-mauve: var(--ctp-macchiato-mauve); 123 | --ctp-pink: var(--ctp-macchiato-pink); 124 | --ctp-flamingo: var(--ctp-macchiato-flamingo); 125 | --ctp-rosewater: var(--ctp-macchiato-rosewater); 126 | } 127 | } 128 | 129 | ::selection { 130 | background-color: transparent; 131 | color: var(--ctp-rosewater); 132 | } 133 | 134 | textarea, 135 | select, 136 | input { 137 | outline: none; 138 | border: 1px solid var(--ctp-overlay0); 139 | border-radius: 3px; 140 | padding: 1px; 141 | 142 | &:focus-visible { 143 | border-color: var(--ctp-lavender); 144 | border-width: 2px; 145 | padding: 0; 146 | } 147 | 148 | caret-color: var(--ctp-rosewater); 149 | 150 | background-color: var(--ctp-base); 151 | color: var(--ctp-text); 152 | scrollbar-color: var(--ctp-text) var(--ctp-base); 153 | 154 | &:disabled { 155 | background-color: transparent; 156 | } 157 | 158 | &::-webkit-scrollbar { 159 | width: 6px; 160 | } 161 | 162 | &::-webkit-scrollbar-thumb { 163 | background-color: var(--ctp-text); 164 | border-radius: 6px; 165 | } 166 | 167 | &::-webkit-scrollbar-track { 168 | background-color: var(--ctp-base); 169 | } 170 | } 171 | 172 | h1, 173 | h2, 174 | h3 { 175 | margin: 0; 176 | } 177 | 178 | ul, 179 | ol { 180 | margin: 0; 181 | padding: 0; 182 | } 183 | 184 | /* make generic elements drop-transparent */ 185 | div, 186 | span, 187 | section, 188 | h3 { 189 | pointer-events: none; 190 | } 191 | 192 | textarea, 193 | select, 194 | input, 195 | button, 196 | ol, 197 | ul, 198 | label { 199 | pointer-events: all; 200 | } -------------------------------------------------------------------------------- /src/ipc.ts: -------------------------------------------------------------------------------- 1 | import { invoke, type InvokeArgs } from "@tauri-apps/api/core"; 2 | import { emit, listen, type EventCallback } from "@tauri-apps/api/event"; 3 | import type { Readable, Subscriber, Unsubscriber } from "svelte/store"; 4 | import type { MutationResult } from "./messages/MutationResult"; 5 | import { currentInput, currentMutation, repoStatusEvent, revisionSelectEvent } from "./stores"; 6 | import { onMount } from "svelte"; 7 | import { resolve } from "@tauri-apps/api/path"; 8 | 9 | export type Query = { type: "wait" } | { type: "data", value: T } | { type: "error", message: string }; 10 | 11 | export interface Settable extends Readable { 12 | set: (value: T) => void; 13 | } 14 | 15 | /** 16 | * multiplexes tauri events into a svelte store; never actually unsubscribes because the store protocol isn't async 17 | */ 18 | export async function event(name: string, initialValue: T): Promise> { 19 | const subscribers = new Set>(); 20 | let lastValue: T = initialValue; 21 | 22 | const unlisten = await listen(name, event => { 23 | for (let subscriber of subscribers) { 24 | subscriber(event.payload); 25 | } 26 | }); 27 | 28 | return { 29 | subscribe(run: Subscriber): Unsubscriber { 30 | // send current value to stream 31 | if (typeof lastValue != "undefined") { 32 | run(lastValue); 33 | } 34 | 35 | // listen for new values 36 | subscribers.add(run); 37 | 38 | return () => subscribers.delete(run); 39 | }, 40 | 41 | set(value: T) { 42 | lastValue = value; 43 | emit(name, value); 44 | } 45 | } 46 | } 47 | 48 | /** 49 | * subscribes to tauri events for a component's lifetime 50 | */ 51 | export function onEvent(name: string, callback: (payload: T) => void) { 52 | onMount(() => { 53 | let promise = listen(name, e => callback(e.payload)); 54 | return () => { 55 | promise.then((unlisten) => { 56 | unlisten(); 57 | }); 58 | }; 59 | }); 60 | } 61 | 62 | /** 63 | * call an IPC which provides readonly information about the repo 64 | */ 65 | type ImmediateQuery = Extract, { type: "data" } | { type: "error" }>; 66 | type DelayedQuery = Extract, { type: "wait" }>; 67 | export async function query(command: string, request: InvokeArgs | null, onWait?: (q: DelayedQuery) => void): Promise> { 68 | try { 69 | if (onWait) { 70 | let fetch = invoke(command, request ?? undefined).then(value => ({ type: "data", value } as ImmediateQuery)); 71 | let result = await Promise.race([fetch, delay()]); 72 | if (result.type == "wait") { 73 | onWait(result); 74 | result = await fetch; 75 | } 76 | return result; 77 | } else { 78 | let result = await invoke(command, request ?? undefined); 79 | return { type: "data", value: result }; 80 | } 81 | } catch (error: any) { 82 | console.log(error); 83 | return { type: "error", message: error.toString() }; 84 | } 85 | } 86 | 87 | /** 88 | * call an IPC which, if successful, has backend side-effects 89 | */ 90 | export function trigger(command: string, request?: InvokeArgs) { 91 | (async () => { 92 | try { 93 | await invoke(command, request); 94 | } 95 | catch (error: any) { 96 | console.log(error); 97 | currentMutation.set({ type: "error", message: error.toString() }); 98 | } 99 | })(); 100 | } 101 | 102 | /** 103 | * call an IPC which, if successful, modifies the repo 104 | */ 105 | export async function mutate(command: string, mutation: T): Promise { 106 | try { 107 | // set a wait state then the data state, unless the data comes in hella fast 108 | let fetch = invoke(command, { mutation }); 109 | let result = await Promise.race([fetch.then(r => Promise.resolve>({ type: "data", value: r })), delay()]); 110 | currentMutation.set(result); 111 | let value = await fetch; 112 | 113 | // succeeded; dismiss modals 114 | if (value.type == "Updated" || value.type == "UpdatedSelection" || value.type == "Unchanged") { 115 | if (value.type != "Unchanged") { 116 | repoStatusEvent.set(value.new_status); 117 | if (value.type == "UpdatedSelection") { 118 | revisionSelectEvent.set(value.new_selection); 119 | } 120 | } 121 | currentMutation.set(null); 122 | 123 | // failed; transition from overlay or delay to error 124 | } else { 125 | currentMutation.set({ type: "data", value }); 126 | } 127 | return true; 128 | } catch (error: any) { 129 | console.log(error); 130 | currentMutation.set({ type: "error", message: error.toString() }); 131 | return false; 132 | } 133 | } 134 | 135 | /** 136 | * utility function for composing IPCs with delayed loading states 137 | */ 138 | export function delay(): Promise> { 139 | return new Promise(function (resolve) { 140 | setTimeout(() => resolve({ type: "wait" }), 250); 141 | }); 142 | } 143 | 144 | export function getInput(title: string, detail: string, fields: T[] | { label: T, choices: string[] }[]): Promise<{ [K in T]: string } | null> { 145 | return new Promise(resolve => { 146 | if (typeof fields[0] == "string") { 147 | fields = fields.map(f => ({ label: f, choices: [] } as { label: T, choices: string[] })); 148 | } 149 | currentInput.set({ 150 | title, detail, fields: fields as { label: T, choices: string[] }[], callback: response => { 151 | currentInput.set(null); 152 | resolve(response.cancel ? null : response.fields as any); 153 | } 154 | }); 155 | }); 156 | } -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import "./global.css"; 2 | import App from "./App.svelte"; 3 | 4 | const app = new App({ 5 | target: document.getElementById("app")!, 6 | }); 7 | 8 | export default app; 9 | -------------------------------------------------------------------------------- /src/messages/AbandonRevisions.ts: -------------------------------------------------------------------------------- 1 | // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. 2 | import type { CommitId } from "./CommitId"; 3 | 4 | export interface AbandonRevisions { ids: Array, } -------------------------------------------------------------------------------- /src/messages/BackoutRevisions.ts: -------------------------------------------------------------------------------- 1 | // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. 2 | import type { RevId } from "./RevId"; 3 | 4 | export interface BackoutRevisions { ids: Array, } -------------------------------------------------------------------------------- /src/messages/ChangeHunk.ts: -------------------------------------------------------------------------------- 1 | // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. 2 | import type { HunkLocation } from "./HunkLocation"; 3 | import type { MultilineString } from "./MultilineString"; 4 | 5 | export interface ChangeHunk { location: HunkLocation, lines: MultilineString, } -------------------------------------------------------------------------------- /src/messages/ChangeId.ts: -------------------------------------------------------------------------------- 1 | // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. 2 | 3 | export interface ChangeId { type: "ChangeId", hex: string, prefix: string, rest: string, } -------------------------------------------------------------------------------- /src/messages/ChangeKind.ts: -------------------------------------------------------------------------------- 1 | // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. 2 | 3 | export type ChangeKind = "None" | "Added" | "Deleted" | "Modified"; -------------------------------------------------------------------------------- /src/messages/CheckoutRevision.ts: -------------------------------------------------------------------------------- 1 | // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. 2 | import type { RevId } from "./RevId"; 3 | 4 | export interface CheckoutRevision { id: RevId, } -------------------------------------------------------------------------------- /src/messages/CommitId.ts: -------------------------------------------------------------------------------- 1 | // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. 2 | 3 | export interface CommitId { type: "CommitId", hex: string, prefix: string, rest: string, } -------------------------------------------------------------------------------- /src/messages/CopyChanges.ts: -------------------------------------------------------------------------------- 1 | // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. 2 | import type { CommitId } from "./CommitId"; 3 | import type { RevId } from "./RevId"; 4 | import type { TreePath } from "./TreePath"; 5 | 6 | export interface CopyChanges { from_id: CommitId, to_id: RevId, paths: Array, } -------------------------------------------------------------------------------- /src/messages/CreateRef.ts: -------------------------------------------------------------------------------- 1 | // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. 2 | import type { RevId } from "./RevId"; 3 | import type { StoreRef } from "./StoreRef"; 4 | 5 | export interface CreateRef { id: RevId, ref: StoreRef, } -------------------------------------------------------------------------------- /src/messages/CreateRevision.ts: -------------------------------------------------------------------------------- 1 | // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. 2 | import type { RevId } from "./RevId"; 3 | 4 | export interface CreateRevision { parent_ids: Array, } -------------------------------------------------------------------------------- /src/messages/DeleteRef.ts: -------------------------------------------------------------------------------- 1 | // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. 2 | import type { StoreRef } from "./StoreRef"; 3 | 4 | export interface DeleteRef { ref: StoreRef, } -------------------------------------------------------------------------------- /src/messages/DescribeRevision.ts: -------------------------------------------------------------------------------- 1 | // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. 2 | import type { RevId } from "./RevId"; 3 | 4 | export interface DescribeRevision { id: RevId, new_description: string, reset_author: boolean, } -------------------------------------------------------------------------------- /src/messages/DisplayPath.ts: -------------------------------------------------------------------------------- 1 | // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. 2 | 3 | export type DisplayPath = string; -------------------------------------------------------------------------------- /src/messages/DuplicateRevisions.ts: -------------------------------------------------------------------------------- 1 | // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. 2 | import type { RevId } from "./RevId"; 3 | 4 | export interface DuplicateRevisions { ids: Array, } -------------------------------------------------------------------------------- /src/messages/FileRange.ts: -------------------------------------------------------------------------------- 1 | // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. 2 | 3 | export interface FileRange { start: number, len: number, } -------------------------------------------------------------------------------- /src/messages/GitFetch.ts: -------------------------------------------------------------------------------- 1 | // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. 2 | import type { StoreRef } from "./StoreRef"; 3 | 4 | export type GitFetch = { "type": "AllBookmarks", remote_name: string, } | { "type": "AllRemotes", branch_ref: StoreRef, } | { "type": "RemoteBookmark", remote_name: string, branch_ref: StoreRef, }; -------------------------------------------------------------------------------- /src/messages/GitPush.ts: -------------------------------------------------------------------------------- 1 | // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. 2 | import type { StoreRef } from "./StoreRef"; 3 | 4 | export type GitPush = { "type": "AllBookmarks", remote_name: string, } | { "type": "AllRemotes", branch_ref: StoreRef, } | { "type": "RemoteBookmark", remote_name: string, branch_ref: StoreRef, }; -------------------------------------------------------------------------------- /src/messages/HunkLocation.ts: -------------------------------------------------------------------------------- 1 | // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. 2 | import type { FileRange } from "./FileRange"; 3 | 4 | export interface HunkLocation { from_file: FileRange, to_file: FileRange, } -------------------------------------------------------------------------------- /src/messages/InputField.ts: -------------------------------------------------------------------------------- 1 | // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. 2 | 3 | export interface InputField { label: string, choices: Array, } -------------------------------------------------------------------------------- /src/messages/InputRequest.ts: -------------------------------------------------------------------------------- 1 | // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. 2 | import type { InputField } from "./InputField"; 3 | 4 | export interface InputRequest { title: string, detail: string, fields: Array, } -------------------------------------------------------------------------------- /src/messages/InputResponse.ts: -------------------------------------------------------------------------------- 1 | // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. 2 | 3 | export interface InputResponse { cancel: boolean, fields: Record, } -------------------------------------------------------------------------------- /src/messages/InsertRevision.ts: -------------------------------------------------------------------------------- 1 | // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. 2 | import type { RevId } from "./RevId"; 3 | 4 | export interface InsertRevision { id: RevId, after_id: RevId, before_id: RevId, } -------------------------------------------------------------------------------- /src/messages/LogCoordinates.ts: -------------------------------------------------------------------------------- 1 | // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. 2 | 3 | export type LogCoordinates = [number, number]; -------------------------------------------------------------------------------- /src/messages/LogLine.ts: -------------------------------------------------------------------------------- 1 | // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. 2 | import type { LogCoordinates } from "./LogCoordinates"; 3 | 4 | export type LogLine = { "type": "FromNode", source: LogCoordinates, target: LogCoordinates, indirect: boolean, } | { "type": "ToNode", source: LogCoordinates, target: LogCoordinates, indirect: boolean, } | { "type": "ToIntersection", source: LogCoordinates, target: LogCoordinates, indirect: boolean, } | { "type": "ToMissing", source: LogCoordinates, target: LogCoordinates, indirect: boolean, }; -------------------------------------------------------------------------------- /src/messages/LogPage.ts: -------------------------------------------------------------------------------- 1 | // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. 2 | import type { LogRow } from "./LogRow"; 3 | 4 | export interface LogPage { rows: Array, has_more: boolean, } -------------------------------------------------------------------------------- /src/messages/LogRow.ts: -------------------------------------------------------------------------------- 1 | // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. 2 | import type { LogCoordinates } from "./LogCoordinates"; 3 | import type { LogLine } from "./LogLine"; 4 | import type { RevHeader } from "./RevHeader"; 5 | 6 | export interface LogRow { revision: RevHeader, location: LogCoordinates, padding: number, lines: Array, } -------------------------------------------------------------------------------- /src/messages/MoveChanges.ts: -------------------------------------------------------------------------------- 1 | // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. 2 | import type { CommitId } from "./CommitId"; 3 | import type { RevId } from "./RevId"; 4 | import type { TreePath } from "./TreePath"; 5 | 6 | export interface MoveChanges { from_id: RevId, to_id: CommitId, paths: Array, } -------------------------------------------------------------------------------- /src/messages/MoveRef.ts: -------------------------------------------------------------------------------- 1 | // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. 2 | import type { RevId } from "./RevId"; 3 | import type { StoreRef } from "./StoreRef"; 4 | 5 | export interface MoveRef { ref: StoreRef, to_id: RevId, } -------------------------------------------------------------------------------- /src/messages/MoveRevision.ts: -------------------------------------------------------------------------------- 1 | // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. 2 | import type { RevId } from "./RevId"; 3 | 4 | export interface MoveRevision { id: RevId, parent_ids: Array, } -------------------------------------------------------------------------------- /src/messages/MoveSource.ts: -------------------------------------------------------------------------------- 1 | // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. 2 | import type { CommitId } from "./CommitId"; 3 | import type { RevId } from "./RevId"; 4 | 5 | export interface MoveSource { id: RevId, parent_ids: Array, } -------------------------------------------------------------------------------- /src/messages/MultilineString.ts: -------------------------------------------------------------------------------- 1 | // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. 2 | 3 | export interface MultilineString { lines: Array, } -------------------------------------------------------------------------------- /src/messages/MutationResult.ts: -------------------------------------------------------------------------------- 1 | // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. 2 | import type { MultilineString } from "./MultilineString"; 3 | import type { RepoStatus } from "./RepoStatus"; 4 | import type { RevHeader } from "./RevHeader"; 5 | 6 | export type MutationResult = { "type": "Unchanged" } | { "type": "Updated", new_status: RepoStatus, } | { "type": "UpdatedSelection", new_status: RepoStatus, new_selection: RevHeader, } | { "type": "PreconditionError", message: string, } | { "type": "InternalError", message: MultilineString, }; -------------------------------------------------------------------------------- /src/messages/Operand.ts: -------------------------------------------------------------------------------- 1 | // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. 2 | import type { RevHeader } from "./RevHeader"; 3 | import type { StoreRef } from "./StoreRef"; 4 | import type { TreePath } from "./TreePath"; 5 | 6 | export type Operand = { "type": "Repository" } | { "type": "Revision", header: RevHeader, } | { "type": "Merge", header: RevHeader, } | { "type": "Parent", header: RevHeader, child: RevHeader, } | { "type": "Change", header: RevHeader, path: TreePath, } | { "type": "Ref", header: RevHeader, ref: StoreRef, }; -------------------------------------------------------------------------------- /src/messages/RenameBranch.ts: -------------------------------------------------------------------------------- 1 | // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. 2 | import type { StoreRef } from "./StoreRef"; 3 | 4 | export interface RenameBranch { ref: StoreRef, new_name: string, } -------------------------------------------------------------------------------- /src/messages/RepoConfig.ts: -------------------------------------------------------------------------------- 1 | // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. 2 | import type { DisplayPath } from "./DisplayPath"; 3 | import type { RepoStatus } from "./RepoStatus"; 4 | 5 | export type RepoConfig = { "type": "Initial" } | { "type": "Workspace", absolute_path: DisplayPath, git_remotes: Array, default_query: string, latest_query: string, status: RepoStatus, theme_override: string | null, mark_unpushed_branches: boolean, } | { "type": "TimeoutError" } | { "type": "LoadError", absolute_path: DisplayPath, message: string, } | { "type": "WorkerError", message: string, }; -------------------------------------------------------------------------------- /src/messages/RepoStatus.ts: -------------------------------------------------------------------------------- 1 | // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. 2 | import type { CommitId } from "./CommitId"; 3 | 4 | export interface RepoStatus { operation_description: string, working_copy: CommitId, } -------------------------------------------------------------------------------- /src/messages/RevAuthor.ts: -------------------------------------------------------------------------------- 1 | // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. 2 | 3 | export interface RevAuthor { email: string, name: string, timestamp: string, } -------------------------------------------------------------------------------- /src/messages/RevChange.ts: -------------------------------------------------------------------------------- 1 | // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. 2 | import type { ChangeHunk } from "./ChangeHunk"; 3 | import type { ChangeKind } from "./ChangeKind"; 4 | import type { TreePath } from "./TreePath"; 5 | 6 | export interface RevChange { kind: ChangeKind, path: TreePath, has_conflict: boolean, hunks: Array, } -------------------------------------------------------------------------------- /src/messages/RevConflict.ts: -------------------------------------------------------------------------------- 1 | // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. 2 | import type { ChangeHunk } from "./ChangeHunk"; 3 | import type { TreePath } from "./TreePath"; 4 | 5 | export interface RevConflict { path: TreePath, hunk: ChangeHunk, } -------------------------------------------------------------------------------- /src/messages/RevHeader.ts: -------------------------------------------------------------------------------- 1 | // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. 2 | import type { CommitId } from "./CommitId"; 3 | import type { MultilineString } from "./MultilineString"; 4 | import type { RevAuthor } from "./RevAuthor"; 5 | import type { RevId } from "./RevId"; 6 | import type { StoreRef } from "./StoreRef"; 7 | 8 | export interface RevHeader { id: RevId, description: MultilineString, author: RevAuthor, has_conflict: boolean, is_working_copy: boolean, is_immutable: boolean, refs: Array, parent_ids: Array, } -------------------------------------------------------------------------------- /src/messages/RevId.ts: -------------------------------------------------------------------------------- 1 | // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. 2 | import type { ChangeId } from "./ChangeId"; 3 | import type { CommitId } from "./CommitId"; 4 | 5 | export interface RevId { change: ChangeId, commit: CommitId, } -------------------------------------------------------------------------------- /src/messages/RevResult.ts: -------------------------------------------------------------------------------- 1 | // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. 2 | import type { RevChange } from "./RevChange"; 3 | import type { RevConflict } from "./RevConflict"; 4 | import type { RevHeader } from "./RevHeader"; 5 | import type { RevId } from "./RevId"; 6 | 7 | export type RevResult = { "type": "NotFound", id: RevId, } | { "type": "Detail", header: RevHeader, parents: Array, changes: Array, conflicts: Array, }; -------------------------------------------------------------------------------- /src/messages/StoreRef.ts: -------------------------------------------------------------------------------- 1 | // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. 2 | 3 | export type StoreRef = { "type": "LocalBookmark", branch_name: string, has_conflict: boolean, is_synced: boolean, tracking_remotes: Array, available_remotes: number, potential_remotes: number, } | { "type": "RemoteBookmark", branch_name: string, remote_name: string, has_conflict: boolean, is_synced: boolean, is_tracked: boolean, is_absent: boolean, } | { "type": "Tag", tag_name: string, }; -------------------------------------------------------------------------------- /src/messages/TrackBranch.ts: -------------------------------------------------------------------------------- 1 | // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. 2 | import type { StoreRef } from "./StoreRef"; 3 | 4 | export interface TrackBranch { ref: StoreRef, } -------------------------------------------------------------------------------- /src/messages/TreePath.ts: -------------------------------------------------------------------------------- 1 | // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. 2 | import type { DisplayPath } from "./DisplayPath"; 3 | 4 | export interface TreePath { repo_path: string, relative_path: DisplayPath, } -------------------------------------------------------------------------------- /src/messages/UndoOperation.ts: -------------------------------------------------------------------------------- 1 | // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. 2 | 3 | export type UndoOperation = null; -------------------------------------------------------------------------------- /src/messages/UntrackBranch.ts: -------------------------------------------------------------------------------- 1 | // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. 2 | import type { StoreRef } from "./StoreRef"; 3 | 4 | export interface UntrackBranch { ref: StoreRef, } -------------------------------------------------------------------------------- /src/mutators/BinaryMutator.ts: -------------------------------------------------------------------------------- 1 | import { mutate } from "../ipc"; 2 | import type { Operand } from "../messages/Operand"; 3 | import type { MoveChanges } from "../messages/MoveChanges"; 4 | import type { MoveRef } from "../messages/MoveRef"; 5 | import type { InsertRevision } from "../messages/InsertRevision"; 6 | import type { MoveRevision } from "../messages/MoveRevision"; 7 | import type { MoveSource } from "../messages/MoveSource"; 8 | import type { ChangeId } from "../messages/ChangeId"; 9 | import type { CommitId } from "../messages/CommitId"; 10 | import RevisionMutator from "./RevisionMutator"; 11 | import ChangeMutator from "./ChangeMutator"; 12 | import RefMutator from "./RefMutator"; 13 | import type { StoreRef } from "../messages/StoreRef"; 14 | 15 | export type RichHint = (string | ChangeId | CommitId | Extract)[]; 16 | export type Eligibility = { type: "yes", hint: RichHint } | { type: "maybe", hint: string } | { type: "no" }; 17 | 18 | export default class BinaryMutator { 19 | #from: Operand; 20 | #to: Operand; 21 | 22 | constructor(from: Operand, to: Operand) { 23 | this.#from = from; 24 | this.#to = to; 25 | } 26 | 27 | static canDrag(from: Operand): Eligibility { 28 | // can't change finalised commits 29 | if ((from.type == "Revision" || from.type == "Change") && from.header.is_immutable) { 30 | return { type: "maybe", hint: "(revision is immutable)" }; 31 | } 32 | 33 | // removing a parent changes the child 34 | if (from.type == "Parent" && from.child.is_immutable) { 35 | return { type: "maybe", hint: "(child is immutable)" }; 36 | } else if (from.type == "Parent" && from.child.parent_ids.length == 1) { 37 | return { type: "maybe", hint: "(child has only one parent)" }; 38 | } 39 | 40 | // can change these listed things (XXX add modes?) 41 | if (from.type == "Revision") { 42 | return { type: "yes", hint: ["Rebasing revision ", from.header.id.change] }; 43 | } else if (from.type == "Parent") { 44 | return { type: "yes", hint: ["Removing parent from revision ", from.child.id.change] }; 45 | } else if (from.type == "Change") { 46 | return { type: "yes", hint: [`Squashing changes at ${from.path.relative_path}`] }; 47 | } else if (from.type == "Ref" && from.ref.type != "Tag") { 48 | return { type: "yes", hint: ["Moving bookmark ", from.ref] }; 49 | } 50 | 51 | return { type: "no" }; 52 | } 53 | 54 | canDrop(): Eligibility { 55 | // generic prohibitions - don't drop undroppables, don't drop on yourself 56 | if (BinaryMutator.canDrag(this.#from).type != "yes" && !(this.#from.type == "Revision" && this.#to.type == "Merge")) { 57 | return { type: "no" }; 58 | } else if (this.#from == this.#to) { 59 | return { type: "no" }; 60 | } 61 | 62 | if (this.#from.type == "Revision") { 63 | if (this.#to.type == "Revision") { 64 | return { type: "yes", hint: ["Rebasing revision ", this.#from.header.id.change, " onto ", this.#to.header.id.change] }; 65 | } else if (this.#to.type == "Parent") { 66 | if (this.#to.child == this.#from.header) { 67 | return { type: "no" }; 68 | } else if (this.#to.child.is_immutable) { 69 | return { type: "maybe", hint: "(can't insert before an immutable revision)" }; 70 | } else { 71 | return { type: "yes", hint: ["Inserting revision ", this.#from.header.id.change, " before ", this.#to.child.id.change] }; 72 | } 73 | } else if (this.#to.type == "Merge") { 74 | if (this.#to.header.id.change.hex == this.#from.header.id.change.hex) { 75 | return { type: "no" }; 76 | } else { 77 | return { type: "yes", hint: ["Adding parent to revision ", this.#to.header.id.change] }; 78 | } 79 | } else if (this.#to.type == "Repository") { 80 | return { type: "yes", hint: ["Abandoning commit ", this.#from.header.id.commit] }; 81 | } 82 | } 83 | 84 | if (this.#from.type == "Parent") { 85 | if (this.#to.type == "Repository") { 86 | return { type: "yes", hint: ["Removing parent from revision ", this.#from.child.id.change] }; 87 | } 88 | } 89 | 90 | if (this.#from.type == "Change") { 91 | if (this.#to.type == "Revision") { 92 | if (this.#to.header.id.change.hex == this.#from.header.id.change.hex) { 93 | return { type: "no" }; 94 | } else if (this.#to.header.is_immutable) { 95 | return { type: "maybe", hint: "(revision is immutable)" }; 96 | } else { 97 | return { type: "yes", hint: [`Squashing changes at ${this.#from.path.relative_path} into `, this.#to.header.id.change] }; 98 | } 99 | } else if (this.#to.type == "Repository") { 100 | if (this.#from.header.parent_ids.length == 1) { 101 | return { type: "yes", hint: [`Restoring changes at ${this.#from.path.relative_path} from parent `, this.#from.header.parent_ids[0]] }; 102 | } else { 103 | return { type: "maybe", hint: "Can't restore (revision has multiple parents)" }; 104 | } 105 | } 106 | } 107 | 108 | if (this.#from.type == "Ref" && this.#from.ref.type != "Tag") { 109 | // local -> rev: set 110 | if (this.#to.type == "Revision" && this.#from.ref.type == "LocalBookmark") { 111 | if (this.#to.header.id.change.hex == this.#from.header.id.change.hex) { 112 | return { type: "no" }; 113 | } else { 114 | return { type: "yes", hint: ["Moving bookmark ", this.#from.ref, " to ", this.#to.header.id.change] }; 115 | } 116 | } 117 | 118 | // remote -> local: track 119 | else if (this.#to.type == "Ref" && this.#to.ref.type == "LocalBookmark" && 120 | this.#from.ref.type == "RemoteBookmark" && this.#from.ref.branch_name == this.#to.ref.branch_name) { 121 | if (this.#from.ref.is_tracked) { 122 | return { type: "maybe", hint: "(already tracked)" }; 123 | } else { 124 | return { type: "yes", hint: ["Tracking remote bookmark ", this.#from.ref] }; 125 | } 126 | } 127 | 128 | // anything -> anywhere: delete 129 | else if (this.#to.type == "Repository") { 130 | if (this.#from.ref.type == "LocalBookmark") { 131 | return { type: "yes", hint: ["Deleting bookmark ", this.#from.ref] }; 132 | } else { 133 | return { 134 | type: "yes", hint: ["Forgetting remote bookmark ", this.#from.ref] 135 | }; 136 | } 137 | } 138 | } 139 | 140 | return { type: "no" }; 141 | } 142 | 143 | doDrop() { 144 | if (this.#from.type == "Revision") { 145 | if (this.#to.type == "Revision") { 146 | // rebase rev onto single target 147 | mutate("move_revision", { id: this.#from.header.id, parent_ids: [this.#to.header.id] }); 148 | return; 149 | } else if (this.#to.type == "Parent") { 150 | // rebase between targets 151 | mutate("insert_revision", { id: this.#from.header.id, after_id: this.#to.header.id, before_id: this.#to.child.id }); 152 | return; 153 | } else if (this.#to.type == "Merge") { 154 | // rebase subtree onto additional targets 155 | let newParents = [...this.#to.header.parent_ids, this.#from.header.id.commit]; 156 | mutate("move_source", { id: this.#to.header.id, parent_ids: newParents }); 157 | return; 158 | } else if (this.#to.type == "Repository") { 159 | // abandon source 160 | new RevisionMutator(this.#from.header).onAbandon(); 161 | return; 162 | } 163 | } 164 | 165 | if (this.#from.type == "Parent") { 166 | if (this.#to.type == "Repository") { 167 | // rebase subtree onto fewer targets 168 | let removeCommit = this.#from.header.id.commit; 169 | let newParents = this.#from.child.parent_ids.filter(id => id.hex != removeCommit.hex); 170 | mutate("move_source", { id: this.#from.child.id, parent_ids: newParents }); 171 | return; 172 | } 173 | } 174 | 175 | if (this.#from.type == "Change") { 176 | if (this.#to.type == "Revision") { 177 | // squash path to target 178 | mutate("move_changes", { from_id: this.#from.header.id, to_id: this.#to.header.id.commit, paths: [this.#from.path] }); 179 | return; 180 | } else if (this.#to.type == "Repository") { 181 | // restore path from source parent to source 182 | new ChangeMutator(this.#from.header, this.#from.path).onRestore(); 183 | return; 184 | } 185 | } 186 | 187 | if (this.#from.type == "Ref") { 188 | if (this.#to.type == "Revision") { 189 | // point ref to revision 190 | mutate("move_ref", { to_id: this.#to.header.id, ref: this.#from.ref }); 191 | return; 192 | } else if (this.#to.type == "Ref" && this.#from.ref.type == "RemoteBookmark") { 193 | // track remote bookmark with existing local 194 | new RefMutator(this.#from.ref).onTrack(); 195 | } else if (this.#to.type == "Repository") { 196 | // various kinds of total or partial deletion 197 | new RefMutator(this.#from.ref).onDelete(); 198 | } 199 | } 200 | 201 | console.log("error: unknown validated mutation"); 202 | } 203 | } 204 | -------------------------------------------------------------------------------- /src/mutators/ChangeMutator.ts: -------------------------------------------------------------------------------- 1 | import type { RevHeader } from "../messages/RevHeader"; 2 | import type { CopyChanges } from "../messages/CopyChanges"; 3 | import type { MoveChanges } from "../messages/MoveChanges"; 4 | import type { TreePath } from "../messages/TreePath"; 5 | import { mutate } from "../ipc"; 6 | 7 | export default class ChangeMutator { 8 | #revision: RevHeader; 9 | #path: TreePath; 10 | 11 | constructor(rev: RevHeader, path: TreePath) { 12 | this.#revision = rev; 13 | this.#path = path; 14 | } 15 | 16 | handle(event: string | undefined) { 17 | if (!event) { 18 | return; 19 | } 20 | 21 | switch (event) { 22 | case "squash": 23 | this.onSquash(); 24 | break; 25 | case "restore": 26 | this.onRestore(); 27 | break; 28 | default: 29 | console.log(`unimplemented mutation '${event}'`, this); 30 | } 31 | } 32 | 33 | onSquash = () => { 34 | mutate("move_changes", { 35 | from_id: this.#revision.id, 36 | to_id: this.#revision.parent_ids[0], 37 | paths: [this.#path] 38 | }); 39 | }; 40 | 41 | onRestore = () => { 42 | mutate("copy_changes", { 43 | from_id: this.#revision.parent_ids[0], 44 | to_id: this.#revision.id, 45 | paths: [this.#path] 46 | }); 47 | }; 48 | } 49 | -------------------------------------------------------------------------------- /src/mutators/RefMutator.ts: -------------------------------------------------------------------------------- 1 | import type { StoreRef } from "../messages/StoreRef"; 2 | import type { TrackBranch } from "../messages/TrackBranch"; 3 | import type { UntrackBranch } from "../messages/UntrackBranch"; 4 | import type { RenameBranch } from "../messages/RenameBranch"; 5 | import type { GitPush } from "../messages/GitPush"; 6 | import type { GitFetch } from "../messages/GitFetch"; 7 | import type { DeleteRef } from "../messages/DeleteRef"; 8 | import { getInput, mutate, query } from "../ipc"; 9 | 10 | export default class RefMutator { 11 | #ref: StoreRef; 12 | 13 | constructor(name: StoreRef) { 14 | this.#ref = name; 15 | } 16 | 17 | handle(event: string | undefined) { 18 | if (!event) { 19 | return; 20 | } 21 | 22 | switch (event) { 23 | case "track": 24 | this.onTrack(); 25 | break; 26 | 27 | case "untrack": 28 | this.onUntrack(); 29 | break; 30 | 31 | case "push-all": 32 | this.onPushAll(); 33 | break; 34 | 35 | case "push-single": 36 | this.onPushSingle(); 37 | break; 38 | 39 | case "fetch-all": 40 | this.onFetchAll(); 41 | break; 42 | 43 | case "fetch-single": 44 | this.onFetchSingle(); 45 | break; 46 | 47 | case "rename": 48 | this.onRename(); 49 | break; 50 | 51 | case "delete": 52 | this.onDelete(); 53 | break; 54 | 55 | default: 56 | console.log(`unimplemented mutation '${event}'`, this); 57 | } 58 | } 59 | 60 | onTrack = () => { 61 | mutate("track_branch", { 62 | ref: this.#ref 63 | }); 64 | }; 65 | 66 | onUntrack = () => { 67 | mutate("untrack_branch", { 68 | ref: this.#ref 69 | }); 70 | }; 71 | 72 | onRename = async () => { 73 | let response = await getInput("Rename Bookmark", "", ["Bookmark Name"]); 74 | if (response) { 75 | let new_name = response["Bookmark Name"]; 76 | mutate("rename_branch", { 77 | ref: this.#ref, 78 | new_name 79 | }) 80 | } 81 | }; 82 | 83 | onDelete = () => { 84 | mutate("delete_ref", { 85 | ref: this.#ref 86 | }); 87 | }; 88 | 89 | onPushAll = () => { 90 | switch (this.#ref.type) { 91 | case "Tag": 92 | console.log("error: Can't push tag"); 93 | break; 94 | 95 | case "RemoteBookmark": 96 | mutate("git_push", { 97 | type: "RemoteBookmark", 98 | remote_name: this.#ref.remote_name, 99 | branch_ref: this.#ref 100 | }); 101 | break; 102 | 103 | case "LocalBookmark": 104 | mutate("git_push", { 105 | type: "AllRemotes", 106 | branch_ref: this.#ref 107 | }); 108 | break; 109 | } 110 | }; 111 | 112 | onPushSingle = async () => { 113 | switch (this.#ref.type) { 114 | case "Tag": 115 | console.log("error: Can't push tag to a specific remote"); 116 | break; 117 | 118 | case "RemoteBookmark": 119 | console.log("error: Can't push tracking bookmark to a specific remote"); 120 | break; 121 | 122 | case "LocalBookmark": 123 | let allRemotes = await query("query_remotes", { tracking_branch: null }); 124 | if (allRemotes.type == "error") { 125 | console.log("error loading remotes: " + allRemotes.message); 126 | return; 127 | } 128 | 129 | let response = await getInput("Select Remote", "", [{ label: "Remote Name", choices: allRemotes.value }]); 130 | if (response) { 131 | let remote_name = response["Remote Name"]; 132 | mutate("git_push", { 133 | type: "RemoteBookmark", 134 | remote_name, 135 | branch_ref: this.#ref 136 | }) 137 | } 138 | break; 139 | } 140 | }; 141 | 142 | onFetchAll = () => { 143 | switch (this.#ref.type) { 144 | case "Tag": 145 | console.log("error: Can't fetch tag"); 146 | break; 147 | 148 | case "RemoteBookmark": 149 | mutate("git_fetch", { 150 | type: "AllRemotes", 151 | branch_ref: this.#ref 152 | }); 153 | break; 154 | 155 | case "LocalBookmark": 156 | mutate("git_fetch", { 157 | type: "AllRemotes", 158 | branch_ref: this.#ref 159 | }); 160 | break; 161 | } 162 | }; 163 | 164 | onFetchSingle = async () => { 165 | switch (this.#ref.type) { 166 | case "Tag": 167 | console.log("error: Can't fetch tag from a specific remote"); 168 | break; 169 | 170 | case "RemoteBookmark": 171 | console.log("error: Can't fetch tracking bookmark from a specific remote"); 172 | break; 173 | 174 | case "LocalBookmark": 175 | let trackedRemotes = await query("query_remotes", { tracking_branch: this.#ref.branch_name }); 176 | if (trackedRemotes.type == "error") { 177 | console.log("error loading remotes: " + trackedRemotes.message); 178 | return; 179 | } 180 | 181 | let response = await getInput("Select Remote", "", [{ label: "Remote Name", choices: trackedRemotes.value }]); 182 | if (response) { 183 | let remote_name = response["Remote Name"]; 184 | mutate("git_fetch", { 185 | type: "RemoteBookmark", 186 | remote_name, 187 | branch_ref: this.#ref 188 | }) 189 | } 190 | break; 191 | } 192 | }; 193 | } 194 | -------------------------------------------------------------------------------- /src/mutators/RevisionMutator.ts: -------------------------------------------------------------------------------- 1 | import type { RevHeader } from "../messages/RevHeader"; 2 | import type { AbandonRevisions } from "../messages/AbandonRevisions"; 3 | import type { BackoutRevisions } from "../messages/BackoutRevisions"; 4 | import type { CheckoutRevision } from "../messages/CheckoutRevision"; 5 | import type { CopyChanges } from "../messages/CopyChanges"; 6 | import type { CreateRevision } from "../messages/CreateRevision"; 7 | import type { DescribeRevision } from "../messages/DescribeRevision"; 8 | import type { DuplicateRevisions } from "../messages/DuplicateRevisions"; 9 | import type { MoveChanges } from "../messages/MoveChanges"; 10 | import type { CreateRef } from "../messages/CreateRef"; 11 | import { getInput, mutate } from "../ipc"; 12 | import type { StoreRef } from "../messages/StoreRef"; 13 | 14 | export default class RevisionMutator { 15 | #revision: RevHeader; 16 | 17 | constructor(rev: RevHeader) { 18 | this.#revision = rev; 19 | } 20 | 21 | // context-free mutations which can be triggered by a menu event 22 | handle(event: string | undefined) { 23 | if (!event) { 24 | return; 25 | } 26 | 27 | switch (event) { 28 | case "new": 29 | this.onNew(); 30 | break; 31 | case "edit": 32 | if (!this.#revision.is_immutable) { 33 | this.onEdit(); 34 | } 35 | break; 36 | case "backout": 37 | this.onBackout(); 38 | break; 39 | case "duplicate": 40 | this.onDuplicate(); 41 | break; 42 | case "abandon": 43 | if (!this.#revision.is_immutable) { 44 | this.onAbandon(); 45 | } 46 | break; 47 | case "squash": 48 | if (!this.#revision.is_immutable && this.#revision.parent_ids.length == 1) { 49 | this.onSquash(); 50 | } 51 | break; 52 | case "restore": 53 | if (!this.#revision.is_immutable && this.#revision.parent_ids.length == 1) { 54 | this.onRestore(); 55 | } 56 | break; 57 | case "branch": 58 | this.onBranch(); 59 | break; 60 | default: 61 | console.log(`unimplemented mutation '${event}'`, this); 62 | } 63 | } 64 | 65 | onNew = () => { 66 | mutate("create_revision", { 67 | parent_ids: [this.#revision.id], 68 | }); 69 | }; 70 | 71 | onEdit = () => { 72 | if (this.#revision.is_working_copy) { 73 | return; 74 | } 75 | 76 | if (this.#revision.is_immutable) { 77 | mutate("create_revision", { 78 | parent_ids: [this.#revision.id], 79 | }); 80 | } else { 81 | mutate("checkout_revision", { 82 | id: this.#revision.id, 83 | }); 84 | } 85 | }; 86 | 87 | onBackout = () => { 88 | mutate("backout_revisions", { 89 | ids: [this.#revision.id], 90 | }); 91 | }; 92 | 93 | onDuplicate = () => { 94 | mutate("duplicate_revisions", { 95 | ids: [this.#revision.id], 96 | }); 97 | }; 98 | 99 | onAbandon = () => { 100 | mutate("abandon_revisions", { 101 | ids: [this.#revision.id.commit], 102 | }); 103 | }; 104 | 105 | onDescribe = (new_description: string, reset_author: boolean) => { 106 | mutate("describe_revision", { 107 | id: this.#revision.id, 108 | new_description, 109 | reset_author, 110 | }); 111 | }; 112 | 113 | onSquash = () => { 114 | mutate("move_changes", { 115 | from_id: this.#revision.id, 116 | to_id: this.#revision.parent_ids[0], 117 | paths: [] 118 | }); 119 | }; 120 | 121 | onRestore = () => { 122 | mutate("copy_changes", { 123 | from_id: this.#revision.parent_ids[0], 124 | to_id: this.#revision.id, 125 | paths: [] 126 | }); 127 | }; 128 | 129 | onBranch = async () => { 130 | let response = await getInput("Create Bookmark", "", ["Bookmark Name"]); 131 | if (response) { 132 | let ref: StoreRef = { 133 | type: "LocalBookmark", 134 | branch_name: response["Bookmark Name"], 135 | has_conflict: false, 136 | is_synced: false, 137 | potential_remotes: 0, 138 | available_remotes: 0, 139 | tracking_remotes: [] 140 | }; 141 | mutate("create_ref", { ref, id: this.#revision.id }) 142 | } 143 | } 144 | } 145 | -------------------------------------------------------------------------------- /src/objects/BranchObject.svelte: -------------------------------------------------------------------------------- 1 | 70 | 71 | 72 | 73 | 74 | 75 | {dragHint ?? dropHint ?? label} 76 | 77 | 78 | 79 | -------------------------------------------------------------------------------- /src/objects/ChangeObject.svelte: -------------------------------------------------------------------------------- 1 | 37 | 38 | 47 | 48 |
49 | 50 | {hint ?? change.path.relative_path} 51 |
52 |
53 |
54 | 55 | 70 | -------------------------------------------------------------------------------- /src/objects/Object.svelte: -------------------------------------------------------------------------------- 1 | 5 | 6 | 79 | 80 | 98 | 99 | 147 | -------------------------------------------------------------------------------- /src/objects/RevisionObject.svelte: -------------------------------------------------------------------------------- 1 | 28 | 29 | 39 | {#if child} 40 | 41 |
42 | id.hex == header.id.commit.hex) != -1)} /> 47 | 48 | 49 | {dragHint ?? (header.description.lines[0] == "" ? "(no description set)" : header.description.lines[0])} 50 | 51 | 52 | 53 | 54 | 55 | {#each header.refs as ref} 56 | {#if ref.type != "Tag"} 57 | {#if !noBranches && (ref.type == "LocalBookmark" || !ref.is_synced || !ref.is_tracked)} 58 |
59 | 60 |
61 | {/if} 62 | {:else} 63 |
64 | 65 |
66 | {/if} 67 | {/each} 68 |
69 |
70 | {:else} 71 | 72 |
73 | 74 | 75 | 76 | {dragHint ?? 77 | dropHint ?? 78 | (header.description.lines[0] == "" ? "(no description set)" : header.description.lines[0])} 79 | 80 | 81 | 82 | 83 | 84 | {#each header.refs as ref} 85 | {#if ref.type != "Tag"} 86 | {#if ref.type == "LocalBookmark" || !ref.is_synced || !ref.is_tracked} 87 |
88 | 89 |
90 | {/if} 91 | {:else} 92 |
93 | 94 |
95 | {/if} 96 | {/each} 97 |
98 |
99 |
100 | {/if} 101 |
102 | 103 | 173 | -------------------------------------------------------------------------------- /src/objects/TagObject.svelte: -------------------------------------------------------------------------------- 1 | 12 | 13 | 14 | {ref.tag_name} 15 | 16 | -------------------------------------------------------------------------------- /src/objects/Zone.svelte: -------------------------------------------------------------------------------- 1 | 5 | 6 | 65 | 66 | 76 | 77 | 87 | -------------------------------------------------------------------------------- /src/shell/ErrorDialog.svelte: -------------------------------------------------------------------------------- 1 | 10 | 11 | 12 | 13 | 14 | 15 | {#if onClose} 16 | OK 17 | {/if} 18 | 19 | 20 | -------------------------------------------------------------------------------- /src/shell/InputDialog.svelte: -------------------------------------------------------------------------------- 1 | 49 | 50 | 51 | {#if detail != ""} 52 |

{detail}

53 | {/if} 54 | {#each fields as field} 55 | 56 | {#if field.choices.length > 0} 57 | { 60 | return { label: c, value: c }; 61 | })} 62 | value={field.choices[0]} /> 63 | {:else if field.label == "Password"} 64 | 65 | {:else} 66 | 67 | {/if} 68 | {/each} 69 | 70 | Enter 71 | Cancel 72 | 73 |
74 | 75 | 89 | -------------------------------------------------------------------------------- /src/shell/ModalDialog.svelte: -------------------------------------------------------------------------------- 1 | 29 | 30 | 41 | 42 | 93 | -------------------------------------------------------------------------------- /src/shell/ModalOverlay.svelte: -------------------------------------------------------------------------------- 1 | 12 | 13 |
14 | 15 |
16 | 17 | 34 | -------------------------------------------------------------------------------- /src/shell/Pane.svelte: -------------------------------------------------------------------------------- 1 | 4 | 5 |
6 |
7 | 8 |
9 |
10 | 11 |
12 |
13 | 14 | 33 | -------------------------------------------------------------------------------- /src/shell/Settings.ts: -------------------------------------------------------------------------------- 1 | export default interface Settings { 2 | markUnpushedBranches: boolean 3 | } -------------------------------------------------------------------------------- /src/shell/StatusBar.svelte: -------------------------------------------------------------------------------- 1 | 59 | 60 | {#if !dropHint} 61 |
62 |
63 | 64 | {$repoConfigEvent?.type == "Workspace" ? $repoConfigEvent.absolute_path : "No workspace"} 65 | 66 |
67 |
68 | {#if $repoConfigEvent?.type == "Workspace"} 69 | {#each $repoConfigEvent.git_remotes as remote} 70 |
71 | onPush(remote)}> 72 | 73 | 74 | {remote} 75 | onFetch(remote)}> 76 | 77 | 78 |
79 | {/each} 80 | {/if} 81 |
82 |
83 | 84 | {$repoConfigEvent?.type != "Workspace" 85 | ? "" 86 | : ($repoStatusEvent?.operation_description ?? "no operation")} 87 | 88 | 89 | Undo 90 | 91 |
92 |
93 | {:else} 94 |
95 |
96 | {#each dropHint as run, i} 97 | {#if typeof run == "string"} 98 | {run}{i == dropHint.length - 1 ? "." : ""} 99 | {:else if run.type == "LocalBookmark" || run.type == "RemoteBookmark"} 100 | 101 | {:else} 102 | {i == dropHint.length - 1 ? "." : ""} 103 | {/if} 104 | {/each} 105 |
106 |
107 | {/if} 108 | 109 | 166 | -------------------------------------------------------------------------------- /src/stores.ts: -------------------------------------------------------------------------------- 1 | import type { MutationResult } from "./messages/MutationResult"; 2 | import type { RepoConfig } from "./messages/RepoConfig"; 3 | import type { RepoStatus } from "./messages/RepoStatus"; 4 | import type { RevHeader } from "./messages/RevHeader"; 5 | import type { Operand } from "./messages/Operand"; 6 | import { writable } from "svelte/store"; 7 | import { event, type Query } from "./ipc"; 8 | import type { InputRequest } from "./messages/InputRequest"; 9 | import type { InputResponse } from "./messages/InputResponse"; 10 | import type { RevChange } from "./messages/RevChange"; 11 | 12 | export const repoConfigEvent = await event("gg://repo/config", { type: "Initial" }); 13 | export const repoStatusEvent = await event("gg://repo/status", undefined); 14 | export const revisionSelectEvent = await event("gg://revision/select", undefined); 15 | export const changeSelectEvent = await event("gg://change/select", undefined); 16 | 17 | export const currentMutation = writable | null>(null); 18 | export const currentContext = writable(); 19 | export const currentSource = writable(); 20 | export const currentTarget = writable(); 21 | export const currentInput = writable void } | null>(); 22 | 23 | export const hasModal = writable(false); 24 | 25 | export function dragOverWidget(event: DragEvent) { 26 | event.stopPropagation(); 27 | currentTarget.set(null); 28 | } -------------------------------------------------------------------------------- /src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | -------------------------------------------------------------------------------- /svelte.config.js: -------------------------------------------------------------------------------- 1 | import { vitePreprocess } from "@sveltejs/vite-plugin-svelte"; 2 | 3 | export default { 4 | // Consult https://svelte.dev/docs#compile-time-svelte-preprocess 5 | // for more information about preprocessors 6 | preprocess: vitePreprocess(), 7 | }; 8 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@tsconfig/svelte/tsconfig.json", 3 | "compilerOptions": { 4 | "target": "ESNext", 5 | "useDefineForClassFields": true, 6 | "module": "ESNext", 7 | "resolveJsonModule": true, 8 | /** 9 | * Typecheck JS in `.svelte` and `.js` files by default. 10 | * Disable checkJs if you'd like to use dynamic types in JS. 11 | * Note that setting allowJs false does not prevent the use 12 | * of JS in `.svelte` files. 13 | */ 14 | "allowJs": true, 15 | "checkJs": true, 16 | "isolatedModules": true, 17 | "strictPropertyInitialization": false 18 | }, 19 | "include": [ 20 | "src/**/*.d.ts", 21 | "src/**/*.ts", 22 | "src/**/*.js", 23 | "src/**/*.svelte" 24 | ], 25 | "references": [ 26 | { 27 | "path": "./tsconfig.node.json" 28 | } 29 | ] 30 | } -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "skipLibCheck": true, 5 | "module": "ESNext", 6 | "moduleResolution": "bundler" 7 | }, 8 | "include": ["vite.config.ts"] 9 | } 10 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vite"; 2 | import { svelte } from "@sveltejs/vite-plugin-svelte"; 3 | 4 | // https://vitejs.dev/config/ 5 | export default defineConfig(async () => ({ 6 | plugins: [svelte()], 7 | 8 | // Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build` 9 | // 10 | // 1. prevent vite from obscuring rust errors 11 | clearScreen: false, 12 | // 2. tauri expects a fixed port, fail if that port is not available 13 | server: { 14 | port: 1420, 15 | strictPort: true, 16 | watch: { 17 | // 3. tell vite to ignore watching `src-tauri` 18 | ignored: ["**/src-tauri/**"], 19 | }, 20 | }, 21 | 22 | build: { 23 | target: ["es2022", "chrome97", "edge97", "safari15"] 24 | } 25 | })); 26 | --------------------------------------------------------------------------------