├── .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 | #  GG - Gui for JJ
2 |
3 | 
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 |
12 |
13 |
14 | {:else}
15 |
21 |
22 |
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 |
9 |
10 |
11 |
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 | dispatch("change", event)}>
22 | {#each options as option}
23 |
24 | {option.value}
25 |
26 | {/each}
27 |
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 |
96 |
97 |
98 |
99 |
147 |
--------------------------------------------------------------------------------
/src/objects/RevisionObject.svelte:
--------------------------------------------------------------------------------
1 |
28 |
29 |
39 | {#if child}
40 |
41 |
42 |
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 |
74 |
75 |
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 | {field.label}:
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 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
93 |
--------------------------------------------------------------------------------
/src/shell/ModalOverlay.svelte:
--------------------------------------------------------------------------------
1 |
12 |
13 |
14 |
15 |
16 |
17 |
34 |
--------------------------------------------------------------------------------
/src/shell/Pane.svelte:
--------------------------------------------------------------------------------
1 |
4 |
5 |
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 |
--------------------------------------------------------------------------------