Compare commits

..
53 Commits
Author SHA1 Message Date
Christoph e7d6a6e4f4 feat(ui): revamp line patch dialog and add merge progress notice
publish / Build and publish Ubuntu AppImage (release) Successful in 8m58s
publish / Build and publish Windows installer (release) Successful in 10m28s
publish / Build and publish AUR packages (release) Successful in 18m56s
Revamp the line patch dialog to improve usability and clarity.
Improve accessibility and German/English localization for UI text.
Add toolbar, hunk navigation, per-line checkbox selection and footer.
Extract merge-in-progress UI into a reusable merge progress notice.

- Replace selection UI with checkboxes, range selection, and counts
- Add hunk navigation, active-hunk tracking, and scroll synchronization
- Extract merge-in-progress UI into a reusable notice component
2026-09-08 08:32:58 +02:00
Christoph 5aa9d291b6 refactor(ui): centralize sync colors into CSS variables
Introduce --color-sync-ahead and --color-sync-behind and update sync
indicators to use those variables. Replace hardcoded sync colors with
var() and color-mix() to unify backgrounds, shadows, and graph accents
across themes, and add light-theme overrides to preserve contrast.

- Add theme-level variables for ahead/behind sync colors
- Replace literal color values with var() and color-mix() usages
- Update dashboard styles to reference the new sync variables
2026-09-08 07:45:20 +02:00
Christoph 82a5f16a0c feat(dashboard): add list and card views with persistent toggle
Add a view switcher to the repository dashboard so users can
toggle between compact list and tile (cards) layouts. The
choice is saved to localStorage so preferences persist
across sessions. Also adjust card layout, sync/branch indicators and
accessibility attributes, and refactor pullRequestTitle to accept
badge state directly for more accurate aria labels.

- Persist viewMode with STORAGE_KEY and add setViewMode helper.
- Add view toggle UI with ARIA states and new icons.
- Introduce list/table and tiles CSS and refine status display.
2026-09-07 22:58:36 +02:00
Christoph daa3298c44 Update version to 2026.9.2 2026-09-07 22:09:14 +02:00
Christoph 71a97b20bb Merge pull request 'Review center' (#37) from review-center into main
publish / Build and publish Windows installer (release) Successful in 8m15s
publish / Build and publish Ubuntu AppImage (release) Successful in 9m45s
publish / Build and publish AUR packages (release) Successful in 19m49s
Reviewed-on: #37
2026-09-07 19:44:17 +00:00
Christoph 97b5db3031 Merge remote-tracking branch 'origin/main' into review-center
# Conflicts:
#	README.md
2026-09-07 21:43:57 +02:00
Christoph f607c6b5cf feat(review-center): enhance detail panel, actions, and search
Refactor the Review Center component to improve layout, accessibility,
and user flow. Add a SelectMenu integration picker and reorganize state
tabs and search into a cleaner toolbar. Bind the detail panel, close it
on outside pointer events, and introduce richer merge/conflict UI with a
local-resolution flow plus comment formatting helpers and action tweaks.

- Add SelectMenu for integration selection and cleaner toolbar layout
- Bind detail panel, handle outside-click closing, and focus helpers
- Improve conflict/merge presentation and action menu behaviors
2026-09-07 21:42:39 +02:00
Christoph 13660c801e feat(review): auto-continue merge and poll provider after push
Coordinate local conflict resolutions with automated push and provider-
side merge status checks. When a local resolution completes the app
advances the merge workflow, pushes the updated branch, and attempts to
continue the merge. The UI shows a checking state and disables relevant
actions while the provider rechecks to prevent duplicate operations.
This streamlines finishing conflict resolution and keeps PR status in
sync with remote providers.

- Automatically push and continue merge when local resolutions finish
- Poll provider for updated mergeability and refresh request details
- Add guards to disable UI actions while waiting for remote status
2026-09-07 18:54:06 +02:00
Christoph c46a875a91 feat(review-center): add local pull-request conflict resolution flow
Add a local conflict resolution workflow that allows resolving PR
merge conflicts from the review center. The change implements host and
repository matching, prepares and merges branches locally, opens the
resolve editor, and tracks a multi-phase state machine to continue,
abort, or push the resolved branch back to the remote.

- Core resolution logic to locate matching repos, prepare merges,
  and manage phases (preparing → conflicts → ready-to-push → complete).
- UI wiring and callbacks to start, reopen, continue, abort, and push
  local resolutions from the review center.
- Helpers to open the conflict editor, monitor merge state, and mark
  completion after a successful push.
2026-09-07 17:33:54 +02:00
Christoph 25d9d28764 Update README.md 2026-09-07 14:42:22 +00:00
Christoph da53ba3c0e Merge pull request 'Review center' (#35) from review-center into main 2026-09-07 14:40:38 +00:00
Christoph 20bc53ebbb Merge pull request 'Delete directory '.claude'' (#34) from test into main 2026-09-07 14:29:12 +00:00
Christoph c6553218a2 feat(dashboard): show PR badges and deep-link to Review Center
Add pull request badges to the repository dashboard and enable deep-
linking to the Review Center with a repository and source pre-filter.
Badges are populated by resolving remotes, matching them to configured
integration sources, and querying providers for open PRs while handling
loading, error, and unavailable states. The dashboard badge opens the
Review Center pre-filled and the app stores initial query/source values
to support the deep-link.

- Resolve and normalize remotes to match integration sources reliably.
- Query provider APIs using stored credentials with sensible timeouts.
- Expose a badge action that navigates to the Review Center pre-filtered.
2026-09-07 16:19:26 +02:00
Christoph 26f09a3350 Delete directory '.claude' 2026-09-07 14:12:17 +00:00
Christoph a55a3ca300 feat(integrations): add review fetching and Linux keyring support
Add a cross-provider Review Center and improve credential handling.
Backend integrations fetch and normalize PRs from GitHub, GitLab,
Gitea, and Azure DevOps with improved timeouts and parsing. Credentials
now use a global lock and support secret-tool on Linux to avoid races.

- Normalize review data across providers (GitHub/GitLab/Gitea/Azure)
- Serialize credential access with OnceLock and use secret-tool on Linux
- Add frontend ReviewCenter component and related UI updates
2026-09-06 22:53:44 +02:00
Christoph 865ea410ea Merge pull request 'Update README.md' (#33) from test into main 2026-09-06 20:51:12 +00:00
Christoph 96d8ec739f Update README.md 2026-09-06 20:12:24 +00:00
Christoph 2b43fb18ac Merge pull request 'Update README.md' (#32) from test into main 2026-09-06 20:09:46 +00:00
Christoph 5c0c47b5c5 Update README.md 2026-09-06 19:32:59 +00:00
Christoph ebb5a5fa48 Merge pull request 'Bisect' (#31) from bisect into main
Reviewed-on: #31
2026-09-06 18:19:09 +00:00
Christoph 2ec493e91f feat(bisect-dialog): revamp commit range inputs and styling
Restructure the bisect dialog to group the search range and make the
known-good and known-bad fields more distinct and informative. The UI
and CSS were overhauled to improve spacing, sizing, color cues and
responsiveness for clearer interaction and readability.

- Introduce a labeled search-range block with dedicated good/bad fields
- Add icons, compact tags and adjusted placeholders for clarity
- Replace and simplify styles to improve layout and mobile behavior
2026-09-06 00:09:23 +02:00
Christoph 7f2b04a506 feat(bisect): add guided Git bisect support
Add interactive Git bisect support to the Tauri backend and UI.
Introduce BisectState and BisectCommit types for structured state.
Expose Tauri commands to query, start, mark, and reset sessions.
Add parsers, unit tests, and a Bisect dialog with toolbar and App wiring.

- Backend: new Tauri commands, state types, and helpers to parse bisect.
- Frontend: Bisect dialog, toolbar entry, and App integration to control flow.
2026-09-06 00:04:42 +02:00
Christoph 3ceedca0ad Merge pull request 'feat(management): extract repository management into dashboard component' (#29) from new_dashboard into main
Reviewed-on: #29
2026-09-05 21:53:31 +00:00
Christoph fcd884ee0b feat(management): extract repository management into dashboard component
Move the inline repository management UI into a new RepositoryDashboard component
and wire it into the application. Refactor app state to compute a reactive
dashboardRepos list and update tab-closing logic so tabs can be closed while
preserving the management view when appropriate.

- Add a full dashboard with search, categories, workspaces and persisted prefs.
- Refactor close behavior to support keeping the management view open.
- Improve UI/CSS and accessibility for the management tab (compact layout and label).
2026-09-05 23:45:23 +02:00
Christoph 4a4a13dcf8 Update version to 2026.9.1 2026-09-02 08:05:42 +02:00
Christoph 106cc98afb feat(branches): add Merge Branch dialog and confirm flow
publish / Build and publish Ubuntu AppImage (release) Successful in 9m52s
publish / Build and publish Windows installer (release) Successful in 11m10s
publish / Build and publish AUR packages (release) Successful in 19m3s
Introduce a modal dialog to configure and confirm branch merges. It
replaces the old prompt-based workflow and lets users choose a merge
strategy with localized labels. Confirming the dialog runs the merge,
refreshes views, and clears the pending merge target.

- Add a merge dialog UI with strategy options and keyboard support.
- Replace prompt-based merge flow with a state-driven confirm dialog.
- Include CSS for dialog layout, responsive sizing, and styling.
2026-09-01 22:27:59 +02:00
Christoph add98f962e feat(ui): add init repository dialog
Add a modal dialog to initialize a new Git repository, replacing the
previous prompt-based flow. It integrates with the app state and invokes
the initialization operation then opens the repository on confirmation.
Also adds styles, keyboard handling, accessibility hints, a folder
browser, initial-branch input, and German/English labels with busy state.

- New dialog component with folder picker, branch input, and i18n.
- Wire dialog open/close and confirm handler to init and open actions.
- Add CSS rules for dialog layout, controls, and error/busy states.
2026-09-01 21:13:46 +02:00
Christoph 90df347e4a feat(git): add advanced clone options (shallow, sparse, flags)
Add advanced clone options support across the frontend and backend.
Users can specify a branch, shallow limits, blobless mode, sparse paths,
and custom clone flags when initiating a clone operation. The backend
validates inputs, parses custom flags without invoking a shell, and
configures sparse checkouts after a successful clone. A small parsing
dependency was added and tracked-file parsing was improved to better
handle git ls-files output.

- Introduce CloneRunOptions and validation helpers for clone inputs
- Parse custom flags with a shell-like tokenizer and block managed flags
- Support sparse checkout configuration and shallow clone constraints
2026-08-31 19:43:30 +02:00
Christoph 4c58b14691 Update version to 2026.8.10 2026-08-31 11:16:56 +02:00
Christoph 8bec7dfc9a style(ui): refine tab and dialog close button visuals
publish / Build and publish Ubuntu AppImage (release) Successful in 9m31s
publish / Build and publish Windows installer (release) Successful in 10m28s
publish / Build and publish AUR packages (release) Successful in 19m9s
Adjust spacing and sizing for repo tabs and close controls. Add
transitions, larger hit areas, and updated hover/focus styles. Introduce
theme-aware variants and neutral dialog close styling for consistency.
Also update help overlay close to match the new behavior.

- increase close button size and tweak padding for alignment
- add transitions and inset focus/hover visuals with themes
- normalize dialog and help close appearance for consistency
2026-08-31 11:14:56 +02:00
Christoph 7950edb145 fix(clone-dialog): improve layout and scrolling
Adjust grid templates and overflow rules to improve layout.
Replace fixed heights with flexible rows so content fills space.
Hide overflow and tweak alignment to avoid double scrollbars.

- Use grid-template-rows to allocate header, controls, and list area
- Make repository list shell and list use height: 100% and overflow:auto
- Align URL field to the top to prevent unexpected scrolling
2026-08-31 10:25:07 +02:00
Christoph e3af6653cd fix(layout): account for repo tab bar in loading overlay
Adjust the fixed loading overlay to avoid overlapping the repo tab bar.
The top offset now adds the app titlebar and repo tabbar heights using
CSS calc with reasonable fallbacks. This keeps the overlay aligned with
top UI elements when a repository tab bar is present.

- Offset top by titlebar plus repo tabbar heights via CSS calc.
- Provide sensible fallback values to maintain stable layout.
2026-08-31 10:10:00 +02:00
Christoph b752c0804b style(repo-loading-overlay): adjust overlay position and backdrop
Remove the extra vertical offset so the loading overlay aligns with the
app titlebar. Update the backdrop color mix and increase blur to improve
contrast and perceived depth while the overlay is visible.

- Align overlay using the titlebar height variable instead of adding a
  fixed offset
- Use a denser color mix and stronger blur for a clearer backdrop effect
2026-08-31 09:05:10 +02:00
Christoph 8f98e79df9 feat(clone-dialog): redesign UI and group Azure DevOps repos
Rework CloneRepositoryDialog into a two-column layout with a source
sidebar and focused content area to make browsing sources easier.
Group Azure DevOps repositories by project with a new derived value
and render sticky project headers for clearer navigation.
Replace showIntegrations with selectIntegrationSource to load the
chosen integration, refresh repositories, and update labels and styles.

- Introduce source sidebar and refreshed dialog layout
- Add azureRepositoryGroups and project grouping UI
- Rename flow to selectIntegrationSource and improve loading logic
2026-08-31 08:43:00 +02:00
Christoph f0a1d89152 Update version to 2026.8.9 2026-08-30 20:26:46 +02:00
Christoph c3762ae7a3 feat(git): add opt-in for unrelated histories during pull
publish / Build and publish Windows installer (release) Successful in 10m2s
publish / Build and publish Ubuntu AppImage (release) Successful in 10m28s
publish / Build and publish AUR packages (release) Successful in 22m1s
Allow pulling repositories with unrelated commit histories when the user
explicitly opts in. The pull argument construction was refactored and
branch resolution made more robust so the backend can include the
--allow-unrelated-histories flag when requested.

- Extract pull argument logic and add support for allowing unrelated histories.
- Prompt users in the UI to confirm merging separate histories and retry pull.
- Restyle and improve the update toast UI for better layout and responsiveness.
2026-08-30 20:25:15 +02:00
Christoph 4db6f30461 refactor(commit-ai): remove local models and simplify AI flow
Remove local on-device model support and related IPC commands,
consolidating commit-generation to cloud providers and simplifying the
AI crate surface. Local-specific types, generation profiles, caching,
and the local prompt builder were removed while message sanitization and
diff-echo detection were preserved. Also harden repository handling and
runtime: unborn HEADs are handled gracefully so empty repos still report
files, Git LFS sync is skipped for repositories without commits, and
tokio runtime features were enabled.

- Remove local model engine, load/status commands, and local profile code
- Handle unborn HEAD and skip LFS sync for repos without commits
- Enable tokio runtime features and route AI generation to cloud only
2026-08-30 19:16:32 +02:00
Christoph 9c93d5a978 feat(integrations): add Git hosting providers and Clone UI
publish / Build and publish Ubuntu AppImage (release) Successful in 24m41s
publish / Build and publish Windows installer (release) Successful in 26m36s
publish / Build and publish AUR packages (release) Successful in 54m9s
Add integrations for GitHub, GitLab (cloud & self-hosted), Azure DevOps,
and Gitea, storing personal access tokens in the operating system
keychain. Azure DevOps supports multiple independently configurable
organizations, and the Clone → Integrations tab loads, filters, sorts,
and clones repositories using stored credentials. Update the API
contract, help overlay, README, changelog, and application version
metadata to document and ship the feature.

- Add list_integration_repositories API command and related types
- New Clone → Integrations UI with search, refresh, and direct clone
- Store tokens in OS keychain and support multiple Azure DevOps orgs
2026-08-30 00:05:01 +02:00
Christoph c242a72edd feat(integrations): support GitHub repositories and enhance repo browser UI
Add GitHub repository support to integrations and update related tests.
Implement server-side GitHub API calls and normalize GitHub base URLs.
Improve the repository browser UI with compact tabs and updated icons.
Add a custom, accessible scrollbar with pointer and keyboard support.

- Add paging, auth headers, and error handling when listing GitHub repos.
- Default GitHub token username to "x-access-token" when saving credentials.
- Introduce compact repo tab styles, new icons, and a draggable scrollbar.
2026-08-29 23:55:27 +02:00
Christoph 91263547db feat(integrations): add Git hosting integrations and repo listing
Add support for integrating with external Git hosts (GitLab, Gitea,
and Azure DevOps). The backend gains a client to fetch paginated
repository lists, normalise base URLs, and surface provider errors.
Credentials are loaded from the OS keychain and a Tauri command is
exposed for the frontend to list integration repositories.

- Implement integration client with pagination, deserialization,
  and provider-specific handling.
- Centralise keychain credential loading and expose listing command.
- Update UI to manage integration metadata, persist settings, and
  save/remove tokens to the OS keychain for cloning and operations.
2026-08-29 23:25:51 +02:00
Christoph 823a50ce85 style(repo-loading): refine overlay visuals and theming
Update the repository loading overlay and related UI styles to use
design tokens, improve contrast, and reduce visual bulk. Sizes,
spacing, radii, and icon dimensions were tuned to create a more
compact, consistent card. Backgrounds, grid textures, and shadows
were switched to CSS variables and color-mix; subtle backdrop-filter
and opacity tweaks improve legibility across themes.

- Replace hardcoded colors with theme variables and color-mix
- Reduce component dimensions and tighten spacing for a compact UI
- Add backdrop blur, adjust grid opacity, and simplify shadows
2026-08-29 22:52:14 +02:00
Christoph 22da397e39 fix(git): make unsetting upstream idempotent
Saving sync settings with no upstream could cause a fatal Git
error when unsetting upstream on a branch that never had tracking
information. This change guards the operation by checking for an existing
merge configuration before unsetting, making it idempotent. A test was
added to verify that clearing an unconfigured upstream is a no-op.

- Add test ensuring clearing an unconfigured upstream is a no-op
2026-08-26 00:35:30 +02:00
Christoph f160e48777 Update version to 2026.8.7 2026-08-23 23:41:30 +02:00
Christoph 4533f8aa38 style(theme): add dialog & panel CSS vars and refactor status panel styles
publish / Build and publish Ubuntu AppImage (release) Successful in 19m39s
publish / Build and publish Windows installer (release) Successful in 22m14s
publish / Build and publish AUR packages (release) Successful in 48m41s
Add CSS custom properties for dialog backdrop, shadows, and panel
highlights to centralize visual theming. Refactor the status panel
overlay to consume these tokens and use color-mix instead of hardcoded
rgba values. This improves visual consistency and lets overlays adapt
cleanly to appearance changes and theme variants.

- Centralize dialog and panel visuals with new CSS variables
- Replace fixed rgba values with tokenized colors and color-mix
- Move borders, shadows, and gradients to use the new theme tokens
2026-08-23 23:38:42 +02:00
Christoph f0bd74be4e Merge pull request 'feat(history): make branch filter groups collapsible' (#28) from new-desgin into main
publish / Build and publish Windows installer (release) Failing after 13s
publish / Build and publish AUR packages (release) Canceled after 0s
publish / Build and publish Ubuntu AppImage (release) Canceled after 54s
Reviewed-on: #28
2026-08-23 21:28:46 +00:00
Christoph 79f4ec21e4 feat(history): make branch filter groups collapsible
Add collapsible local and remote branch groups to the branch
visibility dialog, including toggle buttons and selection counts.
Introduce comprehensive dialog styling and responsive rules to match
the app UI. Opening the dialog now defaults to local open and remote
closed for faster access.

- Add CSS for branch-filter dialog layout, theming, and responsiveness
- Implement group toggles with chevrons and visible selected counts
- Default to local group open and remote group closed on dialog open
2026-08-23 23:20:13 +02:00
Christoph 44547f507a Merge pull request 'New desgin' (#27) from new-desgin into main
Reviewed-on: #27
2026-08-23 21:15:39 +00:00
Christoph 58027f5e22 feat(settings): add appearance and custom theme support
Introduce an appearance preference with modern, classic, and custom
modes and a persisted custom theme palette. Add helpers to load,
persist, and apply appearance and custom colors, and wire them into the
app settings lifecycle. Implement a comprehensive light theme and
appearance presets using CSS variables so custom palettes are applied
consistently across the UI, and include appearance in analytics when
settings are saved.

- Persist and apply appearance and custom color palette to :root
- Add muted light theme plus classic/modern presets and custom mapping
- Include appearance and customTheme in settings save and analytics payload
2026-08-23 23:14:07 +02:00
Christoph 3477070ec3 style(theme): refresh dark theme palette and control contrast
Update the dark theme tokens and UI surfaces to improve legibility
and clarify interactive boundaries across the app. Controls such as
buttons, inputs, and focus outlines were standardized and simplified
to reduce visual noise while preserving hierarchy.

- Overhaul color tokens and surface backgrounds for consistent tones
- Simplify primary button treatment and adjust focus/outline behavior
- Add targeted dark-mode rules to separate chrome (tabs, toolbars)
2026-08-23 22:20:25 +02:00
Christoph Brandau 7d3288a7ef chore(release): bump to 2026.8.6 and update changelog
publish / Build and publish Windows installer (release) Successful in 21m54s
publish / Build and publish Ubuntu AppImage (release) Successful in 20m10s
publish / Build and publish AUR packages (release) Successful in 48m1s
Bump version to 2026.8.6 across packaging, app config, and docs.
The change aligns binaries, config files, and changelog with the new release.
A new 2026.8.6 entry is added to the changelog and the UI surfaces release notes.

- Aligns version across packaging, app config, and UI
- Adds 2026.8.6 changelog entry and release notes in UI
- Prepares release by updating docs and build metadata
2026-08-18 21:43:32 +02:00
Christoph Brandau 6d806e87ea feat(git-lfs): improve activation and add HTTP/1.1 retry
The change adds a safer LFS activation flow that ensures a
root .gitattributes file is not hidden by ignore rules and
activates local LFS filters. It also merges patterns from the
repository attributes with those reported by Git LFS to avoid
duplicates and inaccuracies.

- Adds a retry path for large LFS uploads by forcing HTTP/1.1
during pushes when an HTTP 413 error is returned.
2026-08-18 21:36:13 +02:00
Christoph Brandau 7408371430 feat(startup): enable startup clone requests from CLI and IPC
Adds startup clone support with a new clone request type and parsing.
It wires a CLI and IPC pathway to forward a clone to a running app.
UI and docs were updated to reflect startup clone behavior.

- Introduce StartupCloneRequest and argument parsing.
- Wire IPC to pass clone requests and clone on startup.
- UI updated to queue clone requests and trigger clone.
2026-08-18 17:41:15 +02:00
Christoph Brandau e4697c74b6 feat(git): add ignore and untrack paths commands
The changes introduce server-side commands to manage gitignore
 rules and to untrack paths without deleting local files.
 A new GitIgnoreKind enum and helper functions normalize targets
 and build proper ignore patterns, and UI code was wired to use
 these commands.

- add_to_gitignore command and related helpers
- untrack_paths command to remove paths from the index
- UI wiring to expose ignore and untrack actions in explorer
2026-08-18 13:09:33 +02:00
47 changed files with 9341 additions and 5315 deletions
-5
View File
@@ -1,5 +0,0 @@
{
"enabledPlugins": {
"frontend-design@claude-plugins-official": true
}
}
-139
View File
@@ -1,139 +0,0 @@
{
"permissions": {
"allow": [
"Bash(cargo build *)",
"Bash(npm run *)",
"Bash(kill %1)",
"Bash(perl -0pi -e 's/\\\\{line \\\\|\\\\| \" \"\\\\}/{displayLine\\(line\\) || \" \"}/g' src/App.svelte)",
"Bash(kill 26306)",
"Bash(convert --version)",
"Bash(magick /mnt/data/Development/GitLite/src-tauri/icons/icon.ico /mnt/data/Development/GitLite/src-tauri/icons/icon.png)",
"Bash(magick /mnt/data/Development/GitLite/src-tauri/icons/icon.ico -type TrueColorAlpha -alpha on PNG32:/mnt/data/Development/GitLite/src-tauri/icons/icon.png)",
"Bash(magick identify *)",
"Bash(magick -size 512x512 xc:\"rgba\\(70,130,180,255\\)\" PNG32:/mnt/data/Development/GitLite/src-tauri/icons/icon.png)",
"Bash(pkill -f \"vite --host\")",
"Bash(pkill -f \"tauri dev\")",
"Bash(pkill -f \"tauri_git_lite\")",
"Bash(xargs kill -9)",
"Bash(grep -qE \"\\(Running \\\\`target|error\\\\[|panicked|terminated\\)\" /tmp/claude-1000/-mnt-data-Development-GitLite/71a8b49f-ac61-4ad9-aaba-dc0219044038/tasks/b4ujhgmdk.output)",
"Bash(grep -qE \"\\(Running \\\\`target|error\\\\[|panicked|terminated\\)\" /tmp/claude-1000/-mnt-data-Development-GitLite/71a8b49f-ac61-4ad9-aaba-dc0219044038/tasks/bt54rikhh.output)",
"Bash(grep -qE \"\\(Running \\\\`target|error\\\\[|panicked|terminated\\)\" /tmp/claude-1000/-mnt-data-Development-GitLite/71a8b49f-ac61-4ad9-aaba-dc0219044038/tasks/b7nyub68s.output)",
"Bash(npm install *)",
"Bash(cargo check *)",
"Bash(npx vite *)",
"Bash(cargo tree *)",
"Bash(jobs)",
"Bash(npx svelte-check *)",
"Bash(git log *)",
"Bash(xxd)",
"Bash(python3 -)",
"Bash(echo \"exit: $?\")",
"Bash(grep -n \"input,\\\\|select,\\\\|input {\\\\|select {\\\\|.repo-form input\\\\|input:focus\\\\|::placeholder\" src/app.css)",
"Bash(sudo -n true)",
"Bash(rustc --version)",
"Read(//mnt/c/Users/cbr/Desktop/src-tauri/src/**)",
"Bash(sudo apt install -y libdbus-1-dev pkg-config)",
"Bash(dpkg -l)",
"Bash(apt list *)",
"Bash(cargo search *)",
"Bash(curl -s \"https://crates.io/api/v1/crates/mistralrs\")",
"Bash(cargo info *)",
"WebFetch(domain:raw.githubusercontent.com)",
"Bash(gh api *)",
"WebFetch(domain:github.com)",
"WebFetch(domain:ericlbuehler.github.io)",
"WebFetch(domain:docs.rs)",
"Bash(curl -s \"https://huggingface.co/api/models/Qwen/Qwen2.5-1.5B-Instruct-GGUF\")",
"Bash(python3 -c ' *)",
"Bash(curl -sI \"https://huggingface.co/Qwen/Qwen2.5-1.5B-Instruct-GGUF/resolve/main/qwen2.5-1.5b-instruct-q4_k_m.gguf\")",
"Bash(curl -s \"https://huggingface.co/api/models/Qwen/Qwen2.5-1.5B-Instruct-GGUF?blobs=true\")",
"Bash(grep -n 'from \"\\\\./lib/git\"\\\\|from \"\\\\./lib/types\"\\\\|^ commit,$' src/App.svelte)",
"Bash(kill 24343 24363 24375 24376)",
"Bash(curl -s \"https://huggingface.co/api/models/Qwen/Qwen2.5-0.5B-Instruct-GGUF?blobs=true\")",
"Bash(curl -s \"https://huggingface.co/api/models/bartowski/Llama-3.2-3B-Instruct-GGUF?blobs=true\")",
"Bash(curl -s \"https://huggingface.co/api/models/bartowski/Llama-3.2-3B-Instruct-GGUF\")",
"Bash(curl -s \"https://huggingface.co/api/models/Qwen/Qwen2.5-3B-Instruct-GGUF\")",
"Bash(curl -s \"https://huggingface.co/api/models/Qwen/Qwen2.5-3B-Instruct-GGUF?blobs=true\")",
"Bash(curl -s \"https://huggingface.co/api/models/Qwen/Qwen2.5-3B-Instruct\")",
"Bash(: *)",
"Bash(exit 0 *)",
"Bash(rustc -O sanitize_test.rs -o sanitize_test)",
"Bash(./sanitize_test)",
"Bash(echo \"exit:$?\")",
"Bash(grep -rlP \"[äöüßÄÖÜ]\" src src-tauri/src src-tauri/crates --include=\"*.rs\" --include=\"*.svelte\" --include=\"*.ts\")",
"Bash(echo \"---exit $?---\")",
"Bash(apt-cache policy *)",
"Bash(timeout 5 curl -sI http://archive.ubuntu.com)",
"Bash(sudo -n apt-get install -y libdbus-1-dev pkg-config)",
"Bash(grep -n '\"Unerwarteter Git-Log-Eintrag: {}\",' src-tauri/src/git.rs)",
"Bash(grep -E \"git_lite$|tauri_git_lite$\")",
"Bash(rustfmt --edition 2024 --check src-tauri/src/git.rs)",
"Bash(echo \"EXIT:$?\")",
"Bash(ls target/)",
"Bash(rustup target *)",
"Bash(echo \"exit code: $?\")",
"Read(//home/cbr/.cargo/registry/src/**)",
"Bash(find / -maxdepth 6 -iname \"mistralrs-*\" -type d)",
"Bash(grep -A2 '^name = \"tauri\"$' \"/mnt/c/Users/cbr/Desktop/Neuer Ordner \\(6\\)/src-tauri/Cargo.lock\")",
"Bash(rustfmt --edition 2024 --check src/badge.rs src/main.rs)",
"Bash(rustfmt --edition 2024 --check src/git.rs)",
"Bash(rustfmt --edition 2024 --check src/badge.rs)",
"Bash(rustfmt --edition 2024 --check src/git.rs src/main.rs)",
"Bash(pkg-config --list-all)",
"Bash(rustc --edition 2021 --crate-type lib -o /dev/null --emit=metadata src/git.rs)",
"Bash(rustc --edition 2021 --crate-type lib -o /dev/null --emit=metadata /mnt/d/Development/GitLite/src-tauri/src/git.rs)",
"Bash(rustc --edition 2021 --crate-type bin -o /dev/null --emit=metadata src/main.rs)",
"Bash(grep -B1 \"^error\\\\[E0432\\\\]\\\\|^error$\")",
"Bash(grep -v \"^--$\")",
"Bash(grep \"^error$\" -A2)",
"Bash(cargo run *)",
"Bash(node_modules/.bin/svelte-check --version)",
"Bash(rustfmt --check --edition 2021 src/git.rs src/main.rs)",
"Bash(rustfmt --check --edition 2021 src/git.rs)",
"Bash(awk *)",
"Bash(rustfmt --edition 2021 /tmp/blame_chunk.rs)",
"Read(//tmp/**)",
"Bash(identify src-tauri/icons/GitCat.ico)",
"Bash(python3 -c \"import PIL; print\\(PIL.__version__\\)\")",
"Bash(python3 *)",
"Bash(xxd -l 16 src-tauri/icons/GitCat.ico)",
"Bash(pkg-config --exists openssl)",
"Bash(sudo apt-get install -y libssl-dev pkg-config)",
"Bash(dpkg -L libssl3t64)",
"Bash(grep *)",
"Read(//home/christoph/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/tauri-2.11.5/src/**)",
"Read(//home/christoph/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/tauri-runtime-wry-2.11.4/src/**)",
"Read(//home/christoph/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/tauri-2.11.5/src/window/**)",
"WebSearch",
"Bash(node -p \"require\\('./package.json'\\).version\")",
"Bash(bash -n PKGBUILD)",
"Bash(makepkg --printsrcinfo)",
"Bash(npx tauri *)",
"Bash(echo \"---exit code:$?---\")",
"Bash(git -C /mnt/data/Development/GitLite status --short)",
"Bash(makepkg -f --noconfirm)",
"Bash(tar -tf gitty-2026.7.19-1-x86_64.pkg.tar.zst)",
"Bash(cp /mnt/data/Development/GitLite/PKGBUILD .)",
"Bash(sed -i 's/^pkgrel=1/pkgrel=3/' PKGBUILD)",
"Bash(node -e \"const fs=require\\('fs'\\); const version='2026.8.1'; const pkgbuild=fs.readFileSync\\('PKGBUILD','utf8'\\).replace\\(/^pkgver=.*\\\\$/m, 'pkgver='+version\\).replace\\(/^pkgrel=.*\\\\$/m, 'pkgrel=1'\\); fs.writeFileSync\\('PKGBUILD', pkgbuild\\);\")",
"Bash(rm PKGBUILD)",
"Bash(git -C /mnt/data/Development/GitLite stash)",
"Bash(git -C /mnt/data/Development/GitLite stash pop)",
"Bash(command -v docker)",
"Read(//home/christoph/.docker/**)",
"Bash(docker info *)",
"Bash(sudo -n test -f /root/.docker/config.json)",
"Bash(systemctl list-units *)",
"Bash(find / -maxdepth 4 -iname \"act_runner*\")",
"Bash(find / -maxdepth 6 -iname \"config.yaml\" -path \"*runner*\")",
"Read(//home/**)",
"Bash(getent passwd *)",
"Bash(docker context *)",
"Bash(git -C /mnt/data/Development/GitLite log --oneline -1 -- PKGBUILD)",
"Bash(git -C /mnt/data/Development/GitLite show --stat HEAD -- PKGBUILD)",
"Bash(git -C /mnt/data/Development/GitLite log --oneline -10)",
"Bash(git -C /mnt/data/Development/GitLite status)",
"Bash(git *)"
]
}
}
+83 -3
View File
@@ -4,6 +4,74 @@ All notable user-facing changes to Gitty are documented in this file.
The project uses calendar-style versions in the form `YYYY.M.PATCH`.
## [2026.8.8] - 2026-08-29
### Added
- Git hosting integrations for GitHub, GitLab.com, GitLab Self-Managed,
Azure DevOps, and Gitea. Personal access tokens are stored separately in
the operating system keychain.
- Azure DevOps supports multiple independently configurable organizations,
each with its own display name, organization URL, username, and token.
- The Clone dialog has an Integrations tab that loads all repositories
available to the selected account, supports filtering and refresh, sorts
repositories alphabetically, and clones the selected repository directly
with its stored credentials.
### Changed
- Repository tabs use a more compact Git-client-style bar. Close buttons
remain visible and turn red only while hovered.
- The integration repository list uses a narrow custom scrollbar that grows
only slightly on hover and no longer covers repository names or metadata.
- Repository loading and status overlays use theme-aware design tokens with
improved contrast and a more compact presentation.
### Fixed
- Clearing sync settings on a branch that never had an upstream is now a safe
no-op instead of failing with a fatal Git error.
## [2026.8.7] - 2026-08-23
### Added
- Appearance settings now offer Modern, Classic, and Custom styles. Custom
themes can define their own persisted color palette, while a complete light
theme is available alongside the refreshed dark appearance.
- The branch visibility dialog groups local and remote branches into
collapsible sections with selected-branch counts. Local branches open by
default, while the remote group starts collapsed for quicker navigation.
### Changed
- Dark-theme colors, surfaces, controls, and focus outlines have been refined
for clearer interactive boundaries and more consistent contrast throughout
the application.
- The branch visibility dialog follows the responsive layout and visual
language of the rest of Gitty more closely.
## [2026.8.6] - 2026-08-18
### Changed
- Activating LFS or adding a tracking pattern now ensures that a root
`.gitattributes` file is not hidden by Git ignore rules. When necessary,
Gitty adds the scoped `!/.gitattributes` exception to `.gitignore`.
- The standard Tauri development launcher now removes an injected non-routing
`127.0.0.1:9` proxy and blocking SSH placeholder from the debug child
process, while preserving real user and company proxy settings.
### Fixed
- Root `.gitattributes` LFS patterns remain visible while the file is
untracked or ignored, instead of disappearing from the LFS dialog.
- Git LFS pushes rejected with HTTP 413 are retried once with a command-scoped
HTTP/1.1 override, which works around Azure DevOps' large HTTP/2 upload
behavior without changing repository or global Git settings.
- LFS and other generic push failures no longer trigger the unrelated
Pull/Push retry flow intended only for non-fast-forward rejections.
## [2026.8.5] - 2026-08-17
### Added
@@ -18,14 +86,23 @@ The project uses calendar-style versions in the form `YYYY.M.PATCH`.
existing file actions.
- Files and folders in the Changes panel now have context-menu actions for
staging or unstaging their scope and for creating a stash containing only
the selected file or folder.
the selected file or folder. New and untracked items can also be added to
the repository `.gitignore` from Changes or the File Explorer as an exact
file, a complete folder, or an extension-wide pattern. Folder rules are only
offered for folder selections. Tracked files and folders can be removed from
the Git index without deleting their working-tree contents.
- Gitty can open a repository directly at startup through the `--repo PATH` or
`--repo=PATH` command-line argument.
- The executable can clone and immediately open a repository with
`clone REMOTE TARGET`, `--clone REMOTE TARGET`, or `--clone=REMOTE TARGET`.
Relative targets use the caller's working directory, and requests are also
forwarded to an already-running Gitty instance.
### Changed
- A successful Git pull now detects repositories that use LFS and downloads
the required LFS objects automatically with the same remote and credentials.
- Successful clones and pulls now detect repositories that use LFS and
download the required LFS objects automatically with the same remote and
credentials. Fresh clones also activate LFS locally before they are opened.
- Unstaged and staged changes use an equal-width side-by-side layout with
independent scrolling, directional stage/unstage actions, and a responsive
vertical fallback. The List/Tree switch is centered above both areas.
@@ -213,6 +290,9 @@ The project uses calendar-style versions in the form `YYYY.M.PATCH`.
[2026.07.22]: https://git.cbsk-tech.de/Christoph/GitLite/releases/tag/2026.7.22
[2026.07.21]: https://git.cbsk-tech.de/Christoph/GitLite/releases/tag/2026.7.21
[2026.7.20]: https://git.cbsk-tech.de/Christoph/GitLite/releases/tag/2026.7.20
[2026.8.8]: https://git.cbsk-tech.de/Christoph/GitLite/releases/tag/2026.8.8
[2026.8.7]: https://git.cbsk-tech.de/Christoph/GitLite/releases/tag/2026.8.7
[2026.8.6]: https://git.cbsk-tech.de/Christoph/GitLite/releases/tag/2026.8.6
[2026.8.5]: https://git.cbsk-tech.de/Christoph/GitLite/releases/tag/2026.8.5
[2026.8.4]: https://git.cbsk-tech.de/Christoph/GitLite/releases/tag/2026.8.4
[2026.8.3]: https://git.cbsk-tech.de/Christoph/GitLite/releases/tag/2026.8.3
+1 -1
View File
@@ -1,7 +1,7 @@
# Maintainer: Christoph Brandau <c.brandau91@googlemail.com>
pkgname=gitty-desktop-bin
pkgver=2026.8.5
pkgver=2026.8.6
pkgrel=1
pkgdesc="A lightweight, modern Git client built with Tauri (prebuilt Arch package)"
arch=('x86_64')
+43 -15
View File
@@ -20,6 +20,7 @@ Fast, simple, and designed for developers who want a clean Git experience withou
- 🔄 Pull, Push & Fetch
- 🔀 Merge & Rebase
- 📦 Repository management
- ☁️ GitHub, GitLab, Azure DevOps, and Gitea integrations
- 🗄️ Git LFS detection, tracking and object management
- 🎨 Modern and intuitive UI
@@ -27,7 +28,7 @@ Fast, simple, and designed for developers who want a clean Git experience withou
## 📸 Preview
> Screenshots coming soon.
> Screenshots comes later.
---
@@ -82,24 +83,51 @@ files.
---
## Command line
Open an existing repository when Gitty starts:
```text
gitty.exe --repo "D:\Projects\ExistingRepo"
```
Clone a remote into an exact local target folder and open it immediately:
```text
gitty.exe --clone "https://example.com/team/project.git" "D:\Projects\Project"
gitty clone "git@example.com:team/project.git" "D:\Projects\Project"
```
`--clone=<REMOTE>` is also accepted. Relative target paths are resolved from
the current working directory. Clone and repository requests are forwarded to
the running window when Gitty is already open.
---
## Git hosting integrations
Gitty connects to GitHub, GitLab.com, GitLab Self-Managed, Azure DevOps, and
Gitea from **Settings → Integrations**. Each connection uses a personal access
token that is stored in the operating system keychain instead of application
settings. Azure DevOps can manage multiple organizations with separate URLs,
usernames, and tokens.
After enabling a connection, open **Clone → Integrations** to load the
repositories available to that account. Repositories are sorted
alphabetically and can be filtered, refreshed, selected, and cloned directly
with the stored credentials.
---
## Git LFS
Gitty bundles the `git-lfs` executable in its desktop installers and checks it
at runtime before offering LFS actions. Arch packages also declare `git-lfs` as
a dependency so Git hooks and command-line workflows outside Gitty use the same
extension. The repository toolbar exposes LFS setup, tracked patterns, object
downloads, and safe cache pruning. Normal clone, pull, checkout, and push
operations continue to use Git's standard LFS filters and pre-push hook. After
every successful pull, Gitty detects LFS usage and automatically downloads the
required LFS objects with the same remote and credentials, so no second pull is
needed.
downloads, and safe cache pruning. After every successful clone or pull, Gitty
detects LFS usage and automatically downloads the required LFS objects with the
same remote and credentials, so no second pull is needed. Fresh clones also get
repository-local LFS filters and the pre-push hook before they are opened.
---
## SigNoz telemetry
After the user opts in, Gitty sends privacy-filtered product events and technical errors as OTLP/HTTP JSON logs. Every Git/Tauri command is also captured as a trace span with its command name, duration and success state, but without command arguments or results. CPU utilization and resident memory usage of the Gitty process are sampled every 30 seconds and exported as OpenTelemetry gauges. The default endpoints are `https://telemetry.cbsk-tech.de/v1/logs`, `https://telemetry.cbsk-tech.de/v1/traces`, and `https://telemetry.cbsk-tech.de/v1/metrics`.
The SigNoz reverse proxy must forward all three paths to the Collector's OTLP/HTTP receiver (normally port `4318`). For development or a separate collector hostname, set `GITTY_OTLP_LOGS_ENDPOINT`, `GITTY_OTLP_TRACES_ENDPOINT`, and `GITTY_OTLP_METRICS_ENDPOINT` to the full signal URLs before starting Gitty.
Telemetry is non-blocking, bounded, disabled until consent is stored, and never includes repository paths, remote URLs, branch names, commit messages, diffs, file names, credentials, source code, URLs, or email addresses.
+17
View File
@@ -101,6 +101,20 @@ interface GitLfsFile {
oid: string;
version: string;
}
type GitIntegrationProvider = "github" | "gitlab" | "gitlab-self-hosted" | "azure-devops" | "gitea";
interface IntegrationRepository {
id: string;
name: string;
fullName: string;
description: string;
cloneUrl: string;
sshUrl: string;
webUrl: string;
updatedAt: string;
private: boolean;
}
```
## Commands
@@ -110,6 +124,7 @@ The command list below includes the repository-management and synchronization AP
- `open_repository(path: string): Promise<GitStatus>`
- `init_repository(path: string, initialBranch?: string): Promise<GitStatus>`
- `clone_repository(...): Promise<RepositoryBundle>`
- `list_integration_repositories(provider: GitIntegrationProvider, baseUrl: string, accountId?: string): Promise<IntegrationRepository[]>`; loads credentials from the operating system keychain and returns every repository accessible through the configured Git hosting account.
- `get_status(path: string): Promise<GitStatus>`
- `git_lfs_status(path: string): Promise<GitLfsStatus>`
- `git_lfs_install(path: string): Promise<GitLfsStatus>`
@@ -137,6 +152,8 @@ The command list below includes the repository-management and synchronization AP
- `repair_worktree(path: string, worktreePath: string): Promise<GitWorktree[]>`
- `stage_files(path: string, files: string[]): Promise<GitStatus>`
- `unstage_files(path: string, files: string[]): Promise<GitStatus>`
- `add_to_gitignore(path: string, target: string, kind: "file" | "extension" | "folder"): Promise<GitStatus>`; appends a repository-root `.gitignore` rule and unstages newly-added matching files.
- `untrack_paths(path: string, targets: string[]): Promise<GitStatus>`; removes files or folders from the Git index while preserving their working-tree contents.
- `restore_files(path: string, files: string[], staged: boolean): Promise<GitStatus>`
- `stash_push(path: string, message?: string, includeUntracked?: boolean, paths?: string[]): Promise<GitStatus>`; when `paths` is provided, only matching files are stashed.
- `commit(path: string, message: string): Promise<GitStatus>`
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "gitty",
"version": "2026.8.5",
"version": "2026.9.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "gitty",
"version": "2026.8.5",
"version": "2026.9.2",
"dependencies": {
"@lucide/svelte": "^1.21.0",
"@tailwindcss/vite": "^4.3.1",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "gitty",
"version": "2026.8.5",
"version": "2026.9.2",
"private": true,
"type": "module",
"scripts": {
@@ -10,7 +10,7 @@
"icons": "node scripts/generate-app-icons.mjs",
"preview": "vite preview --host 127.0.0.1",
"tauri": "tauri",
"tauri:dev": "tauri dev",
"tauri:dev": "node scripts/tauri-dev.mjs",
"tauri:build": "tauri build",
"check": "svelte-check --tsconfig ./tsconfig.json"
},
+61
View File
@@ -0,0 +1,61 @@
import { spawn } from "node:child_process";
const debugEnvironment = { ...process.env };
const clearedVariables = [];
const blockedProxy = /^https?:\/\/127\.0\.0\.1:9\/?$/i;
const proxyVariables = new Set([
"all_proxy",
"http_proxy",
"https_proxy",
"git_http_proxy",
"git_https_proxy",
]);
for (const [name, value] of Object.entries(debugEnvironment)) {
if (proxyVariables.has(name.toLowerCase()) && blockedProxy.test(value ?? "")) {
delete debugEnvironment[name];
clearedVariables.push(name);
}
}
for (const [name, value] of Object.entries(debugEnvironment)) {
if (
name.toLowerCase() === "git_ssh_command"
&& /^cmd(?:\.exe)?\s+\/c\s+exit\s+1$/i.test((value ?? "").trim())
) {
delete debugEnvironment[name];
clearedVariables.push(name);
}
}
if (process.argv.includes("--check-environment")) {
process.stdout.write(
clearedVariables.length > 0
? `Debug environment ready; cleared: ${clearedVariables.sort().join(", ")}\n`
: "Debug environment ready; no blocked proxy variables found.\n",
);
process.exit(0);
}
if (clearedVariables.length > 0) {
process.stdout.write(
`Starting Gitty without the blocked debug proxy (${clearedVariables.sort().join(", ")}).\n`,
);
}
const isWindows = process.platform === "win32";
const child = spawn(isWindows ? "tauri.cmd" : "tauri", ["dev"], {
cwd: process.cwd(),
env: debugEnvironment,
stdio: "inherit",
shell: isWindows,
});
child.on("error", (error) => {
process.stderr.write(`Could not start Tauri development mode: ${error.message}\n`);
process.exitCode = 1;
});
child.on("exit", (code) => {
process.exitCode = code ?? 1;
});
+38 -3229
View File
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -20,10 +20,11 @@ tauri-plugin-dialog = "=2.7.0"
tauri-plugin-aptabase = "1.0"
keyring = { version = "3", features = ["apple-native", "windows-native", "async-secret-service", "crypto-rust", "async-io"] }
commit_ai = { path = "crates/commit_ai" }
tokio = "1.52.3"
tokio = { version = "1.52.3", features = ["macros", "rt-multi-thread", "time"] }
log = "0.4"
reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] }
sysinfo = { version = "=0.38.3", default-features = false, features = ["system"] }
shlex = "2"
[build-dependencies]
tauri-build = { version = "2", features = [] }
-2
View File
@@ -5,8 +5,6 @@ version = "0.1.0"
edition = "2024"
[dependencies]
mistralrs = "0.8"
tokio = { version = "1", features = ["sync"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
+6 -359
View File
@@ -5,318 +5,8 @@ pub use cloud::{
review_openai, split_anthropic, split_custom, split_openai,
};
use std::{
collections::hash_map::DefaultHasher,
hash::{Hash, Hasher},
sync::Arc,
};
use mistralrs::{GgufModelBuilder, Model, RequestBuilder, TextMessageRole};
use tokio::sync::RwLock;
/// One selectable local (on-device) model. Larger models produce better commit messages
/// but take longer to download (first run only, then cached) and run slower on CPU.
#[derive(Debug, Clone, serde::Serialize)]
pub struct LocalModelOption {
pub id: &'static str,
pub label: &'static str,
pub approx_size_mb: u32,
repo: &'static str,
file: &'static str,
tokenizer_repo: &'static str,
}
pub const DEFAULT_LOCAL_MODEL_ID: &str = "qwen2.5-0.5b";
pub const LOCAL_MODELS: &[LocalModelOption] = &[
LocalModelOption {
id: "qwen2.5-0.5b",
label: "Qwen2.5 0.5B Instruct — fast, lower quality",
approx_size_mb: 490,
repo: "Qwen/Qwen2.5-0.5B-Instruct-GGUF",
file: "qwen2.5-0.5b-instruct-q4_k_m.gguf",
tokenizer_repo: "Qwen/Qwen2.5-0.5B-Instruct",
},
LocalModelOption {
id: "qwen2.5-1.5b",
label: "Qwen2.5 1.5B Instruct — recommended",
approx_size_mb: 1050,
repo: "Qwen/Qwen2.5-1.5B-Instruct-GGUF",
file: "qwen2.5-1.5b-instruct-q4_k_m.gguf",
tokenizer_repo: "Qwen/Qwen2.5-1.5B-Instruct",
},
LocalModelOption {
id: "qwen2.5-3b",
label: "Qwen2.5 3B Instruct — best quality, slower",
approx_size_mb: 2100,
repo: "Qwen/Qwen2.5-3B-Instruct-GGUF",
file: "qwen2.5-3b-instruct-q4_k_m.gguf",
tokenizer_repo: "Qwen/Qwen2.5-3B-Instruct",
},
];
fn find_local_model(model_id: &str) -> Option<&'static LocalModelOption> {
LOCAL_MODELS.iter().find(|option| option.id == model_id)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum LocalGenerationProfile {
Fast,
Balanced,
Detailed,
}
impl Default for LocalGenerationProfile {
fn default() -> Self {
Self::Fast
}
}
impl LocalGenerationProfile {
pub fn from_id(value: Option<&str>) -> Self {
match value
.unwrap_or_default()
.trim()
.to_ascii_lowercase()
.as_str()
{
"balanced" => Self::Balanced,
"detailed" => Self::Detailed,
_ => Self::Fast,
}
}
pub fn diff_unified_context(self) -> &'static str {
match self {
Self::Fast => "--unified=1",
Self::Balanced => "--unified=2",
Self::Detailed => "--unified=3",
}
}
fn max_diff_chars(self) -> usize {
match self {
Self::Fast => 8_000,
Self::Balanced => 12_000,
Self::Detailed => 24_000,
}
}
fn max_output_tokens(self) -> usize {
match self {
Self::Fast => 160,
Self::Balanced => 360,
Self::Detailed => 750,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
#[serde(rename_all = "lowercase")]
pub enum CommitAiPhase {
/// Nothing has been requested yet.
Idle,
/// Downloading (first run only, then cached by hf-hub) and/or loading into memory.
Loading,
Ready,
Error,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct CommitAiStatus {
pub phase: CommitAiPhase,
pub model_id: Option<String>,
pub error: Option<String>,
}
struct Inner {
phase: CommitAiPhase,
model_id: Option<String>,
error: Option<String>,
model: Option<Arc<Model>>,
cache: Option<GenerationCache>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct GenerationCacheKey {
model_id: String,
profile: LocalGenerationProfile,
input_hash: u64,
}
#[derive(Debug, Clone)]
struct GenerationCache {
key: GenerationCacheKey,
message: String,
}
/// Manages the local (on-device) model only. Cloud providers are stateless HTTP calls
/// (see [`cloud`]) and don't need this — there's nothing to download or keep loaded.
#[derive(Clone)]
pub struct CommitAiEngine {
inner: Arc<RwLock<Inner>>,
}
impl Default for CommitAiEngine {
fn default() -> Self {
Self {
inner: Arc::new(RwLock::new(Inner {
phase: CommitAiPhase::Idle,
model_id: None,
error: None,
model: None,
cache: None,
})),
}
}
}
impl CommitAiEngine {
pub fn new() -> Self {
Self::default()
}
pub async fn status(&self) -> CommitAiStatus {
let guard = self.inner.read().await;
CommitAiStatus {
phase: guard.phase,
model_id: guard.model_id.clone(),
error: guard.error.clone(),
}
}
/// Downloads (first run only; hf-hub caches the files afterwards) and loads the given
/// local model. Safe to call repeatedly — a call for the model that's already
/// ready/loading is a no-op; a call for a *different* model switches to it (the
/// previous one is dropped once no generation is still using it).
pub async fn ensure_loaded(&self, model_id: &str) {
{
let guard = self.inner.read().await;
let same_model = guard.model_id.as_deref() == Some(model_id);
if same_model && matches!(guard.phase, CommitAiPhase::Ready | CommitAiPhase::Loading) {
return;
}
}
let Some(option) = find_local_model(model_id) else {
let mut guard = self.inner.write().await;
guard.phase = CommitAiPhase::Error;
guard.model_id = Some(model_id.to_string());
guard.error = Some(format!("Unknown local model: {model_id}"));
guard.cache = None;
return;
};
{
let mut guard = self.inner.write().await;
guard.phase = CommitAiPhase::Loading;
guard.model_id = Some(model_id.to_string());
guard.error = None;
guard.model = None;
guard.cache = None;
}
let result = GgufModelBuilder::new(option.repo, vec![option.file])
.with_tok_model_id(option.tokenizer_repo)
.with_logging()
.build()
.await;
let mut guard = self.inner.write().await;
// If the user switched to yet another model while this one was loading, drop this
// (now stale) result instead of overwriting the newer request's state.
if guard.model_id.as_deref() != Some(model_id) {
return;
}
match result {
Ok(model) => {
guard.model = Some(Arc::new(model));
guard.phase = CommitAiPhase::Ready;
guard.error = None;
guard.cache = None;
}
Err(err) => {
guard.phase = CommitAiPhase::Error;
guard.error = Some(err.to_string());
guard.cache = None;
}
}
}
pub async fn generate_commit_message(
&self,
diff: &str,
notes: Option<&str>,
profile: LocalGenerationProfile,
) -> Result<String, String> {
let (model, cache_key) = {
let guard = self.inner.read().await;
match (guard.phase, &guard.model) {
(CommitAiPhase::Ready, Some(model)) => {
let cache_key = GenerationCacheKey {
model_id: guard.model_id.clone().unwrap_or_default(),
profile,
input_hash: generation_input_hash(diff, notes),
};
if let Some(cache) = &guard.cache {
if cache.key == cache_key {
return Ok(cache.message.clone());
}
}
(model.clone(), cache_key)
}
_ => return Err("The local AI model is not ready yet.".to_string()),
}
};
let (system, user) = build_local_messages(diff, notes, profile)?;
let request = RequestBuilder::new()
.set_sampler_max_len(profile.max_output_tokens())
.add_message(TextMessageRole::System, system)
.add_message(TextMessageRole::User, user);
let response = model
.send_chat_request(request)
.await
.map_err(|err| err.to_string())?;
let content = response
.choices
.first()
.and_then(|choice| choice.message.content.clone())
.ok_or_else(|| "The model did not return a response.".to_string())?;
let message = sanitize_message(&content);
if message.is_empty() {
return Err("The model did not return a response.".to_string());
}
if looks_like_diff_echo(&message) {
return Err(
"The local model returned the diff instead of a commit message. Try a larger local model (1.5B or 3B) or a cloud provider.".to_string(),
);
}
{
let mut guard = self.inner.write().await;
guard.cache = Some(GenerationCache {
key: cache_key,
message: message.clone(),
});
}
Ok(message)
}
}
fn generation_input_hash(diff: &str, notes: Option<&str>) -> u64 {
let mut hasher = DefaultHasher::new();
diff.hash(&mut hasher);
notes.unwrap_or("").hash(&mut hasher);
hasher.finish()
}
/// Models occasionally ignore the "no code fences" instruction (small local models
/// especially) — strip a wrapping ``` fence and wrapping quotes so the result can go
/// straight into the commit-message box.
/// Strip a wrapping code fence and wrapping quotes so the result can go straight into
/// the commit-message box.
pub(crate) fn sanitize_message(raw: &str) -> String {
let mut text = raw.trim().to_string();
if text.starts_with("```") {
@@ -333,9 +23,9 @@ pub(crate) fn sanitize_message(raw: &str) -> String {
trimmed.to_string()
}
/// Weak models (small local ones especially) sometimes just echo the prompt's diff
/// sections back instead of writing a commit message. Catch that so the UI can show a
/// clear error instead of dumping raw diff text into the commit-message box.
/// Some models echo the prompt's diff sections instead of writing a commit message.
/// Catch that so the UI can show a clear error instead of dumping raw diff text into
/// the commit-message box.
pub(crate) fn looks_like_diff_echo(message: &str) -> bool {
let lower = message.to_ascii_lowercase();
lower.contains("diff --git")
@@ -358,55 +48,12 @@ fn truncate_at_char_boundary(input: &str, max_chars: usize) -> String {
format!("{}\n\n[... diff truncated ...]", &input[..cut])
}
pub(crate) fn build_local_messages(
diff: &str,
notes: Option<&str>,
profile: LocalGenerationProfile,
) -> Result<(String, String), String> {
if diff.trim().is_empty() {
return Err("No staged changes available for a commit message.".to_string());
}
let diff = truncate_at_char_boundary(diff, profile.max_diff_chars());
// Appended to every profile below: small local models occasionally just echo the input
// (the "Staged files:" / "Diff stat:" / "Detailed diff:" sections built in git.rs's
// `staged_diff_local`) instead of writing a new commit message. Naming those exact
// section headers here makes the failure mode explicit enough for weak models to avoid.
const ANTI_ECHO: &str = " Never repeat, quote, or paraphrase the diff or its headers — \
do not include 'diff --git', '@@', 'Staged files:', 'Diff stat:', or 'Detailed diff:' \
anywhere in your answer.";
let system = match profile {
LocalGenerationProfile::Fast => {
format!(
"You generate Git commit messages. Respond only with one Conventional Commits subject line: <type>(<scope>): <subject>. Max 72 characters. No body, bullets, preamble, explanation, code fences, or quotes. Answer in English.{ANTI_ECHO}"
)
}
LocalGenerationProfile::Balanced => {
format!(
"You generate Git commit messages. Respond with a Conventional Commits subject line, then a blank line, then at most two short bullet points. Keep every line under 72 characters. No preamble, explanation, code fences, or quotes. Answer in English.{ANTI_ECHO}"
)
}
LocalGenerationProfile::Detailed => {
format!(
"You generate Git commit messages. Respond with a Conventional Commits subject line, then a blank line, then a concise body and up to four short bullet points grouped by affected area/file. Keep every line under 72 characters. No preamble, explanation, code fences, or quotes. Answer in English.{ANTI_ECHO}"
)
}
};
let mut user = String::new();
if let Some(n) = notes.filter(|n| !n.trim().is_empty()) {
user.push_str(&format!("Developer notes:\n{n}\n\n"));
}
user.push_str(&format!("Staged changes:\n{diff}"));
Ok((system, user))
}
pub(crate) fn build_messages(diff: &str, notes: Option<&str>) -> Result<(String, String), String> {
if diff.trim().is_empty() {
return Err("No staged changes available for a commit message.".to_string());
}
// Rough token estimate — small models often have an 8-32k context window.
// Rough token estimate to keep requests within common context windows.
const MAX_CHARS: usize = 24_000;
let diff = truncate_at_char_boundary(diff, MAX_CHARS);
+1700 -151
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+170 -37
View File
@@ -3,6 +3,7 @@
mod badge;
mod external_tools;
mod git;
mod integrations;
mod telemetry;
use badge::set_sync_badge;
@@ -10,27 +11,31 @@ use external_tools::{
detect_external_tools, launch_external_diff, launch_external_merge, launch_external_tool,
};
use git::{
SearchCancellationState, add_remote, add_worktree, amend_commit, apply_file_patch,
cancel_code_search, cancel_file_history, checkout_branch, cherry_pick_abort,
SearchCancellationState, add_remote, add_to_gitignore, add_worktree, amend_commit,
apply_file_patch, cancel_code_search, cancel_file_history, checkout_branch, cherry_pick_abort,
cherry_pick_commit, cherry_pick_continue, clone_repository, commit, commit_ai_generate,
commit_ai_load, commit_ai_local_models, commit_ai_review, commit_ai_split, commit_ai_status,
compare_commits, compare_file_to_head, compare_file_to_parent, create_branch, create_tag,
cred_delete, cred_load, cred_save, delete_branch, delete_commit_note, delete_remote_branch,
delete_remote_branches, delete_tag, diff_file_against_working_tree, fetch, fetch_commit_notes,
get_commit_note, get_file_blame, get_file_patch, get_remote_url, get_status, git_lfs_install,
git_lfs_prune, git_lfs_pull, git_lfs_status, git_lfs_track, git_lfs_untrack, init_repository,
commit_ai_review, commit_ai_split, compare_commits, compare_file_to_head,
compare_file_to_parent, create_branch, create_tag, cred_delete, cred_load, cred_save,
delete_branch, delete_commit_note, delete_remote_branch, delete_remote_branches, delete_tag,
diff_file_against_working_tree, fetch, fetch_commit_notes, get_bisect_state, get_commit_note,
get_file_blame, get_file_patch, get_remote_url, get_status, git_lfs_install, git_lfs_prune,
git_lfs_pull, git_lfs_status, git_lfs_track, git_lfs_untrack, init_repository,
last_commit_message, list_branches, list_commits, list_file_history,
list_interactive_rebase_commits, list_reflog, list_remotes, list_repository_files,
list_stashes, list_tags, list_worktrees, lock_worktree, merge_abort, merge_branch,
list_stashes, list_tags, list_worktrees, lock_worktree, mark_bisect, merge_abort, merge_branch,
merge_continue, move_worktree, open_repo_in_explorer, open_repository, open_repository_bundle,
open_repository_file, prune_worktrees, pull, push, push_commit_notes, push_tag, read_conflict,
rebase_abort, rebase_branch, rebase_continue, remove_remote, remove_worktree, rename_branch,
rename_remote_branch, repair_worktree, resolve_conflict, resolve_conflict_side,
rename_remote_branch, repair_worktree, reset_bisect, resolve_conflict, resolve_conflict_side,
restore_file_from_commit, restore_files, restore_reflog_entry, restore_to_commit,
revert_commit, run_sequence_editor_if_requested, search_code_introductions,
set_branch_upstream, set_commit_note, stage_files, start_interactive_rebase, stash_apply,
stash_drop, stash_pop, stash_push, undo_last_commit, unlock_worktree, unstage_files,
update_remote,
set_branch_upstream, set_commit_note, stage_files, start_bisect, start_interactive_rebase,
stash_apply, stash_drop, stash_pop, stash_push, undo_last_commit, unlock_worktree,
unstage_files, untrack_paths, update_remote,
};
use integrations::{
add_integration_review_comment, get_integration_review_details, list_integration_repositories, list_integration_review_requests, open_in_browser,
run_integration_review_action,
};
use std::path::{Path, PathBuf};
use std::sync::Mutex;
@@ -39,11 +44,68 @@ use telemetry::{emit_frontend_log, emit_frontend_span, set_telemetry_enabled};
struct StartupRepository(Mutex<Option<String>>);
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)]
#[serde(rename_all = "camelCase")]
struct StartupCloneRequest {
remote_url: String,
parent_path: String,
directory_name: String,
}
struct StartupClone(Mutex<Option<StartupCloneRequest>>);
fn resolve_startup_path(path: &str, cwd: &Path) -> Option<PathBuf> {
let path = path.trim();
if path.is_empty() {
return None;
}
let path = PathBuf::from(path);
Some(if path.is_absolute() {
path
} else {
cwd.join(path)
})
}
fn clone_request_from_args(
args: impl IntoIterator<Item = String>,
cwd: &Path,
) -> Option<StartupCloneRequest> {
let mut args = args.into_iter();
while let Some(arg) = args.next() {
let remote_url = if arg == "clone" || arg == "--clone" {
args.next()
} else {
arg.strip_prefix("--clone=").map(ToString::to_string)
};
let Some(remote_url) = remote_url.filter(|value| !value.trim().is_empty()) else {
continue;
};
let target = resolve_startup_path(&args.next()?, cwd)?;
let directory_name = target.file_name()?.to_string_lossy().trim().to_string();
let parent_path = target.parent()?.to_string_lossy().into_owned();
if directory_name.is_empty() || parent_path.trim().is_empty() {
return None;
}
return Some(StartupCloneRequest {
remote_url,
parent_path,
directory_name,
});
}
None
}
fn repository_path_from_args(args: impl IntoIterator<Item = String>, cwd: &Path) -> Option<String> {
let mut args = args.into_iter();
let mut repository = None;
while let Some(arg) = args.next() {
if arg == "clone" || arg == "--clone" || arg.starts_with("--clone=") {
return None;
}
if arg == "--repo" {
repository = args.next();
break;
@@ -58,27 +120,24 @@ fn repository_path_from_args(args: impl IntoIterator<Item = String>, cwd: &Path)
}
}
repository.filter(|path| !path.trim().is_empty()).map(|path| {
let path = PathBuf::from(path);
if path.is_absolute() {
path
} else {
cwd.join(path)
}
.to_string_lossy()
.into_owned()
})
repository
.and_then(|path| resolve_startup_path(&path, cwd))
.map(|path| path.to_string_lossy().into_owned())
}
#[cfg(test)]
mod startup_repository_tests {
use super::repository_path_from_args;
use super::{clone_request_from_args, repository_path_from_args};
use std::path::Path;
#[test]
fn accepts_direct_relative_repository_path() {
let path = repository_path_from_args(["projects/repo".to_string()], Path::new("/home/user"));
assert_eq!(path.as_deref(), Some("/home/user/projects/repo"));
let path =
repository_path_from_args(["projects/repo".to_string()], Path::new("/home/user"));
assert_eq!(
path.map(|value| value.replace('\\', "/")),
Some("/home/user/projects/repo".to_string())
);
}
#[test]
@@ -96,7 +155,60 @@ mod startup_repository_tests {
["--repo=projects/repo".to_string()],
Path::new("/home/user"),
);
assert_eq!(path.as_deref(), Some("/home/user/projects/repo"));
assert_eq!(
path.map(|value| value.replace('\\', "/")),
Some("/home/user/projects/repo".to_string())
);
}
#[test]
fn accepts_clone_command_with_exact_relative_target() {
let request = clone_request_from_args(
[
"clone".to_string(),
"https://example.com/team/project.git".to_string(),
"clones/local-copy".to_string(),
],
Path::new("/home/user"),
)
.expect("clone request should be parsed");
assert_eq!(request.remote_url, "https://example.com/team/project.git");
assert_eq!(request.directory_name, "local-copy");
assert_eq!(request.parent_path.replace('\\', "/"), "/home/user/clones");
}
#[test]
fn accepts_clone_option_and_clone_equals_option() {
for args in [
vec![
"--clone".to_string(),
"git@example.com:team/project.git".to_string(),
"/projects/project".to_string(),
],
vec![
"--clone=git@example.com:team/project.git".to_string(),
"/projects/project".to_string(),
],
] {
let request = clone_request_from_args(args, Path::new("/home/user"))
.expect("clone option should be parsed");
assert_eq!(request.directory_name, "project");
assert_eq!(request.parent_path.replace('\\', "/"), "/projects");
}
}
#[test]
fn repository_parser_does_not_treat_clone_values_as_repository_paths() {
let path = repository_path_from_args(
[
"--clone".to_string(),
"https://example.com/team/project.git".to_string(),
"clones/project".to_string(),
],
Path::new("/home/user"),
);
assert!(path.is_none());
}
}
@@ -105,6 +217,11 @@ fn take_startup_repository(state: tauri::State<'_, StartupRepository>) -> Option
state.0.lock().ok()?.take()
}
#[tauri::command]
fn take_startup_clone(state: tauri::State<'_, StartupClone>) -> Option<StartupCloneRequest> {
state.0.lock().ok()?.take()
}
struct ConsoleLogger;
impl log::Log for ConsoleLogger {
@@ -167,14 +284,20 @@ async fn main() {
return;
}
let startup_repository = repository_path_from_args(
std::env::args().skip(1),
&std::env::current_dir().unwrap_or_default(),
);
let startup_args: Vec<String> = std::env::args().skip(1).collect();
let startup_cwd = std::env::current_dir().unwrap_or_default();
let startup_clone = clone_request_from_args(startup_args.clone(), &startup_cwd);
let startup_repository = repository_path_from_args(startup_args, &startup_cwd);
let builder = tauri::Builder::default()
.plugin(tauri_plugin_single_instance::init(|app, args, cwd| {
if let Some(path) = repository_path_from_args(args.into_iter().skip(1), Path::new(&cwd)) {
let args: Vec<String> = args.into_iter().skip(1).collect();
if let Some(request) = clone_request_from_args(args.clone(), Path::new(&cwd)) {
if let Ok(mut pending) = app.state::<StartupClone>().0.lock() {
*pending = Some(request);
}
let _ = app.emit("open-startup-repository", ());
} else if let Some(path) = repository_path_from_args(args, Path::new(&cwd)) {
if let Ok(mut pending) = app.state::<StartupRepository>().0.lock() {
*pending = Some(path);
}
@@ -195,8 +318,8 @@ async fn main() {
.build(),
)
.manage(StartupRepository(Mutex::new(startup_repository)))
.manage(StartupClone(Mutex::new(startup_clone)))
.manage(SearchCancellationState::default())
.manage(commit_ai::CommitAiEngine::new())
.plugin(tauri_plugin_dialog::init());
// Linux installs are expected to come from the system package manager (see the
@@ -254,6 +377,8 @@ async fn main() {
cherry_pick_abort,
stage_files,
unstage_files,
add_to_gitignore,
untrack_paths,
stash_push,
stash_apply,
stash_pop,
@@ -265,9 +390,6 @@ async fn main() {
amend_commit,
undo_last_commit,
last_commit_message,
commit_ai_status,
commit_ai_load,
commit_ai_local_models,
commit_ai_generate,
commit_ai_review,
commit_ai_split,
@@ -293,6 +415,10 @@ async fn main() {
start_interactive_rebase,
list_reflog,
restore_reflog_entry,
get_bisect_state,
start_bisect,
mark_bisect,
reset_bisect,
list_repository_files,
open_repository_bundle,
list_file_history,
@@ -311,12 +437,19 @@ async fn main() {
cred_load,
cred_save,
cred_delete,
list_integration_repositories,
list_integration_review_requests,
get_integration_review_details,
add_integration_review_comment,
run_integration_review_action,
open_in_browser,
set_sync_badge,
close_splashscreen,
set_telemetry_enabled,
emit_frontend_log,
emit_frontend_span,
take_startup_repository
take_startup_repository,
take_startup_clone
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Gitty",
"version": "2026.8.5",
"version": "2026.9.2",
"identifier": "com.gitty",
"build": {
"beforeDevCommand": "npm run prepare:lfs && npm run dev",
+727 -299
View File
File diff suppressed because it is too large Load Diff
+1467 -200
View File
File diff suppressed because it is too large Load Diff
+12 -5
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import { BookOpen, Database, Plus, X } from "@lucide/svelte";
import { Folder, GitBranch, GitPullRequest, Plus, X } from "@lucide/svelte";
interface RepositoryTabItem {
path: string;
@@ -7,11 +7,12 @@
branch: string | null;
}
export let activeView: "management" | "repository" = "management";
export let activeView: "management" | "review-center" | "repository" = "management";
export let repoTabs: RepositoryTabItem[] = [];
export let isBusy: boolean = false;
export let language: "en" | "de" = "en";
export let onOpenManagement: () => void = () => {};
export let onOpenReviewCenter: () => void = () => {};
export let isActive: (path: string) => boolean = () => false;
export let onSelect: (path: string) => void | Promise<void> = () => {};
export let onClose: (path: string, event: MouseEvent) => void | Promise<void> = () => {};
@@ -27,12 +28,18 @@
onclick={onOpenManagement}
disabled={isBusy}
title={language === "de" ? "Repository-Verwaltung" : "Repository Management"}
aria-label={language === "de" ? "Repository-Verwaltung" : "Repository Management"}
>
<BookOpen size={14} aria-hidden="true" />
<span>{language === "de" ? "Repository-Verwaltung" : "Repository Management"}</span>
<Folder size={15} aria-hidden="true" />
</button>
<div class="repo-tabs-scroll">
<div class="repo-tab-wrap review-center-tab" class:active={activeView === "review-center"} role="presentation">
<button class="repo-tab" type="button" onclick={onOpenReviewCenter} disabled={isBusy} title="Review Center" aria-label="Review Center">
<GitPullRequest size={13} aria-hidden="true" />
<span>PR</span>
</button>
</div>
{#each repoTabs as repo (repo.path)}
<div
class="repo-tab-wrap"
@@ -47,7 +54,7 @@
disabled={isBusy}
title={repo.path}
>
<Database size={15} aria-hidden="true" />
<GitBranch size={13} aria-hidden="true" />
<span>{repo.name}</span>
</button>
<button
+9
View File
@@ -1,6 +1,7 @@
<script lang="ts">
import {
Box,
Bug,
ChevronDown,
Code2,
CloudDownload,
@@ -36,6 +37,7 @@
export let onCompare: () => void = () => {};
export let onInteractiveRebase: () => void = () => {};
export let onReflog: () => void = () => {};
export let onBisect: () => void = () => {};
export let onOpenInExplorer: () => void = () => {};
export let onOpenInEditor: () => void = () => {};
export let onOpenTerminal: () => void = () => {};
@@ -208,6 +210,13 @@
<small>{isGerman ? "Commits ordnen und zusammenfassen" : "Reorder and combine commits"}</small>
</span>
</button>
<button type="button" role="menuitem" onclick={() => runHistoryAction(onBisect)}>
<Bug size={15} aria-hidden="true" />
<span>
<strong>Git Bisect</strong>
<small>{isGerman ? "Fehlerhaften Commit schrittweise finden" : "Find a bad commit step by step"}</small>
</span>
</button>
</div>
{/if}
</div>
+1 -2
View File
@@ -15,8 +15,7 @@
function providerLabel(value: CommitAiProvider): string {
if (value === "openai") return "OpenAI";
if (value === "anthropic") return "Anthropic";
if (value === "custom") return "Custom endpoint";
return "Local AI";
return "Custom endpoint";
}
function locationLabel(finding: AiReviewFinding): string {
+6 -76
View File
@@ -1,20 +1,18 @@
<script lang="ts">
import { onDestroy, onMount } from "svelte";
import { AlertCircle, Bot, Check, Cpu, Eye, EyeOff, Gauge, Globe, Key, LoaderCircle, Sparkles, X, Zap } from "@lucide/svelte";
import { Bot, Check, Eye, EyeOff, Globe, Key, LoaderCircle, X } from "@lucide/svelte";
import { credDelete, credLoad, credSave } from "../git";
import type { AiSettings, CommitAiLocalProfile, CommitAiProvider, LocalModelOption } from "../types";
import SelectMenu from "./SelectMenu.svelte";
import type { AiSettings, CommitAiProvider } from "../types";
interface Props {
settings: AiSettings;
localModels: LocalModelOption[];
onSave: (settings: AiSettings) => void;
onClose: () => void;
}
let { settings, localModels = [], onSave, onClose }: Props = $props();
let { settings, onSave, onClose }: Props = $props();
type CloudProvider = Exclude<CommitAiProvider, "local">;
type CloudProvider = CommitAiProvider;
const CRED_KEYS: Record<CloudProvider, string> = {
openai: "ai:openai",
@@ -22,9 +20,7 @@
custom: "ai:custom",
};
let provider = $state<CommitAiProvider>("local");
let localModelId = $state("");
let localProfile = $state<CommitAiLocalProfile>("fast");
let provider = $state<CommitAiProvider>("openai");
let openaiModel = $state("");
let anthropicModel = $state("");
let customBaseUrl = $state("");
@@ -41,8 +37,6 @@
$effect(() => {
provider = settings.provider;
localModelId = settings.localModelId;
localProfile = settings.localProfile ?? "fast";
openaiModel = settings.openaiModel;
anthropicModel = settings.anthropicModel;
customBaseUrl = settings.customBaseUrl;
@@ -103,8 +97,6 @@
]);
onSave({
provider,
localModelId,
localProfile,
openaiModel: openaiModel.trim() || "gpt-4o-mini",
anthropicModel: anthropicModel.trim() || "claude-3-5-haiku-latest",
customBaseUrl: customBaseUrl.trim(),
@@ -117,26 +109,6 @@
}
}
function formatSize(mb: number): string {
return mb >= 1000 ? `${(mb / 1000).toFixed(1)} GB` : `${mb} MB`;
}
function recommendedModelForProfile(profile: CommitAiLocalProfile): string {
if (profile === "balanced") return "qwen2.5-1.5b";
if (profile === "detailed") return "qwen2.5-3b";
return "qwen2.5-0.5b";
}
function selectLocalProfile(profile: CommitAiLocalProfile) {
const previousRecommended = recommendedModelForProfile(localProfile);
localProfile = profile;
const nextRecommended = recommendedModelForProfile(profile);
if (!localModelId || localModelId === previousRecommended) {
localModelId = nextRecommended;
}
}
let selectedLocalModel = $derived(localModels.find((option) => option.id === localModelId));
</script>
<div
@@ -156,17 +128,6 @@
<form class="ai-settings-form" onsubmit={(e) => { e.preventDefault(); void handleSave(); }}>
<div class="ai-provider-options" role="radiogroup" aria-label="AI provider">
<button
type="button"
class="ai-provider-option ai-provider-option-local"
class:active={provider === "local"}
disabled
title="Local AI is still in development and not yet available"
>
<Cpu size={16} aria-hidden="true" />
Local AI
<span class="ai-provider-badge">In development</span>
</button>
<button type="button" class="ai-provider-option" class:active={provider === "openai"} onclick={() => { provider = "openai"; }}>
<Bot size={16} aria-hidden="true" />
OpenAI
@@ -181,38 +142,7 @@
</button>
</div>
{#if provider === "local"}
<div class="cred-field">
<span class="cred-field-label">Local speed</span>
<div class="ai-local-profile-options" role="radiogroup" aria-label="Local AI speed">
<button type="button" class="ai-local-profile-option" class:active={localProfile === "fast"} onclick={() => selectLocalProfile("fast")}>
<Zap size={15} aria-hidden="true" />
Fast
</button>
<button type="button" class="ai-local-profile-option" class:active={localProfile === "balanced"} onclick={() => selectLocalProfile("balanced")}>
<Gauge size={15} aria-hidden="true" />
Balanced
</button>
<button type="button" class="ai-local-profile-option" class:active={localProfile === "detailed"} onclick={() => selectLocalProfile("detailed")}>
<Sparkles size={15} aria-hidden="true" />
Detailed
</button>
</div>
</div>
<label class="cred-field">
<span class="cred-field-label">Model</span>
<SelectMenu value={localModelId} options={localModels.map((option) => ({ value: option.id, label: `${option.label} - ${formatSize(option.approx_size_mb)}` }))} onChange={(value) => { localModelId = value; }} />
</label>
<div class="cred-token-hint">
<AlertCircle size={13} aria-hidden="true" />
<span>
Switching downloads the model{selectedLocalModel ? ` (${formatSize(selectedLocalModel.approx_size_mb)})` : ""}
in the background — depending on your internet connection this can take several minutes.
After that it stays cached locally and loads instantly on the next start.
The speed setting only changes Local AI; API providers keep their existing prompt.
</span>
</div>
{:else if provider === "openai"}
{#if provider === "openai"}
<label class="cred-field">
<span class="cred-field-label">Model</span>
<input type="text" bind:value={openaiModel} placeholder="gpt-4o-mini" autocomplete="off" spellcheck="false" />
+158 -14
View File
@@ -6,11 +6,13 @@
ChevronDown,
ChevronRight,
CircleDashed,
CloudCog,
Code2,
FolderOpen,
GitCompare,
GitMerge,
Languages,
KeyRound,
Palette,
RefreshCw,
RotateCw,
@@ -29,38 +31,50 @@
type ExternalToolKind,
type ExternalToolPreset,
} from "../externalTools";
import { configuredIntegrationCount, defaultGitIntegrationSettings } from "../integrations";
import type {
AnalyticsSettings,
AppAppearance,
AppLanguage,
AppTheme,
CustomThemeColors,
DetectedExternalTool,
ExternalToolsSettings,
GitIntegrationSecretUpdate,
GitIntegrationSettings,
ToolOpenMode,
} from "../types";
import IntegrationSettingsPage from "./IntegrationSettingsPage.svelte";
import SelectMenu from "./SelectMenu.svelte";
type SettingsPage = "general" | "tools";
type SettingsPage = "general" | "integrations" | "tools";
interface Props {
analytics: AnalyticsSettings;
theme: AppTheme;
appearance: AppAppearance;
customTheme: CustomThemeColors;
language: AppLanguage;
autoRefresh: boolean;
externalTools: ExternalToolsSettings;
integrations: GitIntegrationSettings;
detectedTools: DetectedExternalTool[];
detectionPending: boolean;
detectionUnavailable: boolean;
onRefreshDetectedTools: () => void | Promise<void>;
onSave: (settings: AnalyticsSettings, theme: AppTheme, language: AppLanguage, autoRefresh: boolean, externalTools: ExternalToolsSettings) => void;
onSave: (settings: AnalyticsSettings, theme: AppTheme, appearance: AppAppearance, customTheme: CustomThemeColors, language: AppLanguage, autoRefresh: boolean, externalTools: ExternalToolsSettings, integrations: GitIntegrationSettings, integrationSecrets: GitIntegrationSecretUpdate[]) => void | Promise<void>;
onClose: () => void;
}
let {
analytics,
theme = "system",
appearance = "modern",
customTheme = { background: "#cfd5dc", surface: "#e2e6ea", accent: "#2eb5d1", text: "#222328" },
language = "en",
autoRefresh = true,
externalTools,
integrations,
detectedTools = [],
detectionPending = false,
detectionUnavailable = false,
@@ -71,30 +85,83 @@
const toolKinds: ExternalToolKind[] = ["editor", "diff", "merge", "terminal", "fileManager"];
let activePage = $state<SettingsPage>("tools");
let activePage = $state<SettingsPage>("integrations");
let activeToolKind = $state<ExternalToolKind>("editor");
let advancedOpen = $state(false);
let analyticsEnabled = $state(true);
let selectedTheme = $state<AppTheme>("system");
let selectedAppearance = $state<AppAppearance>("modern");
let customColors = $state<CustomThemeColors>({ background: "#cfd5dc", surface: "#e2e6ea", accent: "#2eb5d1", text: "#222328" });
let selectedLanguage = $state<AppLanguage>("en");
let autoRefreshEnabled = $state(true);
let tools = $state<ExternalToolsSettings>(defaultExternalToolsSettings());
let integrationDraft = $state<GitIntegrationSettings>(defaultGitIntegrationSettings());
let integrationSecretUpdates = $state<GitIntegrationSecretUpdate[]>([]);
let saving = $state(false);
const isGerman = $derived(selectedLanguage === "de");
$effect(() => {
analyticsEnabled = analytics.enabled;
selectedTheme = theme;
selectedAppearance = appearance;
customColors = structuredClone(customTheme);
selectedLanguage = language;
autoRefreshEnabled = autoRefresh;
tools = structuredClone(externalTools);
integrationDraft = structuredClone(integrations);
});
function save() {
onSave({
...analytics,
enabled: analyticsEnabled,
noticeSeen: true,
}, selectedTheme, selectedLanguage, autoRefreshEnabled, $state.snapshot(tools));
async function save() {
if (saving) return;
saving = true;
try {
await onSave({
...analytics,
enabled: analyticsEnabled,
noticeSeen: true,
}, selectedTheme, selectedAppearance, $state.snapshot(customColors), selectedLanguage, autoRefreshEnabled, $state.snapshot(tools), $state.snapshot(integrationDraft), $state.snapshot(integrationSecretUpdates));
} finally {
saving = false;
}
}
function resetCustomColors() {
customColors = selectedTheme === "dark"
? { background: "#222328", surface: "#2b2e34", accent: "#2eb5d1", text: "#f0f1f2" }
: { background: "#cfd5dc", surface: "#e2e6ea", accent: "#2eb5d1", text: "#222328" };
}
function cssColorToHex(value: string, fallback: string): string {
const color = value.trim();
if (/^#[0-9a-f]{6}$/i.test(color)) return color.toLowerCase();
if (!color || typeof document === "undefined") return fallback;
const probe = document.createElement("span");
probe.style.color = color;
if (!probe.style.color) return fallback;
probe.style.display = "none";
document.body.appendChild(probe);
const match = getComputedStyle(probe).color.match(/^rgba?\((\d+),\s*(\d+),\s*(\d+)/i);
probe.remove();
if (!match) return fallback;
return `#${match.slice(1, 4).map((part) => Number(part).toString(16).padStart(2, "0")).join("")}`;
}
function currentThemeColors(): CustomThemeColors {
const styles = getComputedStyle(document.documentElement);
const fallback = selectedTheme === "dark"
? { background: "#222328", surface: "#2b2e34", accent: "#2eb5d1", text: "#f0f1f2" }
: { background: "#cfd5dc", surface: "#e2e6ea", accent: "#2eb5d1", text: "#222328" };
return {
background: cssColorToHex(styles.getPropertyValue("--app-bg"), fallback.background),
surface: cssColorToHex(styles.getPropertyValue("--color-surface"), fallback.surface),
accent: cssColorToHex(styles.getPropertyValue("--color-accent"), fallback.accent),
text: cssColorToHex(styles.getPropertyValue("--color-ink"), fallback.text),
};
}
function selectAppearance(next: AppAppearance) {
if (next === "custom" && selectedAppearance !== "custom") customColors = currentThemeColors();
selectedAppearance = next;
}
function toolLabel(kind: ExternalToolKind): string {
@@ -266,10 +333,22 @@
</span>
{#if !detectionUnavailable}<em>{detectedTools.length}</em>{/if}
</button>
<button type="button" class:active={activePage === "integrations"} onclick={() => { activePage = "integrations"; }}>
<CloudCog size={16} aria-hidden="true" />
<span>
<strong>{isGerman ? "Integrationen" : "Integrations"}</strong>
<small>GitHub, GitLab, Azure DevOps & Gitea</small>
</span>
<em>{configuredIntegrationCount(integrationDraft)}</em>
</button>
<div class="settings-nav-note">
<ShieldCheck size={15} aria-hidden="true" />
<p>{isGerman ? "Tool-Argumente werden direkt und ohne zusätzliche Shell übergeben." : "Tool arguments are passed directly without an extra shell."}</p>
{#if activePage === "integrations"}<KeyRound size={15} aria-hidden="true" />{:else}<ShieldCheck size={15} aria-hidden="true" />{/if}
<p>
{activePage === "integrations"
? (isGerman ? "Tokens werden sicher im Schlüsselbund des Betriebssystems gespeichert." : "Tokens are stored securely in the operating system keychain.")
: (isGerman ? "Tool-Argumente werden direkt und ohne zusätzliche Shell übergeben." : "Tool arguments are passed directly without an extra shell.")}
</p>
</div>
</nav>
@@ -292,6 +371,40 @@
</div>
</section>
<section class="general-setting-panel">
<header><SlidersHorizontal size={16} /><div><h4>{isGerman ? "Darstellungsstil" : "Design style"}</h4><p>{isGerman ? "Aktuell, klassisch oder selbst gestaltet." : "Current, classic, or designed by you."}</p></div></header>
<div class="settings-segmented" role="radiogroup" aria-label={isGerman ? "Darstellungsstil" : "Design style"}>
<label class:active={selectedAppearance === "modern"}><input type="radio" name="appearance" value="modern" checked={selectedAppearance === "modern"} onchange={() => selectAppearance("modern")} /><span>{isGerman ? "Aktuell" : "Modern"}</span></label>
<label class:active={selectedAppearance === "classic"}><input type="radio" name="appearance" value="classic" checked={selectedAppearance === "classic"} onchange={() => selectAppearance("classic")} /><span>{isGerman ? "Klassisch" : "Classic"}</span></label>
<label class:active={selectedAppearance === "custom"}><input type="radio" name="appearance" value="custom" checked={selectedAppearance === "custom"} onchange={() => selectAppearance("custom")} /><span>{isGerman ? "Eigene" : "Custom"}</span></label>
</div>
</section>
{#if selectedAppearance === "custom"}
<section class="general-setting-panel general-setting-wide custom-theme-panel">
<header>
<Palette size={16} />
<div><h4>{isGerman ? "Theme-Generator" : "Theme generator"}</h4><p>{isGerman ? "Erstelle dein eigenes Farbprofil." : "Create your own color profile."}</p></div>
<button class="theme-reset-button" type="button" onclick={resetCustomColors}><RotateCw size={13} />{isGerman ? "Zurücksetzen" : "Reset"}</button>
</header>
<div
class="theme-preview"
style={`--preview-bg:${customColors.background};--preview-surface:${customColors.surface};--preview-accent:${customColors.accent};--preview-text:${customColors.text};`}
aria-label={isGerman ? "Vorschau des eigenen Themes" : "Custom theme preview"}
>
<span class="theme-preview-sidebar"></span>
<span class="theme-preview-content"><i></i><b></b><em></em></span>
</div>
<div class="theme-color-grid">
<label><span>{isGerman ? "Hintergrund" : "Background"}</span><input type="color" bind:value={customColors.background} aria-label={isGerman ? "Hintergrundfarbe" : "Background color"} /><code>{customColors.background}</code></label>
<label><span>{isGerman ? "Fläche" : "Surface"}</span><input type="color" bind:value={customColors.surface} aria-label={isGerman ? "Flächenfarbe" : "Surface color"} /><code>{customColors.surface}</code></label>
<label><span>{isGerman ? "Akzent" : "Accent"}</span><input type="color" bind:value={customColors.accent} aria-label={isGerman ? "Akzentfarbe" : "Accent color"} /><code>{customColors.accent}</code></label>
<label><span>{isGerman ? "Schrift" : "Text"}</span><input type="color" bind:value={customColors.text} aria-label={isGerman ? "Schriftfarbe" : "Text color"} /><code>{customColors.text}</code></label>
</div>
<p class="theme-generator-note">{isGerman ? "Die Farben werden beim Speichern auf die gesamte Oberfläche angewendet." : "The colors are applied across the interface when you save."}</p>
</section>
{/if}
<section class="general-setting-panel">
<header><Languages size={16} /><div><h4>{isGerman ? "Sprache" : "Language"}</h4><p>{isGerman ? "Sprache der Oberfläche." : "Language used by the interface."}</p></div></header>
<div class="settings-segmented settings-language" role="radiogroup" aria-label={isGerman ? "App-Sprache" : "App language"}>
@@ -316,7 +429,7 @@
</label>
</section>
</div>
{:else}
{:else if activePage === "tools"}
<div class="settings-page-head tools-page-head">
<div>
<h3>{isGerman ? "Externe Tools" : "External tools"}</h3>
@@ -435,6 +548,19 @@
</div>
{/if}
</section>
{:else}
<div class="settings-page-head">
<div>
<h3>{isGerman ? "Integrationen" : "Integrations"}</h3>
<p>{isGerman ? "Verbinde Gitty mit deinen Git-Hosting-Diensten." : "Connect Gitty to your Git hosting services."}</p>
</div>
</div>
<IntegrationSettingsPage
language={selectedLanguage}
settings={integrationDraft}
onChange={(next) => { integrationDraft = next; }}
onSecretsChange={(updates) => { integrationSecretUpdates = updates; }}
/>
{/if}
</div>
</div>
@@ -443,7 +569,7 @@
<span>{isGerman ? "Änderungen werden erst beim Speichern übernommen." : "Changes are applied only after saving."}</span>
<div>
<button class="btn-secondary" type="button" onclick={onClose}>{isGerman ? "Abbrechen" : "Cancel"}</button>
<button class="btn-primary" type="submit"><Check size={16} aria-hidden="true" />{isGerman ? "Änderungen speichern" : "Save changes"}</button>
<button class="btn-primary" type="submit" disabled={saving}><Check size={16} aria-hidden="true" />{saving ? (isGerman ? "Wird gespeichert…" : "Saving…") : (isGerman ? "Änderungen speichern" : "Save changes")}</button>
</div>
</footer>
</form>
@@ -534,6 +660,22 @@
.settings-segmented label { display: flex; align-items: center; justify-content: center; min-height: 31px; border: 1px solid transparent; border-radius: 6px; color: var(--color-ink-dim); font-size: 10.5px; font-weight: 750; }
.settings-segmented label.active { border-color: var(--color-border-input); color: var(--color-ink); background: var(--color-surface-hover); }
.settings-segmented input { position: absolute; width: 1px; height: 1px; opacity: 0; pointer-events: none; }
.custom-theme-panel > header { align-items: center; }
.custom-theme-panel > header > div { min-width: 0; }
.theme-reset-button { display: inline-flex; align-items: center; gap: 5px; min-height: 27px; margin-left: auto; padding: 0 8px; border: 1px solid var(--color-border); color: var(--color-ink-muted); background: var(--color-surface-raised); font-size: 9.5px; font-weight: 750; }
.theme-reset-button:hover:not(:disabled) { border-color: var(--color-border-input); color: var(--color-ink); background: var(--color-surface-hover); }
.theme-preview { display: grid; grid-template-columns: 28% 1fr; min-height: 78px; overflow: hidden; border: 1px solid color-mix(in srgb, var(--preview-text) 30%, var(--preview-surface)); background: var(--preview-bg); }
.theme-preview-sidebar { border-right: 1px solid color-mix(in srgb, var(--preview-text) 24%, var(--preview-surface)); background: color-mix(in srgb, var(--preview-surface) 86%, var(--preview-bg)); }
.theme-preview-content { display: grid; grid-template-columns: 1fr auto; align-content: start; gap: 8px; margin: 10px; padding: 10px; border: 1px solid color-mix(in srgb, var(--preview-text) 22%, var(--preview-surface)); color: var(--preview-text); background: var(--preview-surface); }
.theme-preview-content i { display: block; width: 54%; height: 7px; background: var(--preview-text); opacity: .82; }
.theme-preview-content b { display: block; width: 34px; height: 18px; grid-row: 1 / 3; grid-column: 2; background: var(--preview-accent); }
.theme-preview-content em { display: block; width: 76%; height: 5px; background: var(--preview-text); opacity: .32; }
.theme-color-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 7px; }
.theme-color-grid label { display: grid; grid-template-columns: minmax(0, 1fr) 32px auto; align-items: center; gap: 8px; min-width: 0; min-height: 38px; padding: 5px 7px; border: 1px solid var(--color-border-subtle); background: var(--color-surface-raised); }
.theme-color-grid label > span { color: var(--color-ink-muted); font-size: 10px; font-weight: 750; }
.theme-color-grid input[type="color"] { width: 32px; height: 25px; padding: 2px; border: 1px solid var(--color-border-input); background: transparent; cursor: pointer; }
.theme-color-grid code { color: var(--color-ink-faint); font: 9px var(--font-mono); text-transform: uppercase; }
.theme-generator-note { margin: -5px 0 0 !important; color: var(--color-ink-faint) !important; }
.settings-switch-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 14px; }
.settings-switch-row span { display: grid; gap: 3px; }
.settings-switch-row strong { color: var(--color-ink); font-size: 11px; }
@@ -564,11 +706,13 @@
.app-settings-head { min-height: 58px; padding: 10px 12px; }
.app-settings-mark { width: 34px; height: 34px; }
.settings-nav button small, .settings-nav button em { display: none; }
.settings-nav > button { grid-template-columns: auto minmax(0, 1fr); }
.settings-nav > button { grid-template-columns: auto minmax(0, 1fr); gap: 5px; padding-inline: 6px; }
.settings-nav button strong { font-size: 10px; }
.settings-page-head { align-items: stretch; flex-direction: column; }
.tool-rescan-button { align-self: flex-start; }
.general-settings-grid { grid-template-columns: 1fr; }
.general-setting-panel.general-setting-wide { grid-column: auto; }
.theme-color-grid { grid-template-columns: 1fr; }
.tool-config-panel { padding: 13px; }
.tool-usage-callout { grid-template-columns: 1fr; gap: 4px; }
}
+142
View File
@@ -0,0 +1,142 @@
<script lang="ts">
import { AlertTriangle, Bug, Check, GitCommitHorizontal, LoaderCircle, Play, RotateCcw, SkipForward, X } from "@lucide/svelte";
import type { BisectState } from "../types";
interface Props {
language: "en" | "de";
bisectState: BisectState | null;
isLoading: boolean;
isBusy: boolean;
operation: string;
error: string;
onStart: (good: string, bad: string) => void;
onMark: (verdict: "good" | "bad" | "skip") => void;
onReset: () => void;
onClose: () => void;
}
let { language = "en", bisectState = null, isLoading = false, isBusy = false, operation = "", error = "", onStart = () => {}, onMark = () => {}, onReset = () => {}, onClose = () => {} }: Props = $props();
let good = $state("");
let bad = $state("HEAD");
const de = $derived(language === "de");
const canStart = $derived(Boolean(good.trim() && bad.trim()) && !isLoading && !isBusy);
</script>
<div class="dialog-backdrop app-chrome-backdrop" role="presentation">
<div class="dialog bisect-dialog" role="dialog" aria-modal="true" aria-label={de ? "Geführtes Git Bisect" : "Guided Git bisect"} tabindex="-1">
<header class="dialog-header">
<div><span class="eyebrow">{de ? "Fehlerursache eingrenzen" : "Find the regression"}</span><h2 class="dialog-title">Git Bisect</h2></div>
<button class="dialog-close" type="button" onclick={onClose} disabled={isBusy} title={de ? "Schließen" : "Close"}><X size={18} aria-hidden="true" /></button>
</header>
<div class="bisect-body">
{#if error}<div class="bisect-notice error"><AlertTriangle size={16} /><span>{error}</span></div>{/if}
{#if isLoading}
<div class="bisect-loading"><LoaderCircle class="spin" size={18} />{de ? "Bisect-Status wird geladen …" : "Loading bisect status …"}</div>
{:else if !bisectState?.active}
<section class="bisect-intro">
<div class="bisect-intro-icon"><Bug size={24} /></div>
<div><h3>{de ? "Welcher Commit hat den Fehler eingeführt?" : "Which commit introduced the bug?"}</h3><p>{de ? "Gitty checkt schrittweise frühere Commits aus. Markiere jede getestete Version als Good, Bad oder Skip." : "Gitty checks out earlier commits step by step. Mark each tested version as Good, Bad, or Skip."}</p></div>
</section>
<div class="bisect-range">
<div class="range-heading">
<span>{de ? "Suchbereich" : "Search range"}</span>
<small>{de ? "Von funktionierend bis fehlerhaft" : "From working to broken"}</small>
</div>
<div class="bisect-fields">
<label class="commit-field good-field">
<span class="field-title"><Check size={14} />{de ? "Funktionierender Commit" : "Known good commit"}<em>Good</em></span>
<input bind:value={good} placeholder={de ? "z. B. v1.4.0 oder Commit-Hash" : "e.g. v1.4.0 or commit hash"} disabled={isBusy} spellcheck="false" autocomplete="off" />
<small>{de ? "Hier trat der Fehler noch nicht auf." : "The bug did not occur here."}</small>
</label>
<label class="commit-field bad-field">
<span class="field-title"><Bug size={14} />{de ? "Fehlerhafter Commit" : "Known bad commit"}<em>Bad</em></span>
<input bind:value={bad} placeholder="HEAD" disabled={isBusy} spellcheck="false" autocomplete="off" />
<small>{de ? "Meistens HEAD, der aktuelle Commit." : "Usually HEAD, the current commit."}</small>
</label>
</div>
</div>
<div class="bisect-notice"><AlertTriangle size={15} /><span>{de ? "Der Arbeitsbaum muss sauber sein. Während des Bisects wechselt Gitty vorübergehend zwischen Commits." : "The working tree must be clean. Gitty temporarily switches between commits during the bisect."}</span></div>
{:else if bisectState.finished && bisectState.culprit}
<section class="bisect-result">
<div class="result-icon"><Bug size={22} /></div>
<div><span>{de ? "Erster fehlerhafter Commit gefunden" : "First bad commit found"}</span><h3>{bisectState.culprit.summary}</h3><div class="commit-meta"><code>{bisectState.culprit.short_hash}</code><span>{bisectState.culprit.author_name}</span><span>{new Date(bisectState.culprit.date).toLocaleString()}</span></div></div>
</section>
<p class="result-copy">{de ? "Beende den Bisect, um zum ursprünglichen Branch und Arbeitsstand zurückzukehren." : "Finish the bisect to return to the original branch and working state."}</p>
{:else if bisectState.current}
<div class="bisect-progress">
<span>{de ? "Aktueller Test-Commit" : "Current test commit"}</span>
{#if bisectState.remaining_steps !== null}<strong>{de ? `Noch ungefähr ${bisectState.remaining_steps} Schritte` : `About ${bisectState.remaining_steps} steps remaining`}</strong>{/if}
</div>
<section class="bisect-current">
<GitCommitHorizontal size={22} />
<div><h3>{bisectState.current.summary}</h3><div class="commit-meta"><code>{bisectState.current.short_hash}</code><span>{bisectState.current.author_name}</span><span>{new Date(bisectState.current.date).toLocaleString()}</span></div></div>
</section>
<div class="bisect-question"><strong>{de ? "Tritt der Fehler bei diesem Commit auf?" : "Does the bug occur at this commit?"}</strong><span>{de ? "Teste die Anwendung oder führe deine Prüfschritte aus, bevor du entscheidest." : "Test the application or run your checks before choosing a verdict."}</span></div>
<div class="verdict-grid">
<button class="verdict good" type="button" onclick={() => onMark("good")} disabled={isBusy}><Check size={17} /><span><strong>Good</strong><small>{de ? "Fehler tritt nicht auf" : "Bug is absent"}</small></span></button>
<button class="verdict bad" type="button" onclick={() => onMark("bad")} disabled={isBusy}><Bug size={17} /><span><strong>Bad</strong><small>{de ? "Fehler tritt auf" : "Bug occurs"}</small></span></button>
<button class="verdict skip" type="button" onclick={() => onMark("skip")} disabled={isBusy}><SkipForward size={17} /><span><strong>Skip</strong><small>{de ? "Nicht testbar" : "Cannot test"}</small></span></button>
</div>
{/if}
</div>
<footer class="dialog-footer bisect-footer">
{#if bisectState?.active}
<button class="btn-secondary reset" type="button" onclick={onReset} disabled={isBusy}><RotateCcw size={15} />{bisectState.finished ? (de ? "Bisect beenden" : "Finish bisect") : (de ? "Bisect abbrechen" : "Abort bisect")}</button>
<button class="btn-secondary" type="button" onclick={onClose} disabled={isBusy}>{de ? "Später fortsetzen" : "Continue later"}</button>
{:else}
<button class="btn-secondary" type="button" onclick={onClose} disabled={isBusy}>{de ? "Abbrechen" : "Cancel"}</button>
<button class="btn-primary" type="button" onclick={() => onStart(good.trim(), bad.trim())} disabled={!canStart}>{#if operation === "Starting Git bisect"}<LoaderCircle class="spin" size={16} />{:else}<Play size={16} />{/if}{de ? "Bisect starten" : "Start bisect"}</button>
{/if}
</footer>
</div>
</div>
<style>
.bisect-dialog{grid-template-rows:auto minmax(0,auto) auto;width:min(640px,calc(100vw - 28px));height:auto;max-height:calc(100vh - 40px);overflow:hidden}
.bisect-body{display:grid;align-content:start;gap:14px;padding:16px 18px;overflow:auto}
.bisect-loading{display:flex;min-height:150px;align-items:center;justify-content:center;gap:8px;color:var(--color-ink-muted)}
.bisect-intro,.bisect-current,.bisect-result{display:flex;align-items:flex-start;gap:12px}
.bisect-intro-icon,.result-icon{display:grid;width:38px;height:38px;flex:0 0 38px;place-items:center;border:1px solid color-mix(in srgb,var(--color-primary) 32%,var(--color-border));border-radius:8px;color:var(--color-primary);background:color-mix(in srgb,var(--color-primary) 9%,var(--color-surface))}
.bisect-intro h3,.bisect-current h3,.bisect-result h3{margin:0;font-size:13px}
.bisect-intro p,.result-copy{max-width:520px;margin:4px 0 0;color:var(--color-ink-muted);font-size:12px;line-height:1.45}
.bisect-range{display:grid;gap:9px;padding:12px;border:1px solid var(--color-border-subtle);border-radius:9px;background:color-mix(in srgb,var(--color-surface) 72%,transparent)}
.range-heading{display:flex;align-items:baseline;justify-content:space-between;gap:12px}
.range-heading>span{color:var(--color-ink);font-size:10px;font-weight:800;letter-spacing:.07em;text-transform:uppercase}
.range-heading>small{color:var(--color-ink-faint);font-size:10px}
.bisect-fields{display:grid;grid-template-columns:1fr 1fr;gap:10px}
.commit-field{display:grid;gap:6px;padding:10px;border:1px solid var(--color-border-subtle);border-radius:7px;background:var(--color-surface-raised)}
.field-title{display:flex;align-items:center;gap:6px;color:var(--color-ink);font-size:11px;font-weight:700}
.field-title em{margin-left:auto;padding:1px 5px;border-radius:4px;font-size:9px;font-style:normal;font-weight:800;text-transform:uppercase}
.good-field .field-title :global(svg),.good-field .field-title em{color:#69c986}
.good-field .field-title em{background:color-mix(in srgb,#69c986 10%,transparent)}
.bad-field .field-title :global(svg),.bad-field .field-title em{color:#ef737b}
.bad-field .field-title em{background:color-mix(in srgb,#ef737b 10%,transparent)}
.bisect-fields input{height:32px;font-family:var(--font-mono);font-size:11.5px}
.bisect-fields small{color:var(--color-ink-faint);font-size:9.5px;line-height:1.35}
.bisect-notice{display:flex;align-items:flex-start;gap:8px;padding:9px 11px;border:1px solid color-mix(in srgb,#e2ad4e 28%,var(--color-border));border-radius:7px;color:var(--color-ink-muted);background:color-mix(in srgb,#e2ad4e 6%,var(--color-surface));font-size:11.5px;line-height:1.4}
.bisect-notice :global(svg){flex:0 0 auto;color:#e2ad4e}
.bisect-notice.error{border-color:color-mix(in srgb,#e45c65 40%,var(--color-border));color:#ef979d;background:color-mix(in srgb,#c92f3a 8%,var(--color-surface))}
.bisect-notice.error :global(svg){color:#ef6972}
.bisect-progress{display:flex;align-items:center;justify-content:space-between;color:var(--color-ink-muted);font-size:11px}
.bisect-progress strong{color:var(--color-primary)}
.bisect-current{padding:14px;border:1px solid var(--color-border);border-radius:8px;background:var(--color-surface)}
.bisect-current>:global(svg){flex:0 0 auto;color:var(--color-primary)}
.commit-meta{display:flex;flex-wrap:wrap;align-items:center;gap:7px 12px;margin-top:7px;color:var(--color-ink-faint);font-size:10px}
.commit-meta code{padding:2px 5px;border-radius:4px;color:var(--color-primary);background:color-mix(in srgb,var(--color-primary) 10%,transparent)}
.bisect-question{display:grid;gap:4px}
.bisect-question span{color:var(--color-ink-muted);font-size:11px}
.verdict-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:9px}
.verdict{min-height:58px;justify-content:flex-start;padding:8px 11px;text-align:left}
.verdict>span{display:grid;gap:2px}
.verdict small{color:var(--color-ink-faint);font-size:9.5px}
.verdict.good{color:#69c986}.verdict.bad{color:#ef737b}.verdict.skip{color:#ddb45c}
.bisect-result{padding:16px;border:1px solid color-mix(in srgb,#ef737b 36%,var(--color-border));border-radius:8px;background:color-mix(in srgb,#c92f3a 7%,var(--color-surface))}
.bisect-result>div:last-child>span{color:#ef838b;font-size:10px;font-weight:800;text-transform:uppercase}
.result-copy{margin:0}
.bisect-footer{justify-content:flex-end;padding-block:9px}
.bisect-footer .reset{margin-right:auto}
@media(max-width:620px){.bisect-fields,.verdict-grid{grid-template-columns:1fr}.bisect-body{padding:14px}.range-heading{align-items:flex-start;flex-direction:column;gap:2px}.bisect-progress{align-items:flex-start;flex-direction:column;gap:4px}}
</style>
+493 -100
View File
@@ -1,22 +1,24 @@
<script lang="ts">
import { onDestroy } from "svelte";
import { onDestroy, onMount } from "svelte";
import { open as openDialog } from "@tauri-apps/plugin-dialog";
import { Download, FolderOpen, LoaderCircle, X } from "@lucide/svelte";
import { Cloud, Download, FolderOpen, GitBranch, Globe2, LoaderCircle, LockKeyhole, RefreshCw, Search, X } from "@lucide/svelte";
import { listIntegrationRepositories } from "../git";
import { configuredIntegrationSources } from "../integrations";
import type { AppLanguage, CloneOptions, GitIntegrationProvider, GitIntegrationRepository, GitIntegrationSettings, GitIntegrationSource } from "../types";
type CloneSource = "url" | "integrations";
interface Props {
isBusy: boolean;
error: string;
onClone: (remoteUrl: string, parentPath: string, directoryName: string) => void;
language: AppLanguage;
integrations: GitIntegrationSettings;
onClone: (remoteUrl: string, parentPath: string, directoryName: string, options: CloneOptions, provider?: GitIntegrationProvider, accountId?: string) => void;
onClose: () => void;
}
let {
isBusy = false,
error = "",
onClone = () => {},
onClose = () => {},
}: Props = $props();
let { isBusy = false, error = "", language = "en", integrations, onClone = () => {}, onClose = () => {} }: Props = $props();
let source = $state<CloneSource>("url");
let remoteUrl = $state("");
let parentPath = $state("");
let directoryName = $state("");
@@ -25,29 +27,164 @@
let browseError = $state("");
let visibleError = $state("");
let errorHideTimer: ReturnType<typeof setTimeout> | undefined;
let selectedSourceId = $state("");
let selectedRepositoryId = $state("");
let repositorySearch = $state("");
let repositoriesBySource = $state<Record<string, GitIntegrationRepository[]>>({});
let loadingSourceId = $state("");
let repositoryError = $state("");
let repositoryListElement = $state<HTMLDivElement>();
let repositoryScrollbarElement = $state<HTMLDivElement>();
let repositoryScrollbarVisible = $state(false);
let repositoryScrollbarTop = $state(0);
let repositoryScrollbarHeight = $state(28);
let repositoryScrollTop = $state(0);
let repositoryScrollMax = $state(0);
let repositoryScrollbarPointerId = $state<number>();
let repositoryScrollbarDragY = 0;
let repositoryScrollbarDragScrollTop = 0;
let repositoryScrollbarFrame: number | undefined;
let repositoryRequestId = 0;
let shallowClone = $state(false);
let branchToClone = $state("");
let shallowLimitMode = $state<"depth" | "since">("depth");
let shallowDepth = $state(1);
let shallowSince = $state("");
let customFlags = $state("");
let sparseCheckout = $state(false);
let bloblessClone = $state(false);
let sparsePathInput = $state("");
let directorySuggestion = $derived(directoryNameFromRemoteUrl(remoteUrl));
let canSubmit = $derived(
!isBusy &&
remoteUrl.trim().length > 0 &&
parentPath.trim().length > 0,
);
const isGerman = $derived(language === "de");
const configuredSources = $derived(configuredIntegrationSources(integrations));
const activeSource = $derived(configuredSources.find((candidate) => candidate.id === selectedSourceId));
const activeRepositories = $derived(activeSource ? repositoriesBySource[activeSource.id] ?? [] : []);
const filteredRepositories = $derived.by(() => {
const query = repositorySearch.trim().toLocaleLowerCase();
if (!query) return activeRepositories;
return activeRepositories.filter((repository) => `${repository.fullName} ${repository.description}`.toLocaleLowerCase().includes(query));
});
const azureRepositoryGroups = $derived.by(() => {
if (activeSource?.provider !== "azure-devops") return [];
const groups = new Map<string, GitIntegrationRepository[]>();
for (const repository of filteredRepositories) {
const separator = repository.fullName.indexOf("/");
const project = separator > 0 ? repository.fullName.slice(0, separator) : (isGerman ? "Weitere Repositories" : "Other repositories");
const repositories = groups.get(project) ?? [];
repositories.push(repository);
groups.set(project, repositories);
}
return [...groups.entries()].map(([project, repositories]) => ({ project, repositories }));
});
const selectedRepository = $derived(activeRepositories.find((repository) => repository.id === selectedRepositoryId));
const directorySuggestion = $derived(directoryNameFromRemoteUrl(remoteUrl));
const sparsePaths = $derived.by(() => [...new Set(sparsePathInput.split(/\r?\n/).map((path) => path.trim()).filter(Boolean))]);
const shallowLimitValid = $derived(!shallowClone || (shallowLimitMode === "depth" ? Number.isInteger(shallowDepth) && shallowDepth > 0 : shallowSince.trim().length > 0));
const cloneOptionsValid = $derived(shallowLimitValid);
const canSubmit = $derived(!isBusy && remoteUrl.trim().length > 0 && parentPath.trim().length > 0 && cloneOptionsValid);
$effect(() => {
const nextError = error || browseError;
if (errorHideTimer) clearTimeout(errorHideTimer);
visibleError = nextError;
if (nextError) {
errorHideTimer = setTimeout(() => {
visibleError = "";
}, 6000);
}
if (nextError) errorHideTimer = setTimeout(() => { visibleError = ""; }, 6000);
});
$effect(() => {
filteredRepositories.length;
loadingSourceId;
repositoryError;
scheduleRepositoryScrollbarUpdate();
});
onMount(() => {
window.addEventListener("resize", scheduleRepositoryScrollbarUpdate);
return () => window.removeEventListener("resize", scheduleRepositoryScrollbarUpdate);
});
onDestroy(() => {
if (errorHideTimer) clearTimeout(errorHideTimer);
if (repositoryScrollbarFrame !== undefined) cancelAnimationFrame(repositoryScrollbarFrame);
});
function scheduleRepositoryScrollbarUpdate() {
if (repositoryScrollbarFrame !== undefined) cancelAnimationFrame(repositoryScrollbarFrame);
repositoryScrollbarFrame = requestAnimationFrame(() => {
repositoryScrollbarFrame = undefined;
updateRepositoryScrollbar();
});
}
function updateRepositoryScrollbar() {
const list = repositoryListElement;
const track = repositoryScrollbarElement;
if (!list || !track) {
repositoryScrollbarVisible = false;
return;
}
const scrollMax = Math.max(0, list.scrollHeight - list.clientHeight);
const trackHeight = track.clientHeight;
const thumbHeight = scrollMax > 0
? Math.max(28, trackHeight * (list.clientHeight / list.scrollHeight))
: trackHeight;
const thumbTravel = Math.max(0, trackHeight - thumbHeight);
repositoryScrollTop = list.scrollTop;
repositoryScrollMax = scrollMax;
repositoryScrollbarHeight = thumbHeight;
repositoryScrollbarTop = scrollMax > 0 ? (list.scrollTop / scrollMax) * thumbTravel : 0;
repositoryScrollbarVisible = scrollMax > 1;
}
function startRepositoryScrollbarDrag(event: PointerEvent) {
if (!repositoryListElement) return;
event.preventDefault();
event.stopPropagation();
repositoryScrollbarPointerId = event.pointerId;
repositoryScrollbarDragY = event.clientY;
repositoryScrollbarDragScrollTop = repositoryListElement.scrollTop;
(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId);
}
function dragRepositoryScrollbar(event: PointerEvent) {
if (repositoryScrollbarPointerId !== event.pointerId || !repositoryListElement || !repositoryScrollbarElement) return;
const thumbTravel = repositoryScrollbarElement.clientHeight - repositoryScrollbarHeight;
if (thumbTravel <= 0) return;
repositoryListElement.scrollTop = repositoryScrollbarDragScrollTop
+ ((event.clientY - repositoryScrollbarDragY) / thumbTravel) * repositoryScrollMax;
}
function stopRepositoryScrollbarDrag(event: PointerEvent) {
if (repositoryScrollbarPointerId !== event.pointerId) return;
repositoryScrollbarPointerId = undefined;
const target = event.currentTarget as HTMLElement;
if (target.hasPointerCapture(event.pointerId)) target.releasePointerCapture(event.pointerId);
}
function jumpRepositoryScrollbar(event: MouseEvent) {
if (event.target !== event.currentTarget || !repositoryListElement || !repositoryScrollbarElement) return;
const track = repositoryScrollbarElement.getBoundingClientRect();
const thumbTravel = track.height - repositoryScrollbarHeight;
if (thumbTravel <= 0) return;
const targetTop = Math.max(0, Math.min(thumbTravel, event.clientY - track.top - repositoryScrollbarHeight / 2));
repositoryListElement.scrollTop = (targetTop / thumbTravel) * repositoryScrollMax;
}
function handleRepositoryScrollbarKey(event: KeyboardEvent) {
if (!repositoryListElement) return;
const page = repositoryListElement.clientHeight * 0.85;
const changes: Record<string, number> = {
ArrowUp: repositoryListElement.scrollTop - 40,
ArrowDown: repositoryListElement.scrollTop + 40,
PageUp: repositoryListElement.scrollTop - page,
PageDown: repositoryListElement.scrollTop + page,
Home: 0,
End: repositoryScrollMax,
};
if (!(event.key in changes)) return;
event.preventDefault();
repositoryListElement.scrollTop = changes[event.key];
}
function directoryNameFromRemoteUrl(url: string): string {
const trimmed = url.trim().split(/[?#]/, 1)[0]?.replace(/[\\/]+$/g, "") ?? "";
const lastSegment = trimmed.split(/[\\/:]/).filter(Boolean).pop() ?? "";
@@ -64,112 +201,368 @@
if (isBusy) return;
browseError = "";
try {
const selected = await openDialog({
title: "Select clone destination",
directory: true,
multiple: false,
defaultPath: parentPath.trim() || undefined,
});
if (typeof selected !== "string") return;
parentPath = selected;
} catch (error) {
browseError = errorToMessage(error);
}
const selected = await openDialog({ title: isGerman ? "Zielordner zum Klonen auswählen" : "Select clone destination", directory: true, multiple: false, defaultPath: parentPath.trim() || undefined });
if (typeof selected === "string") parentPath = selected;
} catch (error) { browseError = errorToMessage(error); }
}
function handleRemoteInput(event: Event) {
const nextRemoteUrl = (event.currentTarget as HTMLInputElement).value;
function setRemoteUrl(nextRemoteUrl: string) {
remoteUrl = nextRemoteUrl;
if (directoryNameEdited) return;
directoryAutoName = directoryNameFromRemoteUrl(nextRemoteUrl);
directoryName = directoryAutoName;
}
function handleRemoteInput(event: Event) {
setRemoteUrl((event.currentTarget as HTMLInputElement).value);
selectedRepositoryId = "";
}
function handleDirectoryInput(event: Event) {
const nextDirectoryName = (event.currentTarget as HTMLInputElement).value;
directoryNameEdited = nextDirectoryName.trim().length > 0 && nextDirectoryName !== directoryAutoName;
}
function submit(event: SubmitEvent) {
event.preventDefault();
if (!canSubmit) return;
onClone(remoteUrl.trim(), parentPath.trim(), directoryName.trim());
function selectRepository(repository: GitIntegrationRepository) {
selectedRepositoryId = repository.id;
setRemoteUrl(repository.cloneUrl);
}
function sortRepositories(repositories: GitIntegrationRepository[]): GitIntegrationRepository[] {
return [...repositories].sort((left, right) => left.fullName.localeCompare(
right.fullName,
isGerman ? "de" : "en",
{ numeric: true, sensitivity: "base" },
));
}
async function loadRepositories(integrationSource: GitIntegrationSource, force = false) {
selectedSourceId = integrationSource.id;
selectedRepositoryId = "";
repositorySearch = "";
repositoryError = "";
if (!force && repositoriesBySource[integrationSource.id]) return;
const requestId = ++repositoryRequestId;
loadingSourceId = integrationSource.id;
try {
const repositories = await listIntegrationRepositories(integrationSource.provider, integrationSource.baseUrl, integrationSource.accountId);
if (requestId === repositoryRequestId) repositoriesBySource = { ...repositoriesBySource, [integrationSource.id]: sortRepositories(repositories) };
} catch (error) {
if (requestId === repositoryRequestId) repositoryError = errorToMessage(error);
} finally {
if (requestId === repositoryRequestId) loadingSourceId = "";
}
}
function selectIntegrationSource(integrationSource: GitIntegrationSource) {
source = "integrations";
void loadRepositories(integrationSource);
}
function showUrlInput() {
source = "url";
selectedRepositoryId = "";
}
function formatUpdatedAt(value: string): string {
if (!value) return "";
const date = new Date(value);
return Number.isNaN(date.getTime()) ? "" : new Intl.DateTimeFormat(isGerman ? "de-DE" : "en-US", { dateStyle: "medium" }).format(date);
}
function submit(event: SubmitEvent) {
event.preventDefault();
if (canSubmit) onClone(
remoteUrl.trim(),
parentPath.trim(),
directoryName.trim(),
{
branch: shallowClone && branchToClone.trim() ? branchToClone.trim() : null,
blobless: sparseCheckout && bloblessClone,
customFlags: shallowClone ? customFlags.trim() : "",
shallowDepth: shallowClone && shallowLimitMode === "depth" ? shallowDepth : null,
shallowSince: shallowClone && shallowLimitMode === "since" ? shallowSince : null,
sparse: sparseCheckout,
sparsePaths: sparseCheckout ? sparsePaths : [],
},
source === "integrations" ? activeSource?.provider : undefined,
source === "integrations" ? activeSource?.accountId : undefined,
);
}
</script>
<div class="dialog-backdrop" role="presentation">
<div class="dialog clone-repository-dialog" role="dialog" aria-modal="true" aria-label="Clone repository" tabindex="-1">
<header class="dialog-header">
<div>
<span class="eyebrow">Repository Management</span>
<h2 class="mt-0.5 text-ink text-base font-bold leading-tight">Clone repository</h2>
</div>
<button class="dialog-close" type="button" onclick={onClose} disabled={isBusy} title="Close">
<X size={18} aria-hidden="true" />
</button>
<div class="dialog clone-repository-dialog" role="dialog" aria-modal="true" aria-label={isGerman ? "Repository klonen" : "Clone repository"} tabindex="-1">
<header class="dialog-header clone-dialog-header">
<div><h2>{isGerman ? "Repository klonen" : "Clone a Repository"}</h2></div>
<button class="dialog-close" type="button" onclick={onClose} disabled={isBusy} title={isGerman ? "Schließen" : "Close"} aria-label={isGerman ? "Klonen schließen" : "Close clone dialog"}><X size={18} /></button>
</header>
<form class="clone-dialog-form" onsubmit={submit}>
<label class="clone-dialog-field">
<span>Remote URL</span>
<!-- svelte-ignore a11y_autofocus -->
<input
bind:value={remoteUrl}
oninput={handleRemoteInput}
autocomplete="off"
spellcheck="false"
placeholder="https://github.com/org/project.git"
disabled={isBusy}
autofocus
/>
</label>
<div class="clone-dialog-layout">
<aside class="clone-source-nav" aria-label={isGerman ? "Repository-Quellen" : "Repository sources"}>
<div class="clone-source-heading">{isGerman ? "Quelle" : "Source"}</div>
<div class="clone-source-list" role="tablist" aria-label={isGerman ? "Repository-Quelle" : "Repository source"}>
<button type="button" role="tab" aria-selected={source === "url"} class:active={source === "url"} onclick={showUrlInput}><Globe2 size={15} /><span>{isGerman ? "Mit URL klonen" : "Clone with URL"}</span></button>
{#each configuredSources as integrationSource}
<button type="button" role="tab" aria-selected={source === "integrations" && selectedSourceId === integrationSource.id} class:active={source === "integrations" && selectedSourceId === integrationSource.id} onclick={() => selectIntegrationSource(integrationSource)}>
{#if integrationSource.provider === "azure-devops"}<Cloud size={15} />{:else}<GitBranch size={15} />{/if}
<span>{integrationSource.provider === "azure-devops" ? `Azure · ${integrationSource.label}` : integrationSource.label}</span>
</button>
{/each}
</div>
{#if configuredSources.length === 0}<p>{isGerman ? "Integrationen kannst du in den Einstellungen einrichten." : "Set up integrations in Settings."}</p>{/if}
</aside>
<label class="clone-dialog-field">
<span>Destination</span>
<div class="clone-dialog-path-field">
<input
bind:value={parentPath}
autocomplete="off"
spellcheck="false"
placeholder="Choose parent folder"
disabled={isBusy}
/>
<button class="btn-secondary" type="button" onclick={chooseParentFolder} disabled={isBusy}>
<FolderOpen size={14} aria-hidden="true" />
Browse
</button>
</div>
</label>
<section class="clone-dialog-content">
<div class="clone-dialog-title">
<span>{source === "integrations" ? (activeSource?.label ?? "Integration") : "URL"}</span>
<h3>{isGerman ? "Repository klonen" : "Clone a Repo"}</h3>
</div>
<label class="clone-dialog-field">
<span>Folder name</span>
<input
bind:value={directoryName}
oninput={handleDirectoryInput}
autocomplete="off"
spellcheck="false"
placeholder={directorySuggestion || "Optional"}
disabled={isBusy}
/>
</label>
<div class="clone-target-grid">
<label class="clone-dialog-field"><span>{isGerman ? "Klonen nach" : "Where to clone to"}</span><div class="clone-dialog-path-field"><input bind:value={parentPath} autocomplete="off" spellcheck="false" placeholder={isGerman ? "Übergeordneten Ordner auswählen" : "Choose parent folder"} disabled={isBusy} /><button class="btn-secondary" type="button" onclick={chooseParentFolder} disabled={isBusy}><FolderOpen size={14} />{isGerman ? "Durchsuchen" : "Browse"}</button></div></label>
<label class="clone-dialog-field"><span>{isGerman ? "Ordnername" : "Folder name"}</span><input bind:value={directoryName} oninput={handleDirectoryInput} autocomplete="off" spellcheck="false" placeholder={directorySuggestion || "Optional"} disabled={isBusy} /></label>
</div>
{#if visibleError}
<div class="clone-dialog-error" role="alert">{visibleError}</div>
{/if}
<section class="clone-options" aria-label={isGerman ? "Clone-Optionen" : "Clone options"}>
<div class="clone-option-card" class:expanded={shallowClone}>
<label class="clone-option-toggle">
<input type="checkbox" bind:checked={shallowClone} disabled={isBusy} />
<span><strong>Shallow Clone</strong><small>{isGerman ? "Nur die neuesten Commits laden" : "Download only the latest commits"}</small></span>
</label>
{#if shallowClone}
<div class="clone-option-body">
<label class="clone-option-detail"><span>{isGerman ? "Zu klonender Branch" : "Branch to clone"}</span><input bind:value={branchToClone} autocomplete="off" spellcheck="false" placeholder={isGerman ? "Standard-Branch" : "Default branch"} disabled={isBusy} /></label>
<fieldset class="clone-history-mode">
<legend>{isGerman ? "Historie begrenzen nach" : "Limit history by"}</legend>
<label><input type="radio" bind:group={shallowLimitMode} value="depth" disabled={isBusy} />{isGerman ? "Commit-Tiefe" : "Commit depth"}</label>
<label><input type="radio" bind:group={shallowLimitMode} value="since" disabled={isBusy} />{isGerman ? "Seit Datum" : "Since date"}</label>
</fieldset>
{#if shallowLimitMode === "depth"}
<label class="clone-option-detail"><span>{isGerman ? "Tiefe" : "Depth"}</span><input type="number" min="1" step="1" bind:value={shallowDepth} disabled={isBusy} aria-invalid={!Number.isInteger(shallowDepth) || shallowDepth < 1} /></label>
{:else}
<label class="clone-option-detail"><span>{isGerman ? "Seit" : "Since"}</span><input type="date" bind:value={shallowSince} disabled={isBusy} aria-invalid={!shallowSince.trim()} /></label>
{/if}
<label class="clone-option-detail"><span>{isGerman ? "Zusätzliche Flags" : "Custom flags"}</span><input bind:value={customFlags} autocomplete="off" spellcheck="false" placeholder="--recurse-submodules --single-branch" disabled={isBusy} /></label>
<small class="clone-option-help">{isGerman ? "Flags wie in der Git-Kommandozeile; verwaltete oder unsichere Flags werden abgewiesen." : "Enter flags as on the Git command line; managed or unsafe flags are rejected."}</small>
</div>
{/if}
</div>
<div class="clone-option-card" class:expanded={sparseCheckout}>
<label class="clone-option-toggle">
<input type="checkbox" bind:checked={sparseCheckout} disabled={isBusy} />
<span><strong>Sparse Checkout</strong><small>{isGerman ? "Nur ausgewählte Pfade auschecken" : "Check out only selected paths"}</small></span>
</label>
{#if sparseCheckout}
<div class="clone-option-body">
<label class="clone-blobless-toggle"><input type="checkbox" bind:checked={bloblessClone} disabled={isBusy} /><span><strong>Blobless Clone</strong><small>{isGerman ? "Lädt zunächst Bäume und Commits ohne Dateiinhalte. Blobs werden bei Bedarf nachgeladen." : "Fetch trees and commits without file contents initially. Blobs are downloaded on demand."}</small></span></label>
<label class="clone-option-detail clone-sparse-paths">
<span>{isGerman ? "Pfade einschließen" : "Paths to include"}</span>
<textarea bind:value={sparsePathInput} rows="3" spellcheck="false" placeholder={"src/\ndocs/\nREADME.md"} disabled={isBusy}></textarea>
<small class="clone-option-help">{isGerman ? "Ein Pfad pro Zeile. Nur diese Pfade werden im Arbeitsverzeichnis ausgecheckt. Ohne Pfade werden nur Dateien im Repository-Root ausgecheckt." : "Enter one path per line. Only these paths will be checked out in the working directory. With no paths, only files in the repository root are checked out."}</small>
</label>
</div>
{/if}
</div>
</section>
<div class="clone-dialog-actions">
<button class="btn-secondary" type="button" onclick={onClose} disabled={isBusy}>
Cancel
</button>
<button class="btn-primary" type="submit" disabled={!canSubmit}>
{#if isBusy}
<LoaderCircle class="spin" size={16} aria-hidden="true" />
{#if source === "url"}
<label class="clone-dialog-field clone-url-field">
<span>{isGerman ? "Repository-URL" : "Repository URL"}</span>
<!-- svelte-ignore a11y_autofocus -->
<input value={remoteUrl} oninput={handleRemoteInput} autocomplete="off" spellcheck="false" placeholder="https://gitlab.com/org/project.git" disabled={isBusy} autofocus />
</label>
{:else}
<Download size={16} aria-hidden="true" />
<section class="integration-browser" aria-label={isGerman ? "Repositories aus Integrationen" : "Repositories from integrations"}>
{#if configuredSources.length === 0}
<div class="integration-empty"><Cloud size={26} /><strong>{isGerman ? "Keine aktive Integration" : "No active integration"}</strong><p>{isGerman ? "Richte unter Einstellungen → Integrationen zuerst GitHub, GitLab, Azure DevOps oder Gitea ein." : "Set up GitHub, GitLab, Azure DevOps, or Gitea under Settings → Integrations first."}</p></div>
{:else}
<div class="repository-toolbar">
<label><Search size={14} /><input bind:value={repositorySearch} placeholder={isGerman ? "Repositories durchsuchen…" : "Search repositories…"} aria-label={isGerman ? "Repositories durchsuchen" : "Search repositories"} /></label>
<button type="button" onclick={() => activeSource && loadRepositories(activeSource, true)} disabled={!activeSource || loadingSourceId.length > 0} title={isGerman ? "Neu laden" : "Refresh"} aria-label={isGerman ? "Repository-Liste neu laden" : "Refresh repository list"}><RefreshCw class={loadingSourceId ? "spin" : ""} size={14} /></button>
</div>
<div class="repository-list-shell">
<div id="integration-repository-list" class="repository-list" bind:this={repositoryListElement} onscroll={updateRepositoryScrollbar} aria-live="polite">
{#if loadingSourceId}
<div class="repository-state"><LoaderCircle class="spin" size={20} /><span>{isGerman ? "Repositories werden geladen…" : "Loading repositories…"}</span></div>
{:else if repositoryError}
<div class="repository-state repository-state-error"><strong>{isGerman ? "Repositories konnten nicht geladen werden" : "Could not load repositories"}</strong><span>{repositoryError}</span></div>
{:else if filteredRepositories.length === 0}
<div class="repository-state"><GitBranch size={20} /><span>{repositorySearch ? (isGerman ? "Keine passenden Repositories." : "No matching repositories.") : (isGerman ? "Keine Repositories gefunden." : "No repositories found.")}</span></div>
{:else if activeSource?.provider === "azure-devops"}
{#each azureRepositoryGroups as group (group.project)}
<section class="repository-project-group" aria-label={group.project}>
<div class="repository-project-header"><span>{group.project}</span><em>{group.repositories.length}</em></div>
{#each group.repositories as repository (repository.id)}
<button type="button" class="repository-option" class:selected={selectedRepositoryId === repository.id} onclick={() => selectRepository(repository)}>
<span class="repository-option-icon"><GitBranch size={15} /></span>
<span class="repository-option-copy"><strong>{repository.name}</strong><small>{repository.description || repository.cloneUrl}</small></span>
<span class="repository-option-meta">{#if repository.private}<LockKeyhole size={12} aria-label={isGerman ? "Privat" : "Private"} />{/if}{formatUpdatedAt(repository.updatedAt)}</span>
</button>
{/each}
</section>
{/each}
{:else}
{#each filteredRepositories as repository (repository.id)}
<button type="button" class="repository-option" class:selected={selectedRepositoryId === repository.id} onclick={() => selectRepository(repository)}>
<span class="repository-option-icon"><GitBranch size={16} /></span>
<span class="repository-option-copy"><strong>{repository.fullName}</strong><small>{repository.description || repository.cloneUrl}</small></span>
<span class="repository-option-meta">{#if repository.private}<LockKeyhole size={12} aria-label={isGerman ? "Privat" : "Private"} />{/if}{formatUpdatedAt(repository.updatedAt)}</span>
</button>
{/each}
{/if}
</div>
<div
class="repository-scrollbar"
class:visible={repositoryScrollbarVisible}
class:dragging={repositoryScrollbarPointerId !== undefined}
bind:this={repositoryScrollbarElement}
role="scrollbar"
tabindex={repositoryScrollbarVisible ? 0 : -1}
aria-controls="integration-repository-list"
aria-label={isGerman ? "Repository-Liste scrollen" : "Scroll repository list"}
aria-orientation="vertical"
aria-valuemin="0"
aria-valuemax={repositoryScrollMax}
aria-valuenow={repositoryScrollTop}
onclick={jumpRepositoryScrollbar}
onkeydown={handleRepositoryScrollbarKey}
>
<div
class="repository-scrollbar-thumb"
role="presentation"
style={`height:${repositoryScrollbarHeight}px;transform:translateY(${repositoryScrollbarTop}px)`}
onpointerdown={startRepositoryScrollbarDrag}
onpointermove={dragRepositoryScrollbar}
onpointerup={stopRepositoryScrollbarDrag}
onpointercancel={stopRepositoryScrollbarDrag}
></div>
</div>
</div>
{#if selectedRepository}<div class="selected-repository"><span>{isGerman ? "Ausgewählt" : "Selected"}</span><strong>{selectedRepository.fullName}</strong><code>{selectedRepository.cloneUrl}</code></div>{/if}
{/if}
Clone
</button>
</section>
{/if}
{#if visibleError}<div class="clone-dialog-error" role="alert">{visibleError}</div>{/if}
</section>
</div>
<div class="clone-dialog-actions"><button class="btn-secondary" type="button" onclick={onClose} disabled={isBusy}>{isGerman ? "Abbrechen" : "Cancel"}</button><button class="btn-primary" type="submit" disabled={!canSubmit}>{#if isBusy}<LoaderCircle class="spin" size={16} />{:else}<Download size={16} />{/if}{isGerman ? "Klonen" : "Clone"}</button></div>
</form>
</div>
</div>
<style>
.clone-repository-dialog { width: min(900px, calc(100vw - 32px)); height: min(660px, calc(100vh - 32px)); }
.clone-dialog-header { min-height: 52px; padding: 0 16px 0 20px; }
.clone-dialog-header h2 { margin: 0; color: var(--color-ink); font-size: 15px; font-weight: 650; }
.clone-dialog-form { grid-template-rows: minmax(0, 1fr) auto; gap: 0; height: calc(100% - 53px); padding: 0; }
.clone-dialog-layout { display: grid; grid-template-columns: 205px minmax(0, 1fr); min-height: 0; }
.clone-source-nav { min-width: 0; padding: 12px 0; border-right: 1px solid var(--color-border-subtle); background: var(--app-dialog-chrome); }
.clone-source-heading { padding: 2px 14px 8px; color: var(--color-ink-faint); font-size: 8.5px; font-weight: 850; text-transform: uppercase; letter-spacing: .08em; }
.clone-source-list { display: grid; gap: 2px; }
.clone-source-list button { display: grid; grid-template-columns: 18px minmax(0, 1fr); align-items: center; gap: 8px; width: 100%; min-height: 38px; padding: 0 14px; border: 0; border-radius: 0; color: var(--color-ink-dim); background: transparent; box-shadow: none; font-size: 10.5px; font-weight: 650; text-align: left; }
.clone-source-list button span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.clone-source-list button :global(svg) { color: var(--color-ink-faint); }
.clone-source-list button:hover { color: var(--color-ink); background: var(--color-surface-hover); }
.clone-source-list button.active { color: var(--color-ink); background: color-mix(in srgb, var(--color-accent) 18%, var(--color-surface-hover)); box-shadow: inset 3px 0 0 var(--color-accent); }
.clone-source-list button.active :global(svg) { color: var(--color-accent); }
.clone-source-nav > p { margin: 12px 14px 0; color: var(--color-ink-faint); font-size: 9px; line-height: 1.45; }
.clone-dialog-content { display: grid; grid-template-rows: auto auto auto minmax(0, 1fr); grid-auto-rows: auto; align-content: stretch; gap: 12px; min-width: 0; min-height: 0; padding: 16px 18px; overflow: hidden; }
.clone-dialog-title { display: grid; gap: 3px; }
.clone-dialog-title > span { color: var(--color-accent); font-size: 8.5px; font-weight: 850; text-transform: uppercase; letter-spacing: .07em; }
.clone-dialog-title h3 { margin: 0; color: var(--color-ink); font-size: 16px; font-weight: 650; }
.clone-url-field { align-self: start; margin-top: 2px; }
.clone-options { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); align-items: start; gap: 8px; }
.clone-option-card { min-width: 0; border: 1px solid var(--color-border-subtle); border-radius: 6px; background: color-mix(in srgb, var(--color-surface-raised) 70%, transparent); }
.clone-option-card.expanded { border-color: color-mix(in srgb, var(--color-accent) 34%, var(--color-border)); }
.clone-option-toggle { display: grid; grid-template-columns: auto minmax(0, 1fr); align-items: center; gap: 8px; min-height: 42px; padding: 6px 9px; cursor: pointer; }
.clone-option-toggle > input { width: 14px; height: 14px; margin: 0; accent-color: var(--color-accent); }
.clone-option-toggle > span { display: grid; min-width: 0; gap: 2px; }
.clone-option-toggle strong { color: var(--color-ink); font-size: 10.5px; }
.clone-option-toggle small { overflow: hidden; color: var(--color-ink-faint); font-size: 8.5px; text-overflow: ellipsis; white-space: nowrap; }
.clone-option-body { display: grid; gap: 8px; padding: 1px 9px 9px 31px; }
.clone-option-detail { display: grid; grid-template-columns: minmax(82px, auto) minmax(64px, 1fr); align-items: center; gap: 8px; color: var(--color-ink-faint); font-size: 8.5px; font-weight: 750; text-transform: uppercase; letter-spacing: .035em; }
.clone-option-detail input { height: 28px; min-width: 0; font-size: 10px; }
.clone-history-mode { display: flex; flex-wrap: wrap; align-items: center; gap: 7px 12px; min-width: 0; margin: 0; padding: 0; border: 0; color: var(--color-ink-dim); font-size: 9.5px; }
.clone-history-mode legend { float: left; min-width: 100%; margin-bottom: 1px; color: var(--color-ink-faint); font-size: 8.5px; font-weight: 750; text-transform: uppercase; letter-spacing: .035em; }
.clone-history-mode label, .clone-blobless-toggle { display: inline-flex; align-items: center; gap: 5px; cursor: pointer; }
.clone-history-mode input, .clone-blobless-toggle > input { width: 13px; height: 13px; margin: 0; accent-color: var(--color-accent); }
.clone-option-help { color: var(--color-ink-faint); font-size: 8px; line-height: 1.35; }
.clone-blobless-toggle { align-items: start; }
.clone-blobless-toggle > span { display: grid; gap: 2px; }
.clone-blobless-toggle strong { color: var(--color-ink); font-size: 9.5px; }
.clone-blobless-toggle small { color: var(--color-ink-faint); font-size: 8px; }
.clone-sparse-paths { grid-template-columns: 1fr; gap: 5px; }
.clone-sparse-paths textarea { min-height: 54px; max-height: 72px; resize: vertical; font: 9.5px/1.35 var(--font-mono); }
.integration-browser { display: grid; grid-template-rows: auto minmax(0, 1fr); grid-auto-rows: auto; gap: 8px; min-height: 0; }
.repository-toolbar { display: grid; grid-template-columns: minmax(0, 1fr) 32px; gap: 6px; }
.repository-toolbar label { position: relative; min-width: 0; }
.repository-toolbar label > :global(svg) { position: absolute; z-index: 1; top: 10px; left: 10px; color: var(--color-ink-faint); }
.repository-toolbar input { height: 34px; padding-left: 31px; font-size: 11px; }
.repository-toolbar button { min-height: 32px; padding: 0; }
.repository-list-shell { position: relative; min-height: 0; height: 100%; overflow: hidden; border: 1px solid var(--color-border-input); border-radius: 7px; background: var(--color-surface-raised); box-shadow: 0 8px 18px rgba(0,0,0,.13); }
.repository-list { min-height: 0; height: 100%; padding-right: 8px; overflow: auto; scrollbar-width: none; border-radius: inherit; background: transparent; }
.repository-list::-webkit-scrollbar { display: none; width: 0; height: 0; }
.repository-scrollbar { position: absolute; z-index: 2; top: 3px; right: 1px; bottom: 3px; width: 7px; border-radius: 4px; opacity: 0; pointer-events: none; touch-action: none; transition: opacity 120ms ease; }
.repository-scrollbar.visible { opacity: 1; pointer-events: auto; }
.repository-scrollbar-thumb { position: absolute; top: 0; right: 2px; width: 3px; min-height: 28px; border-radius: 3px; background: var(--app-scrollbar-thumb); cursor: pointer; transition: width 100ms ease, background 100ms ease; }
.repository-scrollbar:hover .repository-scrollbar-thumb,
.repository-scrollbar:focus-visible .repository-scrollbar-thumb,
.repository-scrollbar.dragging .repository-scrollbar-thumb { right: 1px; width: 4px; background: var(--app-scrollbar-thumb-hover); }
.repository-scrollbar:focus-visible { outline: 1px solid color-mix(in srgb, var(--color-accent) 70%, transparent); outline-offset: 1px; }
.repository-project-group + .repository-project-group { border-top: 1px solid var(--color-border-subtle); }
.repository-project-header { position: sticky; z-index: 1; top: 0; display: flex; align-items: center; justify-content: space-between; gap: 10px; min-height: 34px; padding: 7px 10px 6px 12px; color: var(--color-accent); background: color-mix(in srgb, var(--color-surface-raised) 96%, transparent); font-size: 9.5px; font-weight: 900; text-transform: uppercase; letter-spacing: .045em; }
.repository-project-header span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.repository-project-header em { display: grid; flex: 0 0 auto; place-items: center; min-width: 19px; height: 16px; padding: 0 5px; color: var(--color-ink); background: color-mix(in srgb, var(--color-ink) 12%, transparent); font-size: 8px; font-style: normal; line-height: 1; letter-spacing: 0; }
.repository-option { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 8px; width: 100%; min-height: 44px; padding: 6px 10px 6px 12px; border: 0; border-bottom: 1px solid var(--color-border-subtle); border-radius: 0; color: var(--color-ink-dim); background: transparent; text-align: left; }
.repository-option:last-child { border-bottom: 0; }
.repository-option:hover { color: var(--color-ink); background: var(--color-surface-hover); }
.repository-option.selected { color: var(--color-ink); background: color-mix(in srgb, var(--color-accent) 15%, var(--color-surface-hover)); box-shadow: inset 3px 0 0 var(--color-accent); }
.repository-option-icon { display: grid; place-items: center; width: 22px; height: 22px; color: var(--color-ink-faint); }
.repository-option.selected .repository-option-icon { color: var(--color-accent); }
.repository-option-copy { display: grid; min-width: 0; gap: 3px; }
.repository-option-copy strong, .repository-option-copy small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.repository-option-copy strong { color: inherit; font-size: 10.5px; }
.repository-option-copy small { color: var(--color-ink-faint); font-size: 9px; }
.repository-project-group .repository-option { min-height: 38px; padding-block: 5px; }
.repository-project-group .repository-option-copy { gap: 0; }
.repository-project-group .repository-option-copy strong { color: var(--color-ink); font-size: 11.5px; font-weight: 800; }
.repository-project-group .repository-option-copy small { display: none; }
.repository-option-meta { display: flex; align-items: center; gap: 5px; color: var(--color-ink-faint); font-size: 8.5px; }
.repository-state, .integration-empty { display: grid; place-items: center; align-content: center; min-height: 188px; padding: 20px; color: var(--color-ink-faint); text-align: center; }
.repository-state { gap: 7px; font-size: 10.5px; }
.repository-state strong, .integration-empty strong { color: var(--color-ink); font-size: 11px; }
.repository-state-error strong { color: #e86060; }
.repository-state-error span { max-width: 520px; line-height: 1.45; }
.integration-empty { min-height: 260px; gap: 8px; }
.integration-empty :global(svg) { color: var(--color-accent); }
.integration-empty p { max-width: 400px; margin: 0; color: var(--color-ink-faint); font-size: 10px; line-height: 1.5; }
.selected-repository { display: grid; grid-template-columns: auto minmax(0, 1fr); align-items: center; gap: 3px 8px; min-width: 0; padding: 8px 9px; border-left: 2px solid var(--color-accent); background: color-mix(in srgb, var(--color-accent) 6%, transparent); }
.selected-repository span { color: var(--color-accent); font-size: 8.5px; font-weight: 850; text-transform: uppercase; letter-spacing: .04em; }
.selected-repository strong { overflow: hidden; color: var(--color-ink); font-size: 10px; text-overflow: ellipsis; white-space: nowrap; }
.selected-repository code { grid-column: 2; overflow: hidden; color: var(--color-ink-faint); font: 8.5px var(--font-mono); text-overflow: ellipsis; white-space: nowrap; }
.clone-target-grid { display: grid; grid-template-columns: minmax(0, 1.5fr) minmax(150px, .72fr); gap: 10px; }
.clone-dialog-actions { min-height: 54px; align-items: center; padding: 9px 16px; border-top: 1px solid var(--color-border-subtle); background: var(--app-dialog-chrome); }
@media (max-width: 700px) {
.clone-repository-dialog { width: min(660px, calc(100vw - 20px)); }
.clone-dialog-layout { grid-template-columns: 155px minmax(0, 1fr); }
.clone-source-list button { padding-inline: 10px; }
.clone-target-grid { grid-template-columns: 1fr; }
.repository-option-meta { display: none; }
}
@media (max-width: 500px) {
.dialog-backdrop { padding: 10px; }
.clone-repository-dialog { width: calc(100vw - 20px); height: min(660px, calc(100vh - 20px)); }
.clone-dialog-layout { grid-template-columns: 1fr; grid-template-rows: auto minmax(0, 1fr); }
.clone-source-nav { padding: 6px 0; overflow-x: auto; border-right: 0; border-bottom: 1px solid var(--color-border-subtle); }
.clone-source-heading, .clone-source-nav > p { display: none; }
.clone-source-list { display: flex; width: max-content; min-width: 100%; padding: 0 6px; }
.clone-source-list button { width: auto; min-height: 34px; padding-inline: 9px; border-radius: 5px; }
.clone-source-list button.active { box-shadow: inset 0 -2px 0 var(--color-accent); }
.clone-options { grid-template-columns: 1fr; }
.clone-dialog-content { grid-template-rows: auto; grid-auto-rows: auto; align-content: start; padding: 13px 12px; overflow: auto; }
.integration-browser { min-height: 260px; }
}
</style>
+6 -4
View File
@@ -1,12 +1,12 @@
<script lang="ts">
import { onMount } from "svelte";
import {
ArrowDownToLine, ArrowUpFromLine, Boxes, CircleHelp, FileCode, GitBranch,
ArrowDownToLine, ArrowUpFromLine, Boxes, Bug, CircleHelp, FileCode, GitBranch,
GitCompare, History, RefreshCw, Search, Settings, SlidersHorizontal, Sparkles,
} from "@lucide/svelte";
import type { AppLanguage, GitBranch as GitBranchInfo, GitCommit, GitRepositoryFile } from "../types";
type ItemKind = "fetch" | "pull" | "push" | "refresh" | "search" | "compare" | "reflog" | "rebase" | "worktrees" | "sync" | "settings" | "ai-settings" | "help" | "branch" | "file" | "commit";
type ItemKind = "fetch" | "pull" | "push" | "refresh" | "search" | "compare" | "reflog" | "rebase" | "bisect" | "worktrees" | "sync" | "settings" | "ai-settings" | "help" | "branch" | "file" | "commit";
interface Item { id: string; group: string; kind: ItemKind; title: string; subtitle: string; search: string; disabled?: boolean; run: () => void | Promise<void>; }
interface Props {
language: AppLanguage; hasRepository: boolean; isBusy: boolean;
@@ -17,7 +17,7 @@
onSelectCommit: (commit: GitCommit) => void | Promise<void>;
onFetch: () => void | Promise<void>; onPull: () => void | Promise<void>; onPush: () => void | Promise<void>;
onRefresh: () => void | Promise<void>; onOpenSearch: () => void; onOpenCompare: () => void;
onOpenReflog: () => void | Promise<void>; onOpenInteractiveRebase: () => void;
onOpenReflog: () => void | Promise<void>; onOpenInteractiveRebase: () => void; onOpenBisect: () => void | Promise<void>;
onOpenWorktrees: () => void | Promise<void>; onOpenSyncSettings: () => void | Promise<void>;
onOpenSettings: () => void; onOpenAiSettings: () => void; onOpenHelp: () => void;
}
@@ -26,7 +26,7 @@
language = "en", hasRepository = false, isBusy = false, branches = [], files = [], commits = [], onClose = () => {},
onCheckoutBranch = () => {}, onOpenFile = () => {}, onSelectCommit = () => {}, onFetch = () => {}, onPull = () => {},
onPush = () => {}, onRefresh = () => {}, onOpenSearch = () => {}, onOpenCompare = () => {}, onOpenReflog = () => {},
onOpenInteractiveRebase = () => {}, onOpenWorktrees = () => {}, onOpenSyncSettings = () => {}, onOpenSettings = () => {},
onOpenInteractiveRebase = () => {}, onOpenBisect = () => {}, onOpenWorktrees = () => {}, onOpenSyncSettings = () => {}, onOpenSettings = () => {},
onOpenAiSettings = () => {}, onOpenHelp = () => {},
}: Props = $props();
@@ -50,6 +50,7 @@
action("compare", "compare", isGerman ? "Branches oder Commits vergleichen" : "Compare branches or commits", isGerman ? "Zwei vollständige Revisionen vergleichen" : "Diff two complete revisions", onOpenCompare),
action("reflog", "reflog", "Reflog", isGerman ? "Verlorene Commits finden und wiederherstellen" : "Find and recover lost commits", onOpenReflog),
action("rebase", "rebase", "Interactive Rebase", isGerman ? "Commit-Historie bearbeiten" : "Edit commit history", onOpenInteractiveRebase),
action("bisect", "bisect", "Git Bisect", isGerman ? "Fehlerhaften Commit schrittweise finden" : "Find a bad commit step by step", onOpenBisect),
action("worktrees", "worktrees", "Worktrees", isGerman ? "Arbeitsverzeichnisse verwalten" : "Manage linked working trees", onOpenWorktrees),
action("sync", "sync", isGerman ? "Synchronisierung konfigurieren" : "Configure synchronization", isGerman ? "Remote, Upstream und Pull-Strategie" : "Remote, upstream and pull strategy", onOpenSyncSettings),
action("settings", "settings", isGerman ? "Einstellungen" : "Settings", isGerman ? "Darstellung, Sprache und Verhalten" : "Appearance, language and behavior", onOpenSettings, false),
@@ -118,6 +119,7 @@
{:else if item.kind === "compare"}<GitCompare size={16} />
{:else if item.kind === "reflog" || item.kind === "commit"}<History size={16} />
{:else if item.kind === "rebase" || item.kind === "branch"}<GitBranch size={16} />
{:else if item.kind === "bisect"}<Bug size={16} />
{:else if item.kind === "worktrees"}<Boxes size={16} />
{:else if item.kind === "sync"}<SlidersHorizontal size={16} />
{:else if item.kind === "settings"}<Settings size={16} />
+7 -16
View File
@@ -1,6 +1,5 @@
<script lang="ts">
import { Check, GitCommitHorizontal, LoaderCircle, RotateCcw, Settings, ShieldCheck, Sparkles } from "@lucide/svelte";
import type { CommitAiPhase, CommitAiProvider } from "../types";
interface Props {
commitMessage: string;
@@ -10,8 +9,6 @@
isBusy: boolean;
operation: string;
stagedCount: number;
commitAiProvider: CommitAiProvider;
commitAiPhase: CommitAiPhase;
commitAiGenerating: boolean;
commitAiReviewing: boolean;
commitAiSplitting: boolean;
@@ -35,8 +32,6 @@
isBusy = false,
operation = "",
stagedCount = 0,
commitAiProvider = "local",
commitAiPhase = "idle",
commitAiGenerating = false,
commitAiReviewing = false,
commitAiSplitting = false,
@@ -57,23 +52,19 @@
onCommit();
}
function aiButtonTitle(provider: CommitAiProvider, phase: CommitAiPhase, staged: number): string {
function aiButtonTitle(staged: number): string {
if (staged === 0) return "Stage changes first";
if (provider === "local" && phase === "loading") return "AI model is downloading/loading — this happens once";
if (provider === "local" && phase === "error") return "AI model failed to load — check AI settings";
return "Generate commit message with AI from the staged diff";
}
let localModelLoading = $derived(commitAiProvider === "local" && commitAiPhase === "loading");
let canGenerate = $derived(
hasRepository &&
!isBusy &&
!commitAiGenerating &&
!commitAiReviewing &&
stagedCount > 0 &&
(commitAiProvider !== "local" || commitAiPhase === "ready"),
stagedCount > 0,
);
let canReview = $derived(canGenerate && commitAiProvider !== "local");
let canReview = $derived(canGenerate);
let canSplit = $derived(canReview && stagedCount > 1 && !commitAiSplitting);
</script>
@@ -90,7 +81,7 @@
type="button"
onclick={onSplitStaged}
disabled={!canSplit}
title={commitAiProvider === "local" ? "Commit splitting currently requires an API provider" : "Suggest logical commits for the staged files"}
title="Suggest logical commits for the staged files"
>
{#if commitAiSplitting}<LoaderCircle class="spin" size={14} aria-hidden="true" />{:else}<GitCommitHorizontal size={14} aria-hidden="true" />{/if}
Split
@@ -100,7 +91,7 @@
type="button"
onclick={onReviewStaged}
disabled={!canReview}
title={commitAiProvider === "local" ? "Pre-commit review currently requires an API provider" : stagedCount === 0 ? "Stage changes first" : "Review staged changes for bugs and risks"}
title={stagedCount === 0 ? "Stage changes first" : "Review staged changes for bugs and risks"}
>
{#if commitAiReviewing}<LoaderCircle class="spin" size={14} aria-hidden="true" />{:else}<ShieldCheck size={14} aria-hidden="true" />{/if}
Review
@@ -110,9 +101,9 @@
type="button"
onclick={onGenerateCommitMessage}
disabled={!canGenerate}
title={aiButtonTitle(commitAiProvider, commitAiPhase, stagedCount)}
title={aiButtonTitle(stagedCount)}
>
{#if commitAiGenerating || localModelLoading}<LoaderCircle class="spin" size={14} aria-hidden="true" />{:else}<Sparkles size={14} aria-hidden="true" />{/if}
{#if commitAiGenerating}<LoaderCircle class="spin" size={14} aria-hidden="true" />{:else}<Sparkles size={14} aria-hidden="true" />{/if}
Generate
</button>
<button class="commit-settings-button" type="button" onclick={onOpenAiSettings} disabled={isBusy} title="AI settings" aria-label="AI settings">
+98 -44
View File
@@ -11,20 +11,24 @@
FileCog,
FileImage,
FileJson,
FileMinus2,
FileSearch,
GitCompare,
FileSpreadsheet,
FileText,
FileType,
FileVideo,
FileX,
Folder,
FolderMinus,
FolderOpen,
FolderX,
ExternalLink,
History,
Terminal,
} from "@lucide/svelte";
import { languageIconForPath } from "../languageIcons";
import type { ExplorerNode, ExplorerNodeKind, FileStatusKind, GitRepositoryFile } from "../types";
import type { ExplorerNode, ExplorerNodeKind, FileStatusKind, GitIgnoreKind, GitRepositoryFile } from "../types";
import LanguageIcon from "./LanguageIcon.svelte";
interface Props {
@@ -46,6 +50,8 @@
onExternalDiff: (node: ExplorerNode) => void;
onFileHistory: (node: ExplorerNode) => void;
onBlame: (node: ExplorerNode) => void;
onIgnore: (target: string, kind: GitIgnoreKind) => void;
onStopTracking: (target: string, kind: "file" | "folder") => void;
collapsed?: boolean;
onToggleCollapsed?: () => void;
}
@@ -69,6 +75,8 @@
onExternalDiff = () => {},
onFileHistory = () => {},
onBlame = () => {},
onIgnore = () => {},
onStopTracking = () => {},
collapsed = false,
onToggleCollapsed = () => {},
}: Props = $props();
@@ -76,6 +84,7 @@
let contextNode = $state<ExplorerNode | null>(null);
let contextMenuX = $state(0);
let contextMenuY = $state(0);
let contextMenuElement = $state<HTMLDivElement | null>(null);
const isGerman = $derived(language === "de");
function mergeExplorerStatus(current: FileStatusKind | null, next: FileStatusKind | null): FileStatusKind | null {
@@ -183,14 +192,19 @@
return "text";
}
function openFileContextMenu(event: MouseEvent, node: ExplorerNode) {
if (node.kind !== "file") return;
function openNodeContextMenu(event: MouseEvent, node: ExplorerNode) {
event.preventDefault();
event.stopPropagation();
contextNode = node;
contextMenuX = Math.max(8, Math.min(event.clientX + 2, window.innerWidth - 192));
contextMenuY = Math.max(8, Math.min(event.clientY + 2, window.innerHeight - 220));
contextMenuX = Math.max(8, Math.min(event.clientX + 2, window.innerWidth - 248));
contextMenuY = Math.max(8, Math.min(event.clientY + 2, window.innerHeight - 320));
requestAnimationFrame(() => {
if (!contextMenuElement) return;
const bounds = contextMenuElement.getBoundingClientRect();
contextMenuX = Math.max(8, Math.min(event.clientX + 2, window.innerWidth - bounds.width - 8));
contextMenuY = Math.max(8, Math.min(event.clientY + 2, window.innerHeight - bounds.height - 8));
});
}
function closeFileContextMenu() {
@@ -232,6 +246,31 @@
onFileHistory(node);
}
function explorerFileNodes(node: ExplorerNode): ExplorerNode[] {
if (node.kind === "file") return [node];
return node.children.flatMap(explorerFileNodes);
}
function isIgnoreableExplorerFile(node: ExplorerNode): boolean {
return node.kind === "file" && !node.tracked && node.path.replace(/\\/g, "/").toLowerCase() !== ".gitignore";
}
function runContextIgnore(kind: GitIgnoreKind) {
const node = contextNode;
if (!node) return;
if (kind === "folder" && node.kind !== "folder") return;
if ((kind === "file" || kind === "extension") && node.kind !== "file") return;
closeFileContextMenu();
onIgnore(node.path, kind);
}
function runContextStopTracking() {
const node = contextNode;
if (!node) return;
closeFileContextMenu();
onStopTracking(node.path, node.kind);
}
function handleWindowKeydown(event: KeyboardEvent) {
if (event.key === "Escape") closeFileContextMenu();
}
@@ -239,6 +278,10 @@
let explorerTree = $derived(buildExplorerTree(repoFiles));
let visibleNodes = $derived(flattenExplorerTree(explorerTree, expandedExplorerPaths));
let hasFolders = $derived(explorerTree.some((node) => node.kind === "folder"));
let contextFiles = $derived(contextNode ? explorerFileNodes(contextNode) : []);
let contextCanIgnore = $derived(contextFiles.some(isIgnoreableExplorerFile));
let contextCanStopTracking = $derived(contextFiles.some((node) => node.tracked));
let contextIgnoreExtension = $derived(contextNode?.kind === "file" && contextCanIgnore ? extensionFor(contextNode.path) : "");
let selectedFileNode = $derived(
selectedExplorerKind === "file"
? visibleNodes.find((node) => node.kind === "file" && node.path === selectedExplorerPath) ?? null
@@ -338,7 +381,7 @@
class:folder={node.kind === "folder"}
style={`--depth: ${node.depth}`}
title={node.path}
oncontextmenu={(event) => openFileContextMenu(event, node)}
oncontextmenu={(event) => openNodeContextMenu(event, node)}
>
{#if node.kind === "folder"}
<button
@@ -420,49 +463,60 @@
{#if contextNode}
<div
bind:this={contextMenuElement}
class="explorer-context-menu"
style={`left: ${contextMenuX}px; top: ${contextMenuY}px;`}
role="menu"
tabindex="-1"
aria-label={`Actions for ${contextNode.path}`}
>
<button type="button" role="menuitem" onclick={openContextFileInEditor} disabled={contextNode.status === "deleted"}>
<FileCode size={14} aria-hidden="true" />
{isGerman ? `In ${editorName} öffnen` : `Open in ${editorName}`}
</button>
<button type="button" role="menuitem" onclick={openContextExternalDiff} disabled={!contextNode.tracked || contextNode.status === "deleted"}>
<GitCompare size={14} aria-hidden="true" />
{isGerman ? `Mit HEAD in ${diffName} vergleichen` : `Compare with HEAD in ${diffName}`}
</button>
<button
type="button"
role="menuitem"
onclick={openContextFileHistory}
disabled={!contextNode.tracked}
title={contextNode.tracked ? "Show the commit history for this file" : "File history is only available for tracked files"}
>
<History size={14} aria-hidden="true" />
File history
</button>
<button
type="button"
role="menuitem"
onclick={openContextFile}
disabled={contextNode.status === "deleted"}
title={contextNode.status === "deleted" ? "Deleted files cannot be revealed in Explorer" : "Reveal this file in Explorer"}
>
<ExternalLink size={14} aria-hidden="true" />
Open in Explorer
</button>
<button
type="button"
role="menuitem"
onclick={openContextBlame}
disabled={!contextNode.tracked || contextNode.status === "deleted"}
title={!contextNode.tracked || contextNode.status === "deleted" ? "Blame is only available for tracked files" : "Show blame for this file"}
>
<FileSearch size={14} aria-hidden="true" />
Blame
</button>
{#if contextNode.kind === "file"}
<button type="button" role="menuitem" onclick={openContextFileInEditor} disabled={contextNode.status === "deleted"}>
<FileCode size={14} aria-hidden="true" />
{isGerman ? `In ${editorName} öffnen` : `Open in ${editorName}`}
</button>
<button type="button" role="menuitem" onclick={openContextExternalDiff} disabled={!contextNode.tracked || contextNode.status === "deleted"}>
<GitCompare size={14} aria-hidden="true" />
{isGerman ? `Mit HEAD in ${diffName} vergleichen` : `Compare with HEAD in ${diffName}`}
</button>
<button type="button" role="menuitem" onclick={openContextFileHistory} disabled={!contextNode.tracked} title={contextNode.tracked ? "Show the commit history for this file" : "File history is only available for tracked files"}>
<History size={14} aria-hidden="true" />
{isGerman ? "Dateiverlauf" : "File history"}
</button>
<button type="button" role="menuitem" onclick={openContextFile} disabled={contextNode.status === "deleted"} title={contextNode.status === "deleted" ? "Deleted files cannot be revealed in Explorer" : "Reveal this file in Explorer"}>
<ExternalLink size={14} aria-hidden="true" />
{isGerman ? "Im Explorer öffnen" : "Open in Explorer"}
</button>
<button type="button" role="menuitem" onclick={openContextBlame} disabled={!contextNode.tracked || contextNode.status === "deleted"} title={!contextNode.tracked || contextNode.status === "deleted" ? "Blame is only available for tracked files" : "Show blame for this file"}>
<FileSearch size={14} aria-hidden="true" />
Blame
</button>
{/if}
{#if contextCanStopTracking || contextCanIgnore}
<div class="menu-separator" role="separator"></div>
{/if}
{#if contextCanStopTracking}
<button class="untrack" type="button" role="menuitem" onclick={runContextStopTracking} disabled={isBusy} title="Keep the working-tree content and remove it from the Git index">
{#if contextNode.kind === "folder"}<FolderMinus size={14} aria-hidden="true" />{:else}<FileMinus2 size={14} aria-hidden="true" />{/if}
{isGerman ? `${contextNode.kind === "folder" ? "Ordner" : "Datei"} nicht mehr tracken` : `Stop tracking ${contextNode.kind}`}
</button>
{/if}
{#if contextCanIgnore && contextNode.kind === "file"}
<button class="ignore" type="button" role="menuitem" onclick={() => runContextIgnore("file")} disabled={isBusy} title={`Add /${contextNode.path.replace(/\\/g, "/")} to .gitignore`}>
<FileX size={14} aria-hidden="true" />
{isGerman ? "Datei ignorieren" : "Ignore file"}
</button>
{#if contextIgnoreExtension}
<button class="ignore" type="button" role="menuitem" onclick={() => runContextIgnore("extension")} disabled={isBusy} title={`Add *.${contextIgnoreExtension} to .gitignore`}>
<FileType size={14} aria-hidden="true" />
{isGerman ? `Alle *.${contextIgnoreExtension}-Dateien ignorieren` : `Ignore all *.${contextIgnoreExtension} files`}
</button>
{/if}
{:else if contextCanIgnore && contextNode.kind === "folder"}
<button class="ignore" type="button" role="menuitem" onclick={() => runContextIgnore("folder")} disabled={isBusy} title={`Add /${contextNode.path.replace(/\\/g, "/").replace(/\/+$/, "")}/ to .gitignore`}>
<FolderX size={14} aria-hidden="true" />
{isGerman ? "Ordner ignorieren" : "Ignore folder"}
</button>
{/if}
</div>
{/if}
+122 -9
View File
@@ -503,8 +503,9 @@
{
id: "lfs-sync",
title: "LFS-Objekte synchronisieren",
summary: "Ein normaler Pull in Gitty prüft nach erfolgreicher Git-Synchronisierung automatisch auf LFS und lädt benötigte Objekte mit demselben Remote und denselben Zugangsdaten. Ein zweiter Pull ist nicht nötig.",
summary: "Clone und Pull prüfen in Gitty automatisch auf LFS und laden benötigte Objekte mit demselben Remote und denselben Zugangsdaten. Ein zweiter Pull ist nicht nötig.",
steps: [
"Neue Klone aus der Oberfläche sowie über --clone aktivieren LFS lokal und laden die Objekte von origin, bevor das Repository geöffnet wird.",
"Normales Push nutzt den LFS-Pre-push-Hook und lädt neue LFS-Objekte vor den Git-Referenzen hoch.",
"Objekte laden im LFS-Dialog ist ein manueller Reparatur- oder Aktualisierungsschritt, falls lokale Inhalte fehlen.",
"Cache bereinigen entfernt sicher nicht mehr benötigte lokale Objekte; aktuell verwendete und noch nicht gepushte Inhalte bleiben erhalten.",
@@ -563,8 +564,9 @@
{
id: "lfs-sync",
title: "Synchronize LFS objects",
summary: "After a successful regular pull, Gitty automatically checks for LFS and downloads required objects with the same remote and credentials. A second pull is not necessary.",
summary: "Clone and pull automatically check for LFS in Gitty and download required objects with the same remote and credentials. A second pull is not necessary.",
steps: [
"Fresh clones from the UI and --clone activate LFS locally and download objects from origin before the repository is opened.",
"A normal push uses the LFS pre-push hook to upload new LFS objects before Git references are published.",
"Pull objects in the LFS dialog is a manual repair or refresh action when local content is missing.",
"Prune cache safely removes unused local objects while retaining current and unpushed content.",
@@ -633,6 +635,19 @@
"Öffne das Tab-Kontextmenü, um ein Repository aus der aktuellen Arbeitsfläche zu entfernen, ohne Dateien zu löschen.",
],
},
{
id: "app-integrations",
title: "Git-Hosting-Integrationen und Clone",
summary: "Gitty verbindet sich mit GitHub, GitLab.com, GitLab Self-Managed, Azure DevOps und Gitea. Danach kannst du deine verfügbaren Repositories direkt im Clone-Dialog durchsuchen und laden.",
steps: [
"Öffne Einstellungen → Integrationen, wähle einen Anbieter und trage Server-URL, Benutzername sowie einen Personal Access Token ein.",
"Für Azure DevOps kannst du mehrere Organisationen anlegen. Jede Organisation besitzt einen eigenen Anzeigenamen, eine Organisations-URL und einen separat gespeicherten Token.",
"Aktiviere die Integration und speichere die Einstellungen. Tokens werden getrennt von den App-Einstellungen im Schlüsselbund des Betriebssystems abgelegt.",
"Öffne Clone → Integrationen und wähle das gewünschte Konto. Gitty lädt alle zugänglichen Repositories und sortiert sie alphabetisch.",
"Filtere bei Bedarf nach Name oder Beschreibung, wähle ein Repository und einen Zielordner und starte den Clone direkt mit den gespeicherten Zugangsdaten.",
],
note: "Vergib Tokens nur die benötigten Leserechte und eine möglichst kurze Laufzeit. Entfernst du einen gespeicherten Token in Gitty, wird die betroffene Integration automatisch deaktiviert.",
},
{
id: "app-commit-detail",
title: "Saubere Commits in Gitty erstellen",
@@ -1095,6 +1110,19 @@
"Use the tab context menu to remove a repository from the workspace without deleting its files.",
],
},
{
id: "app-integrations",
title: "Git hosting integrations and Clone",
summary: "Gitty connects to GitHub, GitLab.com, GitLab Self-Managed, Azure DevOps, and Gitea. You can then browse and clone the repositories available to your accounts directly from the Clone dialog.",
steps: [
"Open Settings → Integrations, select a provider, and enter its server URL, username, and personal access token.",
"Azure DevOps supports multiple organizations. Every organization has its own display name, organization URL, and separately stored token.",
"Enable the integration and save the settings. Tokens are kept in the operating system keychain rather than application settings.",
"Open Clone → Integrations and select an account. Gitty loads every accessible repository and sorts the list alphabetically.",
"Filter by name or description when needed, select a repository and destination, and clone it directly with the stored credentials.",
],
note: "Give tokens only the required read permissions and the shortest practical lifetime. Removing a stored token in Gitty automatically disables the affected integration.",
},
{
id: "app-commit-detail",
title: "Create clean commits in Gitty",
@@ -1520,6 +1548,48 @@
label: "Neu in Gitty",
description: "Änderungen seit der letzten veröffentlichten Version und wichtige Neuerungen früherer Releases.",
sections: [
{
id: "changelog-2026-8-8",
title: "Version 2026.8.8",
summary: "Dieses Release verbindet Gitty mit den wichtigsten Git-Hosting-Diensten und macht das Klonen aus deinen eigenen Repository-Listen deutlich schneller.",
steps: [
"Neue Integrationen für GitHub, GitLab.com, GitLab Self-Managed, Azure DevOps und Gitea lassen sich zentral in den Einstellungen verwalten. Personal Access Tokens werden sicher im Schlüsselbund des Betriebssystems gespeichert.",
"Azure DevOps unterstützt mehrere Organisationen mit jeweils eigenem Anzeigenamen, eigener Organisations-URL, eigenem Benutzernamen und Token.",
"Der Clone-Dialog besitzt einen Integrationen-Reiter. Er lädt alle zugänglichen Repositories des gewählten Kontos, sortiert sie alphabetisch und unterstützt Suche, Aktualisieren und direktes Klonen mit den gespeicherten Zugangsdaten.",
"Die Repository-Tab-Leiste ist kompakter und näher an klassischen Git-Clients gestaltet. Das Schließen-X bleibt sichtbar und wird nur beim Überfahren rot.",
"Eine schmale eigene Scrollbar im Repository-Browser überdeckt weder Namen noch Metadaten und wird beim Überfahren nur leicht breiter.",
"Repository-Loading- und Status-Flächen reagieren konsistenter auf das aktive Theme und sind kompakter und kontrastreicher.",
"Das Entfernen eines nicht vorhandenen Upstreams ist jetzt ein sicherer No-op und löst keinen fatalen Git-Fehler mehr aus.",
],
note: "Die Integrationen verwenden HTTPS und Personal Access Tokens. Welche Repositories sichtbar sind, richtet sich nach den Berechtigungen des jeweiligen Tokens und Kontos.",
},
{
id: "changelog-2026-8-7",
title: "Version 2026.8.7",
summary: "Dieses Release erweitert die Darstellungseinstellungen und macht die Branch-Auswahl bei vielen lokalen und entfernten Branches übersichtlicher.",
steps: [
"In den Einstellungen stehen die Darstellungsstile Aktuell, Klassisch und Eigene zur Verfügung. Beim eigenen Stil lässt sich eine individuelle Farbpalette konfigurieren und dauerhaft speichern.",
"Ein vollständiges helles Theme ergänzt die überarbeitete dunkle Darstellung. Farben, Flächen, Bedienelemente und Fokusrahmen besitzen klarere Grenzen und konsistentere Kontraste.",
"Der Dialog zur Branch-Sichtbarkeit trennt lokale und entfernte Branches in auf- und zuklappbare Gruppen und zeigt für jede Gruppe die Anzahl der ausgewählten Branches.",
"Beim Öffnen ist die lokale Gruppe ausgeklappt und die Remote-Gruppe zunächst geschlossen, damit häufig verwendete Branches schneller erreichbar sind.",
"Die Branch-Auswahl passt sich kleineren Fenstergrößen besser an und folgt dem visuellen Stil der übrigen Gitty-Dialoge.",
],
note: "Darstellungsstil und eigene Farben werden lokal gespeichert und beim nächsten Start automatisch wieder angewendet.",
},
{
id: "changelog-2026-8-6",
title: "Version 2026.8.6",
summary: "Dieses Wartungsrelease stabilisiert Git-LFS-Workflows vom Tracking über Clone und Pull bis zum Push großer Dateien nach Azure DevOps.",
steps: [
"LFS-Muster aus der .gitattributes im Repository-Stamm bleiben im LFS-Dialog sichtbar, auch wenn die Datei noch ungetrackt ist oder zuvor durch eine Ignore-Regel ausgeblendet wurde.",
"Beim Aktivieren von Git LFS und beim Hinzufügen eines Tracking-Musters stellt Gitty sicher, dass die .gitattributes nicht ignoriert wird. Nur wenn nötig, wird die gezielte Ausnahme !/.gitattributes am Ende der .gitignore ergänzt.",
"Clone und Pull verwenden für erkannte LFS-Repositories denselben Remote und dieselben Zugangsdaten auch zum Laden der LFS-Objekte. Neue Klone aktivieren Filter und Pre-Push-Hook automatisch.",
"Wenn Azure DevOps einen großen LFS-Upload über HTTP/2 mit HTTP 413 ablehnt, wiederholt Gitty den Push einmal mit einer nur für diesen Befehl geltenden HTTP/1.1-Konfiguration. Globale und Repository-Einstellungen bleiben unverändert.",
"LFS-, Größen- und andere allgemeine Push-Fehler werden nicht mehr als Non-Fast-Forward verwechselt. Der unnötige Ablauf „Pull vor Push“ mit anschließendem „Push after pull“ erscheint nur noch bei einem tatsächlichen veralteten lokalen Branch.",
"Der Tauri-Debug-Launcher entfernt ausschließlich bekannte nicht routende Test-Proxys aus dem Gitty-Unterprozess. Echte Benutzer- und Unternehmens-Proxys bleiben erhalten, sodass Remote- und LFS-Abläufe auch im Debug-Build testbar sind.",
],
note: "Die HTTP/1.1-Wiederholung greift nur nach einem LFS-Fehler 413. Änderungen an .gitattributes und .gitignore bleiben normale Repository-Änderungen und müssen committed und gepusht werden.",
},
{
id: "changelog-2026-8-5",
title: "Version 2026.8.5",
@@ -1527,11 +1597,11 @@
steps: [
"Git LFS ist direkt über das Synchronisierungsmenü erreichbar. Gitty prüft die verfügbare Erweiterung, die Repository-Konfiguration und den Pre-Push-Hook und zeigt an, ob Git LFS mit Gitty gebündelt oder systemweit installiert ist.",
"LFS-Muster lassen sich hinzufügen, als Lockable markieren und wieder entfernen. Der Dialog zeigt außerdem die LFS-Dateien des aktuellen Checkouts, lädt fehlende Objekte und bereinigt nicht mehr benötigte Cache-Objekte.",
"Nach einem erfolgreichen Pull erkennt Gitty LFS-Repositories automatisch und lädt die benötigten LFS-Objekte mit demselben Remote und denselben Zugangsdaten. Ein zweiter manueller Pull ist nicht erforderlich.",
"Nach einem erfolgreichen Clone oder Pull erkennt Gitty LFS-Repositories automatisch und lädt die benötigten LFS-Objekte mit demselben Remote und denselben Zugangsdaten. Neue Klone aktivieren LFS außerdem lokal, sodass kein zweiter manueller Pull erforderlich ist.",
"Unstaged und Staged stehen jetzt gleich breit nebeneinander, scrollen unabhängig voneinander und verwenden eindeutige Pfeile für Stage und Unstage. Bei schmalen Fenstern wechselt die Darstellung automatisch untereinander.",
"Der mittig angeordnete List-/Tree-Umschalter zeigt Änderungen entweder als kompakte Liste oder gruppiert sie in beiden Bereichen nach aufklappbaren Ordnern.",
"Über das neu gestaltete Kontextmenü einer Datei oder eines Ordners lassen sich gezielt einzelne Dateien oder alle Änderungen im Ordner stagen, unstagen oder in einem eigenen Stash sichern. Dateiname, übergeordneter Pfad und Anzahl der betroffenen Dateien sind dabei klar voneinander getrennt.",
"Repositories können beim Start über --repo PATH oder --repo=PATH direkt geöffnet werden. Relative Pfade werden dabei aufgelöst.",
"Über das neu gestaltete Kontextmenü einer Datei oder eines Ordners lassen sich gezielt einzelne Dateien oder alle Änderungen im Ordner stagen, unstagen oder in einem eigenen Stash sichern. Neue und ungetrackte Inhalte können im Changes-Bereich und im File Explorer als exakte Datei, kompletter Ordner oder Dateiendungs-Muster in die .gitignore übernommen werden; die Ordneroption erscheint nur beim Rechtsklick auf einen Ordner. Bereits getrackte Dateien und Ordner lassen sich mit „Stop tracking“ aus dem Git-Index entfernen, bleiben aber auf der Festplatte erhalten. Dateiname, übergeordneter Pfad und Anzahl der betroffenen Dateien sind dabei klar voneinander getrennt.",
"Repositories können beim Start über --repo PATH oder --repo=PATH direkt geöffnet werden. Mit clone REMOTE ZIEL, --clone REMOTE ZIEL oder --clone=REMOTE ZIEL klont Gitty ein Remote-Repository in den exakt angegebenen lokalen Ordner und öffnet es anschließend. Relative Pfade werden gegen das aktuelle Arbeitsverzeichnis aufgelöst; der Aufruf wird auch an eine bereits laufende Gitty-Instanz weitergegeben.",
"Quadratische Bedienelemente und Flächen vereinheitlichen das Erscheinungsbild; runde Statuspunkte, Avatare und charakteristische Branch-Markierungen bleiben erhalten.",
],
note: "Von Git LFS erzeugte Änderungen an .gitattributes gehören zum Repository und müssen wie jede andere Änderung committed werden. Bereits vorhandene Git-Historie wird durch neue Tracking-Muster nicht rückwirkend umgeschrieben.",
@@ -1636,6 +1706,48 @@
label: "What's new",
description: "Changes since the latest published version and notable additions from earlier releases.",
sections: [
{
id: "changelog-2026-8-8",
title: "Version 2026.8.8",
summary: "This release connects Gitty to the major Git hosting services and makes cloning from your own repository lists substantially faster.",
steps: [
"New integrations for GitHub, GitLab.com, GitLab Self-Managed, Azure DevOps, and Gitea can be managed centrally in Settings. Personal access tokens are stored securely in the operating system keychain.",
"Azure DevOps supports multiple organizations, each with its own display name, organization URL, username, and token.",
"The Clone dialog has an Integrations tab. It loads every repository accessible to the selected account, sorts the list alphabetically, and supports search, refresh, and direct cloning with stored credentials.",
"The repository tab bar is more compact and closer to familiar Git clients. Its close button remains visible and turns red only while hovered.",
"A narrow custom scrollbar in the repository browser no longer covers names or metadata and grows only slightly on hover.",
"Repository loading and status surfaces respond more consistently to the active theme with improved contrast and a more compact presentation.",
"Clearing a missing upstream is now a safe no-op instead of producing a fatal Git error.",
],
note: "Integrations use HTTPS and personal access tokens. The repositories shown depend on the permissions granted to the selected account and token.",
},
{
id: "changelog-2026-8-7",
title: "Version 2026.8.7",
summary: "This release expands appearance settings and makes branch selection easier to navigate in repositories with many local and remote branches.",
steps: [
"Settings now provide Modern, Classic, and Custom appearance styles. Custom mode supports an individual color palette that is persisted across restarts.",
"A complete light theme complements the refreshed dark appearance. Colors, surfaces, controls, and focus outlines have clearer boundaries and more consistent contrast.",
"The branch visibility dialog separates local and remote branches into collapsible groups and displays the number of selected branches for each group.",
"The local group opens by default while the remote group starts collapsed, keeping frequently used branches quicker to reach.",
"The branch selector responds better to smaller window sizes and follows the visual language of the other Gitty dialogs.",
],
note: "The selected appearance style and custom colors are stored locally and restored automatically on the next start.",
},
{
id: "changelog-2026-8-6",
title: "Version 2026.8.6",
summary: "This maintenance release stabilizes Git LFS workflows from tracking through clone and pull to pushing large files to Azure DevOps.",
steps: [
"LFS patterns from the root .gitattributes remain visible in the LFS dialog even while the file is untracked or was previously hidden by an ignore rule.",
"When Git LFS is activated or a tracking pattern is added, Gitty ensures that .gitattributes is not ignored. Only when required, the scoped !/.gitattributes exception is appended to .gitignore.",
"Clone and pull use the same remote and credentials to download LFS objects for detected LFS repositories. Fresh clones also activate the filters and pre-push hook automatically.",
"When Azure DevOps rejects a large LFS upload over HTTP/2 with HTTP 413, Gitty retries the push once with an HTTP/1.1 setting scoped to that command. Global and repository settings remain unchanged.",
"LFS, size, and other generic push failures are no longer mistaken for non-fast-forward rejections. The Pull before push and Push after pull flow is now offered only when the local branch is genuinely behind its remote.",
"The Tauri debug launcher removes only known non-routing test proxies from the Gitty child process. Real user and company proxies are preserved, keeping remote and LFS workflows testable in debug builds.",
],
note: "The HTTP/1.1 retry runs only after an LFS HTTP 413 failure. Changes to .gitattributes and .gitignore remain ordinary repository changes that must be committed and pushed.",
},
{
id: "changelog-2026-8-5",
title: "Version 2026.8.5",
@@ -1643,11 +1755,11 @@
steps: [
"Git LFS is available directly from the Sync menu. Gitty checks the available extension, repository configuration, and pre-push hook, and reports whether Git LFS is bundled with Gitty or installed system-wide.",
"LFS patterns can be added, marked as Lockable, and removed again. The dialog also lists LFS files in the current checkout, downloads missing objects, and prunes unused cache objects.",
"After a successful pull, Gitty automatically detects LFS repositories and downloads the required LFS objects with the same remote and credentials. A second manual pull is no longer required.",
"After a successful clone or pull, Gitty automatically detects LFS repositories and downloads the required LFS objects with the same remote and credentials. Fresh clones also activate LFS locally, so a second manual pull is no longer required.",
"Unstaged and Staged now sit side by side at equal width, scroll independently, and use clear arrows for Stage and Unstage. Narrow windows automatically fall back to a vertical layout.",
"The centered List/Tree switch presents changes either as a compact list or groups them into collapsible folders in both areas.",
"The redesigned file and folder context menu can stage, unstage, or save only that file or the folder's complete set of changes in a dedicated stash. The selected name, parent path, and affected file count are now clearly separated.",
"Repositories can be opened directly at startup with --repo PATH or --repo=PATH. Relative paths are resolved automatically.",
"The redesigned file and folder context menu can stage, unstage, or save only that file or the folder's complete set of changes in a dedicated stash. In Changes and the File Explorer, new and untracked items can be added to .gitignore as an exact file, a complete folder, or an extension-wide pattern; the folder option only appears for folder selections. Tracked files and folders can be removed from the Git index with Stop tracking while remaining on disk. The selected name, parent path, and affected file count are now clearly separated.",
"Repositories can be opened directly at startup with --repo PATH or --repo=PATH. With clone REMOTE TARGET, --clone REMOTE TARGET, or --clone=REMOTE TARGET, Gitty clones a remote into the exact local folder and opens it afterward. Relative paths are resolved against the current working directory, and requests are forwarded to an already-running Gitty instance.",
"Square controls and surfaces make the interface more consistent while circular status markers, avatars, and characteristic branch shapes remain intact.",
],
note: "Changes to .gitattributes created by Git LFS belong to the repository and must be committed like any other change. New tracking patterns do not rewrite existing Git history retroactively.",
@@ -2004,7 +2116,8 @@
.help-search-icon { position: absolute; left: 12px; display: grid; color: var(--color-ink-faint); pointer-events: none; }
.help-search input { width: 100%; height: 40px; padding: 0 68px 0 39px; border-color: var(--color-border-input); border-radius: 8px; background: var(--app-input-bg); color: var(--color-ink); font-size: 12.5px; }
.help-search kbd { position: absolute; right: 8px; }
.help-close { justify-self: end; width: 36px; min-height: 36px; padding: 0; border-color: transparent; border-radius: 7px; background: transparent; color: var(--color-ink-muted); }
.help-close { justify-self: end; width: 36px; min-height: 36px; padding: 0; border-color: transparent; border-radius: 7px; background: transparent; color: var(--color-ink-muted); transition: color 120ms ease, border-color 120ms ease, background 120ms ease, box-shadow 120ms ease; }
.help-close:hover:not(:disabled), .help-close:focus-visible:not(:disabled) { color: #fff; border-color: #f0646d; background: #d93641; box-shadow: inset 0 0 0 1px rgba(255,255,255,.08); }
.help-layout { display: grid; grid-template-columns: 250px minmax(0, 1fr); min-height: 0; }
.help-nav { display: flex; flex-direction: column; min-height: 0; padding: 14px 10px 12px; border-right: 1px solid var(--color-border); background: var(--color-surface-dim); }
+58 -24
View File
@@ -117,6 +117,8 @@
let customVisibleBranches = $state<Set<string>>(new Set());
let loadedVisibilityRepository = $state("");
let branchDialogOpen = $state(false);
let localBranchGroupOpen = $state(true);
let remoteBranchGroupOpen = $state(false);
let expandedRefsCommitHash = $state("");
let panelElement = $state<HTMLElement | null>(null);
let contextCommit = $state<GitCommit | null>(null);
@@ -428,6 +430,8 @@
}
function openBranchDialog() {
localBranchGroupOpen = true;
remoteBranchGroupOpen = false;
branchDialogOpen = true;
}
@@ -1202,32 +1206,62 @@
<div class="branch-filter-dialog-list">
{#if localBranchNames.length > 0}
<span class="branch-filter-group-label">Local</span>
{#each localBranchNames as branch}
<label class:muted={!branchIsVisible(branch)} class="branch-filter-option" title={branch}>
<input
type="checkbox"
checked={branchIsVisible(branch)}
onchange={() => toggleGraphBranch(branch)}
/>
<GitBranch size={14} aria-hidden="true" />
<span>{branch}</span>
</label>
{/each}
<section class="branch-filter-group">
<button
class="branch-filter-group-toggle"
type="button"
aria-expanded={localBranchGroupOpen}
onclick={() => { localBranchGroupOpen = !localBranchGroupOpen; }}
>
{#if localBranchGroupOpen}<ChevronDown size={14} aria-hidden="true" />{:else}<ChevronRight size={14} aria-hidden="true" />{/if}
<span>Local</span>
<em>{localBranchNames.filter(branchIsVisible).length}/{localBranchNames.length}</em>
</button>
{#if localBranchGroupOpen}
<div class="branch-filter-group-list">
{#each localBranchNames as branch}
<label class:muted={!branchIsVisible(branch)} class="branch-filter-option" title={branch}>
<input
type="checkbox"
checked={branchIsVisible(branch)}
onchange={() => toggleGraphBranch(branch)}
/>
<GitBranch size={14} aria-hidden="true" />
<span>{branch}</span>
</label>
{/each}
</div>
{/if}
</section>
{/if}
{#if remoteBranchNames.length > 0}
<span class="branch-filter-group-label">Remote</span>
{#each remoteBranchNames as branch}
<label class:muted={!branchIsVisible(branch)} class="branch-filter-option remote" title={branch}>
<input
type="checkbox"
checked={branchIsVisible(branch)}
onchange={() => toggleGraphBranch(branch)}
/>
<GitBranch size={14} aria-hidden="true" />
<span>{branch}</span>
</label>
{/each}
<section class="branch-filter-group">
<button
class="branch-filter-group-toggle"
type="button"
aria-expanded={remoteBranchGroupOpen}
onclick={() => { remoteBranchGroupOpen = !remoteBranchGroupOpen; }}
>
{#if remoteBranchGroupOpen}<ChevronDown size={14} aria-hidden="true" />{:else}<ChevronRight size={14} aria-hidden="true" />{/if}
<span>Remote</span>
<em>{remoteBranchNames.filter(branchIsVisible).length}/{remoteBranchNames.length}</em>
</button>
{#if remoteBranchGroupOpen}
<div class="branch-filter-group-list">
{#each remoteBranchNames as branch}
<label class:muted={!branchIsVisible(branch)} class="branch-filter-option" title={branch}>
<input
type="checkbox"
checked={branchIsVisible(branch)}
onchange={() => toggleGraphBranch(branch)}
/>
<GitBranch size={14} aria-hidden="true" />
<span>{branch}</span>
</label>
{/each}
</div>
{/if}
</section>
{/if}
</div>
</div>
@@ -0,0 +1,149 @@
<script lang="ts">
import { onMount } from "svelte";
import { open as openDialog } from "@tauri-apps/plugin-dialog";
import { Folder, FolderOpen, GitBranch, LoaderCircle, Plus, X } from "@lucide/svelte";
import type { AppLanguage } from "../types";
interface Props {
isBusy: boolean;
language: AppLanguage;
onInit: (path: string, branch: string) => void;
onClose: () => void;
}
let {
isBusy = false,
language = "en",
onInit = () => {},
onClose = () => {},
}: Props = $props();
let path = $state("");
let branch = $state("main");
let browseError = $state("");
const isGerman = $derived(language === "de");
const pathPlaceholder = navigator.userAgent.includes("Windows")
? "C:\\Projects\\my-repository"
: "/home/user/projects/my-repository";
function submit(event: SubmitEvent) {
event.preventDefault();
const targetPath = path.trim();
const branchName = branch.trim();
if (!targetPath || !branchName || isBusy) return;
onInit(targetPath, branchName);
}
function handleKeydown(event: KeyboardEvent) {
if (event.key === "Escape" && !isBusy) onClose();
}
async function chooseFolder() {
if (isBusy) return;
browseError = "";
try {
const selected = await openDialog({
title: isGerman ? "Ordner für das neue Repository auswählen" : "Select folder for the new repository",
directory: true,
multiple: false,
defaultPath: path.trim() || undefined,
});
if (typeof selected === "string") path = selected;
} catch (error) {
browseError = error instanceof Error ? error.message : String(error);
}
}
onMount(() => {
window.addEventListener("keydown", handleKeydown);
return () => window.removeEventListener("keydown", handleKeydown);
});
</script>
<div class="dialog-backdrop app-chrome-backdrop" role="presentation">
<div
class="dialog init-repository-dialog"
role="dialog"
aria-modal="true"
aria-labelledby="init-repository-title"
tabindex="-1"
>
<header class="dialog-header init-repository-header">
<div class="init-repository-heading">
<span class="init-repository-icon" aria-hidden="true"><Plus size={18} /></span>
<div>
<span class="eyebrow">{isGerman ? "Neues Repository" : "New repository"}</span>
<h2 id="init-repository-title">{isGerman ? "Repository initialisieren" : "Initialize repository"}</h2>
</div>
</div>
<button class="dialog-close" type="button" onclick={onClose} disabled={isBusy} title={isGerman ? "Schließen" : "Close"}>
<X size={18} aria-hidden="true" />
</button>
</header>
<form class="init-repository-form" onsubmit={submit}>
<p class="init-repository-description">
{isGerman
? "GitLite richtet in diesem Ordner ein neues Git-Repository ein. Vorhandene Dateien bleiben unverändert."
: "GitLite will create a new Git repository in this folder. Existing files will remain unchanged."}
</p>
<label class="new-branch-field init-repository-field init-repository-path-field">
<span>{isGerman ? "Zielordner" : "Repository folder"}</span>
<div class="init-repository-path-control">
<Folder size={16} aria-hidden="true" />
<!-- svelte-ignore a11y_autofocus -->
<input
bind:value={path}
autocomplete="off"
spellcheck="false"
placeholder={pathPlaceholder}
disabled={isBusy}
autofocus
aria-describedby="init-repository-path-hint"
/>
<button class="btn-secondary init-repository-browse" type="button" onclick={chooseFolder} disabled={isBusy}>
<FolderOpen size={15} aria-hidden="true" />
{isGerman ? "Auswählen…" : "Browse…"}
</button>
</div>
<small id="init-repository-path-hint">
{isGerman ? "Der Ordner wird angelegt, falls er noch nicht existiert." : "The folder will be created if it does not exist."}
</small>
{#if browseError}<small class="init-repository-error" role="alert">{browseError}</small>{/if}
</label>
<label class="new-branch-field init-repository-field">
<span>{isGerman ? "Name des ersten Branches" : "Initial branch name"}</span>
<div>
<GitBranch size={16} aria-hidden="true" />
<input
bind:value={branch}
autocomplete="off"
spellcheck="false"
placeholder="main"
disabled={isBusy}
aria-describedby="init-repository-branch-hint"
/>
</div>
<small id="init-repository-branch-hint">
{isGerman ? "Du kannst den Branch später jederzeit umbenennen." : "You can rename the branch at any time."}
</small>
</label>
<div class="new-branch-actions init-repository-actions">
<button class="btn-secondary" type="button" onclick={onClose} disabled={isBusy}>
{isGerman ? "Abbrechen" : "Cancel"}
</button>
<button class="btn-primary" type="submit" disabled={isBusy || path.trim().length === 0 || branch.trim().length === 0}>
{#if isBusy}
<LoaderCircle class="spin" size={16} aria-hidden="true" />
{:else}
<Plus size={16} aria-hidden="true" />
{/if}
{isGerman ? "Repository erstellen" : "Create repository"}
</button>
</div>
</form>
</div>
</div>
@@ -0,0 +1,290 @@
<script lang="ts">
import { Building2, CheckCircle2, CircleDashed, Eye, EyeOff, KeyRound, Plus, Server, Trash2 } from "@lucide/svelte";
import { siGitea, siGithub, siGitlab, type SimpleIcon } from "simple-icons";
import { gitIntegrationProviders, organizationNameFromUrl, providerLabel } from "../integrations";
import type { AppLanguage, AzureDevOpsOrganization, GitIntegrationConfig, GitIntegrationProvider, GitIntegrationSecretUpdate, GitIntegrationSettings } from "../types";
interface Props {
language: AppLanguage;
settings: GitIntegrationSettings;
onChange: (settings: GitIntegrationSettings) => void;
onSecretsChange: (updates: GitIntegrationSecretUpdate[]) => void;
}
let { language, settings, onChange, onSecretsChange }: Props = $props();
let selected = $state<GitIntegrationProvider>("github");
let selectedAzureOrganizationId = $state("");
let tokenValues = $state<Record<string, string>>({});
let secretUpdates = $state<Record<string, GitIntegrationSecretUpdate>>({});
let showToken = $state(false);
const isGerman = $derived(language === "de");
const selectedAzureOrganization = $derived(settings.azureDevOpsOrganizations.find((organization) => organization.id === selectedAzureOrganizationId));
const current = $derived<GitIntegrationConfig | AzureDevOpsOrganization | undefined>(selected === "azure-devops" ? selectedAzureOrganization : settings.providers[selected]);
const currentAccountId = $derived(selected === "azure-devops" ? selectedAzureOrganization?.id : undefined);
const azureDevOpsIcon: SimpleIcon = {
title: "Azure DevOps", slug: "azuredevops", hex: "0078D4", source: "https://azure.microsoft.com/products/devops", svg: "",
path: "M0 8.877 2.247 5.91l8.405-3.416v19.127l-8.405-3.53L0 15.123V8.877Zm12.154-6.968 11.846 2.423v15.336l-11.846 2.423V1.909Z",
};
const providerIcons: Record<GitIntegrationProvider, SimpleIcon> = { github: siGithub, gitlab: siGitlab, "gitlab-self-hosted": siGitlab, "azure-devops": azureDevOpsIcon, gitea: siGitea };
function providerDescription(provider: GitIntegrationProvider): string {
const descriptions = isGerman
? { github: "Konto auf github.com", gitlab: "Cloud-Konto auf gitlab.com", "gitlab-self-hosted": "Eigene GitLab-Instanz", "azure-devops": "Mehrere Organisationen", gitea: "Cloud- oder eigene Instanz" }
: { github: "Cloud account on github.com", gitlab: "Cloud account on gitlab.com", "gitlab-self-hosted": "Your own GitLab instance", "azure-devops": "Multiple organizations", gitea: "Cloud or self-hosted instance" };
return descriptions[provider];
}
function providerColor(provider: GitIntegrationProvider): string {
return provider === "github" ? "var(--color-ink)" : `#${providerIcons[provider].hex}`;
}
function secretId(provider: GitIntegrationProvider, accountId?: string): string {
return accountId ? `${provider}:${accountId}` : provider;
}
function emitSecrets() {
onSecretsChange(Object.values(secretUpdates));
}
function updateCurrent(patch: Partial<GitIntegrationConfig & AzureDevOpsOrganization>) {
if (!current) return;
if (selected === "azure-devops" && selectedAzureOrganization) {
onChange({
...settings,
azureDevOpsOrganizations: settings.azureDevOpsOrganizations.map((organization) => organization.id === selectedAzureOrganization.id ? { ...organization, ...patch } : organization),
});
return;
}
onChange({ ...settings, providers: { ...settings.providers, [selected]: { ...settings.providers[selected], ...patch } } });
}
function setToken(value: string) {
if (!current) return;
const id = secretId(selected, currentAccountId);
tokenValues[id] = value;
if (value.trim()) secretUpdates[id] = { provider: selected, accountId: currentAccountId, token: value };
else delete secretUpdates[id];
tokenValues = { ...tokenValues };
secretUpdates = { ...secretUpdates };
emitSecrets();
}
function forgetToken() {
if (!current) return;
const id = secretId(selected, currentAccountId);
tokenValues[id] = "";
secretUpdates[id] = { provider: selected, accountId: currentAccountId, removeToken: true };
tokenValues = { ...tokenValues };
secretUpdates = { ...secretUpdates };
updateCurrent({ enabled: false, tokenStored: false });
emitSecrets();
}
function pendingRemoval(provider: GitIntegrationProvider, accountId?: string): boolean {
return secretUpdates[secretId(provider, accountId)]?.removeToken === true;
}
function tokenValue(): string {
return tokenValues[secretId(selected, currentAccountId)] ?? "";
}
function selectProvider(provider: GitIntegrationProvider) {
selected = provider;
showToken = false;
if (provider === "azure-devops" && !settings.azureDevOpsOrganizations.some((organization) => organization.id === selectedAzureOrganizationId)) {
selectedAzureOrganizationId = settings.azureDevOpsOrganizations[0]?.id ?? "";
}
}
function createOrganizationId(): string {
if (typeof crypto !== "undefined" && "randomUUID" in crypto) return crypto.randomUUID();
return `org-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
}
function addAzureOrganization() {
const id = createOrganizationId();
const number = settings.azureDevOpsOrganizations.length + 1;
const organization: AzureDevOpsOrganization = {
id,
name: isGerman ? `Organisation ${number}` : `Organization ${number}`,
enabled: true,
baseUrl: "https://dev.azure.com/",
username: "",
tokenStored: false,
};
onChange({ ...settings, azureDevOpsOrganizations: [...settings.azureDevOpsOrganizations, organization] });
selectedAzureOrganizationId = id;
showToken = false;
}
function removeAzureOrganization(organization: AzureDevOpsOrganization) {
const id = secretId("azure-devops", organization.id);
if (organization.tokenStored) secretUpdates[id] = { provider: "azure-devops", accountId: organization.id, removeToken: true };
else delete secretUpdates[id];
delete tokenValues[id];
secretUpdates = { ...secretUpdates };
tokenValues = { ...tokenValues };
const remaining = settings.azureDevOpsOrganizations.filter((candidate) => candidate.id !== organization.id);
onChange({ ...settings, azureDevOpsOrganizations: remaining });
selectedAzureOrganizationId = remaining[0]?.id ?? "";
showToken = false;
emitSecrets();
}
function organizationDisplayName(organization: AzureDevOpsOrganization): string {
return organization.name.trim() || organizationNameFromUrl(organization.baseUrl) || (isGerman ? "Unbenannte Organisation" : "Unnamed organization");
}
function isOrganizationConfigured(organization: AzureDevOpsOrganization): boolean {
return organization.tokenStored && organization.baseUrl.trim().length > 0 && !pendingRemoval("azure-devops", organization.id);
}
function isConfigured(provider: GitIntegrationProvider): boolean {
if (provider === "azure-devops") return settings.azureDevOpsOrganizations.some(isOrganizationConfigured);
const config = settings.providers[provider];
return config.tokenStored && config.baseUrl.trim().length > 0 && !pendingRemoval(provider);
}
function currentConfigured(): boolean {
if (!current) return false;
return current.tokenStored && current.baseUrl.trim().length > 0 && !pendingRemoval(selected, currentAccountId);
}
function baseUrlPlaceholder(): string {
if (selected === "azure-devops") return "https://dev.azure.com/meine-organisation";
if (selected === "github") return "https://github.com";
if (selected === "gitlab") return "https://gitlab.com";
if (selected === "gitlab-self-hosted") return "https://gitlab.example.com";
return "https://gitea.example.com";
}
</script>
<div class="integration-layout">
<div class="integration-providers" role="tablist" aria-label={isGerman ? "Git-Anbieter" : "Git providers"}>
{#each gitIntegrationProviders as provider}
<button type="button" role="tab" aria-selected={selected === provider} class:active={selected === provider} onclick={() => selectProvider(provider)}>
<span class="provider-logo" style={`--provider-color:${providerColor(provider)}`}><svg viewBox="0 0 24 24" aria-hidden="true"><path d={providerIcons[provider].path} /></svg></span>
<span class="provider-copy"><strong>{providerLabel(provider)}</strong><small>{providerDescription(provider)}</small></span>
<span class="provider-state" class:configured={isConfigured(provider)} title={isConfigured(provider) ? (isGerman ? "Konfiguriert" : "Configured") : (isGerman ? "Nicht verbunden" : "Not connected")}></span>
</button>
{/each}
</div>
<section class="integration-config" aria-label={`${providerLabel(selected)} ${isGerman ? "konfigurieren" : "configuration"}`}>
<header class="integration-summary">
<span class="provider-logo provider-logo-large" style={`--provider-color:${providerColor(selected)}`}><svg viewBox="0 0 24 24" aria-hidden="true"><path d={providerIcons[selected].path} /></svg></span>
<div><h4>{providerLabel(selected)}</h4><p>{providerDescription(selected)}</p></div>
{#if selected === "azure-devops"}
<span class="integration-status" class:configured={isConfigured(selected)}><Building2 size={13} />{settings.azureDevOpsOrganizations.length} {isGerman ? "Orgas" : "orgs"}</span>
{:else}
<span class="integration-status" class:configured={currentConfigured()}>{#if currentConfigured()}<CheckCircle2 size={13} />{:else}<CircleDashed size={13} />{/if}{currentConfigured() ? (isGerman ? "Konfiguriert" : "Configured") : (isGerman ? "Nicht verbunden" : "Not connected")}</span>
{/if}
</header>
{#if selected === "azure-devops"}
<div class="azure-organizations">
<div class="azure-organizations-head"><div><strong>{isGerman ? "Organisationen" : "Organizations"}</strong><small>{isGerman ? "Jede Organisation verwendet einen eigenen Token." : "Each organization uses its own token."}</small></div><button type="button" onclick={addAzureOrganization}><Plus size={14} />{isGerman ? "Hinzufügen" : "Add"}</button></div>
{#if settings.azureDevOpsOrganizations.length === 0}
<div class="azure-organizations-empty"><Building2 size={22} /><span>{isGerman ? "Noch keine Azure-DevOps-Organisation angelegt." : "No Azure DevOps organization has been added yet."}</span><button type="button" onclick={addAzureOrganization}><Plus size={14} />{isGerman ? "Erste Organisation anlegen" : "Add first organization"}</button></div>
{:else}
<div class="azure-organization-list" role="tablist" aria-label={isGerman ? "Azure-DevOps-Organisationen" : "Azure DevOps organizations"}>
{#each settings.azureDevOpsOrganizations as organization (organization.id)}
<div class="azure-organization-row" class:active={selectedAzureOrganizationId === organization.id}>
<button type="button" role="tab" aria-selected={selectedAzureOrganizationId === organization.id} onclick={() => { selectedAzureOrganizationId = organization.id; showToken = false; }}>
<span><strong>{organizationDisplayName(organization)}</strong><small>{organization.baseUrl}</small></span><i class:configured={isOrganizationConfigured(organization)}></i>
</button>
<button class="azure-remove" type="button" onclick={() => removeAzureOrganization(organization)} title={isGerman ? "Organisation entfernen" : "Remove organization"} aria-label={`${organizationDisplayName(organization)} ${isGerman ? "entfernen" : "remove"}`}><Trash2 size={13} /></button>
</div>
{/each}
</div>
{/if}
</div>
{/if}
{#if current}
{#if selected === "azure-devops"}
<label class="integration-field"><span><Building2 size={13} />{isGerman ? "Anzeigename" : "Display name"}</span><input value={selectedAzureOrganization?.name ?? ""} oninput={(event) => updateCurrent({ name: event.currentTarget.value })} placeholder={isGerman ? "z. B. Contoso Platform" : "e.g. Contoso Platform"} /></label>
{/if}
<label class="integration-field">
<span><Server size={13} />{selected === "azure-devops" ? (isGerman ? "Organisations-URL" : "Organization URL") : (isGerman ? "Server-URL" : "Server URL")}</span>
<input value={current.baseUrl} oninput={(event) => updateCurrent({ baseUrl: event.currentTarget.value })} placeholder={baseUrlPlaceholder()} spellcheck="false" inputmode="url" />
<small>{isGerman ? "Basis-URL ohne Repository-Pfad." : "Base URL without a repository path."}</small>
</label>
<label class="integration-field"><span>{isGerman ? "Benutzername oder E-Mail" : "Username or email"}</span><input value={current.username} oninput={(event) => updateCurrent({ username: event.currentTarget.value })} autocomplete="off" placeholder={selected === "azure-devops" ? "name@example.com" : (isGerman ? "Benutzername" : "Username")} spellcheck="false" /></label>
<label class="integration-field">
<span><KeyRound size={13} />Personal Access Token</span>
<div class="token-row"><input type={showToken ? "text" : "password"} value={tokenValue()} oninput={(event) => setToken(event.currentTarget.value)} autocomplete="new-password" placeholder={current.tokenStored && !pendingRemoval(selected, currentAccountId) ? (isGerman ? "Token ist sicher gespeichert" : "Token is stored securely") : (isGerman ? "Token einfügen" : "Paste token")} spellcheck="false" /><button type="button" onclick={() => { showToken = !showToken; }} title={showToken ? (isGerman ? "Token ausblenden" : "Hide token") : (isGerman ? "Token anzeigen" : "Show token")} aria-label={showToken ? (isGerman ? "Token ausblenden" : "Hide token") : (isGerman ? "Token anzeigen" : "Show token")}>{#if showToken}<EyeOff size={15} />{:else}<Eye size={15} />{/if}</button></div>
<small>{isGerman ? "Der Token wird separat im Schlüsselbund des Betriebssystems gespeichert." : "The token is stored separately in the operating system keychain."}</small>
</label>
<div class="integration-actions">
<label class="integration-enabled"><span><strong>{selected === "azure-devops" ? (isGerman ? "Organisation aktivieren" : "Enable organization") : (isGerman ? "Integration aktivieren" : "Enable integration")}</strong><small>{isGerman ? "Für Hosting- und Clone-Funktionen verwenden." : "Use for hosting and clone features."}</small></span><input type="checkbox" checked={current.enabled} onchange={(event) => updateCurrent({ enabled: event.currentTarget.checked })} /></label>
{#if current.tokenStored && !pendingRemoval(selected, currentAccountId)}<button class="forget-token" type="button" onclick={forgetToken}><Trash2 size={14} />{isGerman ? "Gespeicherten Token entfernen" : "Remove stored token"}</button>{/if}
</div>
{/if}
</section>
</div>
<style>
.integration-layout { display: grid; grid-template-columns: 220px minmax(0, 1fr); gap: 14px; min-height: 420px; }
.integration-providers { display: flex; flex-direction: column; gap: 5px; }
.integration-providers > button { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 10px; min-height: 64px; padding: 9px 10px; border: 1px solid var(--color-border-subtle); border-radius: 9px; color: var(--color-ink-dim); background: var(--app-settings-row-bg); text-align: left; }
.integration-providers > button:hover { color: var(--color-ink); border-color: var(--color-border); background: var(--color-surface-hover); }
.integration-providers > button.active { border-color: color-mix(in srgb, var(--color-accent) 34%, var(--color-border)); color: var(--color-ink); background: color-mix(in srgb, var(--color-accent) 8%, var(--color-surface-raised)); box-shadow: inset 2px 0 0 var(--color-accent); }
.provider-logo { display: grid; place-items: center; width: 32px; height: 32px; border: 1px solid color-mix(in srgb, var(--provider-color) 34%, var(--color-border)); border-radius: 8px; color: var(--provider-color); background: color-mix(in srgb, var(--provider-color) 10%, transparent); }
.provider-logo svg { width: 17px; height: 17px; fill: currentColor; }
.provider-logo-large { width: 42px; height: 42px; border-radius: 10px; }
.provider-logo-large svg { width: 22px; height: 22px; }
.provider-copy { display: grid; min-width: 0; gap: 3px; }
.provider-copy strong { color: inherit; font-size: 11px; }
.provider-copy small { overflow: hidden; color: var(--color-ink-faint); font-size: 9px; text-overflow: ellipsis; white-space: nowrap; }
.provider-state { width: 7px; height: 7px; border-radius: 50%; background: var(--color-ink-faint); }
.provider-state.configured { background: var(--color-success); box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-success) 12%, transparent); }
.integration-config { display: grid; align-content: start; gap: 13px; min-width: 0; padding: 16px; border: 1px solid var(--color-border-subtle); border-radius: 12px; background: color-mix(in srgb, var(--app-settings-row-bg) 72%, transparent); }
.integration-summary { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 11px; padding-bottom: 13px; border-bottom: 1px solid var(--color-border-subtle); }
.integration-summary h4 { margin: 0; color: var(--color-ink); font-size: 14px; }
.integration-summary p { margin: 3px 0 0; color: var(--color-ink-dim); font-size: 10.5px; }
.integration-status { display: inline-flex; align-items: center; gap: 5px; padding: 5px 7px; border: 1px solid var(--color-border-subtle); border-radius: 6px; color: var(--color-ink-faint); font-size: 9px; font-weight: 750; }
.integration-status.configured { border-color: color-mix(in srgb, var(--color-success) 24%, var(--color-border)); color: var(--color-success); background: color-mix(in srgb, var(--color-success) 6%, transparent); }
.azure-organizations { display: grid; gap: 8px; padding: 10px; border: 1px solid var(--color-border-subtle); border-radius: 9px; background: var(--color-surface-raised); }
.azure-organizations-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.azure-organizations-head > div { display: grid; gap: 2px; }
.azure-organizations-head strong { color: var(--color-ink); font-size: 10.5px; }
.azure-organizations-head small { color: var(--color-ink-faint); font-size: 8.5px; }
.azure-organizations-head button, .azure-organizations-empty button { min-height: 27px; padding: 0 8px; font-size: 9.5px; font-weight: 750; }
.azure-organization-list { display: grid; gap: 5px; max-height: 142px; overflow: auto; }
.azure-organization-row { display: grid; grid-template-columns: minmax(0, 1fr) 30px; gap: 4px; border: 1px solid var(--color-border-subtle); border-radius: 7px; background: color-mix(in srgb, var(--app-settings-row-bg) 72%, transparent); }
.azure-organization-row.active { border-color: color-mix(in srgb, var(--color-accent) 38%, var(--color-border)); box-shadow: inset 2px 0 0 var(--color-accent); }
.azure-organization-row > button:first-child { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; min-height: 42px; padding: 5px 8px; border: 0; color: var(--color-ink-dim); background: transparent; text-align: left; }
.azure-organization-row > button:first-child span { display: grid; min-width: 0; gap: 2px; }
.azure-organization-row strong, .azure-organization-row small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.azure-organization-row strong { color: var(--color-ink); font-size: 10px; }
.azure-organization-row small { color: var(--color-ink-faint); font-size: 8.5px; }
.azure-organization-row i { width: 6px; height: 6px; border-radius: 50%; background: var(--color-ink-faint); }
.azure-organization-row i.configured { background: var(--color-success); }
.azure-remove { min-height: 30px; align-self: center; padding: 0; border: 0; color: var(--color-ink-faint); background: transparent; }
.azure-remove:hover { color: #e86060; background: color-mix(in srgb, #e86060 8%, transparent); }
.azure-organizations-empty { display: grid; place-items: center; gap: 7px; padding: 14px; color: var(--color-ink-faint); text-align: center; }
.azure-organizations-empty > :global(svg) { color: var(--color-accent); }
.azure-organizations-empty span { font-size: 9.5px; }
.integration-field { display: grid; gap: 6px; min-width: 0; color: var(--color-ink-muted); font-size: 10.5px; font-weight: 750; }
.integration-field > span { display: flex; align-items: center; gap: 5px; }
.integration-field input { height: 36px; border-color: var(--color-border); background: var(--color-surface-raised); font-size: 11px; }
.integration-field small { color: var(--color-ink-faint); font-size: 9.5px; font-weight: 500; }
.token-row { display: grid; grid-template-columns: minmax(0, 1fr) 36px; gap: 6px; }
.token-row button { display: grid; place-items: center; min-height: 36px; padding: 0; }
.integration-actions { display: grid; gap: 10px; padding-top: 2px; }
.integration-enabled { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 14px; padding: 11px 12px; border: 1px solid var(--color-border-subtle); border-radius: 8px; background: var(--color-surface-raised); }
.integration-enabled > span { display: grid; gap: 3px; }
.integration-enabled strong { color: var(--color-ink); font-size: 10.5px; }
.integration-enabled small { color: var(--color-ink-faint); font-size: 9px; }
.integration-enabled input { width: 32px; height: 18px; accent-color: var(--color-accent); }
.forget-token { justify-self: start; min-height: 28px; color: #e86060; font-size: 10px; }
@media (max-width: 680px) { .integration-layout { grid-template-columns: 1fr; min-height: 0; } .integration-providers { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); } .integration-providers > button { min-height: 54px; } .provider-copy small { display: none; } }
@media (max-width: 430px) { .integration-providers { grid-template-columns: 1fr; } .integration-summary { grid-template-columns: auto minmax(0, 1fr); } .integration-status { grid-column: 1 / -1; justify-self: start; } .azure-organizations-head { align-items: stretch; flex-direction: column; } .azure-organizations-head button { align-self: start; } }
</style>
+78 -113
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import { Check, ExternalLink, FileDiff, LoaderCircle, MousePointer2, X } from "@lucide/svelte";
import { ArrowDown, ArrowUp, Check, ExternalLink, FileDiff, LoaderCircle, Minus, Plus, RefreshCw, Trash2, X } from "@lucide/svelte";
import type { GitFileStatus, PatchApplyAction } from "../types";
type PatchLineKind = "context" | "add" | "delete" | "meta";
@@ -60,18 +60,26 @@
const isGerman = $derived(language === "de");
let parsed = $state<ParsedPatch>({ headerLines: [], hunks: [], binary: false });
let patchWidth = $state(0);
let patchScroll = $state<HTMLDivElement | null>(null);
let selectedLineIds = $state<Set<string>>(new Set());
let lastSelectedLineId = $state("");
let scopeLabel = $derived(staged ? "Staged changes" : "Unstaged changes");
const t = (de: string, en: string) => isGerman ? de : en;
let scopeLabel = $derived(staged ? t("Gestagte Änderungen", "Staged changes") : t("Nicht gestagte Änderungen", "Unstaged changes"));
let activeHunk = $state(0);
let hasTextPatch = $derived(!isLoading && !error && !!patch.trim() && !parsed.binary && parsed.hunks.length > 0);
function clearSelection() { selectedLineIds = new Set(); lastSelectedLineId = ""; }
let displayPath = $derived(file.old_path ? `${file.old_path} -> ${file.path}` : file.path);
let selectableLines = $derived(
parsed.hunks.flatMap((hunk) => hunk.lines.filter((line) => line.kind === "add" || line.kind === "delete")),
);
let addedCount = $derived(selectableLines.filter(line => line.kind === "add").length);
let deletedCount = $derived(selectableLines.filter(line => line.kind === "delete").length);
let selectedCount = $derived(selectableLines.filter((line) => selectedLineIds.has(line.id)).length);
$effect(() => {
activeHunk = 0;
parsed = parsePatch(patch);
selectedLineIds = new Set();
lastSelectedLineId = "";
@@ -191,6 +199,7 @@
}
function toggleHunkSelection(hunk: PatchHunk) {
if (isBusy || isLoading) return;
const changed = hunk.lines.filter((line) => line.kind === "add" || line.kind === "delete");
const allSelected = changed.length > 0 && changed.every((line) => selectedLineIds.has(line.id));
const next = new Set(selectedLineIds);
@@ -266,149 +275,105 @@
function scrollToHunk(hunkId: string) {
const target = patchScroll?.querySelector<HTMLElement>(`[data-hunk-id="${hunkId}"]`);
if (patchScroll && target) patchScroll.scrollTop = Math.max(target.offsetTop - 8, 0);
if (patchScroll && target) {
patchScroll.scrollTop += target.getBoundingClientRect().top - patchScroll.getBoundingClientRect().top;
activeHunk = parsed.hunks.findIndex(hunk => hunk.id === hunkId);
}
}
function updateActiveHunk() {
if (!patchScroll) return;
if (patchScroll.scrollTop > 0 && patchScroll.scrollTop + patchScroll.clientHeight >= patchScroll.scrollHeight - 2) {
activeHunk = Math.max(0, parsed.hunks.length - 1);
return;
}
const top = patchScroll.getBoundingClientRect().top;
const sections = Array.from(patchScroll.querySelectorAll<HTMLElement>("[data-hunk-id]"));
let index = 0;
sections.forEach((section, i) => { if (section.getBoundingClientRect().top <= top + 2) index = i; });
activeHunk = index;
}
</script>
<div class="dialog-backdrop" role="presentation">
<div class="dialog line-patch-dialog" role="dialog" aria-modal="true" aria-label="Line patch">
<div class="dialog-backdrop app-chrome-backdrop" role="presentation">
<div class="dialog line-patch-dialog" role="dialog" aria-modal="true" aria-label={t("Geänderte Zeilen", "Changed lines")}>
<header class="dialog-header">
<div>
<span class="eyebrow">{scopeLabel}</span>
<p class="dialog-title" title={displayPath}>{displayPath}</p>
</div>
<div class="patch-identity"><FileDiff size={23} aria-hidden="true" /><div><p class="dialog-title" title={displayPath}>{displayPath}</p><span class="patch-scope">{scopeLabel}</span></div></div>
<div class="dialog-header-actions">
<div class="tool-surface-choice" aria-label={isGerman ? "Diff öffnen mit" : "Open diff with"}>
<span>{isGerman ? "Öffnen mit" : "Open with"}</span>
<button class="active" type="button" aria-pressed="true" title={isGerman ? "In Gitty anzeigen" : "Show in Gitty"}>
<FileDiff size={13} aria-hidden="true" />Gitty
</button>
<button type="button" onclick={onExternalDiff} disabled={isBusy || isLoading} title={isGerman ? `In ${diffName} öffnen` : `Open in ${diffName}`}>
<ExternalLink size={13} aria-hidden="true" /><span>{diffName}</span>
</button>
</div>
<button class="btn-sm" type="button" onclick={onRefresh} disabled={isBusy || isLoading}>Refresh</button>
<button class="btn-sm dialog-close" type="button" onclick={onClose} disabled={isBusy} aria-label="Close">
<X size={16} aria-hidden="true" />
</button>
<button class="external-diff" type="button" onclick={onExternalDiff} disabled={isBusy || isLoading} title={t(`In ${diffName} öffnen`, `Open in ${diffName}`)}><span>{t("Extern öffnen", "Open externally")}</span><ExternalLink size={14} /><span>·</span><span>{diffName}</span></button>
<button class="icon-action" type="button" onclick={onRefresh} disabled={isBusy || isLoading} aria-label={t("Aktualisieren", "Refresh")} title={t("Aktualisieren", "Refresh")}><RefreshCw size={16} /></button>
<button class="icon-action" type="button" onclick={onClose} disabled={isBusy} aria-label={t("Schließen", "Close")}><X size={16} /></button>
</div>
</header>
<div class="line-patch-body">
{#if isLoading}
<div class="blank-state">
<LoaderCircle class="spin" size={18} aria-hidden="true" />
Loading patch...
</div>
{:else if error}
<div class="blank-state">{error}</div>
{:else if !patch.trim()}
<div class="blank-state">No line patch available for this file.</div>
{:else if parsed.binary || parsed.hunks.length === 0}
<div class="blank-state">This change cannot be split into text lines.</div>
<div class="blank-state"><LoaderCircle class="spin" size={18} />{t("Änderungen werden geladen …", "Loading changes …")}</div>
{:else if error}<div class="blank-state" role="alert">{error}</div>
{:else if !patch.trim()}<div class="blank-state">{t("Keine Änderungen für diese Datei vorhanden.", "No changes available for this file.")}</div>
{:else if parsed.binary || parsed.hunks.length === 0}<div class="blank-state">{t("Diese Änderung lässt sich nicht in Textzeilen aufteilen.", "This change cannot be split into text lines.")}</div>
{:else}
<div class:active={selectedCount > 0} class="line-patch-selection-bar">
<div>
<MousePointer2 size={14} aria-hidden="true" />
{#if selectedCount > 0}
<strong>{selectedCount} {selectedCount === 1 ? "line" : "lines"} selected</strong>
<span>Shift-click to select a range.</span>
{:else}
<strong>Select changed lines</strong>
<span>Choose individual additions or deletions below.</span>
{/if}
<div class="patch-toolbar">
<strong>{t("Geänderte Zeilen auswählen", "Select changed lines")}</strong><span class="range-hint">{t("Shift-Klick für einen Bereich", "Shift-click to select a range")}</span>
<div class="patch-summary"><span class="add-count">+{addedCount}</span><span class="delete-count">{deletedCount}</span><span>{parsed.hunks.length} {t(parsed.hunks.length === 1 ? "Abschnitt" : "Abschnitte", parsed.hunks.length === 1 ? "hunk" : "hunks")}</span>
<button class="icon-action" type="button" onclick={() => scrollToHunk(parsed.hunks[activeHunk - 1].id)} disabled={activeHunk === 0} aria-label={t("Vorheriger Abschnitt", "Previous hunk")} title={t("Vorheriger Abschnitt", "Previous hunk")}><ArrowUp size={16} /></button>
<button class="icon-action" type="button" onclick={() => scrollToHunk(parsed.hunks[activeHunk + 1].id)} disabled={activeHunk >= parsed.hunks.length - 1} aria-label={t("Nächster Abschnitt", "Next hunk")} title={t("Nächster Abschnitt", "Next hunk")}><ArrowDown size={16} /></button>
</div>
{#if selectedCount > 0}
<div class="line-patch-selected-actions">
<button class="line-patch-hunk-button discard" type="button" onclick={() => applySelected(staged ? "discard-staged" : "discard-unstaged")} disabled={isBusy}>
Discard selected
</button>
<button class="line-patch-hunk-button {staged ? "unstage" : "stage"}" type="button" onclick={() => applySelected(staged ? "unstage" : "stage")} disabled={isBusy}>
{staged ? "Unstage selected" : "Stage selected"}
</button>
<button class="btn-sm" type="button" onclick={() => { selectedLineIds = new Set(); }} disabled={isBusy}>Clear</button>
</div>
{/if}
</div>
<div class="line-patch-workspace">
<div class="line-patch-scroll" bind:this={patchScroll}>
{#each parsed.hunks as hunk (hunk.id)}
<section class="line-patch-hunk" data-hunk-id={hunk.id}>
<div class="line-patch-workspace" style={`--patch-visible-width: ${patchWidth}px`}>
<div class="line-patch-scroll" bind:this={patchScroll} bind:clientWidth={patchWidth} onscroll={updateActiveHunk}>
{#each parsed.hunks as hunk, index (hunk.id)}
<section class="line-patch-hunk" data-hunk-id={hunk.id} aria-label={`${t("Abschnitt", "Hunk")} ${index + 1}`}>
<div class="line-patch-hunk-head">
<button
class:all={hunkSelectionState(hunk) === "all"}
class:some={hunkSelectionState(hunk) === "some"}
class="line-patch-select-hunk"
type="button"
onclick={() => toggleHunkSelection(hunk)}
aria-label={`Select changed lines in ${hunk.header}`}
aria-pressed={hunkSelectionState(hunk) === "all"}
title="Select all changed lines in this hunk"
>
{#if hunkSelectionState(hunk) !== "none"}<Check size={12} aria-hidden="true" />{/if}
<button class:all={hunkSelectionState(hunk) === "all"} class:some={hunkSelectionState(hunk) === "some"} class="line-patch-select-hunk" type="button" role="checkbox" aria-checked={hunkSelectionState(hunk) === "some" ? "mixed" : hunkSelectionState(hunk) === "all"} onclick={() => toggleHunkSelection(hunk)} disabled={isBusy || isLoading} aria-label={`${t("Abschnitt auswählen", "Select hunk")} ${index + 1}`} title={t("Alle geänderten Zeilen dieses Abschnitts auswählen", "Select all changed lines in this hunk")}>
{#if hunkSelectionState(hunk) === "all"}<Check size={11} />{:else if hunkSelectionState(hunk) === "some"}<Minus size={11} />{/if}
</button>
<code>{hunk.header}</code>
<strong>{t("Abschnitt", "Hunk")} {index + 1}</strong><code title={hunk.header}>{hunk.header}</code>
<div class="line-patch-hunk-actions">
{#if staged}
<button class="line-patch-hunk-button discard" type="button" onclick={() => applyHunkAction("discard-staged", hunk)} disabled={isBusy}>
Discard Hunk
</button>
<button class="line-patch-hunk-button unstage" type="button" onclick={() => applyHunkAction("unstage", hunk)} disabled={isBusy}>
Unstage Hunk
</button>
{:else}
<button class="line-patch-hunk-button discard" type="button" onclick={() => applyHunkAction("discard-unstaged", hunk)} disabled={isBusy}>
Discard Hunk
</button>
<button class="line-patch-hunk-button stage" type="button" onclick={() => applyHunkAction("stage", hunk)} disabled={isBusy}>
Stage Hunk
</button>
{/if}
<button class="line-patch-hunk-button discard" type="button" onclick={() => applyHunkAction(staged ? "discard-staged" : "discard-unstaged", hunk)} disabled={isBusy}><Trash2 size={14} />{t("Verwerfen", "Discard")}</button>
<button class="line-patch-hunk-button {staged ? "unstage" : "stage"}" type="button" onclick={() => applyHunkAction(staged ? "unstage" : "stage", hunk)} disabled={isBusy}>{#if staged}<Minus size={14} />{:else}<Plus size={14} />{/if}{staged ? t("Abschnitt unstagen", "Unstage hunk") : t("Abschnitt stagen", "Stage hunk")}</button>
</div>
</div>
<div class="line-patch-lines">
{#each hunk.lines as line (line.id)}
<div class:selected={selectedLineIds.has(line.id)} class:selectable={line.kind === "add" || line.kind === "delete"} class={`line-patch-row ${line.kind}`}>
{#if line.kind === "add" || line.kind === "delete"}
<button
class="line-patch-line-select"
type="button"
onclick={(event) => toggleLine(event, line)}
aria-label={`${selectedLineIds.has(line.id) ? "Deselect" : "Select"} ${line.kind === "add" ? "added" : "deleted"} line ${line.newLine ?? line.oldLine ?? ""}`}
aria-pressed={selectedLineIds.has(line.id)}
title="Select line (Shift-click for range)"
>
{#if selectedLineIds.has(line.id)}<Check size={11} aria-hidden="true" />{/if}
<button class="line-patch-line-select" type="button" role="checkbox" aria-checked={selectedLineIds.has(line.id)} onclick={(event) => toggleLine(event, line)} disabled={isBusy || isLoading} aria-label={`${line.kind === "add" ? t("Hinzugefügte Zeile", "Added line") : t("Entfernte Zeile", "Deleted line")} ${line.newLine ?? line.oldLine ?? ""}`} title={t("Zeile auswählen (Shift-Klick für Bereich)", "Select line (Shift-click for range)")}>
{#if selectedLineIds.has(line.id)}<Check size={11} />{/if}
</button>
{:else}
<span class="line-patch-line-select-placeholder"></span>
{/if}
<span class="line-patch-line-number">{line.oldLine ?? ""}</span>
<span class="line-patch-line-number">{line.newLine ?? ""}</span>
<span class="line-patch-prefix">{linePrefix(line)}</span>
<code>{lineBody(line)}</code>
{:else}<span class="line-patch-line-select-placeholder"></span>{/if}
<span class="line-patch-line-number">{line.oldLine ?? ""}</span><span class="line-patch-line-number">{line.newLine ?? ""}</span><span class="line-patch-prefix">{linePrefix(line)}</span><code>{lineBody(line)}</code>
</div>
{/each}
</div>
</section>
{/each}
</div>
<nav class="diff-overview line-patch-overview" aria-label="Change overview">
{#each parsed.hunks as hunk, index (hunk.id)}
<button
class="diff-overview-marker {hunkKind(hunk)}"
type="button"
style={`--marker-position: ${hunkPosition(index)}%`}
onclick={() => scrollToHunk(hunk.id)}
title={`Jump to hunk ${index + 1} of ${parsed.hunks.length}`}
aria-label={`Jump to hunk ${index + 1} of ${parsed.hunks.length}`}
></button>
{/each}
<nav class="diff-overview line-patch-overview" aria-label={t("Änderungsübersicht", "Change overview")}>
{#each parsed.hunks as hunk, index (hunk.id)}<button class="diff-overview-marker {hunkKind(hunk)}" type="button" style={`--marker-position: ${hunkPosition(index)}%`} onclick={() => scrollToHunk(hunk.id)} title={`${t("Zu Abschnitt", "Jump to hunk")} ${index + 1}`} aria-label={`${t("Zu Abschnitt", "Jump to hunk")} ${index + 1}`}></button>{/each}
</nav>
</div>
{/if}
</div>
{#if hasTextPatch}
<footer class="patch-footer">
<div class="selection-summary"><span class="selection-symbol" class:has-selection={selectedCount > 0}><Check size={13} /></span><strong aria-live="polite">{selectedCount} {t(selectedCount === 1 ? "Zeile ausgewählt" : "Zeilen ausgewählt", selectedCount === 1 ? "line selected" : "lines selected")}</strong><button class="clear-selection" type="button" onclick={clearSelection} disabled={isBusy || selectedCount === 0}>{t("Auswahl aufheben", "Clear selection")}</button></div>
<div class="selection-actions"><button class="discard-selection" type="button" onclick={() => applySelected(staged ? "discard-staged" : "discard-unstaged")} disabled={isBusy || selectedCount === 0}>{t("Auswahl verwerfen", "Discard selected")}</button><button class="stage-selection" type="button" onclick={() => applySelected(staged ? "unstage" : "stage")} disabled={isBusy || selectedCount === 0}>{selectedCount} {t(selectedCount === 1 ? "Zeile" : "Zeilen", selectedCount === 1 ? "line" : "lines")} {staged ? t("unstagen", "to unstage") : t("stagen", "to stage")}</button></div>
</footer>
{/if}
</div>
</div>
<style>
.line-patch-dialog{width:min(1700px,100%);height:min(960px,100%);grid-template-rows:auto minmax(0,1fr) auto;font-size:13px}
.dialog-header{padding:12px 18px}.patch-identity{display:flex;align-items:center;gap:12px;min-width:0}.patch-identity>div{min-width:0}.patch-identity :global(svg){flex:none;color:var(--color-ink-muted)}.patch-identity .dialog-title{font-size:15px;line-height:1.4;margin:0;font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.patch-scope{display:block;color:var(--color-ink-muted);font-size:12px;margin-top:2px}
.dialog-header-actions{gap:8px}.dialog-header-actions .external-diff{display:flex;align-items:center;gap:8px;border:0;background:transparent;font-size:12px;color:var(--color-ink-muted);padding:5px 10px}.line-patch-dialog .icon-action{display:inline-flex;align-items:center;justify-content:center;flex:none;width:30px;min-width:30px;height:30px;min-height:30px;padding:0;border:1px solid var(--color-border);background:transparent;color:var(--color-ink-muted)}
.patch-toolbar{display:flex;align-items:center;gap:20px;padding:8px 18px;min-height:46px;border-bottom:1px solid var(--color-border-subtle);background:var(--app-dialog-bg)}.patch-toolbar strong{font-size:12px;font-weight:600}.range-hint{color:var(--color-ink-faint);font-size:12px}.patch-summary{display:flex;align-items:center;gap:10px;margin-left:auto;color:var(--color-ink-muted);font-size:12px;white-space:nowrap}.patch-summary .add-count{color:var(--code-add-text)}.patch-summary .delete-count{color:var(--code-delete-text);margin-right:10px}
.line-patch-scroll{position:relative}.line-patch-hunk{min-width:100%;width:max-content}.line-patch-hunk-head{width:var(--patch-visible-width, 100%);min-width:0;max-width:100%;height:44px;gap:14px;padding:6px 14px;background:var(--app-dialog-chrome)}.line-patch-hunk-head>strong{font-size:12px;font-weight:600;white-space:nowrap}.line-patch-hunk-head code{font-size:12px;color:var(--color-ink-faint);overflow:hidden;text-overflow:ellipsis;min-width:0}.line-patch-hunk-actions{margin-left:auto;padding-left:10px;gap:8px}.line-patch-hunk-button{display:inline-flex;align-items:center;gap:6px;min-height:28px;padding:4px 9px;font-size:12px;font-weight:500;background:transparent}.line-patch-hunk-button.discard{border-color:transparent}.line-patch-hunk-button.discard:hover:not(:disabled){border-color:transparent}
.line-patch-dialog .line-patch-select-hunk,.line-patch-dialog .line-patch-line-select{display:flex;align-items:center;justify-content:center;width:14px;min-width:14px;max-width:14px;height:14px;min-height:14px;max-height:14px;padding:0;line-height:1;border:1px solid var(--color-ink-faint);background:transparent;box-shadow:none;align-self:center;border-radius:2px!important;color:var(--app-bg);flex:none}
.line-patch-dialog .line-patch-select-hunk.all,.line-patch-dialog .line-patch-select-hunk.some,.line-patch-dialog .line-patch-line-select[aria-checked="true"]{background:var(--color-accent);border-color:var(--color-accent)}
.line-patch-lines{font-size:13px}.line-patch-row{grid-template-columns:28px 44px 44px 24px minmax(max-content,1fr);align-items:center;height:22px;min-height:22px;padding:0 14px;line-height:22px}.line-patch-row code{font-size:13px;line-height:22px}.line-patch-line-number{font-size:11px;line-height:22px}.line-patch-row.selected{box-shadow:inset 3px 0 0 var(--color-accent);filter:none}.line-patch-row.add.selected{background:color-mix(in srgb,var(--color-accent) 5%,var(--code-add-bg))}.line-patch-row.delete.selected{background:color-mix(in srgb,var(--color-accent) 5%,var(--code-delete-bg))}
.patch-footer{display:flex;align-items:center;justify-content:space-between;gap:16px;min-height:60px;padding:10px 18px;border-top:1px solid var(--color-border);background:var(--app-dialog-chrome)}.selection-summary,.selection-actions{display:flex;align-items:center;gap:12px}.selection-summary strong{font-size:12px;font-weight:600;white-space:nowrap}.selection-symbol{display:flex;width:16px;height:16px;align-items:center;justify-content:center;border:1px solid var(--color-border-input);color:var(--color-ink-faint)}.selection-symbol.has-selection{background:var(--color-accent);color:var(--app-bg);border-color:var(--color-accent)}.patch-footer button{min-height:30px;padding:5px 12px;font-size:12px;white-space:nowrap}.patch-footer .clear-selection{border:0;background:transparent;color:var(--color-ink-muted)}.patch-footer .discard-selection{border:1px solid color-mix(in srgb,var(--code-delete-text) 60%,var(--color-border));color:var(--code-delete-text);background:transparent}.patch-footer .stage-selection{background:var(--color-primary);border-color:var(--color-primary);color:white}.patch-footer .stage-selection:hover:not(:disabled){background:var(--color-primary-dark)}
@media(max-width:850px){.range-hint{display:none}.patch-toolbar{gap:10px}.patch-footer{flex-wrap:wrap}.selection-actions{margin-left:auto}.line-patch-hunk-head{gap:8px}.line-patch-hunk-head code{max-width:180px}.dialog-header-actions .external-diff{max-width:180px;overflow:hidden}}
@media(max-width:560px){.dialog-header{padding:10px}.patch-identity{gap:8px}.patch-identity .dialog-title{font-size:13px}.external-diff span{display:none}.dialog-header-actions .external-diff{width:30px;min-width:30px;height:30px;padding:0;justify-content:center;overflow:visible}.patch-toolbar{flex-wrap:wrap;padding:8px}.patch-summary{margin-left:0}.patch-footer{padding:8px}.selection-summary{gap:8px}.selection-actions{width:100%;justify-content:flex-end}.line-patch-hunk-head{height:auto;min-height:44px;flex-wrap:wrap}.line-patch-hunk-actions{width:100%;justify-content:flex-end}.line-patch-hunk-head code{max-width:160px}}
</style>
+119
View File
@@ -0,0 +1,119 @@
<script lang="ts">
import { onMount } from "svelte";
import { Check, GitMerge, LoaderCircle, X } from "@lucide/svelte";
import type { AppLanguage, GitBranch, MergeStrategy } from "../types";
interface Props {
branch: GitBranch;
currentBranch: string;
isBusy: boolean;
language: AppLanguage;
onMerge: (strategy: MergeStrategy) => void;
onClose: () => void;
}
let {
branch,
currentBranch,
isBusy = false,
language = "en",
onMerge = () => {},
onClose = () => {},
}: Props = $props();
let strategy = $state<MergeStrategy>("default");
const isGerman = $derived(language === "de");
const options = $derived([
{
value: "default" as const,
label: isGerman ? "Standard" : "Default",
description: isGerman ? "Git wählt Fast-forward oder erstellt einen Merge-Commit." : "Git chooses fast-forward or creates a merge commit.",
},
{
value: "squash" as const,
label: "Squash",
description: isGerman ? "Fasst alle Änderungen zu einem neuen Commit zusammen." : "Combines all changes into one new commit.",
},
{
value: "ff-only" as const,
label: "Fast-forward only",
description: isGerman ? "Bricht ab, wenn ein Merge-Commit erforderlich wäre." : "Stops if a merge commit would be required.",
},
{
value: "no-ff" as const,
label: "No fast-forward",
description: isGerman ? "Erstellt immer einen eigenen Merge-Commit." : "Always creates a dedicated merge commit.",
},
]);
function submit(event: SubmitEvent) {
event.preventDefault();
if (!isBusy) onMerge(strategy);
}
function handleKeydown(event: KeyboardEvent) {
if (event.key === "Escape" && !isBusy) onClose();
}
onMount(() => {
window.addEventListener("keydown", handleKeydown);
return () => window.removeEventListener("keydown", handleKeydown);
});
</script>
<div class="dialog-backdrop app-chrome-backdrop" role="presentation">
<div class="dialog merge-branch-dialog" role="dialog" aria-modal="true" aria-labelledby="merge-branch-title" tabindex="-1">
<header class="dialog-header merge-branch-header">
<div class="merge-branch-heading">
<span class="merge-branch-icon" aria-hidden="true"><GitMerge size={18} /></span>
<div>
<span class="eyebrow">{isGerman ? "Branches zusammenführen" : "Combine branches"}</span>
<h2 id="merge-branch-title">{isGerman ? "Merge konfigurieren" : "Configure merge"}</h2>
</div>
</div>
<button class="dialog-close" type="button" onclick={onClose} disabled={isBusy} title={isGerman ? "Schließen" : "Close"}>
<X size={18} aria-hidden="true" />
</button>
</header>
<form class="merge-branch-form" onsubmit={submit}>
<div class="merge-branch-route" aria-label={isGerman ? "Merge-Richtung" : "Merge direction"}>
<div>
<span>{isGerman ? "Quell-Branch" : "Source branch"}</span>
<strong>{branch.name}</strong>
</div>
<GitMerge size={18} aria-hidden="true" />
<div>
<span>{isGerman ? "In aktuellen Branch" : "Into current branch"}</span>
<strong>{currentBranch || "HEAD"}</strong>
</div>
</div>
<fieldset class="merge-strategy-fieldset" disabled={isBusy}>
<legend>{isGerman ? "Merge-Strategie" : "Merge strategy"}</legend>
<div class="merge-strategy-grid">
{#each options as option}
<label class:active={strategy === option.value} class="merge-strategy-option">
<input type="radio" name="merge-strategy" value={option.value} bind:group={strategy} />
<span class="merge-strategy-check" aria-hidden="true">
{#if strategy === option.value}<Check size={13} />{/if}
</span>
<span class="merge-strategy-copy">
<strong>{option.label}</strong>
<small>{option.description}</small>
</span>
</label>
{/each}
</div>
</fieldset>
<div class="merge-branch-actions">
<button class="btn-secondary" type="button" onclick={onClose} disabled={isBusy}>{isGerman ? "Abbrechen" : "Cancel"}</button>
<button class="btn-primary" type="submit" disabled={isBusy}>
{#if isBusy}<LoaderCircle class="spin" size={16} aria-hidden="true" />{:else}<GitMerge size={16} aria-hidden="true" />{/if}
{isGerman ? "Branch mergen" : "Merge branch"}
</button>
</div>
</form>
</div>
</div>
@@ -0,0 +1,30 @@
<script lang="ts">
import { Check, ChevronRight, GitMerge, X } from "@lucide/svelte";
let { conflictCount, isBusy, language, onResolve, onContinue, onAbort }: {
conflictCount: number; isBusy: boolean; language: "de" | "en";
onResolve: () => void; onContinue: () => void; onAbort: () => void;
} = $props();
const de = $derived(language === "de");
const hasConflicts = $derived(conflictCount > 0);
</script>
<section class="merge-progress" class:ready={!hasConflicts} role="status">
<span class="status-icon">{#if hasConflicts}<GitMerge size={18} />{:else}<Check size={18} />{/if}</span>
<div class="status-copy">
<strong>{hasConflicts ? (de ? "Merge läuft" : "Merge in progress") : (de ? "Bereit zum Abschließen" : "Ready to complete")}</strong>
<span>{hasConflicts ? `${conflictCount} ${de ? (conflictCount === 1 ? "Datei mit Konflikten" : "Dateien mit Konflikten") : (conflictCount === 1 ? "file with conflicts" : "files with conflicts")}` : (de ? "Alle Konflikte sind gelöst." : "All conflicts are resolved.")}</span>
</div>
<div class="merge-actions">
{#if hasConflicts}<button class="resolve-action" type="button" onclick={onResolve} disabled={isBusy}><GitMerge size={14} />{de ? "Konflikte lösen" : "Resolve conflicts"}</button>{/if}
<button class="continue-action" type="button" onclick={onContinue} disabled={isBusy || hasConflicts} title={hasConflicts ? (de ? "Zuerst alle Konflikte lösen" : "Resolve all conflicts first") : undefined}>{de ? "Fortsetzen" : "Continue"}<ChevronRight size={14} /></button>
<span class="action-divider" aria-hidden="true"></span>
<button class="abort-action" type="button" onclick={onAbort} disabled={isBusy}><X size={14} />{de ? "Abbrechen" : "Abort"}</button>
</div>
</section>
<style>
.merge-progress{--status-color:var(--color-sync-ahead);display:flex;align-items:center;gap:12px;flex-shrink:0;min-height:50px;padding:8px 14px;border-bottom:1px solid var(--color-border);border-left:3px solid var(--status-color);background:color-mix(in srgb,var(--status-color) 5%,var(--app-bg));color:var(--color-ink);font-size:13px}
.merge-progress.ready{--status-color:#68c878}.status-icon{display:flex;align-items:center;color:var(--status-color)}.status-copy{display:flex;align-items:center;gap:14px;min-width:0;flex-wrap:wrap}.status-copy strong{font-size:13px;font-weight:600;white-space:nowrap}.status-copy>span{color:var(--color-ink-muted);font-size:12px}
.merge-actions{display:flex;align-items:center;gap:8px;margin-left:auto;flex-shrink:0}.merge-actions button{display:inline-flex;align-items:center;justify-content:center;gap:7px;min-height:30px;padding:5px 10px;border:1px solid var(--color-border-input);background:transparent;color:var(--color-ink-muted);font-size:12px;font-weight:500;white-space:nowrap}.merge-actions button:hover:not(:disabled){background:var(--color-surface-hover);color:var(--color-ink)}.merge-actions button:disabled{opacity:.4}.merge-actions .resolve-action{border-color:color-mix(in srgb,var(--status-color) 45%,var(--color-border));background:color-mix(in srgb,var(--status-color) 10%,transparent);color:var(--status-color)}.ready .continue-action{color:var(--status-color);border-color:color-mix(in srgb,var(--status-color) 45%,var(--color-border));background:color-mix(in srgb,var(--status-color) 10%,transparent)}.action-divider{height:20px;width:1px;background:var(--color-border);margin:0 3px}.merge-actions .abort-action{border-color:transparent}.merge-actions .abort-action:hover:not(:disabled){color:#ef8080;background:color-mix(in srgb,#ef8080 8%,transparent)}
@media(max-width:700px){.merge-progress{flex-wrap:wrap;gap:8px}.status-copy{gap:8px}.merge-actions{width:100%;justify-content:flex-end}.merge-actions button{font-size:11px;padding:5px 8px}}
</style>
+53 -81
View File
@@ -33,7 +33,7 @@
<style>
.repo-loading {
position: fixed;
top: calc(var(--app-titlebar-height, 42px) + 56px);
top: calc(var(--app-titlebar-height, 40px) + var(--app-repo-tabbar-height, 36px));
right: 0;
bottom: 0;
left: 0;
@@ -41,17 +41,24 @@
display: grid;
place-items: center;
overflow: hidden;
background: linear-gradient(135deg, #070a10 0%, #0c111a 48%, #090e16 100%);
background:
radial-gradient(
circle at 50% 42%,
color-mix(in srgb, var(--color-accent) 13%, transparent),
transparent 38%
),
color-mix(in srgb, var(--app-dialog-bg) 78%, #05070a 22%);
backdrop-filter: blur(9px);
animation: overlay-in 180ms ease;
}
.repo-loading-grid {
position: absolute;
inset: 0;
opacity: 0.28;
opacity: 0.34;
background-image:
linear-gradient(rgba(111, 140, 255, 0.09) 1px, transparent 1px),
linear-gradient(90deg, rgba(77, 182, 214, 0.07) 1px, transparent 1px);
linear-gradient(color-mix(in srgb, var(--color-primary) 9%, transparent) 1px, transparent 1px),
linear-gradient(90deg, color-mix(in srgb, var(--color-accent) 8%, transparent) 1px, transparent 1px);
background-size: 42px 42px;
mask-image: radial-gradient(circle at center, black 0%, transparent 68%);
animation: grid-drift 10s linear infinite;
@@ -62,23 +69,21 @@
display: flex;
flex-direction: column;
align-items: center;
gap: 18px;
width: min(420px, calc(100vw - 42px));
padding: 30px 34px 28px;
border: 1px solid rgba(90, 111, 154, 0.28);
border-radius: 16px;
gap: 14px;
width: min(360px, calc(100vw - 32px));
padding: 26px 30px 24px;
border: 1px solid var(--color-border-input);
border-radius: 14px;
background:
linear-gradient(180deg, rgba(23, 29, 43, 0.92), rgba(12, 17, 27, 0.94)),
var(--color-surface-raised);
box-shadow:
0 26px 78px rgba(0, 0, 0, 0.54),
inset 0 1px 0 rgba(255, 255, 255, 0.05);
var(--app-panel-highlight),
var(--app-dialog-bg);
box-shadow: var(--app-dialog-shadow);
}
.repo-loading-mark {
position: relative;
width: 168px;
height: 168px;
width: 116px;
height: 116px;
display: grid;
place-items: center;
isolation: isolate;
@@ -86,25 +91,25 @@
.repo-loading-halo {
position: absolute;
inset: 10px;
border: 1px solid rgba(111, 140, 255, 0.22);
border-radius: 34px;
inset: 8px;
border: 1px solid color-mix(in srgb, var(--color-primary) 34%, transparent);
border-radius: 24px;
transform: rotate(45deg);
}
.repo-loading-halo.halo-one {
animation: halo-breathe 2.4s ease-in-out infinite;
}
.repo-loading-halo.halo-two {
inset: 24px;
border-color: rgba(77, 182, 214, 0.24);
inset: 18px;
border-color: color-mix(in srgb, var(--color-accent) 38%, transparent);
animation: halo-breathe 2.4s ease-in-out infinite reverse;
}
.repo-loading-traces {
position: absolute;
inset: -18px;
width: 204px;
height: 204px;
inset: -14px;
width: 144px;
height: 144px;
overflow: visible;
z-index: 0;
}
@@ -114,18 +119,18 @@
stroke-linecap: round;
stroke-dasharray: 165;
stroke-dashoffset: 165;
filter: drop-shadow(0 0 8px rgba(77, 182, 214, 0.32));
filter: drop-shadow(0 0 7px color-mix(in srgb, var(--color-accent) 38%, transparent));
animation: trace-draw 2.6s ease-in-out infinite;
}
.repo-loading-traces .trace-main {
stroke: #6f8cff;
stroke: var(--color-primary);
}
.repo-loading-traces .trace-branch {
stroke: #4db6d6;
stroke: var(--color-accent);
animation-delay: 0.28s;
}
.repo-loading-traces .trace-cut {
stroke: rgba(177, 186, 208, 0.42);
stroke: color-mix(in srgb, var(--color-ink-muted) 52%, transparent);
stroke-dasharray: 128;
stroke-dashoffset: 128;
animation-delay: 0.55s;
@@ -134,12 +139,12 @@
.repo-loading-icon {
position: relative;
z-index: 1;
width: 132px;
height: 132px;
width: 82px;
height: 82px;
object-fit: contain;
filter:
drop-shadow(0 18px 24px rgba(0, 0, 0, 0.55))
drop-shadow(0 0 18px rgba(77, 182, 214, 0.2));
drop-shadow(0 10px 16px color-mix(in srgb, var(--color-ink) 24%, transparent))
drop-shadow(0 0 14px color-mix(in srgb, var(--color-accent) 24%, transparent));
animation: icon-float 2.4s ease-in-out infinite;
}
@@ -153,16 +158,16 @@
.repo-loading-label {
color: var(--color-ink);
font-size: 15px;
font-weight: 800;
font-size: 14px;
font-weight: 750;
}
.repo-loading-name {
max-width: 260px;
max-width: 250px;
overflow: hidden;
color: var(--color-ink-faint);
font-family: var(--font-mono);
font-size: 12.5px;
font-size: 12px;
text-overflow: ellipsis;
white-space: nowrap;
}
@@ -172,60 +177,20 @@
width: min(230px, 100%);
height: 4px;
overflow: hidden;
border: 1px solid var(--color-border-subtle);
border-radius: 999px;
background: rgba(111, 140, 255, 0.14);
background: color-mix(in srgb, var(--color-primary) 12%, var(--color-surface-dim));
}
.repo-loading-bar span {
position: absolute;
inset: 0;
width: 46%;
border-radius: inherit;
background: linear-gradient(90deg, transparent, #6f8cff 40%, #4db6d6 74%, transparent);
box-shadow: 0 0 16px rgba(77, 182, 214, 0.26);
background: linear-gradient(90deg, transparent, var(--color-primary) 40%, var(--color-accent) 74%, transparent);
box-shadow: 0 0 14px color-mix(in srgb, var(--color-accent) 32%, transparent);
animation: bar-slide 1.35s ease-in-out infinite;
}
:global(:root[data-theme="light"]) .repo-loading {
background:
radial-gradient(circle at 50% 42%, rgba(49, 95, 214, 0.12), transparent 34%),
linear-gradient(135deg, #f7f9fd 0%, #eef3f9 48%, #f8fafc 100%);
}
:global(:root[data-theme="light"]) .repo-loading-grid {
opacity: 0.38;
background-image:
linear-gradient(rgba(49, 95, 214, 0.1) 1px, transparent 1px),
linear-gradient(90deg, rgba(15, 143, 181, 0.08) 1px, transparent 1px);
}
:global(:root[data-theme="light"]) .repo-loading-card {
border-color: rgba(61, 89, 142, 0.2);
background:
linear-gradient(180deg, rgba(255,255,255,0.96), rgba(244,247,252,0.94)),
var(--color-surface-raised);
box-shadow:
0 26px 72px rgba(28,44,74,0.18),
inset 0 1px 0 rgba(255,255,255,0.9);
}
:global(:root[data-theme="light"]) .repo-loading-traces .trace {
filter: drop-shadow(0 0 8px rgba(49,95,214,0.22));
}
:global(:root[data-theme="light"]) .repo-loading-traces .trace-cut {
stroke: rgba(49,95,214,0.34);
}
:global(:root[data-theme="light"]) .repo-loading-icon {
filter:
drop-shadow(0 18px 24px rgba(28,44,74,0.2))
drop-shadow(0 0 18px rgba(49,95,214,0.18));
}
:global(:root[data-theme="light"]) .repo-loading-bar {
background: rgba(49,95,214,0.12);
}
@keyframes overlay-in { from { opacity: 0; } to { opacity: 1; } }
@keyframes grid-drift { to { transform: translate3d(42px, 42px, 0); } }
@keyframes icon-float {
@@ -254,4 +219,11 @@
.repo-loading-bar span { animation: none; }
.repo-loading-traces .trace { stroke-dashoffset: 0; opacity: 0.72; }
}
@media (max-width: 520px) {
.repo-loading-card {
width: calc(100vw - 20px);
padding: 22px 20px 20px;
}
}
</style>
@@ -0,0 +1,479 @@
<script lang="ts">
import { ArrowDown, ArrowUp, Check, ChevronDown, ChevronRight, Circle, Download, FolderGit2, GitBranch, GitPullRequest, LayoutGrid, List, LoaderCircle, Plus, Search, Star, TriangleAlert, Trash2, X } from "@lucide/svelte";
import { listIntegrationReviewRequests, listRemotes } from "../git";
import { configuredIntegrationSources, integrationCredentialKey } from "../integrations";
import type { GitIntegrationSettings, GitIntegrationSource, IntegrationReviewRequest, StoredCredential } from "../types";
import SelectMenu from "./SelectMenu.svelte";
interface DashboardRepository {
path: string;
name: string;
branch: string | null;
ahead: number;
behind: number;
changed: number;
lastOpened: number;
known: boolean;
favorite: boolean;
isOpen: boolean;
recent: boolean;
}
interface Workspace { id: string; name: string; }
interface PullRequestBadgeState {
status: "loading" | "ready" | "error" | "unavailable";
count: number;
sourceId: string;
repository: string;
message: string;
}
interface RemoteIdentity { host: string; repository: string; organization: string; }
export let repos: DashboardRepository[] = [];
export let language: "de" | "en" = "en";
export let isBusy = false;
export let onOpen: (path: string) => unknown;
export let onClose: (path: string) => unknown;
export let onAdd: () => unknown;
export let onClone: () => unknown;
export let onInit: () => unknown;
export let onFavorite: (path: string) => unknown;
export let onRemoveRecent: (path: string) => unknown;
export let integrations: GitIntegrationSettings;
export let loadCredential: (key: string) => Promise<StoredCredential | null>;
export let onOpenPullRequests: (repository: string, sourceId: string) => unknown;
const STORAGE_KEY = "gitty.dashboard.v1";
let query = "";
let viewMode: "cards" | "list" = "list";
let selectedWorkspace = "";
let collapsedCategories = new Set<string>();
let workspaces: Workspace[] = [];
let assignments: Record<string, string> = {};
let workspaceDialog: HTMLDialogElement;
let workspaceInput: HTMLInputElement;
let workspaceName = "";
let workspaceError = "";
let workspaceSelection = new Set<string>();
let pullRequestBadges: Record<string, PullRequestBadgeState> = {};
let pullRequestLoadGeneration = 0;
let lastPullRequestLoadKey = "";
$: de = language === "de";
$: normalizedQuery = query.trim().toLocaleLowerCase();
$: filteredRepos = repos.filter((repo) => {
const matchesSearch = !normalizedQuery || `${repo.name} ${repo.path} ${repo.branch ?? ""}`.toLocaleLowerCase().includes(normalizedQuery);
return matchesSearch && (!selectedWorkspace || assignments[repo.path] === selectedWorkspace);
});
$: openRepos = filteredRepos.filter((repo) => repo.isOpen);
$: favoriteRepos = filteredRepos.filter((repo) => repo.favorite);
$: recentRepos = filteredRepos.filter((repo) => repo.recent && !repo.isOpen);
$: categories = [
{ id: "open", label: "Open", repos: openRepos },
{ id: "favorites", label: de ? "Favoriten" : "Favorites", repos: favoriteRepos },
{ id: "recent", label: "Recent", repos: recentRepos },
];
$: changedCount = repos.filter((repo) => repo.known && repo.changed > 0).length;
$: workspaceOptions = [
{ value: "", label: de ? "Alle Workspaces" : "All workspaces" },
...workspaces.map((workspace) => ({ value: workspace.id, label: workspace.name, group: "Workspaces" })),
];
$: integrationSources = configuredIntegrationSources(integrations);
$: pullRequestLoadKey = JSON.stringify({
repositories: repos.map((repo) => repo.path),
sources: integrationSources.map((source) => [source.id, source.baseUrl]),
});
$: if (pullRequestLoadKey !== lastPullRequestLoadKey) {
lastPullRequestLoadKey = pullRequestLoadKey;
void loadPullRequestBadges();
}
loadPreferences();
function loadPreferences() {
try {
const saved = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? "{}") as { workspaces?: unknown; assignments?: unknown; viewMode?: unknown };
viewMode = saved.viewMode === "cards" ? "cards" : "list";
if (Array.isArray(saved.workspaces)) {
workspaces = saved.workspaces.filter((item): item is Workspace => Boolean(
item && typeof item === "object"
&& "id" in item && typeof item.id === "string" && item.id.startsWith("workspace-")
&& "name" in item && typeof item.name === "string" && item.name.trim(),
));
}
if (saved.assignments && typeof saved.assignments === "object" && !Array.isArray(saved.assignments)) {
assignments = Object.fromEntries(Object.entries(saved.assignments).filter(([, workspaceId]) =>
typeof workspaceId === "string" && workspaces.some((workspace) => workspace.id === workspaceId)));
}
} catch {
workspaces = [];
assignments = {};
}
}
function savePreferences() {
try { localStorage.setItem(STORAGE_KEY, JSON.stringify({ workspaces, assignments, viewMode })); }
catch { /* Preferences remain optional when storage is unavailable. */ }
}
function setViewMode(mode: "cards" | "list") {
viewMode = mode;
savePreferences();
}
function openWorkspaceDialog() {
workspaceName = "";
workspaceError = "";
workspaceSelection = new Set();
workspaceDialog.showModal();
requestAnimationFrame(() => workspaceInput.focus());
}
function toggleWorkspaceRepo(path: string) {
const next = new Set(workspaceSelection);
if (next.has(path)) next.delete(path);
else next.add(path);
workspaceSelection = next;
}
function createWorkspace(event: SubmitEvent) {
event.preventDefault();
const name = workspaceName.trim();
if (!name) {
workspaceError = de ? "Bitte einen Namen eingeben." : "Please enter a name.";
return;
}
if (workspaces.some((workspace) => workspace.name.toLocaleLowerCase() === name.toLocaleLowerCase())) {
workspaceError = de ? "Dieser Workspace existiert bereits." : "This workspace already exists.";
return;
}
const id = `workspace-${crypto.randomUUID()}`;
workspaces = [...workspaces, { id, name }];
assignments = { ...assignments, ...Object.fromEntries([...workspaceSelection].map((path) => [path, id])) };
selectedWorkspace = id;
savePreferences();
workspaceDialog.close();
}
function deleteSelectedWorkspace() {
if (!selectedWorkspace) return;
const deletedWorkspace = selectedWorkspace;
workspaces = workspaces.filter((workspace) => workspace.id !== deletedWorkspace);
assignments = Object.fromEntries(Object.entries(assignments).filter(([, workspaceId]) => workspaceId !== deletedWorkspace));
selectedWorkspace = "";
savePreferences();
}
function openCard(path: string) { if (!isBusy) onOpen(path); }
function closeCard(event: MouseEvent, path: string) { event.stopPropagation(); if (!isBusy) onClose(path); }
function favoriteCard(event: MouseEvent, path: string) { event.stopPropagation(); if (!isBusy) onFavorite(path); }
function removeRecentCard(event: MouseEvent, path: string) { event.stopPropagation(); if (!isBusy) onRemoveRecent(path); }
function openPullRequests(event: MouseEvent, repo: DashboardRepository) {
event.stopPropagation();
const badge = pullRequestBadges[repo.path];
if (!isBusy && badge?.status === "ready") onOpenPullRequests(badge.repository || repo.name, badge.sourceId);
}
function remoteIdentity(value: string): RemoteIdentity | null {
const trimmed = value.trim();
if (!trimmed) return null;
let host = "";
let path = "";
const scp = trimmed.match(/^[^@\s]+@([^:\s]+):(.+)$/);
if (scp) {
host = scp[1];
path = scp[2];
} else {
try {
const url = new URL(trimmed.includes("://") ? trimmed : `https://${trimmed}`);
host = url.hostname;
path = url.pathname;
} catch {
return null;
}
}
const parts = path.replace(/^\/+|\/+$/g, "").replace(/\.git$/i, "").split("/").filter(Boolean);
const gitIndex = parts.findIndex((part) => part.toLocaleLowerCase() === "_git");
const normalizedHost = host.toLocaleLowerCase();
if (gitIndex > 0 && parts[gitIndex + 1]) return { host: normalizedHost, repository: `${parts[gitIndex - 1]}/${parts[gitIndex + 1]}`.toLocaleLowerCase(), organization: normalizedHost === "dev.azure.com" ? (parts[0] ?? "").toLocaleLowerCase() : "" };
if (parts[0]?.toLocaleLowerCase() === "v3" && parts.length >= 4) return { host: normalizedHost, repository: `${parts[2]}/${parts[3]}`.toLocaleLowerCase(), organization: parts[1].toLocaleLowerCase() };
return { host: normalizedHost, repository: parts.join("/").toLocaleLowerCase(), organization: "" };
}
function sourceMatchesRemote(source: GitIntegrationSource, identity: RemoteIdentity): boolean {
try {
const sourceUrl = new URL(source.baseUrl);
const sourceHost = sourceUrl.hostname.toLocaleLowerCase();
const remoteHost = identity.host;
if (source.provider === "azure-devops" ? sourceHost.replace(/^ssh\./, "") !== remoteHost.replace(/^ssh\./, "") : sourceHost !== remoteHost) return false;
if (source.provider !== "azure-devops") return true;
const organization = sourceUrl.hostname.toLocaleLowerCase() === "dev.azure.com"
? sourceUrl.pathname.split("/").filter(Boolean)[0]?.toLocaleLowerCase() ?? ""
: sourceUrl.hostname.split(".")[0]?.toLocaleLowerCase() ?? "";
return !organization || !identity.organization || organization === identity.organization;
} catch {
return false;
}
}
function requestMatchesRepository(request: IntegrationReviewRequest, repository: string): boolean {
const requestRepository = request.repositoryName.replace(/^\/+|\/+$/g, "").replace(/\.git$/i, "").toLocaleLowerCase();
if (!requestRepository) return false;
if (requestRepository === repository || repository.endsWith(`/${requestRepository}`) || requestRepository.endsWith(`/${repository}`)) return true;
const requestParts = requestRepository.split("/");
const remoteParts = repository.split("/");
return requestParts[requestParts.length - 1] === remoteParts[remoteParts.length - 1];
}
function updatePullRequestBadge(path: string, state: PullRequestBadgeState, generation: number) {
if (generation !== pullRequestLoadGeneration) return;
pullRequestBadges = { ...pullRequestBadges, [path]: state };
}
function withTimeout<T>(promise: Promise<T>, timeoutMs: number, message: string): Promise<T> {
return new Promise<T>((resolve, reject) => {
const timer = window.setTimeout(() => reject(new Error(message)), timeoutMs);
promise.then((value) => { window.clearTimeout(timer); resolve(value); }, (error) => { window.clearTimeout(timer); reject(error); });
});
}
async function loadPullRequestBadges() {
const generation = ++pullRequestLoadGeneration;
const sources = integrationSources;
pullRequestBadges = Object.fromEntries(repos.map((repo) => [repo.path, {
status: sources.length ? "loading" : "unavailable",
count: 0,
sourceId: "",
repository: "",
message: sources.length ? "" : (de ? "Keine passende Integration eingerichtet." : "No matching integration configured."),
} satisfies PullRequestBadgeState]));
if (!repos.length || !sources.length) return;
const grouped = new Map<string, Array<{ repo: DashboardRepository; identity: RemoteIdentity }>>();
await Promise.all(repos.map(async (repo) => {
try {
const remotes = await listRemotes(repo.path);
const identities = remotes
.sort((left, right) => Number(right.name === "origin") - Number(left.name === "origin"))
.map((remote) => remoteIdentity(remote.fetch_url))
.filter((identity): identity is RemoteIdentity => Boolean(identity));
const match = identities.map((identity) => ({ identity, source: sources.find((source) => sourceMatchesRemote(source, identity)) })).find((item) => item.source);
if (!match?.source) {
updatePullRequestBadge(repo.path, { status: "unavailable", count: 0, sourceId: "", repository: "", message: de ? "Keine passende Integration für das Remote gefunden." : "No matching integration for this remote." }, generation);
return;
}
grouped.set(match.source.id, [...(grouped.get(match.source.id) ?? []), { repo, identity: match.identity }]);
} catch (error) {
updatePullRequestBadge(repo.path, { status: "error", count: 0, sourceId: "", repository: "", message: error instanceof Error ? error.message : String(error) }, generation);
}
}));
await Promise.all([...grouped.entries()].map(async ([sourceId, entries]) => {
const source = sources.find((candidate) => candidate.id === sourceId);
if (!source) return;
try {
const credential = await withTimeout(loadCredential(integrationCredentialKey(source.provider, source.accountId)), 15_000, de ? "Der System-Schlüsselbund hat nicht geantwortet." : "The system keychain did not respond.");
if (!credential?.password) throw new Error(de ? "Kein Integrationstoken gespeichert." : "No integration token stored.");
const requests = await withTimeout(listIntegrationReviewRequests(source.provider, source.baseUrl, credential.username, credential.password, "open"), 46_000, de ? `${source.label} hat nicht geantwortet.` : `${source.label} did not respond.`);
for (const { repo, identity } of entries) {
updatePullRequestBadge(repo.path, {
status: "ready",
count: requests.filter((request) => requestMatchesRepository(request, identity.repository)).length,
sourceId,
repository: identity.repository,
message: "",
}, generation);
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
for (const { repo, identity } of entries) updatePullRequestBadge(repo.path, { status: "error", count: 0, sourceId, repository: identity.repository, message }, generation);
}
}));
}
function pullRequestTitle(badge: PullRequestBadgeState | undefined): string {
if (!badge || badge.status === "loading") return de ? "Offene Pull Requests werden geladen" : "Loading open pull requests";
if (badge.status === "error" || badge.status === "unavailable") return badge.message;
return de ? `${badge.count} offene Pull Requests` : `${badge.count} open pull requests`;
}
function toggleCategory(id: string) {
const next = new Set(collapsedCategories);
if (next.has(id)) next.delete(id);
else next.add(id);
collapsedCategories = next;
}
</script>
<section class="repo-dashboard" aria-label={de ? "Repository-Verwaltung" : "Repository management"}>
<header class="dashboard-header">
<h1>Repository Management</h1>
<div class="header-actions">
<button type="button" onclick={onClone} disabled={isBusy}><Download size={14} />{de ? "Klonen" : "Clone"}</button>
<button type="button" onclick={onInit} disabled={isBusy}><FolderGit2 size={14} />{de ? "Initialisieren" : "Initialize"}</button>
<button class="primary" type="button" onclick={onAdd} disabled={isBusy}><Plus size={14} />Repository</button>
</div>
</header>
<div class="dashboard-toolbar">
<label class="dashboard-search">
<Search size={14} />
<input bind:value={query} autocomplete="off" spellcheck="false" placeholder={de ? "Repositories suchen …" : "Search repositories …"} aria-label={de ? "Repositories suchen" : "Search repositories"} />
</label>
<div class="view-switch" role="group" aria-label={de ? "Repository-Ansicht" : "Repository view"}>
<button type="button" class:active={viewMode === "list"} aria-pressed={viewMode === "list"} aria-label={de ? "Listenansicht" : "List view"} title={de ? "Listenansicht" : "List view"} onclick={() => setViewMode("list")}><List size={16} /></button>
<button type="button" class:active={viewMode === "cards"} aria-pressed={viewMode === "cards"} aria-label={de ? "Kachelansicht" : "Card view"} title={de ? "Kachelansicht" : "Card view"} onclick={() => setViewMode("cards")}><LayoutGrid size={15} /></button>
</div>
<div class="workspace-tools">
<SelectMenu
class="workspace-select"
value={selectedWorkspace}
options={workspaceOptions}
ariaLabel={de ? "Workspace auswählen" : "Select workspace"}
onChange={(value) => { selectedWorkspace = value; }}
/>
{#if selectedWorkspace}
<button class="workspace-delete" type="button" onclick={deleteSelectedWorkspace} aria-label={de ? "Ausgewählten Workspace löschen" : "Delete selected workspace"} title={de ? "Workspace löschen" : "Delete workspace"}><Trash2 size={15} strokeWidth={2} /></button>
{/if}
<button type="button" onclick={openWorkspaceDialog}><Plus size={14} />Workspace</button>
</div>
</div>
<div class="dashboard-summary">{repos.length} Repositories <span>·</span> {changedCount} {de ? "mit Änderungen" : "with changes"}</div>
<div class="dashboard-content" class:table-view={viewMode === "list"}>
{#each categories as category (category.id)}
<section class="dashboard-section">
<header class="section-header">
<button class="section-toggle" type="button" aria-expanded={!collapsedCategories.has(category.id)} onclick={() => toggleCategory(category.id)}>
{#if collapsedCategories.has(category.id)}<ChevronRight size={14} />{:else}<ChevronDown size={14} />{/if}
<strong>{category.label}</strong><span>{category.repos.length}</span><i></i>
</button>
</header>
{#if !collapsedCategories.has(category.id)}
{#if category.repos.length > 0}
<div class="repo-grid" class:list-view={viewMode === "list"} class:tiles-view={viewMode === "cards"}>
{#each category.repos as repo (repo.path)}
{@const pullRequests = pullRequestBadges[repo.path]}
<article class="repo-card" class:open={repo.isOpen} class:favorite-category={category.id === "favorites"}>
<button class="card-main" type="button" onclick={() => openCard(repo.path)} disabled={isBusy}>
<span class="card-title"><FolderGit2 size={15} /><strong title={repo.path}>{repo.name}</strong></span>
<span class="card-branch" title={repo.branch ?? undefined}><GitBranch size={13} />{repo.branch ?? (de ? "Branch unbekannt" : "Unknown branch")}</span>
<span class="card-status">
{#if !repo.known}<span class="muted"><Circle class="filled" size={9} />{de ? "Status nicht geladen" : "Status not loaded"}</span>
{:else if repo.changed > 0}<span class="changed"><Circle class="filled" size={9} />{repo.changed} {de ? (repo.changed === 1 ? "Änderung" : "Änderungen") : (repo.changed === 1 ? "change" : "changes")}</span>
{:else}<span class="clean"><Circle class="filled" size={9} />{de ? "Sauber" : "Clean"}</span>{/if}
</span>
<span class="list-sync">{#if repo.known}<span class:ahead={repo.ahead > 0} title={de ? `${repo.ahead} Commits voraus` : `${repo.ahead} commits ahead`}><ArrowUp size={14} />{repo.ahead}</span><span class:behind={repo.behind > 0} title={de ? `${repo.behind} Commits zurück` : `${repo.behind} commits behind`}><ArrowDown size={14} />{repo.behind}</span>{:else}<span class="muted"></span>{#if viewMode === "cards"}<span class="muted"></span>{/if}{/if}</span>
</button>
<div class="card-actions">
<button class:has-open-prs={pullRequests?.status === "ready" && pullRequests.count > 0} class:pr-error={pullRequests?.status === "error"} class="pr-badge" type="button" onclick={(event) => openPullRequests(event, repo)} disabled={isBusy || !pullRequests || pullRequests.status !== "ready"} aria-label={`${repo.name}: ${pullRequestTitle(pullRequests)}`} title={pullRequestTitle(pullRequests)}>
{#if !pullRequests || pullRequests.status === "loading"}<LoaderCircle class="spin" size={14} />{:else if pullRequests.status === "error"}<TriangleAlert size={14} />{:else}<GitPullRequest size={14} /><span>{pullRequests.status === "ready" ? pullRequests.count : ""}</span>{/if}
</button>
<button class:active={repo.favorite} type="button" onclick={(event) => favoriteCard(event, repo.path)} disabled={isBusy} aria-label={`${repo.name}: ${de ? "Favorit umschalten" : "Toggle favorite"}`} aria-pressed={repo.favorite}><Star size={15} /></button>
{#if repo.isOpen && category.id === "open"}<button type="button" onclick={(event) => closeCard(event, repo.path)} disabled={isBusy} aria-label={`${repo.name}: ${de ? "Schließen" : "Close"}`} title={de ? "Repository schließen" : "Close repository"}><X size={15} /></button>{/if}
{#if category.id === "recent"}<button type="button" onclick={(event) => removeRecentCard(event, repo.path)} disabled={isBusy} aria-label={`${repo.name}: ${de ? "Aus Recent entfernen" : "Remove from recent"}`} title={de ? "Aus Recent entfernen" : "Remove from recent"}><X size={15} /></button>{/if}
</div>
</article>
{/each}
</div>
{:else}
<p class="category-empty">{de ? "Keine Repositories in dieser Kategorie." : "No repositories in this category."}</p>
{/if}
{/if}
</section>
{/each}
</div>
<footer class="dashboard-footer">{repos.length} {de ? "lokale Repositories" : "local repositories"}</footer>
</section>
<dialog class="workspace-dialog" bind:this={workspaceDialog} aria-labelledby="workspace-title">
<form onsubmit={createWorkspace}>
<header><h2 id="workspace-title">{de ? "Workspace anlegen" : "Create workspace"}</h2><button type="button" onclick={() => workspaceDialog.close()} aria-label={de ? "Dialog schließen" : "Close dialog"}><X size={14} /></button></header>
<label for="workspace-name">Name</label>
<input id="workspace-name" bind:this={workspaceInput} bind:value={workspaceName} maxlength="64" autocomplete="off" aria-invalid={Boolean(workspaceError)} />
{#if workspaceError}<p class="dialog-error" role="alert">{workspaceError}</p>{/if}
{#if repos.length > 0}
<fieldset><legend>{de ? "Repositories zuordnen" : "Assign repositories"}</legend><div class="workspace-repos">
{#each repos as repo (repo.path)}<label><input type="checkbox" checked={workspaceSelection.has(repo.path)} onchange={() => toggleWorkspaceRepo(repo.path)} /><span>{repo.name}</span><small>{repo.path}</small></label>{/each}
</div></fieldset>
{/if}
<footer><button type="button" onclick={() => workspaceDialog.close()}>{de ? "Abbrechen" : "Cancel"}</button><button class="primary" type="submit"><Check size={14} />{de ? "Anlegen" : "Create"}</button></footer>
</form>
</dialog>
<style>
.repo-dashboard{display:flex;flex:1;min-height:0;flex-direction:column;overflow:hidden;color:var(--color-ink);background:var(--app-bg);font-size:12px}button,input{font:inherit}button{color:inherit;cursor:pointer}button:disabled{cursor:default;opacity:.5}
.dashboard-header{display:flex;min-height:58px;align-items:center;justify-content:space-between;gap:16px;padding:8px 20px;border-bottom:1px solid var(--color-border-subtle)}.dashboard-header h1{margin:0;font-size:18px;line-height:1.2;font-weight:700}.header-actions,.workspace-tools{display:flex;align-items:center;gap:8px}.header-actions button,.workspace-tools button{display:inline-flex;min-height:30px;align-items:center;gap:6px;padding:0 10px;border:1px solid var(--color-border);background:var(--app-button-bg)}button.primary{border-color:var(--color-primary);background:var(--color-primary);color:#fff}
.dashboard-toolbar{display:flex;min-height:48px;align-items:center;gap:8px;padding:8px 20px;border-bottom:1px solid var(--color-border-subtle)}.dashboard-search{display:flex;width:300px;height:30px;align-items:center;gap:8px;padding:0 9px;border:1px solid var(--color-border-input);border-radius:var(--ui-radius-sm);color:var(--color-ink-faint);background:var(--app-input-bg)}.dashboard-search:focus-within{border-color:var(--color-accent);box-shadow:0 0 0 2px color-mix(in srgb,var(--color-primary) 14%,transparent)}.dashboard-search :global(svg){flex:0 0 auto}.dashboard-search input{width:100%;min-width:0;height:100%;padding:0;border:0;border-radius:0;outline:0;color:var(--color-ink);background:transparent;box-shadow:none}.dashboard-search input:focus,.dashboard-search input:focus-visible{border:0;outline:0;box-shadow:none}.workspace-tools{margin-left:auto}.workspace-tools :global(.workspace-select){width:240px}.workspace-tools :global(.workspace-select .select-menu-trigger){height:30px;min-height:30px;padding:0 9px;border-color:var(--color-border-input);background:var(--app-input-bg);font-size:12px;font-weight:500}.workspace-tools .workspace-delete{width:30px;min-width:30px;padding:0;border-color:color-mix(in srgb,#df626b 55%,var(--color-border));color:#df747b;background:color-mix(in srgb,#c92f3a 8%,var(--app-button-bg))}.workspace-tools .workspace-delete:hover:not(:disabled){border-color:#df626b;color:#fff;background:#c93b45;box-shadow:0 0 0 2px color-mix(in srgb,#c92f3a 18%,transparent)}
.dashboard-summary{padding:8px 20px;color:var(--color-ink-muted)}.dashboard-summary span{padding:0 6px;color:var(--color-ink-faint)}.dashboard-content{flex:1;min-height:0;overflow:auto;padding:0 20px 24px}.dashboard-section+.dashboard-section{margin-top:20px}.section-header{min-height:31px;margin-bottom:6px}.section-toggle{display:flex;width:100%;min-height:31px;align-items:center;justify-content:flex-start;gap:7px;padding:0;border:0;background:transparent;color:var(--color-ink);text-align:left}.section-toggle:hover:not(:disabled){border:0;background:transparent;color:var(--color-ink)}.section-toggle :global(svg){flex:0 0 auto;color:var(--color-ink-muted)}.section-toggle strong{font-size:13px;line-height:1.2;font-weight:750}.section-toggle span{color:var(--color-ink-faint);font-weight:400}.section-toggle i{height:1px;flex:1;background:var(--color-border-subtle)}.category-empty{margin:0;padding:9px 12px;border:1px solid var(--color-border-subtle);color:var(--color-ink-faint);background:var(--color-surface)}
.repo-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:10px}.repo-card{position:relative;min-width:0;height:138px;border:1px solid var(--color-border);background:var(--color-surface)}.repo-card:hover,.repo-card:focus-within{border-color:var(--color-border-input);background:var(--color-surface-hover)}.card-main{display:flex;width:100%;height:100%;flex-direction:column;align-items:flex-start;gap:7px;padding:12px 104px 11px 14px;border:0;background:transparent;text-align:left}.card-main:focus-visible{outline:2px solid var(--color-accent);outline-offset:-2px}.card-title{display:flex;max-width:100%;align-items:center;gap:8px}.card-title strong{overflow:hidden;font-size:14px;font-weight:700;text-overflow:ellipsis;white-space:nowrap}.card-branch,.card-status,.card-status>span{display:flex;align-items:center;gap:6px}.card-branch{max-width:100%;overflow:hidden;color:var(--color-ink-muted);text-overflow:ellipsis;white-space:nowrap}.card-status{flex-wrap:wrap;gap:8px 18px;margin-top:auto}.card-status :global(.filled){fill:currentColor}.changed{color:#eeb94e}.behind{color:var(--color-sync-behind)}.clean{color:#68c878}.ahead{color:var(--color-sync-ahead)}.muted{color:var(--color-ink-muted)}
.card-actions{position:absolute;top:8px;right:8px;z-index:2;display:flex;align-items:center;gap:2px}.card-actions button{display:grid;width:26px;height:26px;place-items:center;padding:0;border:0;color:var(--color-ink-muted);background:transparent}.card-actions button:hover:not(:disabled){color:var(--color-ink);background:var(--color-surface-dim)}.card-actions button.active{color:var(--color-accent)}.card-actions button.active :global(svg){fill:color-mix(in srgb,var(--color-accent) 22%,transparent)}.card-actions .pr-badge{display:flex;width:auto;min-width:31px;grid-template-columns:none;align-items:center;justify-content:center;gap:4px;padding:0 5px;color:var(--color-ink-faint);font-size:11px}.card-actions .pr-badge:disabled{opacity:.72}.card-actions .pr-badge.has-open-prs{color:var(--color-accent)}.card-actions .pr-badge.pr-error{color:#e0a35b}.card-actions .pr-badge:hover:not(:disabled){color:var(--color-accent);background:color-mix(in srgb,var(--color-accent) 9%,transparent)}
.dashboard-footer{display:flex;min-height:30px;align-items:center;padding:0 20px;border-top:1px solid var(--color-border-subtle);color:var(--color-ink-muted)}
.workspace-dialog{width:min(420px,calc(100vw - 32px));margin:auto;padding:0;border:1px solid var(--color-border);color:var(--color-ink);background:var(--app-dialog-bg);box-shadow:var(--app-dialog-shadow);font-size:12px}.workspace-dialog::backdrop{background:var(--app-dialog-backdrop)}.workspace-dialog form>header,.workspace-dialog form>footer{display:flex;align-items:center;justify-content:space-between;padding:10px 12px;background:var(--app-dialog-chrome)}.workspace-dialog form>header{border-bottom:1px solid var(--color-border)}.workspace-dialog form>footer{justify-content:flex-end;gap:8px;border-top:1px solid var(--color-border)}.workspace-dialog h2{margin:0;font-size:13px}.workspace-dialog header button{width:26px;height:26px;padding:0;border:0;background:transparent}.workspace-dialog form>label,.workspace-dialog form>input,.workspace-dialog fieldset,.dialog-error{margin-right:12px;margin-left:12px}.workspace-dialog form>label{display:block;margin-top:12px;margin-bottom:5px;color:var(--color-ink-muted)}.workspace-dialog form>input{width:calc(100% - 24px);height:30px;padding:0 8px;border:1px solid var(--color-border-input);color:var(--color-ink);background:var(--app-input-bg)}.dialog-error{margin-top:7px;color:#ed9292}.workspace-dialog fieldset{margin-top:14px;margin-bottom:14px;padding:0;border:0}.workspace-dialog legend{margin-bottom:6px;color:var(--color-ink-muted)}.workspace-repos{max-height:190px;overflow:auto;border:1px solid var(--color-border-subtle)}.workspace-repos label{display:grid;grid-template-columns:auto minmax(100px,auto) 1fr;align-items:center;gap:8px;min-height:32px;padding:4px 8px;border-bottom:1px solid var(--color-border-subtle)}.workspace-repos label:last-child{border-bottom:0}.workspace-repos small{overflow:hidden;color:var(--color-ink-faint);text-overflow:ellipsis;white-space:nowrap}.workspace-dialog footer button{display:inline-flex;min-height:28px;align-items:center;gap:6px;padding:0 10px;border:1px solid var(--color-border);background:var(--app-button-bg)}
@media(max-width:1000px){.dashboard-toolbar{flex-wrap:wrap;gap:8px}.dashboard-search{flex:1 1 260px}.workspace-tools{margin-left:0}.repo-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}
@media(max-width:680px){.dashboard-header{min-height:auto;align-items:flex-start;flex-direction:column}.header-actions{flex-wrap:wrap}.workspace-tools{width:100%}.workspace-tools :global(.workspace-select){min-width:0;flex:1}.repo-grid{grid-template-columns:1fr}.repo-card{height:132px}}
.view-switch{display:flex;flex:0 0 auto;height:30px;border:1px solid var(--color-border-input);background:var(--app-input-bg)}
.view-switch button{display:grid;width:32px;min-height:0;height:28px;place-items:center;padding:0;border:0;border-radius:0;color:var(--color-ink-muted);background:transparent}
.view-switch button+button{border-left:1px solid var(--color-border-subtle)}
.view-switch button.active{color:var(--color-accent);background:color-mix(in srgb,var(--color-accent) 12%,var(--app-input-bg));box-shadow:inset 0 -2px var(--color-accent)}
.view-switch button:hover{color:var(--color-ink);background:var(--color-surface-hover)}
.view-switch button:focus-visible{outline:2px solid var(--color-accent);outline-offset:2px}
/* Approved list design: shared columns across every category. */
.table-view{--repo-list-columns:minmax(180px,1.8fr) minmax(140px,1fr) minmax(150px,1fr) minmax(110px,.75fr)}
.table-view .repo-card{display:grid;grid-template-columns:minmax(0,1fr) minmax(170px,22%)}
.list-view .card-main{display:grid;grid-template-columns:var(--repo-list-columns);gap:16px;padding:0 18px;align-items:center}
.repo-grid.list-view{display:block;border:0}
.table-view .repo-card{height:auto;min-height:48px;border:0;border-bottom:1px solid var(--color-border-subtle);background:transparent}
.table-view .repo-card:hover,.table-view .repo-card:focus-within{background:color-mix(in srgb,var(--color-accent) 5%,var(--color-surface))}
.table-view .repo-card:focus-within{box-shadow:inset 2px 0 var(--color-accent)}
.list-view .card-main{height:auto;min-height:48px;min-width:0;justify-content:stretch;align-content:center;text-align:left}
.list-view .card-title{min-width:0;gap:9px}.list-view .card-title strong{font-size:12px;font-weight:500}.list-view .card-title :global(svg){flex:none}
.list-view .card-branch{min-width:0;font-size:12px}.list-view .card-branch :global(svg){flex:none}
.list-view .card-status{margin:0;gap:5px;font-size:12px;flex-wrap:nowrap;white-space:nowrap}.list-view .card-status .muted{font-style:italic}
.list-sync{display:flex;align-items:center;gap:18px;color:var(--color-ink-faint);font-size:12px}.list-sync>span{display:flex;align-items:center;gap:5px}
.list-view .card-actions{position:static;display:grid;grid-template-columns:minmax(60px,1fr) 40px 40px;gap:0;align-items:center;margin:0}
.list-view .card-actions .pr-badge{justify-self:start;justify-content:flex-start;padding:0;gap:8px;color:var(--color-accent)}
.list-view .pr-badge span{min-width:18px;padding:1px 4px;border:1px solid var(--color-border);border-radius:2px;color:var(--color-ink-muted);font-size:11px;text-align:center}
.list-view .card-actions button{height:28px;width:28px}.list-view .card-actions button :global(svg){width:16px;height:16px}
.list-view .card-actions button.active :global(svg){fill:var(--color-accent)}
.table-view .category-empty{border:0;border-bottom:1px solid var(--color-border-subtle);background:transparent}
.list-view .card-actions .pr-badge{width:auto;min-width:60px}.list-view .card-actions .pr-badge :global(svg){flex:0 0 16px}
/* Approved tile layout: repository identity above a shared status footer. */
.tiles-view{gap:12px}
.tiles-view .repo-card{height:150px;border-radius:3px;background:color-mix(in srgb,var(--color-surface) 35%,var(--app-bg))}
.tiles-view .repo-card:focus-within{box-shadow:inset 3px 0 var(--color-accent)}
.tiles-view .card-main{display:grid;grid-template-columns:minmax(0,1fr) 104px 42px;grid-template-rows:24px 24px 1fr 30px;gap:0 10px;padding:16px 16px 10px;align-items:center;align-content:stretch}
.tiles-view .card-main:after{position:absolute;left:16px;right:16px;bottom:51px;height:1px;background:var(--color-border);content:"";pointer-events:none}
.tiles-view .card-title{grid-column:1/-1;grid-row:1;min-width:0;padding:0 60px 0 48px;max-width:100%;display:block}
.tiles-view .card-title strong{display:block;font-size:13px;font-weight:650;line-height:22px}
.tiles-view .card-title>:global(svg){position:absolute;top:21px;left:18px;width:32px;height:32px;color:var(--color-ink-muted);stroke-width:1.4}
.tiles-view .card-branch{grid-column:1/-1;grid-row:2;padding-left:48px;min-width:0;display:block;overflow:hidden;text-overflow:ellipsis;font-size:11px;line-height:22px}
.tiles-view .card-branch>:global(svg){display:inline-block;margin-right:6px;vertical-align:-2px}
.tiles-view .card-status{grid-column:1;grid-row:4;min-width:0;flex-wrap:nowrap;margin:0;font-size:11px;gap:0;white-space:nowrap}
.tiles-view .card-status>span{min-width:0;overflow:hidden;text-overflow:ellipsis}
.tiles-view .list-sync{grid-column:2;grid-row:4;gap:16px;font-size:11px}
.tiles-view .card-actions{position:static;display:contents}
.tiles-view .card-actions button{position:absolute;top:14px;right:14px;width:26px;height:26px}
.tiles-view .card-actions button[aria-pressed]{right:44px}
.tiles-view .card-actions .pr-badge{top:auto;bottom:12px;right:14px;width:42px;min-width:0;height:26px;gap:6px;padding:0;font-size:12px;color:var(--color-ink-muted)}
.tiles-view .card-actions .pr-badge.has-open-prs{color:var(--color-accent)}
.tiles-view .card-actions .pr-badge.pr-error{color:#e0a35b}
.tiles-view .card-actions .pr-badge>:global(svg){width:16px;height:16px;flex:none}
.tiles-view .card-actions button.active>:global(svg){fill:var(--color-accent)}
.table-view{--repo-list-columns:minmax(180px,1.8fr) minmax(140px,1fr) minmax(150px,1fr) minmax(110px,.75fr)}
.table-view .dashboard-section{min-width:900px}
.list-view .card-title strong,.list-view .card-branch,.list-view .card-status{font-size:13px}
.list-view .card-actions .pr-badge.pr-error{color:#e0a35b}
.list-view .card-actions .pr-badge:disabled{color:var(--color-ink-faint)}
@media(min-width:1001px) and (max-width:1150px){.tiles-view{grid-template-columns:repeat(2,minmax(0,1fr))}}
@media(max-width:420px){.tiles-view .card-main{grid-template-columns:minmax(0,1fr) 82px 34px;gap:0 6px;padding-right:12px;padding-left:12px}.tiles-view .list-sync{gap:8px}.tiles-view .card-title{padding-left:42px}.tiles-view .card-branch{padding-left:42px}.tiles-view .card-title>:global(svg){left:14px;width:28px;height:28px}.tiles-view .card-status{font-size:10px}}
.tiles-view .card-title strong{font-size:14px}
.tiles-view .card-branch,.tiles-view .card-status,.tiles-view .list-sync{font-size:12px}
.tiles-view .card-status .changed,.tiles-view .card-status .clean{color:var(--color-ink-muted)}
.tiles-view .card-status .changed>:global(svg){color:#eeb94e;flex:none}.tiles-view .card-status .clean>:global(svg){color:#68c878;flex:none}
.tiles-view .favorite-category .card-actions button[aria-pressed]{right:14px}
.tiles-view .favorite-category .card-title{padding-right:30px}
.list-view .card-status .changed,.list-view .card-status .clean{color:var(--color-ink-muted)}
.list-view .card-status .changed>:global(svg){color:#eeb94e}.list-view .card-status .clean>:global(svg){color:#68c878}
</style>
+133 -273
View File
@@ -1,9 +1,8 @@
<script lang="ts">
import { untrack } from "svelte";
import { AlertCircle, Check, ExternalLink, GitMerge, LoaderCircle, X } from "@lucide/svelte";
import { onMount, untrack } from "svelte";
import { AlertCircle, Check, ChevronDown, ChevronLeft, ChevronRight, ExternalLink, FileText, GitMerge, LoaderCircle, Pencil, X } from "@lucide/svelte";
import type { ConflictChoice, ConflictFile, ConflictPart, GitFileStatus, PreparedResolution } from "../types";
type ConflictRegion = Extract<ConflictPart, { kind: "conflict" }>;
type ResolveSplitRow =
| { type: "marker"; conflictIndex: number }
| {
@@ -53,9 +52,33 @@
let resolveContent = $state("");
let manualMode = $state(false);
let binarySide = $state<"ours" | "theirs" | null>(null);
let resolveBeforePane = $state<HTMLDivElement | null>(null);
let resolveAfterPane = $state<HTMLDivElement | null>(null);
let isSyncingResolveScroll = false;
let dialogElement: HTMLDivElement;
onMount(() => {
const previousFocus = document.activeElement instanceof HTMLElement ? document.activeElement : null;
dialogElement.focus();
return () => previousFocus?.focus();
});
function trapFocus(event: KeyboardEvent) {
if (event.key !== "Tab") return;
const focusable = Array.from(dialogElement.querySelectorAll<HTMLElement>('button:not(:disabled), textarea:not(:disabled), [tabindex="0"]')).filter(element => element.getClientRects().length > 0);
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (!first) { event.preventDefault(); return; }
if (event.shiftKey && (document.activeElement === first || document.activeElement === dialogElement)) { event.preventDefault(); last.focus(); }
else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus(); }
}
let comparison = $state<HTMLDivElement | null>(null);
let activeConflict = $state(0);
let bothMenu = $state<number | null>(null);
const t = (de: string, en: string) => isGerman ? de : en;
let previewLines = $derived(manualMode ? resolveContent.split("\n") : conflictParts.flatMap(part =>
part.kind === "text" ? part.lines : conflictChoices[part.index] == null
? [`[${t("Konflikt", "Conflict")} ${part.index + 1} ${t("noch offen", "unresolved")}]`]
: buildResolution([part], conflictChoices).split("\n")));
function navigateConflict(direction: number) {
activeConflict = Math.max(0, Math.min(conflictRegionCount - 1, activeConflict + direction));
comparison?.querySelector(`[data-conflict="${activeConflict}"]`)?.scrollIntoView({ block: "nearest" });
}
$effect(() => {
const c = conflict;
@@ -63,6 +86,8 @@
const parts = conflictParts;
untrack(() => {
activeConflict = 0;
bothMenu = null;
if (!c) {
conflictChoices = [];
resolveContent = "";
@@ -95,7 +120,6 @@
});
let conflictRegionCount = $derived(conflictParts.filter((p) => p.kind === "conflict").length);
let conflictRegions = $derived(conflictOnlyParts(conflictParts));
let resolveSplitRows = $derived(buildResolveSplitRows(conflictParts));
let unresolvedCount = $derived(manualMode ? 0 : conflictChoices.filter((c) => c == null).length);
let resolvedContent = $derived(manualMode ? resolveContent : buildResolution(conflictParts, conflictChoices));
@@ -147,10 +171,6 @@
return out.join("\n");
}
function conflictOnlyParts(parts: ConflictPart[]): ConflictRegion[] {
return parts.filter((part): part is ConflictRegion => part.kind === "conflict");
}
function buildResolveSplitRows(parts: ConflictPart[]): ResolveSplitRow[] {
const rows: ResolveSplitRow[] = [];
let leftNum = 0;
@@ -174,7 +194,6 @@
continue;
}
rows.push({ type: "marker", conflictIndex: part.index });
const count = Math.max(part.oursLines.length, part.theirsLines.length);
for (let i = 0; i < count; i++) {
const hasOurs = i < part.oursLines.length;
@@ -192,36 +211,22 @@
rightKind: hasTheirs ? "theirs" : "empty",
});
}
rows.push({ type: "marker", conflictIndex: part.index });
}
return rows;
}
function syncResolveScroll(source: "before" | "after") {
if (isSyncingResolveScroll) return;
const sourcePane = source === "before" ? resolveBeforePane : resolveAfterPane;
const targetPane = source === "before" ? resolveAfterPane : resolveBeforePane;
if (!sourcePane || !targetPane) return;
isSyncingResolveScroll = true;
targetPane.scrollTop = sourcePane.scrollTop;
targetPane.scrollLeft = sourcePane.scrollLeft;
requestAnimationFrame(() => {
isSyncingResolveScroll = false;
});
}
function legacyConflictParts(): any[] {
return [];
}
function setConflictChoice(index: number, choice: ConflictChoice) {
activeConflict = index;
bothMenu = null;
const next = [...conflictChoices];
next[index] = choice;
conflictChoices = next;
}
function setAllConflicts(choice: ConflictChoice) {
bothMenu = null;
conflictChoices = conflictParts.filter((p) => p.kind === "conflict").map(() => choice);
}
@@ -258,266 +263,121 @@
}
</script>
<div
class="dialog-backdrop"
role="presentation"
>
<div
class="dialog"
style="grid-template-rows: auto minmax(0,1fr) auto;"
role="dialog"
aria-modal="true"
aria-label="Resolve conflicts"
tabindex="-1"
>
<header class="dialog-header">
<div>
<span class="eyebrow">Resolve</span>
<h2 class="dialog-title">Conflicts</h2>
<svelte:window onkeydown={(event) => { if (event.key === "Escape") { if (bothMenu !== null) bothMenu = null; else if (!isBusy) onClose(); } }} onclick={() => { bothMenu = null; }} />
{#snippet bothChoices(index: number)}
<div class="both-control">
<button type="button" aria-haspopup="menu" aria-expanded={bothMenu === index} disabled={isBusy} onclick={(event) => { event.stopPropagation(); bothMenu = bothMenu === index ? null : index; }}>
{index === -1 ? t("Beide", "Both") : t("Beide übernehmen", "Accept both")}<ChevronDown size={14} />
</button>
{#if bothMenu === index}
<div class="both-menu" role="menu">
<button type="button" role="menuitem" onclick={() => index === -1 ? setAllConflicts("both-ot") : setConflictChoice(index, "both-ot")}>{t("Aktuell, dann eingehend", "Current, then incoming")}</button>
<button type="button" role="menuitem" onclick={() => index === -1 ? setAllConflicts("both-to") : setConflictChoice(index, "both-to")}>{t("Eingehend, dann aktuell", "Incoming, then current")}</button>
</div>
<div class="dialog-header-actions">
{#if conflictTarget}
<div class="tool-surface-choice" aria-label={isGerman ? "Konflikt bearbeiten mit" : "Edit conflict with"}>
<span>{isGerman ? "Bearbeiten mit" : "Edit with"}</span>
<button class="active" type="button" aria-pressed="true" title={isGerman ? "In Gitty bearbeiten" : "Edit in Gitty"}>
<GitMerge size={13} aria-hidden="true" />Gitty
</button>
<button type="button" onclick={() => onExternalMerge(conflictTarget)} disabled={isBusy} title={isGerman ? `In ${mergeName} öffnen` : `Open in ${mergeName}`}>
<ExternalLink size={13} aria-hidden="true" /><span>{mergeName}</span>
</button>
</div>
{/if}
<button class="dialog-close" type="button" onclick={onClose} title={isGerman ? "Schließen" : "Close"} aria-label={isGerman ? "Konflikte schließen" : "Close conflicts"}>
<X size={18} aria-hidden="true" />
</button>
{/if}
</div>
{/snippet}
<div class="dialog-backdrop app-chrome-backdrop conflict-backdrop">
<div class="conflict-workbench" bind:this={dialogElement} onkeydown={trapFocus} role="dialog" aria-modal="true" aria-label={t("Konflikte lösen", "Resolve conflicts")} tabindex="-1">
<header class="workbench-header">
<h2><GitMerge size={24} />{t("Konflikte lösen", "Resolve conflicts")}</h2>
<div class="header-actions">
{#if conflictTarget}<button type="button" onclick={() => onExternalMerge(conflictTarget)} disabled={isBusy} title={mergeName}><ExternalLink size={18} />{t("Externes Merge-Tool", "External merge tool")}</button>{/if}
<button class="close-button" type="button" onclick={onClose} disabled={isBusy} aria-label={t("Schließen", "Close")}><X size={22} /></button>
</div>
</header>
{#if conflictedFiles.length === 0}
<div class="blank-state">All conflicts resolved. Continue the current operation when ready.</div>
<div class="empty-message">{t("Alle Konflikte gelöst. Du kannst den aktuellen Vorgang fortsetzen.", "All conflicts resolved. You can continue the current operation.")}</div>
{:else}
<div class="dialog-body">
<aside class="dialog-files" aria-label="Conflicted files">
{#each conflictedFiles as file (file.path)}
<button
class="dialog-file-row"
class:active={conflictTarget === file.path}
class:prepared={preparedResolutions[file.path] != null}
type="button"
onclick={() => onSelectFile(file.path)}
disabled={isBusy}
title={file.path}
>
<span class={`status-badge ${preparedResolutions[file.path] ? "added" : "conflicted"}`}>
{preparedResolutions[file.path] ? "ready" : "conflicted"}
</span>
<strong>{file.path}</strong>
{#if preparedResolutions[file.path]}
<Check size={15} aria-hidden="true" />
{/if}
</button>
{/each}
</aside>
<div class="resolve-editor" aria-label="Conflict editor">
{#if !conflict}
<div class="blank-state">Select a file to resolve.</div>
{:else if conflict.binary}
<div class="resolve-binary">
<div class="resolve-binary-note">
<AlertCircle size={16} aria-hidden="true" />
<span>Binary file — pick which version to keep, then mark it resolved.</span>
</div>
<div class="resolve-binary-options">
<button
class="resolve-binary-card ours"
class:active={binarySide === "ours"}
type="button"
onclick={() => { binarySide = "ours"; }}
disabled={isBusy || conflict.ours_size == null}
>
<span class="resolve-side-label">Current (ours)</span>
<strong>{formatBytes(conflict.ours_size)}</strong>
<span class="resolve-binary-hint">{conflict.ours_size == null ? "Deleted on this side" : "Keep this version"}</span>
</button>
<button
class="resolve-binary-card theirs"
class:active={binarySide === "theirs"}
type="button"
onclick={() => { binarySide = "theirs"; }}
disabled={isBusy || conflict.theirs_size == null}
>
<span class="resolve-side-label">Incoming (theirs)</span>
<strong>{formatBytes(conflict.theirs_size)}</strong>
<span class="resolve-binary-hint">{conflict.theirs_size == null ? "Deleted on this side" : "Keep this version"}</span>
</button>
</div>
</div>
<div class="resolve-actions">
<span class="resolve-path" title={conflictTarget}>{conflictTarget}</span>
{#if currentPrepared}
<span class="prepared-tag"><Check size={14} aria-hidden="true" /> Prepared</span>
{/if}
<button type="button" onclick={handleMarkResolved} disabled={!canMarkResolved}>
<Check size={16} aria-hidden="true" />
{currentPrepared ? "Update decision" : "Mark as resolved"}
<div class="workbench-body">
<aside class="file-sidebar" aria-label={t("Dateien mit Konflikten", "Conflicted files")}>
<div class="files-heading">{t("Dateien", "Files")} · {conflictedFiles.length}</div>
<div class="file-list">
{#each conflictedFiles as file (file.path)}
{@const prepared = preparedResolutions[file.path] != null}
<button class="file-item" class:selected={conflictTarget === file.path} type="button" onclick={() => onSelectFile(file.path)} disabled={isBusy} title={file.path}>
<FileText size={19} /><span class="file-copy"><strong>{file.path}</strong><small class:ready={prepared}>{prepared ? t("Vorbereitet", "Prepared") : conflictTarget === file.path ? t("In Bearbeitung", "In progress") : t("Offen", "Open")}</small></span>
{#if prepared}<Check class="ready" size={20} />{:else}<span class="open-dot"></span>{/if}
</button>
{/each}
</div>
<div class="file-progress">{preparedCount} {t("von", "of")} {conflictedFiles.length} {t("vorbereitet", "prepared")}</div>
</aside>
<main class="conflict-main">
<header class="file-header">
<h3 title={conflictTarget}>{conflictTarget || t("Datei auswählen", "Select a file")}</h3>
{#if conflictRegionCount > 0 && !manualMode}
<nav aria-label={t("Konfliktnavigation", "Conflict navigation")}><span>{t("Konflikt", "Conflict")} {activeConflict + 1} {t("von", "of")} {conflictRegionCount}</span><button type="button" aria-label={t("Vorheriger Konflikt", "Previous conflict")} disabled={activeConflict === 0} onclick={() => navigateConflict(-1)}><ChevronLeft size={20} /></button><button type="button" aria-label={t("Nächster Konflikt", "Next conflict")} disabled={activeConflict >= conflictRegionCount - 1} onclick={() => navigateConflict(1)}><ChevronRight size={20} /></button></nav>
{/if}
</header>
{#if !conflict}
<div class="empty-message">{t("Wähle eine Datei aus, um ihren Konflikt zu lösen.", "Select a file to resolve its conflict.")}</div>
{:else if conflict.binary}
<div class="binary-content"><p><AlertCircle size={18} />{t("Binärdatei wähle die Version, die erhalten bleiben soll.", "Binary file — choose the version to keep.")}</p>
<div class="binary-options">{#each ["ours", "theirs"] as side}
{@const size = side === "ours" ? conflict.ours_size : conflict.theirs_size}
<button type="button" class:chosen={binarySide === side} disabled={isBusy || size == null} onclick={() => { binarySide = side as "ours" | "theirs"; }}><strong>{side === "ours" ? t("Aktuell (ours)", "Current (ours)") : t("Eingehend (theirs)", "Incoming (theirs)")}</strong><span>{size == null ? t("Auf dieser Seite gelöscht", "Deleted on this side") : formatBytes(size)}</span>{#if binarySide === side}<Check size={20} />{/if}</button>
{/each}</div>
</div>
{:else}
<div class="resolve-toolbar">
{#if manualMode}
<button type="button" onclick={() => { manualMode = false; }} disabled={isBusy}>Back to guided</button>
{:else}
<span class="resolve-toolbar-label">Apply to all:</span>
<button type="button" onclick={() => setAllConflicts("ours")} disabled={isBusy}>Current</button>
<button type="button" onclick={() => setAllConflicts("theirs")} disabled={isBusy}>Incoming</button>
<button type="button" onclick={() => setAllConflicts("both-ot")} disabled={isBusy}>Both</button>
<button type="button" onclick={enableManualEdit} disabled={isBusy}>Edit manually</button>
{/if}
<span class="resolve-status">
{#if unresolvedCount > 0}
<AlertCircle size={14} aria-hidden="true" />
{unresolvedCount} of {conflictRegionCount} unresolved
{:else if resolveHasMarkers}
<AlertCircle size={14} aria-hidden="true" />
Conflict markers still present
{:else}
<Check size={14} aria-hidden="true" />
{conflictRegionCount} {conflictRegionCount === 1 ? "conflict" : "conflicts"} resolved
{/if}
</span>
<div class="comparison-toolbar">
{#if manualMode}<button type="button" onclick={() => { manualMode = false; }} disabled={isBusy}>{t("Zurück zur Auswahl", "Back to guided")}</button>
{:else}<span>{t("Für alle Konflikte", "For all conflicts")}</span><div class="bulk-actions"><button type="button" onclick={() => setAllConflicts("ours")} disabled={isBusy}>{t("Aktuell", "Current")}</button><button type="button" onclick={() => setAllConflicts("theirs")} disabled={isBusy}>{t("Eingehend", "Incoming")}</button>{@render bothChoices(-1)}</div><button class="manual-button" type="button" onclick={enableManualEdit} disabled={isBusy}><Pencil size={16} />{t("Manuell bearbeiten", "Edit manually")}</button>{/if}
</div>
{#if manualMode}
<textarea
class="resolve-textarea"
bind:value={resolveContent}
spellcheck="false"
disabled={isBusy}
></textarea>
<textarea class="manual-editor" aria-label={t("Konflikt manuell bearbeiten", "Edit conflict manually")} bind:value={resolveContent} spellcheck="false" disabled={isBusy}></textarea>
{:else}
<div class="resolve-structured">
{#if conflictRegions.length > 0}
<div class="resolve-conflict-controls" aria-label="Conflict decisions">
{#each conflictRegions as part (part.index)}
<div class="resolve-conflict-bar" class:unresolved={conflictChoices[part.index] == null}>
<span class="resolve-conflict-label">Conflict {part.index + 1}</span>
<div class="resolve-choice-buttons">
<button type="button" class:active={conflictChoices[part.index] === "ours"} onclick={() => setConflictChoice(part.index, "ours")} disabled={isBusy}>Current</button>
<button type="button" class:active={conflictChoices[part.index] === "theirs"} onclick={() => setConflictChoice(part.index, "theirs")} disabled={isBusy}>Incoming</button>
<button type="button" class:active={conflictChoices[part.index] === "both-ot"} onclick={() => setConflictChoice(part.index, "both-ot")} disabled={isBusy} title="Both - current first">Both C+I</button>
<button type="button" class:active={conflictChoices[part.index] === "both-to"} onclick={() => setConflictChoice(part.index, "both-to")} disabled={isBusy} title="Both - incoming first">Both I+C</button>
</div>
<div class="comparison-scroll" bind:this={comparison}>
<div class="comparison-grid">
<div class="side-heading ours-heading">{t("Aktuell (ours)", "Current (ours)")}</div><div class="side-heading theirs-heading">{t("Eingehend (theirs)", "Incoming (theirs)")}</div>
{#each resolveSplitRows as row, i (i)}
{#if row.type === "marker"}
{@const choice = conflictChoices[row.conflictIndex]}
<div class="conflict-decision" data-conflict={row.conflictIndex}>
<strong>{t("Konflikt", "Conflict")} {row.conflictIndex + 1}{#if choice == null}<span class="pending"> · {t("Offen", "Open")}</span>{/if}</strong>
<button class="accept-ours" class:chosen={choice === "ours"} aria-pressed={choice === "ours"} type="button" disabled={isBusy} onclick={() => setConflictChoice(row.conflictIndex, "ours")}>{t("Aktuell übernehmen", "Accept current")}</button>
<button class="accept-theirs" class:chosen={choice === "theirs"} aria-pressed={choice === "theirs"} type="button" disabled={isBusy} onclick={() => setConflictChoice(row.conflictIndex, "theirs")}>{t("Eingehend übernehmen", "Accept incoming")}</button>
{@render bothChoices(row.conflictIndex)}
{#if choice}<small class="decision-label">{choice === "ours" ? t("Aktuell gewählt", "Current selected") : choice === "theirs" ? t("Eingehend gewählt", "Incoming selected") : choice === "both-ot" ? t("Aktuell → Eingehend", "Current → Incoming") : t("Eingehend → Aktuell", "Incoming → Current")}</small>{/if}
</div>
{/each}
</div>
{/if}
<div class="resolve-split" role="table" aria-label="Full conflict comparison">
<div
class="resolve-pane"
bind:this={resolveBeforePane}
aria-label="Current file content"
onscroll={() => syncResolveScroll("before")}
>
<div class="resolve-pane-grid">
{#each resolveSplitRows as row, i (`left-${i}`)}
{#if row.type === "marker"}
<div class="resolve-split-marker" class:unresolved={conflictChoices[row.conflictIndex] == null}>Conflict {row.conflictIndex + 1}</div>
{:else}
<div class="resolve-num" class:ours={row.leftKind === "ours"} class:empty={row.leftKind === "empty"}>{row.leftNum ?? ""}</div>
<div
class="resolve-cell"
class:ours={row.leftKind === "ours"}
class:empty={row.leftKind === "empty"}
class:dimmed={row.conflictIndex != null && !oursActive(conflictChoices[row.conflictIndex])}
>{displayLine(row.leftText ?? "") || " "}</div>
{/if}
{/each}
</div>
</div>
<div
class="resolve-pane"
bind:this={resolveAfterPane}
aria-label="Incoming file content"
onscroll={() => syncResolveScroll("after")}
>
<div class="resolve-pane-grid">
{#each resolveSplitRows as row, i (`right-${i}`)}
{#if row.type === "marker"}
<div class="resolve-split-marker" class:unresolved={conflictChoices[row.conflictIndex] == null}>Conflict {row.conflictIndex + 1}</div>
{:else}
<div class="resolve-num" class:theirs={row.rightKind === "theirs"} class:empty={row.rightKind === "empty"}>{row.rightNum ?? ""}</div>
<div
class="resolve-cell"
class:theirs={row.rightKind === "theirs"}
class:empty={row.rightKind === "empty"}
class:dimmed={row.conflictIndex != null && !theirsActive(conflictChoices[row.conflictIndex])}
>{displayLine(row.rightText ?? "") || " "}</div>
{/if}
{/each}
</div>
</div>
</div>
{#if false}
{#each legacyConflictParts() as part, partIndex (partIndex)}
{#if part.kind === "text"}
{#if part.lines.length > 0}
<pre class="resolve-context">{#each part.lines as line}<span class="resolve-line context">{displayLine(line) || " "}</span>{/each}</pre>
{:else}
<div class="code-line" class:ours-line={row.leftKind === "ours"} class:dimmed={row.conflictIndex != null && conflictChoices[row.conflictIndex] != null && !oursActive(conflictChoices[row.conflictIndex])}><span>{row.leftNum ?? ""}</span><code>{displayLine(row.leftText ?? "") || " "}</code></div>
<div class="code-line incoming-line" class:theirs-line={row.rightKind === "theirs"} class:dimmed={row.conflictIndex != null && conflictChoices[row.conflictIndex] != null && !theirsActive(conflictChoices[row.conflictIndex])}><span>{row.rightNum ?? ""}</span><code>{displayLine(row.rightText ?? "") || " "}</code></div>
{/if}
{:else}
<div class="resolve-conflict" class:unresolved={conflictChoices[part.index] == null}>
<div class="resolve-conflict-bar">
<span class="resolve-conflict-label">Conflict {part.index + 1}</span>
<div class="resolve-choice-buttons">
<button type="button" class:active={conflictChoices[part.index] === "ours"} onclick={() => setConflictChoice(part.index, "ours")} disabled={isBusy}>Current</button>
<button type="button" class:active={conflictChoices[part.index] === "theirs"} onclick={() => setConflictChoice(part.index, "theirs")} disabled={isBusy}>Incoming</button>
<button type="button" class:active={conflictChoices[part.index] === "both-ot"} onclick={() => setConflictChoice(part.index, "both-ot")} disabled={isBusy} title="Both current first">Both C+I</button>
<button type="button" class:active={conflictChoices[part.index] === "both-to"} onclick={() => setConflictChoice(part.index, "both-to")} disabled={isBusy} title="Both incoming first">Both I+C</button>
</div>
</div>
<div class="resolve-side ours" class:dimmed={!oursActive(conflictChoices[part.index])}>
<span class="resolve-side-label">Current (ours)</span>
<pre class="resolve-lines">{#each part.oursLines as line}<span class="resolve-line ours">{displayLine(line) || " "}</span>{/each}</pre>
</div>
<div class="resolve-side theirs" class:dimmed={!theirsActive(conflictChoices[part.index])}>
<span class="resolve-side-label">Incoming (theirs)</span>
<pre class="resolve-lines">{#each part.theirsLines as line}<span class="resolve-line theirs">{displayLine(line) || " "}</span>{/each}</pre>
</div>
</div>
{/if}
{/each}
{/if}
{/each}
</div>
</div>
{/if}
<div class="resolve-actions">
<span class="resolve-path" title={conflictTarget}>{conflictTarget}</span>
{#if currentPrepared}
<span class="prepared-tag"><Check size={14} aria-hidden="true" /> Prepared</span>
{/if}
<button type="button" onclick={handleMarkResolved} disabled={!canMarkResolved}>
<Check size={16} aria-hidden="true" />
{currentPrepared ? "Update decision" : "Mark as resolved"}
</button>
</div>
<section class="result-preview" aria-label={t("Ergebnisvorschau", "Result preview")}>
<header><strong>{t("Ergebnisvorschau", "Result preview")}</strong><span class:pending={resolveHasMarkers} class:ready={!resolveHasMarkers} aria-live="polite">{manualMode && resolveHasMarkers ? t("Konfliktmarker vorhanden", "Conflict markers remain") : unresolvedCount > 0 ? `${unresolvedCount} ${t(unresolvedCount === 1 ? "Konflikt offen" : "Konflikte offen", unresolvedCount === 1 ? "conflict open" : "conflicts open")}` : t("Alle Konflikte gelöst", "All conflicts resolved")}</span></header>
<div class="preview-code">{#each previewLines as line, i}<div class="code-line"><span>{i + 1}</span><code class:pending={line.startsWith(`[${t("Konflikt", "Conflict")} `)}>{displayLine(line) || " "}</code></div>{/each}</div>
</section>
{/if}
</div>
{#if conflict}<div class="file-actions"><span>{!canMarkResolved ? t("Alle Konflikte dieser Datei lösen, dann vorbereiten.", "Resolve all conflicts in this file, then prepare it.") : currentPrepared ? t("Diese Datei ist vorbereitet.", "This file is prepared.") : t("Ergebnis prüfen und Datei vorbereiten.", "Review the result and prepare the file.")}</span><button type="button" disabled={!canMarkResolved} onclick={handleMarkResolved}>{currentPrepared ? t("Vorbereitung aktualisieren", "Update preparation") : t("Datei vorbereiten", "Prepare file")}</button></div>{/if}
</main>
</div>
<footer class="dialog-footer">
<span class="dialog-footer-info">{preparedCount} of {conflictedFiles.length} prepared</span>
<button class="btn-primary" type="button" onclick={onApply} disabled={isBusy || preparedCount === 0}>
{#if operation.startsWith("Resolving")}
<LoaderCircle class="spin" size={16} aria-hidden="true" />
{:else}
<Check size={16} aria-hidden="true" />
{/if}
Apply {preparedCount} resolved
</button>
</footer>
<footer class="workbench-footer"><span>{t("Änderungen werden erst beim Anwenden gespeichert.", "Changes are saved only when applied.")}</span><button class="apply-button" type="button" onclick={onApply} disabled={isBusy || preparedCount === 0}>{#if isBusy}<LoaderCircle class="spin" size={16} />{/if}{preparedCount} {t(preparedCount === 1 ? "vorbereitete Lösung anwenden" : "vorbereitete Lösungen anwenden", preparedCount === 1 ? "prepared resolution to apply" : "prepared resolutions to apply")}</button></footer>
{/if}
</div>
</div>
<style>
.conflict-workbench{--ours:var(--color-accent);--theirs:#b78ae6;display:flex;flex-direction:column;width:min(1700px,100%);height:min(960px,100%);min-width:0;overflow:hidden;border:1px solid var(--color-border-input);border-radius:6px;background:var(--app-dialog-bg);color:var(--color-ink);box-shadow:var(--app-dialog-shadow);font-size:13px}
.conflict-workbench button{display:inline-flex;align-items:center;justify-content:center;gap:8px;min-height:30px;padding:5px 10px;border:1px solid var(--color-border-input);border-radius:3px;background:transparent;color:var(--color-ink);font-size:13px;font-weight:400;white-space:nowrap}
.conflict-workbench button:hover:not(:disabled){background:var(--color-surface-hover);border-color:var(--color-ink-faint)}.conflict-workbench button:disabled{opacity:.45;cursor:default}.conflict-workbench button:focus-visible{outline:2px solid var(--color-accent);outline-offset:-2px}
.workbench-header{display:flex;align-items:center;justify-content:space-between;gap:16px;min-height:52px;padding:10px 16px;border-bottom:1px solid var(--color-border);background:var(--app-dialog-chrome)}h2{display:flex;align-items:center;gap:10px;margin:0;font-size:15px;font-weight:600}.header-actions{display:flex;gap:12px;align-items:center}.header-actions .close-button{border:0;padding:6px}
.workbench-body{display:flex;flex:1;min-height:0}.file-sidebar{display:flex;flex-direction:column;flex:0 0 220px;min-width:0;border-right:1px solid var(--color-border);padding:0 10px}.files-heading{padding:14px 10px 12px;font-size:13px;color:var(--color-ink-muted)}.file-list{flex:1;overflow:auto}.file-sidebar .file-item{display:flex;width:100%;gap:12px;padding:10px 10px;border:1px solid transparent;align-items:flex-start;justify-content:flex-start;text-align:left;white-space:normal}.file-sidebar .file-item.selected{background:var(--color-surface);border-left-color:var(--color-accent)}.file-copy{display:grid;gap:5px;flex:1;min-width:0}.file-copy strong{font-weight:400;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:13px}.file-copy small{font-size:13px;color:#eda33d}.file-item :global(svg){flex-shrink:0}.open-dot{height:11px;width:11px;background:#eda33d;border-radius:50%;margin-top:5px;flex-shrink:0}.ready,.file-copy small.ready,.file-item :global(svg.ready){color:#68c878}.file-progress{border-top:1px solid var(--color-border);padding:12px 10px;color:var(--color-ink-muted)}
.conflict-main{display:flex;flex-direction:column;flex:1;min-width:0;min-height:0}.file-header{display:flex;justify-content:space-between;align-items:center;gap:12px;margin:0 10px;padding:10px 6px;border-bottom:1px solid var(--color-border);min-height:48px}.file-header h3{font-size:14px;font-weight:500;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;margin:0}.file-header nav{display:flex;align-items:center;gap:12px;white-space:nowrap}.file-header nav span{margin-right:8px}.file-header nav button{padding:7px 9px}
.comparison-toolbar{display:flex;gap:10px;align-items:center;padding:8px 12px;flex-wrap:wrap}.comparison-toolbar>span{color:var(--color-ink-muted)}.bulk-actions{display:flex}.bulk-actions>button+button{border-left:0}.comparison-toolbar .manual-button{margin-left:auto;border:0;color:var(--ours);padding-right:0}
.comparison-scroll{flex:1.25;min-height:150px;overflow:auto;margin:0 10px;border:1px solid var(--color-border)}.comparison-grid{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr);align-content:start;min-width:760px}.side-heading{position:sticky;top:0;z-index:2;background:var(--app-dialog-bg);padding:7px 10px;border-top:2px solid var(--ours);font-size:13px}.theirs-heading{border-color:var(--theirs);border-left:1px solid var(--color-border)}
.code-line{display:flex;min-width:0;line-height:20px;min-height:20px;font-family:var(--font-mono);font-size:13px}.code-line>span{flex:0 0 44px;text-align:right;padding-right:14px;color:var(--color-ink-faint);user-select:none}.code-line code{font:inherit;white-space:pre;overflow-x:auto;min-width:0;flex:1;padding-right:12px}.incoming-line{border-left:1px solid var(--color-border)}.ours-line{background:color-mix(in srgb,var(--ours) 13%,transparent)}.theirs-line{background:color-mix(in srgb,var(--theirs) 13%,transparent)}.dimmed code{opacity:.45}
.conflict-decision{grid-column:1/-1;display:flex;align-items:center;flex-wrap:wrap;gap:10px;padding:7px 10px;border-top:1px solid var(--color-border);border-bottom:1px solid var(--color-border);background:color-mix(in srgb,var(--color-surface) 45%,var(--app-dialog-bg));scroll-margin-top:36px}.conflict-decision strong{font-weight:500;margin-right:12px}.pending{color:#eda33d}.conflict-decision .accept-ours{color:var(--ours);border-color:color-mix(in srgb,var(--ours) 65%,var(--color-border))}.conflict-decision .accept-theirs{color:var(--theirs);border-color:color-mix(in srgb,var(--theirs) 65%,var(--color-border))}.conflict-decision .chosen{background:color-mix(in srgb,var(--ours) 10%,transparent)}.conflict-decision .accept-theirs.chosen{background:color-mix(in srgb,var(--theirs) 10%,transparent)}.decision-label{color:var(--ours);font-size:12px}.both-control{position:relative}.both-menu{position:absolute;right:0;top:100%;z-index:5;min-width:220px;padding:4px;background:var(--app-dialog-chrome);border:1px solid var(--color-border-input);box-shadow:var(--app-panel-shadow)}.both-menu button{width:100%;border:0;text-align:left;justify-content:flex-start}
.result-preview{display:flex;flex:1;min-height:120px;flex-direction:column;margin:10px 10px 0;border:1px solid var(--color-border)}.result-preview header{display:flex;justify-content:space-between;align-items:center;gap:12px;padding:7px 10px;border-bottom:1px solid var(--color-border)}.result-preview strong{font-weight:500;font-size:13px}.preview-code{overflow:auto;flex:1;padding:6px 0}.preview-code code{overflow:visible}.file-actions{display:flex;justify-content:space-between;align-items:center;gap:16px;min-height:48px;padding:8px 12px;border-top:1px solid var(--color-border)}.file-actions>span{color:var(--color-ink-muted)}.file-actions button{background:var(--color-surface)}.workbench-footer{display:flex;align-items:center;justify-content:space-between;gap:20px;padding:10px 16px;border-top:1px solid var(--color-border);min-height:52px}.workbench-footer>span{color:var(--color-ink-muted)}.workbench-footer .apply-button{background:var(--color-primary);border-color:var(--color-primary);color:white;padding:6px 12px;font-weight:500}.workbench-footer .apply-button:hover:not(:disabled){background:var(--color-primary-dark)}
.empty-message{flex:1;padding:32px;color:var(--color-ink-muted)}.manual-editor{flex:1.25;min-height:150px;resize:none;margin:0 10px;padding:14px;font:13px/20px var(--font-mono);background:var(--app-input-bg);color:var(--color-ink);border:1px solid var(--color-border-input)}.binary-content{flex:1;padding:24px}.binary-content p{display:flex;gap:10px;align-items:center;color:var(--color-ink-muted)}.binary-options{display:flex;gap:16px;margin-top:24px}.binary-options button{flex:1;flex-direction:column;gap:12px;padding:24px;white-space:normal}.binary-options button.chosen{border-color:var(--ours);background:color-mix(in srgb,var(--ours) 10%,transparent)}
.conflict-backdrop{display:flex;align-items:center;justify-content:center}
.conflict-workbench{outline:none}.open-dot{border-radius:50%!important}
@media(max-width:1100px){.file-sidebar{flex-basis:210px}.conflict-workbench{font-size:13px}.conflict-workbench button{font-size:12px;padding:7px 10px}.file-copy strong{font-size:13px}.comparison-toolbar{gap:10px;padding:10px}.decision-label{display:none}.file-header{padding:12px 4px}.file-header nav{gap:4px}.code-line{font-size:12px}}
@media(max-width:700px){.conflict-backdrop{padding:8px}.conflict-workbench{width:100%;height:100%}.workbench-header{padding:10px;gap:8px}h2{font-size:13px;gap:8px}.header-actions{gap:6px}.header-actions button{font-size:11px}.workbench-body{flex-direction:column}.file-sidebar{flex:0 0 auto;border-right:0;border-bottom:1px solid var(--color-border)}.files-heading{font-size:13px;padding:8px}.file-list{display:flex;max-height:80px}.file-sidebar .file-item{flex:0 0 180px;min-width:180px;width:180px;padding:8px}.file-copy small{white-space:nowrap;font-size:12px}.file-progress{display:none}.file-header{min-height:48px;padding:6px}.file-header h3{font-size:13px}.file-header nav span{font-size:11px}.comparison-toolbar{padding:6px;gap:6px}.comparison-scroll{min-height:100px}.result-preview{min-height:100px}.file-actions{min-height:48px;padding:6px;gap:8px}.file-actions>span,.workbench-footer>span{font-size:11px}.workbench-footer{min-height:60px;padding:8px;gap:8px}.workbench-footer .apply-button{white-space:normal;padding:8px;font-size:13px}.result-preview header{padding:6px}.result-preview strong{font-size:13px}}
</style>
+631
View File
@@ -0,0 +1,631 @@
<script lang="ts">
import { onMount } from "svelte";
import SelectMenu from "./SelectMenu.svelte";
import { cubicOut } from "svelte/easing";
import { fly } from "svelte/transition";
import {
AlertTriangle, AtSign, Bold, Check, ChevronDown, ChevronRight, Code2,
CircleCheck, Clock3, CircleDotDashed, ExternalLink, GitBranch, GitMerge, GitPullRequest, Inbox, Italic,
Link, List, LoaderCircle, PanelRightOpen, RefreshCw, RotateCcw, Search, Settings2, X, XCircle,
} from "@lucide/svelte";
import { addIntegrationReviewComment, getIntegrationReviewDetails, listIntegrationReviewRequests, openInBrowser, runIntegrationReviewAction } from "../git";
import { configuredIntegrationSources, integrationCredentialKey, providerLabel } from "../integrations";
import type { AppLanguage, GitIntegrationSettings, IntegrationReviewAction, IntegrationReviewRequest, IntegrationReviewState, StoredCredential } from "../types";
type LocalResolutionPhase = "idle" | "preparing" | "conflicts" | "ready-to-continue" | "ready-to-push" | "complete" | "error";
interface Props {
language: AppLanguage;
integrations: GitIntegrationSettings;
initialQuery?: string;
initialSourceId?: string;
localResolutionRequestId?: string;
localResolutionPhase?: LocalResolutionPhase;
localResolutionMessage?: string;
loadCredential: (key: string) => Promise<StoredCredential | null>;
onOpenSettings: () => void;
onStartLocalResolution?: (request: IntegrationReviewRequest, sourceId: string) => void | Promise<void>;
onOpenLocalResolver?: () => void | Promise<void>;
onContinueLocalResolution?: () => void | Promise<void>;
onAbortLocalResolution?: () => void | Promise<void>;
onPushLocalResolution?: () => void | Promise<void>;
}
let { language = "en", integrations, initialQuery = "", initialSourceId = "", localResolutionRequestId = "", localResolutionPhase = "idle", localResolutionMessage = "", loadCredential, onOpenSettings = () => {}, onStartLocalResolution = () => {}, onOpenLocalResolver = () => {}, onContinueLocalResolution = () => {}, onAbortLocalResolution = () => {}, onPushLocalResolution = () => {} }: Props = $props();
let requests = $state<IntegrationReviewRequest[]>([]);
let loading = $state(false);
let errors = $state<Array<{ source: string; message: string }>>([]);
let query = $state("");
let stateFilter = $state<IntegrationReviewState>("open");
let selectedSourceId = $state("");
let selectedId = $state("");
let detailOpen = $state(false);
let detailPanel = $state<HTMLElement>();
let actionMenuId = $state("");
let actionBusyId = $state("");
let actionNotice = $state("");
let detailLoadingId = $state("");
let commentDraft = $state("");
let commentTextarea = $state<HTMLTextAreaElement>();
let commentPosting = $state(false);
let collapsedRepositories = $state(new Set<string>());
let loadGeneration = 0;
let loadedStates = $state(new Set<"open" | "merged" | "closed">());
let completedResolutionRefreshKey = "";
const SOURCE_TIMEOUT_MS = 46_000;
const de = $derived(language === "de");
const sources = $derived(configuredIntegrationSources(integrations));
const activeSource = $derived(sources.find((source) => source.id === selectedSourceId) ?? null);
const normalizedQuery = $derived(query.trim().toLocaleLowerCase());
const filtered = $derived(requests.filter((request) => {
const haystack = `${request.title} ${request.repositoryName} ${request.author} ${request.number} ${request.sourceBranch} ${request.targetBranch}`.toLocaleLowerCase();
return request.state === stateFilter && (!normalizedQuery || haystack.includes(normalizedQuery));
}));
const groupedRequests = $derived.by(() => {
const groups = new Map<string, IntegrationReviewRequest[]>();
for (const request of filtered) {
const key = request.repositoryName || providerLabel(request.provider);
groups.set(key, [...(groups.get(key) ?? []), request]);
}
return [...groups.entries()].map(([name, items]) => ({ name, items }));
});
const selected = $derived(requests.find((request) => request.id === selectedId) ?? null);
const backendRestartRequired = $derived(errors.some((error) => /command.*not found|unknown command|not registered/i.test(error.message)));
onMount(() => {
const closeActionMenu = () => { actionMenuId = ""; };
const closeDetailsOutside = (event: PointerEvent) => {
if (detailOpen && detailPanel && event.target instanceof Node && !detailPanel.contains(event.target)) {
detailOpen = false;
}
};
window.addEventListener("click", closeActionMenu);
window.addEventListener("pointerdown", closeDetailsOutside, true);
query = initialQuery;
const source = sources.find((candidate) => candidate.id === initialSourceId) ?? sources[0];
if (source) {
selectedSourceId = source.id;
void loadRequests("open", source.id);
}
return () => {
window.removeEventListener("click", closeActionMenu);
window.removeEventListener("pointerdown", closeDetailsOutside, true);
};
});
$effect(() => {
const key = localResolutionPhase === "complete" && localResolutionRequestId && selectedSourceId
? `${selectedSourceId}:${localResolutionRequestId}`
: "";
if (!key || key === completedResolutionRefreshKey) return;
completedResolutionRefreshKey = key;
void refreshCompletedResolution(key, localResolutionRequestId, selectedSourceId);
});
function withTimeout<T>(promise: Promise<T>, source: string, timeoutMs = SOURCE_TIMEOUT_MS): Promise<T> {
return new Promise<T>((resolve, reject) => {
const seconds = Math.round(timeoutMs / 1_000);
const timer = window.setTimeout(() => reject(new Error(de ? `${source} hat nach ${seconds} Sekunden nicht geantwortet.` : `${source} did not respond within ${seconds} seconds.`)), timeoutMs);
promise.then((value) => { window.clearTimeout(timer); resolve(value); }, (error) => { window.clearTimeout(timer); reject(error); });
});
}
async function loadRequests(state: "open" | "merged" | "closed" = stateFilter === "draft" ? "open" : stateFilter, sourceId = selectedSourceId) {
const source = sources.find((candidate) => candidate.id === sourceId);
if (!source) return;
const generation = ++loadGeneration;
loading = true;
errors = [];
requests = requests.filter((request) => state === "open" ? request.state !== "open" && request.state !== "draft" : request.state !== state);
selectedId = "";
detailOpen = false;
actionMenuId = "";
actionNotice = "";
try {
const credential = await withTimeout(loadCredential(integrationCredentialKey(source.provider, source.accountId)), `${source.label} keychain`, 15_000);
if (!credential?.password) throw new Error(de ? "Kein Token für diese Integration gespeichert." : "No token is stored for this integration.");
const loaded = await withTimeout(listIntegrationReviewRequests(source.provider, source.baseUrl, credential.username, credential.password, state), source.label);
if (generation !== loadGeneration) return;
requests = [...requests, ...loaded].sort((left, right) => Date.parse(right.updatedAt || right.createdAt) - Date.parse(left.updatedAt || left.createdAt));
loadedStates = new Set([...loadedStates, state]);
const missingBranches = loaded.filter((request) => !request.sourceBranch || !request.targetBranch);
if (missingBranches.length) void enrichMissingBranches(missingBranches, source, credential, generation);
} catch (error) {
if (generation !== loadGeneration) return;
errors = [{ source: source.label, message: error instanceof Error ? error.message : String(error) }];
} finally {
if (generation === loadGeneration) loading = false;
}
}
async function enrichMissingBranches(items: IntegrationReviewRequest[], source: (typeof sources)[number], credential: StoredCredential, generation: number) {
const queue = [...items];
const worker = async () => {
while (queue.length && generation === loadGeneration) {
const request = queue.shift();
if (!request) return;
try {
const details = await withTimeout(getIntegrationReviewDetails(source.provider, source.baseUrl, credential.username, credential.password, request), source.label, 20_000);
if (generation !== loadGeneration) return;
requests = requests.map((item) => item.id === details.id ? details : item);
} catch {
// Keep the request visible; selecting it retries the detail request and surfaces the error.
}
}
};
await Promise.all(Array.from({ length: Math.min(4, queue.length) }, worker));
}
function selectSource(sourceId: string) {
if (sourceId === selectedSourceId) return;
selectedSourceId = sourceId;
stateFilter = "open";
requests = [];
errors = [];
selectedId = "";
detailOpen = false;
actionMenuId = "";
collapsedRepositories = new Set();
loadedStates = new Set();
void loadRequests("open", sourceId);
}
function selectState(state: IntegrationReviewState) {
stateFilter = state;
selectedId = "";
detailOpen = false;
const apiState = state === "draft" ? "open" : state;
if (!loadedStates.has(apiState)) void loadRequests(apiState);
}
function count(state: IntegrationReviewState): number {
return requests.filter((request) => request.state === state).length;
}
function countLabel(state: IntegrationReviewState): string {
return loadedStates.has(state === "draft" ? "open" : state) ? String(count(state)) : "";
}
function stateLabel(state: IntegrationReviewState): string {
if (state === "draft") return "Draft";
if (state === "merged") return de ? "Zusammengeführt" : "Merged";
if (state === "closed") return de ? "Geschlossen" : "Closed";
return "Open";
}
function formatDate(value: string): string {
if (!value) return "";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "";
return new Intl.DateTimeFormat(de ? "de-DE" : "en-US", { dateStyle: "medium", timeStyle: "short" }).format(date);
}
function formatRelativeDate(value: string): string {
if (!value) return "";
const timestamp = Date.parse(value);
if (Number.isNaN(timestamp)) return "";
const minutes = Math.max(0, Math.round((Date.now() - timestamp) / 60_000));
if (minutes < 1) return de ? "gerade eben" : "just now";
if (minutes < 60) return de ? `vor ${minutes} Min.` : `${minutes}m ago`;
const hours = Math.round(minutes / 60);
if (hours < 24) return de ? `vor ${hours} Std.` : `${hours}h ago`;
const days = Math.round(hours / 24);
return de ? `vor ${days} T.` : `${days}d ago`;
}
function initials(name: string): string {
const parts = name.trim().split(/\s+/).filter(Boolean);
if (!parts.length) return "?";
return (parts.length > 1 ? parts[0][0] + parts[parts.length - 1][0] : parts[0].slice(0, 2)).toLocaleUpperCase();
}
function actionLabel(provider: IntegrationReviewRequest["provider"]): string {
return provider === "azure-devops" ? "Open in Azure" : `Open in ${providerLabel(provider)}`;
}
function requestTypeLabel(provider: IntegrationReviewRequest["provider"]): string {
return provider === "gitlab" || provider === "gitlab-self-hosted" ? "Merge Request" : "Pull Request";
}
function toggleRepository(name: string) {
const next = new Set(collapsedRepositories);
if (next.has(name)) next.delete(name);
else next.add(name);
collapsedRepositories = next;
}
function formatComment(prefix: string, suffix = prefix) {
const textarea = commentTextarea;
if (!textarea) return;
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const selection = commentDraft.slice(start, end);
commentDraft = `${commentDraft.slice(0, start)}${prefix}${selection}${suffix}${commentDraft.slice(end)}`;
requestAnimationFrame(() => {
textarea.focus();
const cursorStart = start + prefix.length;
textarea.setSelectionRange(cursorStart, cursorStart + selection.length);
});
}
function selectRequest(request: IntegrationReviewRequest, showDetail = false) {
if (selectedId !== request.id) commentDraft = "";
selectedId = request.id;
if (showDetail) detailOpen = true;
if (request.changedFiles === null && detailLoadingId !== request.id) void loadDetails(request);
}
function openDetail(request: IntegrationReviewRequest) {
selectRequest(request, true);
}
async function loadDetails(request: IntegrationReviewRequest) {
const source = activeSource;
if (!source) return;
detailLoadingId = request.id;
try {
const credential = await withTimeout(loadCredential(integrationCredentialKey(source.provider, source.accountId)), `${source.label} keychain`, 15_000);
if (!credential?.password) return;
const details = await withTimeout(getIntegrationReviewDetails(source.provider, source.baseUrl, credential.username, credential.password, request), source.label);
requests = requests.map((item) => item.id === details.id ? details : item);
} catch (error) {
errors = [{ source: source.label, message: error instanceof Error ? error.message : String(error) }];
} finally { detailLoadingId = ""; }
}
async function refreshCompletedResolution(key: string, requestId: string, sourceId: string) {
const source = sources.find((candidate) => candidate.id === sourceId);
const initialRequest = requests.find((candidate) => candidate.id === requestId);
if (!source || !initialRequest) return;
let request: IntegrationReviewRequest = initialRequest;
actionNotice = de ? "Branch wurde gepusht. Der Merge-Status wird geprüft …" : "Branch pushed. Checking the merge status …";
try {
const credential = await withTimeout(loadCredential(integrationCredentialKey(source.provider, source.accountId)), `${source.label} keychain`, 15_000);
if (!credential?.password) return;
for (const delay of [700, 1_500, 3_000, 5_000]) {
await new Promise((resolve) => window.setTimeout(resolve, delay));
if (completedResolutionRefreshKey !== key || selectedSourceId !== sourceId) return;
const details: IntegrationReviewRequest = await withTimeout(getIntegrationReviewDetails(source.provider, source.baseUrl, credential.username, credential.password, request), source.label, 20_000);
requests = requests.map((candidate) => candidate.id === details.id ? details : candidate);
request = details;
if (details.mergeStatus === "mergeable") {
actionNotice = de ? "Der Konflikt ist gelöst. Der Request kann jetzt zusammengeführt werden." : "The conflict is resolved. The request can now be merged.";
return;
}
if (details.mergeStatus !== "conflicts" && details.mergeStatus !== "checking") return;
}
actionNotice = de ? "Der Anbieter prüft den neuen Branch noch. Aktualisiere den Status in einigen Sekunden erneut." : "The provider is still checking the updated branch. Refresh the status again in a few seconds.";
} catch (error) {
errors = [{ source: source.label, message: error instanceof Error ? error.message : String(error) }];
}
}
function hasConflicts(request: IntegrationReviewRequest): boolean { return request.mergeStatus === "conflicts"; }
function isLocalResolutionActive(request: IntegrationReviewRequest): boolean {
return request.id === localResolutionRequestId && localResolutionPhase !== "idle";
}
function isWaitingForResolvedStatus(request: IntegrationReviewRequest): boolean {
return isLocalResolutionActive(request) && localResolutionPhase === "complete" && hasConflicts(request);
}
async function startLocalResolution(request: IntegrationReviewRequest) {
const source = activeSource;
if (!source || !request.sourceBranch || !request.targetBranch) return;
actionMenuId = "";
selectedId = request.id;
detailOpen = true;
await onStartLocalResolution(request, source.id);
}
async function runPrimaryAction(request: IntegrationReviewRequest) {
if (isWaitingForResolvedStatus(request)) return;
if (hasConflicts(request)) await startLocalResolution(request);
else if (request.state === "open" || request.state === "draft") await performReviewAction(request, "merge");
else if (request.state === "closed") await performReviewAction(request, "reopen");
else await openRequest(request);
}
function primaryActionLabel(request: IntegrationReviewRequest): string {
if (isWaitingForResolvedStatus(request)) return de ? "Status wird geprüft" : "Checking status";
if (hasConflicts(request)) return de ? "Konflikt lösen" : "Resolve conflict";
if (request.state === "open" || request.state === "draft") return de ? "Zusammenführen" : "Merge";
if (request.state === "closed") return de ? "Wieder öffnen" : "Reopen";
return actionLabel(request.provider);
}
async function openRequest(request: IntegrationReviewRequest | null = selected) {
if (!request?.webUrl) return;
try { await openInBrowser(request.webUrl); }
catch (error) { errors = [{ source: providerLabel(request.provider), message: error instanceof Error ? error.message : String(error) }]; }
}
function reviewActionLabel(action: IntegrationReviewAction): string {
if (action === "merge") return de ? "Zusammenführen" : "Merge";
if (action === "approve") return de ? "Freigeben" : "Approve";
if (action === "close") return de ? "Request schließen" : "Close request";
return de ? "Request wieder öffnen" : "Reopen request";
}
async function performReviewAction(request: IntegrationReviewRequest, action: IntegrationReviewAction) {
const source = activeSource;
if (!source || actionBusyId) return;
if (action !== "approve") {
const prompt = action === "merge"
? (de ? `Request #${request.number} wirklich zusammenführen?` : `Merge request #${request.number}?`)
: action === "close"
? (de ? `Request #${request.number} wirklich schließen?` : `Close request #${request.number}?`)
: (de ? `Request #${request.number} wieder öffnen?` : `Reopen request #${request.number}?`);
if (!window.confirm(prompt)) return;
}
actionMenuId = "";
actionNotice = "";
actionBusyId = request.id;
errors = [];
try {
const credential = await withTimeout(loadCredential(integrationCredentialKey(source.provider, source.accountId)), `${source.label} keychain`, 15_000);
if (!credential?.password) throw new Error(de ? "Kein Token für diese Integration gespeichert." : "No token is stored for this integration.");
await withTimeout(runIntegrationReviewAction(source.provider, source.baseUrl, credential.username, credential.password, request, action), source.label);
actionNotice = de ? `${reviewActionLabel(action)} erfolgreich.` : `${reviewActionLabel(action)} succeeded.`;
requests = [];
loadedStates = new Set();
await loadRequests(stateFilter === "draft" ? "open" : stateFilter);
} catch (error) {
errors = [{ source: source.label, message: error instanceof Error ? error.message : String(error) }];
} finally {
actionBusyId = "";
}
}
async function submitComment() {
const request = selected;
const source = activeSource;
const body = commentDraft.trim();
if (!request || !source || !body || commentPosting) return;
commentPosting = true;
errors = [];
try {
const credential = await withTimeout(loadCredential(integrationCredentialKey(source.provider, source.accountId)), `${source.label} keychain`, 15_000);
if (!credential?.password) throw new Error(de ? "Kein Token für diese Integration gespeichert." : "No token is stored for this integration.");
await withTimeout(addIntegrationReviewComment(source.provider, source.baseUrl, credential.username, credential.password, request, body), source.label);
commentDraft = "";
await loadDetails(request);
} catch (error) {
errors = [{ source: source.label, message: error instanceof Error ? error.message : String(error) }];
} finally { commentPosting = false; }
}
</script>
<section class:has-inspector={detailOpen && !!selected} class="review-center" aria-label="Review Center">
<header class="review-header">
<div class="review-heading"><GitPullRequest size={17} /><h1>Review Center</h1></div>
</header>
{#if sources.length === 0}
<div class="empty-state"><div class="empty-icon"><GitPullRequest size={23} /></div><h2>{de ? "Keine Integration eingerichtet" : "No integration configured"}</h2><p>{de ? "Verbinde GitHub, GitLab, Gitea oder Azure DevOps, um Pull- und Merge-Requests hier zu sehen." : "Connect GitHub, GitLab, Gitea, or Azure DevOps to see pull and merge requests here."}</p><button class="primary" type="button" onclick={onOpenSettings}><Settings2 size={14} />{de ? "Integrationen öffnen" : "Open integrations"}</button></div>
{:else}
<div class="review-toolbar">
<nav class="state-tabs" aria-label={de ? "Request-Status" : "Request status"}>
{#each ["open", "draft", "merged", "closed"] as state}
<button class:active={stateFilter === state} type="button" onclick={() => selectState(state as IntegrationReviewState)}>{state === "draft" ? (de ? "Entwürfe" : "Drafts") : stateLabel(state as IntegrationReviewState)}{#if countLabel(state as IntegrationReviewState)}<span>{countLabel(state as IntegrationReviewState)}</span>{/if}</button>
{/each}
</nav>
<label class="review-search"><Search size={14} /><input bind:value={query} placeholder={de ? "Requests durchsuchen …" : "Search requests …"} /></label>
<div class="integration-picker">
<SelectMenu value={selectedSourceId} options={sources.map(source => ({ value: source.id, label: source.label }))} ariaLabel={de ? "Integration auswählen" : "Select integration"} onChange={selectSource} />
</div>
<div class="group-actions">
<button class="icon-button" title={de ? "Aktualisieren" : "Refresh"} aria-label={de ? "Aktualisieren" : "Refresh"} type="button" onclick={() => loadRequests()} disabled={loading}><RefreshCw class={loading ? "spin" : ""} size={14} /></button>
</div>
</div>
{#if actionNotice}<div class="action-notice"><Check size={13} />{actionNotice}</div>{/if}
{#if errors.length > 0}
<details class="source-warning" open>
<summary><AlertTriangle size={14} /><span>{backendRestartRequired ? (de ? "Gitty muss neu gestartet werden, damit das Review-Backend geladen wird." : "Restart Gitty to load the Review Center backend.") : (de ? "Die ausgewählte Integration konnte nicht geladen werden." : "The selected integration could not be loaded.")}</span></summary>
<ul>{#each errors as error}<li><strong>{error.source}:</strong> {error.message}</li>{/each}</ul>
</details>
{/if}
<div class:inspector-open={detailOpen && selected} class="review-layout">
<main class="table-pane">
<div class="table-head">
<span>Status</span><span>Request</span><span>{de ? "Autor" : "Author"}</span><span>{de ? "Prüfer" : "Reviewers"}</span><span>{de ? "Repository / Branch" : "Repository / Branch"}</span><span>{de ? "Aktion" : "Action"}</span>
</div>
{#if loading && requests.length === 0}
<div class="loading-state"><LoaderCircle class="spin" size={17} />{de ? "Requests werden geladen …" : "Loading requests …"}</div>
{:else if groupedRequests.length === 0}
<div class="list-empty"><Inbox size={20} /><strong>{de ? "Keine passenden Requests" : "No matching requests"}</strong><span>{de ? "Passe Status oder Suche an." : "Adjust the status or search."}</span></div>
{:else}
<div class="request-groups">
{#each groupedRequests as group (group.name)}
<section class="request-group">
<button class="group-header" type="button" onclick={() => toggleRepository(group.name)}>
{#if collapsedRepositories.has(group.name)}<ChevronRight size={14} />{:else}<ChevronDown size={14} />{/if}
<GitBranch size={13} /><strong>{group.name}</strong><span>{group.items.length}</span>
</button>
{#if !collapsedRepositories.has(group.name)}
{#each group.items as request (request.id)}
<div class:selected={selectedId === request.id} class="request-row" role="button" tabindex="0" onclick={() => selectRequest(request)} onkeydown={(event) => { if (event.key === "Enter") selectRequest(request); }}>
<span class="request-status" class:open={request.state === "open"} class:draft={request.state === "draft"} class:merged={request.state === "merged"} class:closed={request.state === "closed"}>
{#if request.state === "merged"}<GitMerge size={13} />{:else if request.state === "closed"}<XCircle size={13} />{:else if request.state === "draft"}<CircleDotDashed size={13} />{:else}<GitPullRequest size={13} />{/if}
<span>{stateLabel(request.state)}<small title={formatDate(request.updatedAt || request.createdAt)}>{formatRelativeDate(request.updatedAt || request.createdAt)}</small></span>
</span>
<span class="request-title"><span><code>#{request.number}</code><strong title={request.title}>{request.title}</strong></span><small class="change-stats"><b>+{request.additions ?? ""}</b><i>/</i><em>{request.deletions ?? ""}</em>{#if request.changedFiles !== null}<span title={de ? "Geänderte Dateien" : "Changed files"}>{request.changedFiles} {request.changedFiles === 1 ? (de ? "Datei geändert" : "file changed") : (de ? "Dateien geändert" : "files changed")}</span>{:else if detailLoadingId === request.id}<span>{de ? "Lädt …" : "Loading …"}</span>{/if}</small></span>
<span class="request-author"><i>{initials(request.author)}</i><span>{request.author || (de ? "Unbekannt" : "Unknown")}</span></span>
<span class="collaborators">{#if request.collaborators.length}{#each request.collaborators.slice(0, 3) as collaborator}<i title={collaborator}>{initials(collaborator)}</i>{/each}{:else}<span></span>{/if}</span>
<span class="repo-branch"><strong>{request.repositoryName}</strong>{#if request.sourceBranch && request.targetBranch}<span class="branch-route"><GitBranch size={11} /><code title={request.sourceBranch}>{request.sourceBranch}</code><b></b><code title={request.targetBranch}>{request.targetBranch}</code></span>{:else}<span class="branch-loading"><LoaderCircle class="spin" size={11} />{de ? "Branches werden geladen …" : "Loading branches …"}</span>{/if}</span>
<span class="row-actions">
<span class:merge-action={request.state === "open" || request.state === "draft"} class:conflict-action={hasConflicts(request)} class="provider-action">
<button class="provider-button" class:merge-primary={request.state === "open" || request.state === "draft"} class:conflict={hasConflicts(request)} disabled={!!actionBusyId || isWaitingForResolvedStatus(request) || (hasConflicts(request) && (!request.sourceBranch || !request.targetBranch || (isLocalResolutionActive(request) && localResolutionPhase === "preparing")))} type="button" onclick={(event) => { event.stopPropagation(); void runPrimaryAction(request); }}>{#if actionBusyId === request.id || isWaitingForResolvedStatus(request) || (isLocalResolutionActive(request) && localResolutionPhase === "preparing")}<LoaderCircle class="spin" size={12} />{:else if request.state === "open" || request.state === "draft"}<GitMerge size={12} />{:else}<ExternalLink size={12} />{/if}{primaryActionLabel(request)}</button>
<button class="action-toggle" class:active={actionMenuId === request.id} type="button" aria-label={de ? "Weitere Aktionen" : "More actions"} onclick={(event) => { event.stopPropagation(); actionMenuId = actionMenuId === request.id ? "" : request.id; }}><ChevronDown size={13} /></button>
{#if actionMenuId === request.id}
<span class="action-menu" role="menu" tabindex="-1">
{#if request.state === "open" || request.state === "draft"}
{#if isWaitingForResolvedStatus(request)}<button class="menu-merge" type="button" role="menuitem" disabled><LoaderCircle class="spin" size={13} />{de ? "Merge-Status wird geprüft" : "Checking merge status"}</button>{:else if hasConflicts(request)}<button class="menu-conflict" type="button" role="menuitem" disabled={!request.sourceBranch || !request.targetBranch || (isLocalResolutionActive(request) && localResolutionPhase === "preparing")} onclick={() => void startLocalResolution(request)}><GitMerge size={13} />{de ? "Konflikt lokal lösen" : "Resolve conflict locally"}</button>{:else}<button class="menu-merge" type="button" role="menuitem" disabled={actionBusyId === request.id} onclick={() => void performReviewAction(request, "merge")}><GitMerge size={13} />{reviewActionLabel("merge")}</button>{/if}
<button class="menu-approve" type="button" role="menuitem" disabled={actionBusyId === request.id} onclick={() => void performReviewAction(request, "approve")}><CircleCheck size={14} />{reviewActionLabel("approve")}</button>
<button class="menu-provider" type="button" role="menuitem" disabled={!request.webUrl} onclick={() => void openRequest(request)}><ExternalLink size={13} />{actionLabel(request.provider)}</button>
<button class="danger" type="button" role="menuitem" disabled={actionBusyId === request.id} onclick={() => void performReviewAction(request, "close")}><XCircle size={13} />{reviewActionLabel("close")}</button>
{:else if request.state === "closed"}
<button type="button" role="menuitem" disabled={actionBusyId === request.id} onclick={() => void performReviewAction(request, "reopen")}><RotateCcw size={13} />{reviewActionLabel("reopen")}</button>
{/if}
</span>
{/if}
</span>
<button class="panel-button" class:active={detailOpen && selectedId === request.id} title={de ? "Detailpanel öffnen" : "Open detail panel"} aria-label={de ? "Detailpanel öffnen" : "Open detail panel"} type="button" onclick={(event) => { event.stopPropagation(); openDetail(request); }}><PanelRightOpen size={14} /></button>
</span>
</div>
{/each}
{/if}
</section>
{/each}
</div>
{/if}
</main>
{#if detailOpen && selected}
<aside bind:this={detailPanel} class="detail-panel" transition:fly={{ x: 140, duration: 210, easing: cubicOut }}>
<header class="detail-header">
<div class="detail-provider"><GitPullRequest size={17} /><strong>{providerLabel(selected.provider)} {requestTypeLabel(selected.provider)}</strong></div>
<div class="detail-header-actions"><button class="icon-button" type="button" aria-label={de ? "Im Anbieter öffnen" : "Open in provider"} onclick={() => void openRequest()}><ExternalLink size={16} /></button><button class="icon-button" type="button" aria-label={de ? "Detailansicht schließen" : "Close details"} onclick={() => { detailOpen = false; }}><X size={17} /></button></div>
</header>
<div class="detail-content">
<main class="detail-main">
<section class="detail-title"><div class="detail-title-line"><span>#{selected.number}</span><h2>{selected.title}</h2></div><div class="detail-summary"><span class="state-badge" class:open={selected.state === "open"} class:draft={selected.state === "draft"} class:merged={selected.state === "merged"} class:closed={selected.state === "closed"}>{stateLabel(selected.state)}</span><span class="avatar">{initials(selected.author)}</span><strong>{selected.author || (de ? "Unbekannt" : "Unknown")}</strong><span class="summary-separator">·</span><span class="detail-branch-route"><GitBranch size={13} />{#if selected.sourceBranch && selected.targetBranch}<code>{selected.sourceBranch}</code><span></span><code>{selected.targetBranch}</code>{:else}<span>{de ? "Branches werden geladen …" : "Loading branches …"}</span>{/if}</span></div></section>
<div class:conflict={hasConflicts(selected) && !isWaitingForResolvedStatus(selected)} class="merge-summary">{#if detailLoadingId === selected.id || isWaitingForResolvedStatus(selected)}<LoaderCircle class="spin" size={14} /><span>{de ? "Der Anbieter prüft den gelösten Branch …" : "The provider is checking the resolved branch …"}</span>{:else if hasConflicts(selected)}<AlertTriangle size={14} /><strong>{de ? "Dieser Request hat Merge-Konflikte." : "This request has merge conflicts."}</strong>{:else}<CircleCheck size={19} /><span>{de ? "Kann ohne erkannte Konflikte zusammengeführt werden." : "Can be merged without detected conflicts."}</span>{/if}<span class="merge-spacer"></span><small class="detail-change-stats"><b>+{selected.additions ?? ""}</b><em>{selected.deletions ?? ""}</em>{#if selected.changedFiles !== null}<span>{selected.changedFiles} {selected.changedFiles === 1 ? (de ? "Datei geändert" : "file changed") : (de ? "Dateien geändert" : "files changed")}</span>{/if}</small></div>
{#if hasConflicts(selected) || isLocalResolutionActive(selected)}
<div class:error={localResolutionPhase === "error"} class:complete={localResolutionPhase === "complete"} class="local-resolution">
<div>{#if localResolutionPhase === "preparing"}<LoaderCircle class="spin" size={14} />{:else if localResolutionPhase === "complete"}<Check size={14} />{:else}<GitMerge size={14} />{/if}<span>{isLocalResolutionActive(selected) && localResolutionMessage ? localResolutionMessage : (de ? "Löse den Konflikt mit dem lokalen Repository und Gittys Konflikt-Editor." : "Resolve this conflict with the local repository and Gitty's conflict editor.")}</span></div>
<footer>
{#if !isLocalResolutionActive(selected) || localResolutionPhase === "idle" || localResolutionPhase === "error"}<button type="button" onclick={() => void startLocalResolution(selected)} disabled={!selected.sourceBranch || !selected.targetBranch}>{de ? "Konflikt lokal lösen" : "Resolve locally"}</button>{/if}
{#if isLocalResolutionActive(selected) && localResolutionPhase === "conflicts"}<button type="button" onclick={() => void onOpenLocalResolver()}>{de ? "Konflikt-Editor öffnen" : "Open conflict editor"}</button><button class="secondary" type="button" onclick={() => void onAbortLocalResolution()}>{de ? "Abbrechen" : "Abort"}</button>{/if}
{#if isLocalResolutionActive(selected) && localResolutionPhase === "ready-to-continue"}<button type="button" onclick={() => void onContinueLocalResolution()}>{de ? "Merge abschließen" : "Complete merge"}</button><button class="secondary" type="button" onclick={() => void onAbortLocalResolution()}>{de ? "Abbrechen" : "Abort"}</button>{/if}
{#if isLocalResolutionActive(selected) && localResolutionPhase === "ready-to-push"}<button type="button" onclick={() => void onPushLocalResolution()}>{de ? "Gelösten Branch pushen" : "Push resolved branch"}</button>{/if}
</footer>
</div>
{/if}
<section class="description"><h3>{de ? "Beschreibung" : "Description"}</h3><p class:muted={!selected.description}>{selected.description || (de ? "Keine Beschreibung vorhanden." : "No description provided.")}</p></section>
<section class="comments-section">
<header><h3>{de ? "Kommentare" : "Comments"}</h3><span>{selected.comments?.length ?? 0}</span></header>
{#if detailLoadingId === selected.id && !(selected.comments?.length)}<div class="comments-loading"><LoaderCircle class="spin" size={13} />{de ? "Kommentare werden geladen …" : "Loading comments …"}</div>{:else if selected.comments?.length}<div class="comment-list">{#each selected.comments as comment (comment.id)}<article><div class="comment-card"><header><span class="avatar">{initials(comment.author)}</span><strong>{comment.author || (de ? "Unbekannt" : "Unknown")}</strong><time>{formatRelativeDate(comment.createdAt)}</time>{#if comment.author === selected.author}<span class="owner-badge" title={de ? "Autor dieses Requests" : "Author of this request"}>{de ? "Autor" : "Author"}</span>{/if}</header><p>{comment.body}</p></div></article>{/each}</div>{:else}<p class="no-comments">{de ? "Noch keine Kommentare." : "No comments yet."}</p>{/if}
<form class="comment-composer" onsubmit={(event) => { event.preventDefault(); void submitComment(); }}><textarea bind:this={commentTextarea} bind:value={commentDraft} maxlength="100000" rows="4" placeholder={de ? "Kommentar hinzufügen …" : "Add a comment …"}></textarea><footer><div class="format-actions"><button type="button" title={de ? "Fett" : "Bold"} onclick={() => formatComment("**")}><Bold size={14} /></button><button type="button" title={de ? "Kursiv" : "Italic"} onclick={() => formatComment("_")}><Italic size={14} /></button><button type="button" title="Code" onclick={() => formatComment("`", "`")}><Code2 size={14} /></button><button type="button" title="Link" onclick={() => formatComment("[", "](url)")}><Link size={14} /></button><button type="button" title={de ? "Liste" : "List"} onclick={() => formatComment("- ", "")}><List size={14} /></button><button type="button" title={de ? "Erwähnen" : "Mention"} onclick={() => formatComment("@", "")}><AtSign size={14} /></button></div><button class="submit-comment" type="submit" disabled={!commentDraft.trim() || commentPosting}>{#if commentPosting}<LoaderCircle class="spin" size={13} />{/if}{de ? "Kommentar senden" : "Add comment"}</button></footer></form>
</section>
</main>
<aside class="detail-sidebar">
<div class="detail-actions">{#if selected.state === "open" || selected.state === "draft"}{#if isWaitingForResolvedStatus(selected)}<button class="detail-action" type="button" disabled><LoaderCircle class="spin" size={14} />{de ? "Status wird geprüft" : "Checking status"}</button>{:else if hasConflicts(selected)}<button class="detail-action danger-action" type="button" disabled={isLocalResolutionActive(selected) && localResolutionPhase === "preparing"} onclick={() => void startLocalResolution(selected)}><GitMerge size={14} />{de ? "Konflikt lösen" : "Resolve conflict"}</button>{:else}<button class="detail-action primary-action" type="button" disabled={!!actionBusyId} onclick={() => void performReviewAction(selected, "merge")}><GitMerge size={14} />{reviewActionLabel("merge")}</button>{/if}<button class="detail-action" type="button" disabled={!!actionBusyId} onclick={() => void performReviewAction(selected, "approve")}><Check size={14} />{reviewActionLabel("approve")}</button><button class="detail-action danger-action" type="button" disabled={!!actionBusyId} onclick={() => void performReviewAction(selected, "close")}><XCircle size={14} />{reviewActionLabel("close")}</button>{:else if selected.state === "closed"}<button class="detail-action primary-action" type="button" disabled={!!actionBusyId} onclick={() => void performReviewAction(selected, "reopen")}><RotateCcw size={14} />{reviewActionLabel("reopen")}</button>{/if}<button class="detail-action" type="button" onclick={() => void openRequest()} disabled={!selected.webUrl}><ExternalLink size={14} />{actionLabel(selected.provider)}</button></div>
<section class="people-section"><header><h3>{de ? "Teilnehmer" : "Participants"}</h3></header><div class="people compact"><span class="avatar">{initials(selected.author)}</span><strong>{selected.author || (de ? "Unbekannt" : "Unknown")}</strong></div></section>
<section class="detail-meta"><h3>Repository</h3><strong><GitBranch size={16} />{selected.repositoryName}</strong></section>
<section class="detail-meta"><h3>{de ? "Aktualisiert" : "Updated"}</h3><span><Clock3 size={16} />{formatDate(selected.updatedAt || selected.createdAt)}</span></section>
</aside>
</div>
</aside>
{/if}
</div>
{/if}
</section>
<style>
.review-center{display:flex;min-height:0;flex:1;flex-direction:column;overflow:hidden;color:var(--color-ink);background:var(--app-bg);font-size:12px}.review-center button,.review-center input{font:inherit}.review-header{display:flex;min-height:48px;align-items:center;padding:0 18px;border-bottom:1px solid var(--color-border-subtle);background:var(--color-surface)}.review-heading{display:flex;align-items:center;gap:9px}.review-heading>:global(svg){color:var(--color-accent)}.review-heading h1{margin:0;font-size:16px;font-weight:650}
.state-tabs{display:flex;min-height:42px;align-items:stretch;gap:18px;padding:0 18px;border-bottom:1px solid var(--color-border-subtle);background:var(--color-surface)}.state-tabs button{position:relative;display:flex;align-items:center;gap:7px;padding:0 5px;border:0;color:var(--color-ink-muted);background:transparent;font-size:11px}.state-tabs button:hover{color:var(--color-ink)}.state-tabs button.active{color:var(--color-accent)}.state-tabs button.active:after{position:absolute;right:0;bottom:0;left:0;height:2px;background:var(--color-accent);content:""}.state-tabs span,.group-header>span{display:grid;min-width:18px;height:18px;place-items:center;padding:0 5px;border-radius:9px;color:var(--color-ink-muted);background:var(--color-surface-raised);font-size:9.5px}
.review-toolbar{display:flex;min-height:48px;align-items:center;gap:10px;padding:7px 18px;border-bottom:1px solid var(--color-border-subtle)}.group-actions{display:flex;align-items:center;gap:2px}.group-actions button,.icon-button{display:inline-flex;height:30px;align-items:center;gap:5px;padding:0 7px;border:1px solid transparent;color:var(--color-ink-muted);background:transparent}.group-actions button:hover,.icon-button:hover{border-color:var(--color-border);color:var(--color-ink);background:var(--color-surface-hover)}.group-actions .icon-button{width:30px;justify-content:center;padding:0;border-color:var(--color-border-subtle);margin-left:3px}.review-search{display:flex;min-width:180px;height:30px;align-items:center;gap:7px;flex:1;padding:0 9px;border:1px solid var(--color-border-input);color:var(--color-ink-faint);background:var(--app-input-bg)}.review-search:focus-within{border-color:var(--color-accent)}.review-search input{width:100%;min-width:0;height:100%;padding:0;border:0;outline:0;color:var(--color-ink);background:transparent}
.source-warning{padding:7px 18px;border-bottom:1px solid color-mix(in srgb,#d3a64d 28%,var(--color-border));color:#d3a64d;background:color-mix(in srgb,#d3a64d 6%,var(--app-bg))}.source-warning summary{display:flex;align-items:center;gap:7px;cursor:pointer}.source-warning ul{display:grid;gap:4px;margin:7px 0 2px;padding-left:22px;color:var(--color-ink-muted);font-size:10.5px}.source-warning li strong{color:var(--color-ink)}.action-notice{display:flex;min-height:30px;align-items:center;gap:7px;padding:0 18px;border-bottom:1px solid color-mix(in srgb,#63c783 28%,var(--color-border));color:#63c783;background:color-mix(in srgb,#63c783 6%,var(--app-bg));font-size:10.5px}
.review-layout{display:grid;grid-template-columns:minmax(0,1fr);flex:1;min-height:0}.review-layout.inspector-open{grid-template-columns:minmax(560px,1fr) minmax(360px,38%)}.table-pane{min-width:0;min-height:0;overflow:auto}.table-head,.request-row{display:grid;grid-template-columns:95px minmax(280px,1.5fr) 135px 145px minmax(210px,1fr) 190px;align-items:center}.table-head{position:sticky;z-index:2;top:0;min-height:30px;padding:0 12px;border-bottom:1px solid var(--color-border);color:var(--color-ink-faint);background:var(--color-surface-raised);font-size:9px;font-weight:700;letter-spacing:.035em;text-transform:uppercase}.request-groups{min-width:1075px}.request-group{border-bottom:1px solid var(--color-border-subtle)}.group-header{display:flex;width:100%;height:34px;align-items:center;justify-content:flex-start;gap:7px;padding:0 12px;border:0;border-bottom:1px solid var(--color-border-subtle);color:var(--color-ink);background:var(--color-surface);text-align:left}.group-header:hover{background:var(--color-surface-hover)}.group-header>:global(svg){color:var(--color-ink-faint)}.group-header strong{font-size:11px}.group-header>span{margin-left:2px}.request-row{position:relative;min-height:52px;padding:0 12px;border-bottom:1px solid var(--color-border-subtle);outline:1px solid transparent;outline-offset:-1px;color:var(--color-ink-muted);background:transparent;cursor:default}.request-row:hover{background:var(--color-surface-hover)}.request-row.selected{z-index:1;outline-color:var(--color-accent);background:var(--color-surface-raised)}.request-status{display:flex;align-items:center;gap:6px;font-size:10px}.request-status>span{display:grid;gap:2px}.request-status small{color:var(--color-ink-faint);font-size:9px}.request-title{display:grid;min-width:0;gap:5px;padding-right:14px}.request-title>span{display:flex;min-width:0;gap:8px}.request-title code{color:var(--color-accent);font-size:10px}.request-title strong{overflow:hidden;color:var(--color-ink);font-size:11px;font-weight:560;text-overflow:ellipsis;white-space:nowrap}.change-stats{display:flex;align-items:center;gap:6px;font-size:9.5px}.change-stats b{color:#63c783}.change-stats em{color:#e0737b;font-style:normal}.change-stats i{color:var(--color-ink-faint);font-style:normal}.change-stats span{color:var(--color-ink-faint)}.request-author{display:flex;min-width:0;align-items:center;gap:7px;padding-right:10px}.request-author i,.avatar,.collaborators i{display:grid;width:23px;height:23px;flex:0 0 auto;place-items:center;border:1px solid color-mix(in srgb,var(--color-accent) 35%,var(--color-border));border-radius:50%;color:#fff;background:color-mix(in srgb,var(--color-accent) 45%,var(--color-surface-raised));font-size:9px;font-style:normal;font-weight:750}.request-author>span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.collaborators{display:flex;padding-left:3px}.collaborators i+ i{margin-left:-5px}.repo-branch{display:grid;min-width:0;gap:4px}.repo-branch>strong{overflow:hidden;color:var(--color-ink-muted);font-size:10px;text-overflow:ellipsis;white-space:nowrap}.branch-route{display:flex;min-width:0;align-items:center;gap:5px;padding-right:12px}.branch-route code{max-width:42%;overflow:hidden;color:var(--color-ink-faint);font-size:9px;text-overflow:ellipsis;white-space:nowrap}.branch-route b{color:var(--color-ink-faint);font-weight:400}.row-actions{display:flex;align-items:center;justify-content:flex-end;gap:5px}.provider-action{position:relative;display:flex}.provider-button,.action-toggle,.detail-action{display:inline-flex;height:28px;align-items:center;justify-content:center;gap:6px;border:1px solid var(--color-accent);color:var(--color-accent);background:color-mix(in srgb,var(--color-accent) 5%,transparent)}.provider-button{min-width:120px;padding:0 8px;border-right:0}.provider-button.merge-primary{color:#63c783;border-color:#4fa565;background:color-mix(in srgb,#4fa565 15%,var(--color-surface))}.provider-button.conflict{color:#e0737b;border-color:#a74b55}.action-toggle{width:27px;padding:0}.provider-button:hover,.action-toggle:hover,.action-toggle.active,.detail-action:hover{color:#fff;background:color-mix(in srgb,var(--color-accent) 24%,var(--color-surface))}.action-menu{position:absolute;z-index:8;top:31px;right:0;display:grid;width:178px;padding:4px;border:1px solid var(--color-border);box-shadow:0 8px 22px #0008;background:var(--color-surface-raised)}.action-menu button{display:flex;height:29px;align-items:center;gap:8px;padding:0 8px;border:0;color:var(--color-ink-muted);background:transparent;text-align:left}.action-menu button:hover{color:var(--color-ink);background:var(--color-surface-hover)}.action-menu button.danger{color:#e0737b}.panel-button{display:grid;width:28px;height:28px;place-items:center;border:1px solid var(--color-border);color:var(--color-ink-muted);background:var(--app-button-bg)}.panel-button:hover,.panel-button.active{border-color:var(--color-accent);color:var(--color-accent)}
.detail-panel{min-width:0;min-height:0;overflow:auto;border-left:1px solid var(--color-border);background:var(--color-surface)}.detail-header{display:flex;min-height:78px;align-items:flex-start;justify-content:space-between;gap:14px;padding:15px 16px;border-bottom:1px solid var(--color-border)}.detail-header>div{min-width:0}.detail-content{display:grid;gap:0;padding:0 16px}.detail-summary{display:flex;min-height:48px;align-items:center;gap:8px;border-bottom:1px solid var(--color-border-subtle)}.state-badge{padding:3px 7px;border:1px solid currentColor;font-size:9px;font-weight:700}.detail-summary .avatar{margin-left:4px}.detail-summary strong{font-size:11px}.description{padding:15px 0;border-bottom:1px solid var(--color-border-subtle)}.description h3{margin:0 0 9px;font-size:11px}.description p{margin:0;white-space:pre-wrap;color:var(--color-ink-muted);font-size:11px;line-height:1.55}.description p.muted{color:var(--color-ink-faint)}.detail-actions{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:7px;margin-top:16px}.detail-action{width:100%;height:32px;padding:0 8px}.detail-action.primary-action{color:#63c783;border-color:#63c783}.detail-action.danger-action{color:#e0737b;border-color:color-mix(in srgb,#e0737b 75%,var(--color-border))}.open{color:#63c783}.draft{color:var(--color-ink-muted)}.merged{color:#b785e8}.closed{color:#e0737b}
.loading-state,.list-empty,.empty-state{display:flex;min-height:180px;align-items:center;justify-content:center;gap:8px;color:var(--color-ink-muted)}.list-empty,.empty-state{flex-direction:column}.list-empty strong{color:var(--color-ink);font-size:12px}.list-empty span{font-size:10px}.empty-state{flex:1;text-align:center}.empty-state h2{margin:4px 0 0;font-size:14px}.empty-state p{max-width:430px;margin:0 0 8px;line-height:1.5}.empty-icon{display:grid;width:42px;height:42px;place-items:center;border:1px solid var(--color-border);color:var(--color-accent);background:var(--color-surface)}.primary{display:inline-flex;min-height:30px;align-items:center;gap:6px;padding:0 10px;border:1px solid var(--color-primary);color:#fff;background:var(--color-primary)}
@media(max-width:1100px){.review-layout.inspector-open{grid-template-columns:minmax(500px,1fr) 360px}.table-head,.request-row{grid-template-columns:78px minmax(230px,1.5fr) minmax(180px,1fr) 90px 170px}.table-head>span:nth-child(3),.request-author{display:none}.request-groups{min-width:760px}}
@media(max-width:760px){.review-toolbar{flex-wrap:wrap}.group-actions{order:2}.review-search{order:1;flex-basis:calc(100% - 200px)}.review-layout.inspector-open{display:block}.detail-panel{position:absolute;inset:90px 0 0 15%;z-index:5;box-shadow:-12px 0 30px #0008}.state-tabs{gap:8px}.group-actions button{font-size:0}.group-actions button>:global(svg){margin:0}.table-head,.request-row{grid-template-columns:72px minmax(220px,1fr) 90px 160px}.table-head>span:nth-child(3),.table-head>span:nth-child(4),.request-author,.branch-route{display:none}.request-groups{min-width:580px}}
.merge-summary{display:flex;min-height:45px;align-items:center;gap:7px;border-bottom:1px solid var(--color-border-subtle);color:#63c783}.merge-summary.conflict{color:#e0737b}.merge-summary>span,.merge-summary>strong{font-size:10.5px}
@media(max-width:1100px){.table-head,.request-row{grid-template-columns:78px minmax(270px,1fr) minmax(190px,1fr) 190px}.table-head>span:nth-child(3),.table-head>span:nth-child(4),.request-author,.collaborators{display:none}.request-groups{min-width:780px}}
@media(max-width:760px){.table-head,.request-row{grid-template-columns:72px minmax(260px,1fr) 190px}.table-head>span:nth-child(5),.repo-branch{display:none}.request-groups{min-width:600px}.merge-summary{align-items:flex-start;flex-wrap:wrap;padding:10px 0}}
.branch-loading{display:flex;align-items:center;gap:5px;color:var(--color-ink-faint);font-size:9px}
.provider-action{overflow:visible;border-radius:2px;box-shadow:0 1px 0 #0005}.provider-button{border-radius:2px 0 0 2px;font-weight:600}.action-toggle{border-radius:0 2px 2px 0;background:color-mix(in srgb,var(--color-accent) 14%,var(--color-surface))}.panel-button{margin-left:2px;border-radius:2px}.review-layout.inspector-open{grid-template-columns:minmax(580px,1fr) minmax(420px,44%)}.detail-header{background:linear-gradient(180deg,color-mix(in srgb,var(--color-surface-raised) 72%,var(--color-surface)),var(--color-surface))}
.comments-section{padding:15px 0;border-bottom:1px solid var(--color-border-subtle)}.comments-section>header{display:flex;align-items:center;gap:7px;margin-bottom:10px}.comments-section h3{margin:0;font-size:11px}.comments-section>header>span{display:grid;min-width:18px;height:18px;place-items:center;border-radius:9px;color:var(--color-ink-faint);background:var(--color-surface-raised);font-size:9px}.comments-loading,.no-comments{margin:8px 0;color:var(--color-ink-faint);font-size:10px}.comments-loading{display:flex;align-items:center;gap:6px}.comment-list{display:grid;gap:10px;margin-bottom:12px}.comment-list article{display:flex;align-items:flex-start;gap:9px}.comment-list article>div{min-width:0;flex:1;padding:9px 10px;border:1px solid var(--color-border-subtle);border-radius:2px;background:var(--app-bg)}.comment-list article header{display:flex;align-items:center;justify-content:space-between;gap:8px;margin-bottom:6px}.comment-list article strong{font-size:10.5px}.comment-list article time{color:var(--color-ink-faint);font-size:9px}.comment-list article p{margin:0;white-space:pre-wrap;color:var(--color-ink-muted);font-size:10.5px;line-height:1.5}.comment-composer{display:flex;align-items:flex-start;gap:9px;margin-top:10px}.comment-composer textarea{box-sizing:border-box;width:100%;min-height:82px;resize:vertical;padding:9px 10px;border:1px solid var(--color-border-input);outline:0;color:var(--color-ink);background:var(--app-input-bg);font:inherit;line-height:1.45}.comment-composer textarea:focus{border-color:var(--color-accent)}.comment-composer button{display:inline-flex;height:29px;align-items:center;gap:6px;padding:0 10px;border:1px solid #4fa565;border-radius:2px;color:#63c783;background:color-mix(in srgb,#4fa565 12%,var(--color-surface))}.comment-composer button:hover:not(:disabled){color:#fff;background:#397849}
@media(max-width:1100px){.review-layout.inspector-open{grid-template-columns:minmax(480px,1fr) 380px}}
@media(max-width:760px){.review-layout.inspector-open{display:block}.detail-panel{position:absolute;inset:90px 0 0 12%;z-index:5;box-shadow:-12px 0 30px #0008}}
.comment-list{max-height:230px;overflow-y:auto;overscroll-behavior:contain;padding-right:5px;scrollbar-gutter:stable}.comment-list::-webkit-scrollbar{width:7px}.comment-list::-webkit-scrollbar-thumb{border:2px solid transparent;border-radius:4px;background:var(--color-border-input);background-clip:padding-box}.comment-list::-webkit-scrollbar-track{background:transparent}
.detail-panel{display:flex;flex-direction:column;overflow:hidden}.detail-header{flex:0 0 auto}.detail-content{display:flex;min-height:0;flex:1;flex-direction:column;overflow:hidden}.detail-summary,.merge-summary,.description,.detail-actions{flex:0 0 auto}.description p{display:-webkit-box;overflow:hidden;-webkit-box-orient:vertical;-webkit-line-clamp:3;line-clamp:3}.comments-section{display:flex;min-height:0;flex:1;flex-direction:column;overflow:hidden}.comments-section>header,.comment-composer{flex:0 0 auto}.comment-list{min-height:0;max-height:none;flex:1;overflow-y:auto}.comment-composer textarea{min-height:68px;max-height:92px}.detail-actions{margin-bottom:12px}
.review-center{position:relative}.review-layout.inspector-open{grid-template-columns:minmax(0,1fr)}.detail-panel{position:absolute;z-index:20;inset:0;display:flex;width:100%;height:100%;flex-direction:column;overflow:hidden;border:0;background:var(--app-bg)}.detail-header{display:flex;min-height:48px;align-items:center;padding:0 20px;border-bottom:1px solid var(--color-border);background:var(--color-surface-raised)}.detail-provider{display:flex;align-items:center;gap:9px;color:var(--color-ink-muted)}.detail-provider>:global(svg){color:var(--color-accent)}.detail-provider strong{font-size:13px;font-weight:600}.detail-header-actions{display:flex;align-items:center;gap:3px;margin-left:auto}.detail-content{display:grid;min-height:0;flex:1;grid-template-columns:minmax(0,1fr) 300px;gap:28px;overflow:hidden;padding:0 28px}.detail-main{display:flex;min-width:0;min-height:0;flex-direction:column;overflow:hidden}.detail-title{flex:0 0 auto;padding:18px 0 0;border-bottom:1px solid var(--color-border-subtle)}.detail-title h2{margin:7px 0 10px;font-size:20px;font-weight:520;line-height:1.25}.detail-title .detail-summary{min-height:34px;border:0}.detail-title .detail-summary>span:last-child{color:var(--color-ink-muted);font-size:10.5px}.detail-main>.description{padding:13px 0}.detail-main>.comments-section{display:flex;min-height:0;flex:1;flex-direction:column;overflow:hidden;padding:13px 0 18px}.detail-main .merge-summary{flex:0 0 auto;margin-bottom:11px;padding:0 12px;border:1px solid var(--color-border);background:var(--color-surface)}.detail-main .comment-list{min-height:0;flex:1}.detail-main .comment-composer{flex:0 0 auto}.detail-sidebar{display:flex;min-height:0;flex-direction:column;gap:0;overflow:hidden;padding:18px 0;border-left:1px solid var(--color-border-subtle);padding-left:22px;background:var(--color-surface)}.detail-sidebar .detail-actions{display:grid;grid-template-columns:1fr;gap:6px;margin:0 0 8px}.detail-sidebar section{padding:17px 0;border-bottom:1px solid var(--color-border-subtle)}.detail-sidebar section header{display:flex;align-items:center;justify-content:space-between;gap:12px}.detail-sidebar section h3{margin:0;color:var(--color-ink-muted);font-size:11px;font-weight:560}.people{display:grid;grid-template-columns:24px minmax(0,1fr);align-items:center;gap:8px;margin-top:12px}.people.compact{grid-template-columns:24px}
@media(max-width:900px){.detail-content{grid-template-columns:minmax(0,1fr) 270px;gap:18px;padding:0 18px}.detail-sidebar{padding-left:16px}.detail-title h2{font-size:17px}}
@media(max-width:680px){.detail-content{display:block;overflow:hidden;padding:0 15px}.detail-main{height:100%}.detail-sidebar{display:none}.detail-panel{inset:0}}
.comment-list{align-content:start;grid-auto-rows:max-content}
.detail-panel{inset:0 0 0 auto;width:50%;min-width:760px;max-width:100%;height:100%;border-left:1px solid var(--color-border);box-shadow:-14px 0 30px #0007}.review-layout.inspector-open{grid-template-columns:minmax(0,1fr)}
.provider-action.merge-action .action-toggle{border-color:#4fa565;color:#70d58d;background:color-mix(in srgb,#4fa565 15%,var(--color-surface))}.provider-action.merge-action .action-toggle:hover,.provider-action.merge-action .action-toggle.active{color:#fff;background:#397849}.provider-action.conflict-action .action-toggle{border-color:#a74b55;color:#e0737b;background:color-mix(in srgb,#a74b55 12%,var(--color-surface))}.panel-button{border-color:color-mix(in srgb,var(--color-accent) 45%,var(--color-border));color:var(--color-accent);background:color-mix(in srgb,var(--color-accent) 6%,var(--color-surface))}.panel-button:hover,.panel-button.active{border-color:var(--color-accent);color:#fff;background:color-mix(in srgb,var(--color-accent) 25%,var(--color-surface))}
.local-resolution{display:grid;flex:0 0 auto;gap:9px;margin:0 0 11px;padding:10px 12px;border:1px solid color-mix(in srgb,#e0737b 52%,var(--color-border));background:color-mix(in srgb,#e0737b 6%,var(--color-surface))}.local-resolution>div{display:flex;align-items:flex-start;gap:8px;color:var(--color-ink-muted);font-size:10.5px;line-height:1.45}.local-resolution>div>:global(svg){flex:0 0 auto;margin-top:1px;color:#e0737b}.local-resolution footer{display:flex;flex-wrap:wrap;justify-content:flex-end;gap:6px}.local-resolution button{display:inline-flex;min-height:28px;align-items:center;padding:0 9px;border:1px solid #c65e67;color:#f08a92;background:color-mix(in srgb,#c65e67 12%,var(--color-surface))}.local-resolution button:hover:not(:disabled){color:#fff;background:#8e3f47}.local-resolution button.secondary{border-color:var(--color-border-input);color:var(--color-ink-muted);background:var(--app-button-bg)}.local-resolution.error{border-color:#d29a47;background:color-mix(in srgb,#d29a47 7%,var(--color-surface))}.local-resolution.error>div>:global(svg){color:#d29a47}.local-resolution.complete{border-color:#4fa565;background:color-mix(in srgb,#4fa565 7%,var(--color-surface))}.local-resolution.complete>div>:global(svg){color:#63c783}
@media(max-width:760px){.detail-panel{inset:0;width:100%;min-width:0;max-width:none}}
/* Review Center B — compact hierarchy, overlay inspector, semantic actions. */
.review-center{font-size:11px}.review-header{min-height:44px}.review-heading h1{font-size:14px}
.review-toolbar{display:grid;min-height:50px;grid-template-columns:auto minmax(220px,1fr) 190px auto;align-items:center;gap:10px;padding:0 14px;background:color-mix(in srgb,var(--color-surface) 35%,var(--app-bg))}
.state-tabs{height:100%;min-height:0;gap:3px;padding:0;border:0;background:transparent}.state-tabs button{min-width:58px;justify-content:center;padding:0 9px;font-size:10.5px}.state-tabs button.active{color:var(--color-ink)}.state-tabs button.active:after{right:7px;left:7px}.state-tabs span,.group-header>span{min-width:17px;height:17px;padding:0 4px;border:1px solid var(--color-border-subtle);border-radius:1px;font-size:9px}
.review-search{min-width:0;height:30px}.review-search:focus-within{box-shadow:inset 2px 0 0 var(--color-accent)}.group-actions{gap:4px}.group-actions .icon-button{width:30px;margin:0;padding:0;border-color:var(--color-border-subtle)}
.table-head,.request-row{grid-template-columns:92px minmax(285px,1.65fr) 132px 126px minmax(220px,1fr) 190px}.table-head{min-height:34px;padding:0 16px;border-top:1px solid var(--color-border-subtle);border-bottom-color:var(--color-border);color:color-mix(in srgb,var(--color-ink-faint) 82%,transparent);background:color-mix(in srgb,var(--color-surface-raised) 80%,var(--app-bg));font-size:8.5px;letter-spacing:.07em}.request-groups{min-width:1080px}.request-group{border-bottom:0}.group-header{height:39px;gap:8px;padding:0 16px;border-bottom-color:var(--color-border);color:var(--color-ink);background:color-mix(in srgb,var(--color-surface) 78%,var(--app-bg))}.group-header:hover{background:color-mix(in srgb,var(--color-surface-hover) 82%,var(--app-bg))}.group-header>:global(svg){color:var(--color-accent)}.group-header strong{font-size:11.5px;font-weight:680;letter-spacing:.005em}.group-header>span{margin-left:4px;color:var(--color-ink-muted);background:color-mix(in srgb,var(--color-surface-raised) 88%,var(--app-bg))}
.request-row{min-height:64px;padding:0 16px;border-bottom-color:color-mix(in srgb,var(--color-border-subtle) 76%,transparent);background:color-mix(in srgb,var(--app-bg) 96%,var(--color-surface))}.request-row:before{position:absolute;top:0;bottom:0;left:0;width:3px;background:transparent;content:""}.request-row:hover{background:color-mix(in srgb,var(--color-surface-hover) 72%,var(--app-bg))}.request-row.selected{outline-color:color-mix(in srgb,var(--color-accent) 74%,var(--color-border));background:color-mix(in srgb,var(--color-accent) 5%,var(--color-surface-raised));box-shadow:inset 0 1px color-mix(in srgb,var(--color-accent) 15%,transparent),inset 0 -1px color-mix(in srgb,var(--color-accent) 15%,transparent)}.request-row.selected:before{background:var(--color-accent);box-shadow:2px 0 10px color-mix(in srgb,var(--color-accent) 24%,transparent)}.request-status{gap:8px}.request-status>:global(svg){width:15px;height:15px}.request-status>span{gap:4px;font-weight:600}.request-status small{font-weight:400}.request-title{gap:7px}.request-title>span{align-items:center}.request-title code{font-size:10.5px;font-weight:650}.request-title strong{font-size:11.5px;font-weight:680}.change-stats{gap:7px;font-size:9.5px}.request-author i,.avatar,.collaborators i{border-radius:1px}.repo-branch{gap:6px}.repo-branch>strong{color:var(--color-ink);font-size:10.5px;font-weight:650}.branch-route code{padding:2px 4px;color:var(--color-ink-muted);background:color-mix(in srgb,var(--color-accent) 6%,transparent)}
.provider-action{box-shadow:none}.provider-button,.action-toggle{height:32px}.provider-button{min-width:124px;padding:0 11px;font-weight:650}.provider-button.merge-primary{color:#72d58f;border-color:#4e9f64;background:color-mix(in srgb,#4fa565 9%,var(--app-bg))}.provider-button.merge-primary:hover{color:#fff;background:#397849}.provider-action.merge-action .action-toggle{background:color-mix(in srgb,#4fa565 20%,var(--app-bg))}.provider-button.conflict{color:#e2ac58;border-color:#b88432;background:color-mix(in srgb,#b88432 9%,var(--app-bg))}.provider-action.conflict-action .action-toggle{color:#e2ac58;border-color:#b88432;background:color-mix(in srgb,#b88432 17%,var(--app-bg))}.panel-button{width:32px;height:32px;margin-left:3px;background:color-mix(in srgb,var(--color-accent) 5%,var(--app-bg))}
.action-menu{top:36px;width:190px;padding:6px;border-color:color-mix(in srgb,var(--color-border) 86%,var(--color-accent));box-shadow:0 12px 30px #000b,0 0 0 1px #0005;background:color-mix(in srgb,var(--color-surface-raised) 92%,#10151a)}.action-menu:before{position:absolute;top:-5px;right:10px;width:8px;height:8px;transform:rotate(45deg);border-top:1px solid var(--color-border);border-left:1px solid var(--color-border);background:inherit;content:""}.action-menu button{position:relative;height:34px;gap:10px;padding:0 10px;border-left:2px solid transparent;color:var(--color-ink-muted);font-size:10.5px}.action-menu button:hover:not(:disabled){color:var(--color-ink);border-left-color:var(--color-accent);background:color-mix(in srgb,var(--color-accent) 8%,var(--color-surface-hover))}.action-menu button>:global(svg){flex:0 0 auto}.action-menu .menu-merge>:global(svg){color:#72d58f}.action-menu .menu-approve>:global(svg),.action-menu .menu-provider>:global(svg){color:var(--color-accent)}.action-menu button.danger{margin-top:4px;border-top:1px solid var(--color-border-subtle);color:#e7767e}.action-menu button.danger:hover{border-left-color:#e7767e;background:color-mix(in srgb,#e7767e 8%,var(--color-surface-hover))}.action-menu .menu-conflict{color:#e2ac58}.action-menu button:disabled{opacity:.48}
.detail-panel{width:50%;min-width:720px;max-width:960px;box-shadow:-14px 0 30px #0008}.detail-header{min-height:47px;padding:0 16px}.detail-provider strong{font-size:12px}.detail-content{grid-template-columns:minmax(0,1fr) 250px;gap:20px;padding:0 16px}.detail-title{padding:15px 0 10px}.detail-title h2{margin:5px 0 8px;font-size:18px;font-weight:620}.detail-summary{min-height:27px;border:0}.detail-summary .avatar{margin-left:3px}
.detail-main>.merge-summary{min-height:43px;margin:11px 0 0;padding:0 10px;border:1px solid color-mix(in srgb,#63c783 48%,var(--color-border));background:color-mix(in srgb,#63c783 6%,var(--color-surface))}.detail-main>.merge-summary.conflict{border-color:color-mix(in srgb,#d6a64f 55%,var(--color-border));color:#d6a64f;background:color-mix(in srgb,#d6a64f 7%,var(--color-surface))}.merge-spacer{flex:1}.detail-change-stats{display:flex;align-items:center;gap:7px;white-space:nowrap;font-size:9px}.detail-change-stats b{color:#63c783}.detail-change-stats em{color:#e0737b;font-style:normal}.detail-change-stats span{color:var(--color-ink-faint)}
.detail-main>.description{padding:12px 0}.detail-main>.comments-section{padding:12px 0 14px}.comment-list{gap:8px}.comment-list article{gap:8px}.comment-list article>div{padding:8px 9px;border-radius:0;background:color-mix(in srgb,var(--color-surface) 48%,var(--app-bg))}.comment-composer{margin-top:9px;padding-top:9px;border-top:1px solid var(--color-border-subtle)}.comment-composer textarea{min-height:72px;max-height:92px}.comment-composer button{color:var(--color-accent);border-color:var(--color-accent);background:color-mix(in srgb,var(--color-accent) 6%,var(--color-surface))}
.detail-sidebar{padding:15px 0 15px 16px;background:color-mix(in srgb,var(--color-surface) 58%,var(--app-bg))}.detail-sidebar .detail-actions{gap:6px;margin-bottom:9px}.detail-action{height:31px}.detail-sidebar section{padding:14px 0}.people{grid-template-columns:23px minmax(0,1fr);gap:8px;margin-top:10px}.people strong,.detail-meta strong,.detail-meta span{overflow:hidden;color:var(--color-ink-muted);font-size:10px;font-weight:500;text-overflow:ellipsis}.detail-meta{display:grid;gap:8px}.detail-actions>.danger-action:first-child{color:#e0aa55;border-color:#b88432;background:color-mix(in srgb,#b88432 8%,var(--color-surface))}
.local-resolution{margin:8px 0 0;border-color:color-mix(in srgb,#d6a64f 55%,var(--color-border));background:color-mix(in srgb,#d6a64f 7%,var(--color-surface))}.local-resolution>div>:global(svg){color:#d6a64f}.local-resolution button{color:#e0aa55;border-color:#b88432;background:color-mix(in srgb,#b88432 10%,var(--color-surface))}
@media(max-width:1280px){.review-toolbar{grid-template-columns:auto minmax(180px,1fr) 155px auto}.state-tabs button{min-width:52px;padding:0 6px}.table-head,.request-row{grid-template-columns:80px minmax(250px,1.5fr) 110px minmax(190px,1fr) 180px}.table-head>span:nth-child(4),.collaborators{display:none}.request-groups{min-width:900px}.detail-panel{width:58%;min-width:680px}}
@media(max-width:900px){.review-toolbar{grid-template-columns:1fr 150px auto;grid-template-rows:38px 38px;padding:0 10px}.state-tabs{grid-column:1/-1;grid-row:1}.review-search{grid-column:1;grid-row:2}.group-actions{grid-column:3;grid-row:2}.detail-panel{width:76%;min-width:620px}.detail-content{grid-template-columns:minmax(0,1fr) 220px;gap:14px}.table-head,.request-row{grid-template-columns:76px minmax(240px,1.5fr) minmax(180px,1fr) 180px}.table-head>span:nth-child(3),.request-author{display:none}.request-groups{min-width:760px}}
@media(max-width:680px){.state-tabs button{min-width:0;flex:1}.group-actions .icon-button:nth-child(-n+2){display:none}.detail-panel{width:100%;min-width:0;max-width:none}.detail-content{display:block;padding:0 13px}.detail-main{height:100%}.detail-sidebar{display:none}}
/* Accepted Review Center concept — faithful final layout. */
.review-header{min-height:54px;padding:0 24px;background:color-mix(in srgb,var(--app-bg) 78%,var(--color-surface))}.review-heading{gap:10px}.review-heading h1{font-size:14px;font-weight:700}
.review-toolbar{min-height:55px;grid-template-columns:350px minmax(220px,1fr) 160px 34px;gap:10px;padding:0 16px;border-bottom-color:var(--color-border);background:color-mix(in srgb,var(--app-bg) 88%,var(--color-surface))}.state-tabs{gap:8px}.state-tabs button{min-width:72px;justify-content:flex-start;padding:0 8px;color:var(--color-ink-muted);font-size:11px}.state-tabs button.active{color:var(--color-ink)}.state-tabs button.active:after{right:0;left:0;height:2px}.state-tabs span{min-width:17px;height:17px;margin-left:auto;border:0;background:var(--color-surface-raised)}.review-search,.group-actions .icon-button{height:34px;background:color-mix(in srgb,var(--app-input-bg) 92%,#11171c)}.review-search{padding:0 11px}.group-actions .icon-button{width:34px}
.table-head,.request-row{grid-template-columns:90px minmax(255px,1.65fr) 105px 120px minmax(180px,1fr) 150px}.table-head{min-height:37px;padding:0 16px;background:color-mix(in srgb,var(--color-surface-raised) 88%,var(--app-bg));font-size:8.5px}.request-groups{min-width:900px}.group-header{height:45px;padding:0 17px;background:color-mix(in srgb,var(--app-bg) 72%,var(--color-surface))}.group-header strong{font-size:11.5px}.request-row{min-height:78px;padding:0 16px}.request-row.selected{background:linear-gradient(90deg,color-mix(in srgb,var(--color-accent) 7%,var(--color-surface-raised)),color-mix(in srgb,var(--color-surface-raised) 72%,var(--app-bg)))}.request-title{gap:8px}.request-title strong{font-size:12px}.request-status{font-size:10.5px}.request-author i,.avatar,.collaborators i{width:28px;height:28px;background:color-mix(in srgb,var(--color-accent) 58%,#26333a)}.provider-button,.action-toggle,.panel-button{height:34px}.provider-button{min-width:102px}.action-toggle{width:34px}.panel-button{width:34px}.action-menu{top:38px;width:154px}.action-menu button{height:36px}
.detail-panel{width:44%;min-width:680px;max-width:none;background:color-mix(in srgb,var(--app-bg) 94%,var(--color-surface))}.detail-header{min-height:54px;padding:0 24px;background:color-mix(in srgb,var(--app-bg) 78%,var(--color-surface))}.detail-provider strong{font-size:13px}.detail-content{grid-template-columns:minmax(0,1fr) 225px;gap:18px;padding:0 0 0 24px}.detail-main{padding-right:0}.detail-title{padding:18px 0 16px}.detail-title-line{display:flex;min-width:0;align-items:baseline;gap:10px}.detail-title-line>span{flex:0 0 auto;color:var(--color-accent);font-size:17px;font-weight:700}.detail-title h2{min-width:0;margin:0;overflow:hidden;font-size:19px;font-weight:700;text-overflow:ellipsis;white-space:nowrap}.detail-title .detail-summary{min-height:33px;gap:7px}.detail-summary .avatar{width:23px;height:23px;margin-left:5px}.detail-summary strong{font-size:10.5px}.summary-separator{color:var(--color-ink-faint)}.detail-branch-route{display:flex;min-width:0;align-items:center;gap:6px;color:var(--color-ink-muted)}.detail-branch-route>:global(svg){color:var(--color-ink-muted)}.detail-branch-route code{max-width:95px;overflow:hidden;color:var(--color-ink-muted);font-size:10.5px;text-overflow:ellipsis;white-space:nowrap}.detail-branch-route>span{color:var(--color-ink-faint)}.state-badge{padding:0;border:0;font-size:10.5px}
.detail-main>.merge-summary{min-height:55px;margin:0 0 4px;padding:0 12px;border-color:color-mix(in srgb,#63c783 65%,var(--color-border));background:color-mix(in srgb,#63c783 5%,var(--app-bg))}.detail-main>.description{padding:14px 0 18px}.description h3,.comments-section h3{font-size:11.5px}.description p{font-size:10.5px}.detail-main>.comments-section{padding:13px 0 0}.comments-section>header{min-height:28px;margin:0 0 8px}.comments-section>header>span{border-radius:1px}.comment-list{gap:10px;padding-right:0}.comment-list article{display:block}.comment-card{padding:0!important;border:1px solid var(--color-border)!important;background:color-mix(in srgb,var(--color-surface) 35%,var(--app-bg))!important}.comment-card header{min-height:42px;margin:0!important;padding:0 10px;border-bottom:0}.comment-card header .avatar{width:25px;height:25px;margin-right:2px}.comment-card header strong{font-size:10.5px}.comment-card header time{margin-left:auto}.owner-badge{padding:3px 6px;border:1px solid var(--color-border);color:var(--color-ink-faint);font-size:8.5px}.comment-card p{padding:0 44px 13px!important;color:var(--color-ink)!important}
.comment-composer{display:grid;gap:0;margin:0;padding:14px 0 18px;border-top:1px solid var(--color-border-subtle)}.comment-composer textarea{min-height:86px;max-height:120px;resize:none;padding:11px 12px;border-bottom:0;background:color-mix(in srgb,var(--app-input-bg) 94%,#11171c)}.comment-composer footer{display:flex;min-height:39px;align-items:center;justify-content:space-between;border:1px solid var(--color-border-input);border-top:0;background:color-mix(in srgb,var(--app-bg) 85%,var(--color-surface))}.format-actions{display:flex;align-items:center;padding-left:5px}.comment-composer .format-actions button{display:grid;width:30px;height:30px;place-items:center;padding:0;border:0;color:var(--color-ink-muted);background:transparent}.comment-composer .format-actions button:hover{color:var(--color-ink);background:var(--color-surface-hover)}.comment-composer .submit-comment{height:29px;margin-right:6px;padding:0 11px;border:1px solid var(--color-accent);border-radius:0;color:var(--color-accent);background:color-mix(in srgb,var(--color-accent) 5%,var(--app-bg))}.comment-composer .submit-comment:hover:not(:disabled){color:#fff;background:color-mix(in srgb,var(--color-accent) 72%,var(--app-bg))}
.detail-sidebar{padding:28px 17px 16px;border-left-color:var(--color-border);background:color-mix(in srgb,var(--color-surface) 46%,var(--app-bg))}.detail-sidebar .detail-actions{gap:10px;margin:0 0 12px}.detail-action{height:39px;font-size:11px}.detail-sidebar section{padding:17px 0}.detail-sidebar section h3{font-size:11.5px}.people{margin-top:12px}.detail-meta{gap:11px}
@media(max-width:1280px){.review-toolbar{grid-template-columns:310px minmax(180px,1fr) 145px 34px}.state-tabs{gap:3px}.state-tabs button{min-width:64px}.table-head,.request-row{grid-template-columns:80px minmax(250px,1.5fr) 105px minmax(190px,1fr) 150px}.request-groups{min-width:840px}.detail-panel{width:50%;min-width:650px}}
@media(max-width:900px){.review-toolbar{grid-template-columns:1fr 150px 34px;grid-template-rows:40px 40px}.detail-panel{width:72%;min-width:600px}.detail-content{grid-template-columns:minmax(0,1fr) 205px;gap:14px;padding-left:16px}.table-head,.request-row{grid-template-columns:76px minmax(240px,1.5fr) minmax(180px,1fr) 150px}.request-groups{min-width:720px}}
@media(max-width:680px){.detail-panel{width:100%;min-width:0}.detail-content{display:block;padding:0 14px}.detail-sidebar{display:none}.detail-title h2{font-size:16px}.detail-title-line>span{font-size:14px}}
/* Match the approved reference at its 1672px desktop width. */
.review-center{--review-divider:color-mix(in srgb,var(--color-border) 82%,transparent);font-size:13px;background:color-mix(in srgb,var(--app-bg) 96%,#172129)}
.review-center.has-inspector{--inspector-width:44%}
.review-header,.detail-header{box-sizing:border-box;height:54px;min-height:54px;background:color-mix(in srgb,var(--app-bg) 94%,var(--color-surface))}
.review-heading h1,.detail-provider strong{font-size:14px}.detail-provider>:global(svg){display:none}.detail-header{gap:4px}.detail-header-actions{display:contents}.detail-header-actions button:first-child{margin-right:auto}.detail-provider{flex:none}
.review-toolbar{height:60px;min-height:60px}.review-search input{font-size:13px}.review-center .state-tabs button{font-size:13px}.state-tabs span{font-size:11px}.table-pane{padding:8px 7px 0;box-sizing:border-box}.table-head{padding:0 16px;height:37px}.table-head,.request-row{grid-template-columns:100px minmax(210px,1fr) 106px 120px 165px 145px}.table-head>span{font-size:10px}.request-groups{min-width:900px}.group-header{height:44px;padding:0 12px}.group-header strong{font-size:13px}.request-row{padding:0 10px;min-height:78px}.request-row.selected{outline-color:color-mix(in srgb,var(--color-accent) 50%,var(--color-border));background:color-mix(in srgb,var(--app-bg) 93%,var(--color-accent));box-shadow:none}.request-row.selected:before{width:2px;box-shadow:none}.request-status{font-size:13px;font-weight:400}.request-status>span{font-weight:400}.request-status small{font-size:11px}.request-title strong{font-size:13px;font-weight:650}.request-title code{font-size:12px}.change-stats{font-size:12px}.request-author{font-size:12px}.repo-branch>strong{font-size:12px;font-weight:500}.branch-route code{padding:0;background:none;font-size:11px}.branch-route{gap:6px}
.review-center .provider-button{min-width:68px;padding:0 8px;font-size:13px;font-weight:500}.provider-button.merge-primary>:global(svg){display:none}.provider-action .action-toggle{width:32px;background:transparent}.row-actions{gap:7px}.panel-button{margin:0;width:32px}.action-menu{width:154px;padding:4px;background:color-mix(in srgb,var(--app-bg) 90%,var(--color-surface-raised));border:1px solid var(--color-border);box-shadow:0 6px 12px #0005}.action-menu button{font-size:13px;gap:10px;height:37px;padding:0 10px}.action-menu button.danger{margin:0;border-top:0;color:var(--color-ink)}.action-menu button.danger>:global(svg){color:#e7767e}.action-menu .menu-merge>:global(svg),.action-menu .menu-approve>:global(svg),.action-menu .menu-provider>:global(svg){color:var(--color-ink-muted)}
.detail-panel{min-width:0;width:var(--inspector-width,44%);box-shadow:-2px 0 8px #0002}.detail-content{grid-template-columns:minmax(0,1fr) 225px;gap:0;padding:0}.detail-main{padding:0 18px 0 24px}.detail-sidebar{padding:28px 17px 16px;background:transparent;border-left:1px solid var(--review-divider)}.detail-title{padding:17px 0 9px;border:0}.detail-title h2{font-size:21px;font-weight:650}.detail-title-line>span{font-size:20px;color:var(--color-ink-muted)}.detail-title .detail-summary{min-height:34px;gap:7px}.detail-summary strong{font-size:12px;font-weight:400}.state-badge{font-size:13px;font-weight:400}.detail-branch-route code{font-size:13px}.detail-main>.merge-summary{min-height:54px;margin:0;padding:0 11px;gap:10px}.merge-summary>span,.merge-summary>strong{font-size:12px}.detail-change-stats{font-size:12px;gap:9px}.merge-summary>:global(svg){flex:none}.detail-main>.description{padding:20px 0 22px}.description h3{font-size:14px;font-weight:600;margin-bottom:10px}.description p{font-size:13px}.detail-main>.comments-section{padding:12px 0 0;overflow:visible;border:0}.comments-section>header{margin-bottom:10px}.comments-section h3{font-size:14px;font-weight:600}.comments-section>header>span{font-size:10px}.comment-list{gap:10px;margin:0;scrollbar-gutter:auto}.comment-list article .comment-card{min-height:78px;border-color:var(--review-divider)!important;background:transparent!important}.comment-list article header{justify-content:flex-start;gap:5px;padding:0 11px}.comment-card header .avatar{margin-right:5px;width:28px;height:28px}.comment-list article strong{font-size:12px;font-weight:500}.comment-list article time{font-size:11px;margin:0}.owner-badge{margin-left:auto;font-size:10px}.comment-list article p{font-size:12px;padding:2px 14px 15px 56px!important}.comment-composer{margin-top:auto;padding:16px 0 26px}.comment-composer textarea{font-size:13px;min-height:86px;height:86px;max-height:86px;border-bottom:1px solid var(--color-border-input);background:transparent}.comment-composer footer{margin-top:8px;border:0;background:transparent;min-height:39px}.comment-composer .submit-comment{height:37px;margin:0;font-size:13px}.format-actions{gap:4px;padding:0}.comment-composer .format-actions button{width:27px}.detail-sidebar .detail-actions{gap:15px;margin-bottom:27px}.review-center .detail-action{font-size:13px;position:relative}.detail-action>:global(svg){position:absolute;left:12px;width:18px;height:18px}.detail-action.primary-action{background:color-mix(in srgb,#63c783 4%,transparent)}.detail-sidebar section{padding:16px 0;border-top:1px solid var(--review-divider);border-bottom:0}.detail-sidebar section h3{font-size:14px;font-weight:600}.people.compact{display:flex;gap:10px}.people strong,.detail-meta strong,.detail-meta span{font-size:12px}.detail-meta strong,.detail-meta span{display:flex;align-items:center;gap:10px}.detail-meta :global(svg){flex:none;color:var(--color-ink-muted)}
@media(max-width:1450px){.review-center.has-inspector{--inspector-width:52%}.detail-content{grid-template-columns:minmax(0,1fr) 190px}.detail-main{padding:0 14px}.detail-title h2{font-size:17px}.detail-title-line>span{font-size:17px}.detail-summary{flex-wrap:wrap}.detail-main>.merge-summary{flex-wrap:wrap;gap:7px;padding:9px}.detail-change-stats{font-size:10px}.detail-main>.merge-summary>span{font-size:11px}.detail-sidebar{padding:24px 12px}.format-actions{gap:0}.comment-composer .format-actions button{width:23px}}
@media(max-width:900px){.review-center.has-inspector{--inspector-width:100%}.detail-content{display:grid;grid-template-columns:minmax(0,1fr) 200px}.detail-sidebar{display:flex}.detail-main{height:auto}.detail-panel{inset:0 0 0 auto;min-width:0}.group-actions .icon-button{display:flex!important}}
@media(max-width:520px){.detail-content{display:flex;flex-direction:column}.detail-main{flex:1;min-height:0}.detail-sidebar{flex:none;padding:8px 14px;border-top:1px solid var(--review-divider);border-left:0}.detail-sidebar section{display:none}.detail-sidebar .detail-actions{grid-template-columns:1fr 1fr;gap:6px;margin:0}.detail-action{height:32px}.detail-header{padding:0 14px}.detail-main>.description{padding:10px 0}.comment-composer{padding-bottom:8px}.detail-title h2{white-space:normal}}
/* The inspector overlays an unchanged background; only viewport size reflows it. */
.review-toolbar{box-sizing:border-box;width:100%;height:56px;min-height:56px;grid-template-columns:316px minmax(140px,1fr) 145px 30px;grid-template-rows:1fr;gap:10px;padding:0 14px 0 8px}
.review-toolbar .state-tabs{grid-column:1;grid-row:1;gap:8px;align-items:stretch}
.review-center .state-tabs button{min-width:0;flex:1;gap:6px;padding:0 6px;justify-content:center;font-size:12px;white-space:nowrap}
.state-tabs span{min-width:14px;height:16px;margin-left:0;padding:0 3px;font-size:10px}
.state-tabs button.active span{background:transparent}
.review-search{grid-column:2;grid-row:1;height:32px;order:0}
.review-search input{font-size:12px}
.integration-picker{position:relative;grid-column:3;grid-row:1;height:32px;min-width:0}
.review-toolbar .group-actions{grid-column:4;grid-row:1;order:0}
.review-toolbar .group-actions .icon-button{width:30px;height:32px}
@media(max-width:720px){.review-toolbar{grid-template-columns:minmax(120px,1fr) 145px 30px;grid-template-rows:36px 38px;height:80px;min-height:80px;gap:0 10px}.review-toolbar .state-tabs{grid-column:1/-1;grid-row:1;max-width:316px}.review-search{grid-column:1;grid-row:2}.integration-picker{grid-column:2;grid-row:2}.review-toolbar .group-actions{grid-column:3;grid-row:2}}
.integration-picker :global(.select-menu){width:100%;height:32px}
.integration-picker :global(.select-menu-trigger){height:32px;min-height:32px;font-size:12px}
/* Compact request actions, matching the approved menu reference. */
.review-center .row-actions .provider-button{min-width:54px;height:27px;min-height:27px;padding:0 8px;font-size:11px;font-weight:500}
.review-center .row-actions .action-toggle{width:25px;height:27px;min-height:27px;background:transparent}
.review-center .row-actions .action-toggle.active{color:#72d58f;background:color-mix(in srgb,#4fa565 8%,var(--app-bg))}
.review-center .row-actions .panel-button{width:27px;height:27px;min-height:27px}
.review-center .action-menu{top:36px;right:-2px;box-sizing:border-box;width:max-content;min-width:122px;padding:3px 0;border:1px solid var(--color-border);border-radius:0;background:color-mix(in srgb,var(--app-bg) 96%,var(--color-surface-raised));box-shadow:0 5px 12px #0006}
.review-center .action-menu:before{right:calc(50% - 4px)}
.review-center .action-menu button{box-sizing:border-box;display:flex;width:100%;height:30px;min-height:30px;justify-content:flex-start;align-items:center;gap:9px;padding:0 11px;margin:0;border:0;border-radius:0;font-size:11px;font-weight:400;line-height:1.2;text-align:left;white-space:nowrap;color:var(--color-ink)}
.review-center .action-menu button>:global(svg){width:14px;height:14px;flex:0 0 14px;color:var(--color-ink-muted)}
.review-center .action-menu button.danger>:global(svg){color:#e7767e}
.review-center .action-menu button.menu-conflict{color:#e2ac58}
.review-center .action-menu button:hover:not(:disabled){background:var(--color-surface-hover)}
</style>
+2 -1
View File
@@ -50,7 +50,8 @@
const rect = trigger.getBoundingClientRect();
const viewportGap = 8;
const menuGap = 5;
const desiredHeight = Math.min(300, options.length * 34 + 24);
const groupHeaderCount = new Set(options.map((option) => option.group).filter(Boolean)).size;
const desiredHeight = Math.min(300, options.length * 32 + groupHeaderCount * 36 + 12);
const spaceBelow = window.innerHeight - rect.bottom - viewportGap;
const spaceAbove = rect.top - viewportGap;
const openAbove = spaceBelow < Math.min(desiredHeight, 180) && spaceAbove > spaceBelow;
+126 -19
View File
@@ -4,13 +4,18 @@
ArrowLeft,
ArrowRight,
FileDiff,
FileMinus2,
FileType,
FileX,
Folder,
FolderOpen,
FolderMinus,
FolderTree,
FolderX,
RotateCcw,
} from "@lucide/svelte";
import iconUrl from "../../../src-tauri/icons/icon.png";
import type { FileStatusKind, GitFileStatus, GitStatus } from "../types";
import type { FileStatusKind, GitFileStatus, GitIgnoreKind, GitStatus } from "../types";
interface Props {
changedFiles: GitFileStatus[];
@@ -27,6 +32,8 @@
onDiscard: (files: GitFileStatus[], staged: boolean) => void;
onDiscardMany: (files: GitFileStatus[]) => void;
onStash: (files: GitFileStatus[], label: string) => void;
onIgnore: (target: string, kind: GitIgnoreKind) => void;
onStopTracking: (target: string, kind: "file" | "folder") => void;
onPatch: (file: GitFileStatus, staged: boolean) => void;
onStageAll: () => void;
onUnstageAll: () => void;
@@ -76,6 +83,8 @@
onDiscard = () => {},
onDiscardMany = () => {},
onStash = () => {},
onIgnore = () => {},
onStopTracking = () => {},
onPatch = () => {},
onStageAll = () => {},
onUnstageAll = () => {},
@@ -188,6 +197,7 @@
let statusContextTarget = $state<StatusContextTarget | null>(null);
let statusContextMenuX = $state(0);
let statusContextMenuY = $state(0);
let statusContextMenuElement = $state<HTMLDivElement | null>(null);
function toggleStatusFolder(lane: StatusLaneKind, path: string) {
const next = new Set(collapsedStatusFolders);
@@ -220,6 +230,12 @@
statusContextMenuX = Math.max(8, Math.min(event.clientX, window.innerWidth - 288));
statusContextMenuY = Math.max(8, Math.min(event.clientY, window.innerHeight - 174));
statusContextTarget = { lane, kind, label, files };
requestAnimationFrame(() => {
if (!statusContextMenuElement) return;
const bounds = statusContextMenuElement.getBoundingClientRect();
statusContextMenuX = Math.max(8, Math.min(event.clientX, window.innerWidth - bounds.width - 8));
statusContextMenuY = Math.max(8, Math.min(event.clientY, window.innerHeight - bounds.height - 8));
});
}
function statusContextName(label: string): string {
@@ -252,6 +268,45 @@
onStash(target.files, target.label);
}
function isIgnoreableNewFile(file: GitFileStatus): boolean {
const path = file.path.replace(/\\/g, "/").toLowerCase();
if (path === ".gitignore") return false;
return file.unstaged === "untracked" || (file.staged === "added" && file.old_path === null);
}
function isTrackedStatusFile(file: GitFileStatus): boolean {
return file.unstaged !== "untracked" && file.staged !== "deleted";
}
function statusContextExtension(label: string): string {
const name = statusContextName(label);
const separator = name.lastIndexOf(".");
return separator > 0 && separator < name.length - 1 ? name.slice(separator + 1) : "";
}
function statusContextFolder(target: StatusContextTarget): string {
if (target.kind === "folder") return target.label.replace(/\\/g, "/").replace(/\/+$/, "");
const normalized = target.label.replace(/\\/g, "/");
const separator = normalized.lastIndexOf("/");
return separator > 0 ? normalized.slice(0, separator) : "";
}
function runStatusContextIgnoreAction(kind: GitIgnoreKind) {
const target = statusContextTarget;
if (!target) return;
const ignoreTarget = kind === "folder" ? statusContextFolder(target) : target.label;
if (!ignoreTarget) return;
closeStatusContextMenu();
onIgnore(ignoreTarget, kind);
}
function runStatusContextStopTracking() {
const target = statusContextTarget;
if (!target) return;
closeStatusContextMenu();
onStopTracking(target.label, target.kind);
}
function handleStatusWindowKeydown(event: KeyboardEvent) {
if (event.key === "Escape") closeStatusContextMenu();
}
@@ -340,6 +395,10 @@
let visibleStagedRows = $derived(statusView === "tree" ? flattenStatusTree(stagedTree, "staged") : listStatusRows(stagedFiles));
let selectedUnstagedCount = $derived(changedFiles.filter((f) => selectedStatusPaths.has(fileKey(f)) && f.unstaged !== null).length);
let selectedStagedCount = $derived(changedFiles.filter((f) => selectedStatusPaths.has(fileKey(f)) && f.staged !== null).length);
let statusContextCanIgnore = $derived(statusContextTarget?.files.some(isIgnoreableNewFile) ?? false);
let statusContextCanStopTracking = $derived(statusContextTarget?.files.some(isTrackedStatusFile) ?? false);
let statusContextIgnoreExtension = $derived(statusContextTarget?.kind === "file" ? statusContextExtension(statusContextTarget.label) : "");
let statusContextIgnoreFolder = $derived(statusContextTarget ? statusContextFolder(statusContextTarget) : "");
$effect(() => {
const validKeys = new Set(changedFiles.map(fileKey));
@@ -517,7 +576,7 @@
</section>
{#if statusContextTarget}
<div class="status-context-menu" style={`left: ${statusContextMenuX}px; top: ${statusContextMenuY}px;`} role="menu" tabindex="-1" aria-label={`Actions for ${statusContextTarget.label}`} onclick={(event) => event.stopPropagation()} onkeydown={(event) => { if (event.key === "Escape") closeStatusContextMenu(); event.stopPropagation(); }}>
<div bind:this={statusContextMenuElement} class="status-context-menu" style={`left: ${statusContextMenuX}px; top: ${statusContextMenuY}px;`} role="menu" tabindex="-1" aria-label={`Actions for ${statusContextTarget.label}`} onclick={(event) => event.stopPropagation()} onkeydown={(event) => { if (event.key === "Escape") closeStatusContextMenu(); event.stopPropagation(); }}>
<div class="status-context-label">
<span class="status-context-object-icon" aria-hidden="true">
{#if statusContextTarget.kind === "folder"}<FolderOpen size={16} />{:else}<FileDiff size={16} />{/if}
@@ -547,6 +606,49 @@
<span>Save {statusContextTarget.files.length === 1 ? "this file" : `${statusContextTarget.files.length} files`} for later</span>
</span>
</button>
{#if statusContextCanIgnore || statusContextCanStopTracking}
<div class="menu-separator" role="separator"></div>
{/if}
{#if statusContextCanStopTracking}
<button type="button" role="menuitem" onclick={runStatusContextStopTracking} disabled={isBusy} title="Keep the working-tree content and remove it from the Git index">
<span class="status-context-action-icon untrack" aria-hidden="true">
{#if statusContextTarget.kind === "folder"}<FolderMinus size={15} />{:else}<FileMinus2 size={15} />{/if}
</span>
<span class="status-context-action-copy">
<strong>Stop tracking {statusContextTarget.kind}</strong>
<span>Keep it on disk and remove it from Git</span>
</span>
</button>
{/if}
{#if statusContextCanIgnore}
{#if statusContextTarget.kind === "file"}
<button type="button" role="menuitem" onclick={() => runStatusContextIgnoreAction("file")} disabled={isBusy} title={`Add /${statusContextTarget.label.replace(/\\/g, "/")} to .gitignore`}>
<span class="status-context-action-icon ignore" aria-hidden="true"><FileX size={15} /></span>
<span class="status-context-action-copy">
<strong>Ignore file</strong>
<span>Add only this file to .gitignore</span>
</span>
</button>
{/if}
{#if statusContextIgnoreExtension}
<button type="button" role="menuitem" onclick={() => runStatusContextIgnoreAction("extension")} disabled={isBusy} title={`Add *.${statusContextIgnoreExtension} to .gitignore`}>
<span class="status-context-action-icon ignore" aria-hidden="true"><FileType size={15} /></span>
<span class="status-context-action-copy">
<strong>Ignore all *.{statusContextIgnoreExtension} files</strong>
<span>Match this file type repository-wide</span>
</span>
</button>
{/if}
{#if statusContextTarget.kind === "folder" && statusContextIgnoreFolder}
<button type="button" role="menuitem" onclick={() => runStatusContextIgnoreAction("folder")} disabled={isBusy} title={`Add /${statusContextIgnoreFolder}/ to .gitignore`}>
<span class="status-context-action-icon ignore" aria-hidden="true"><FolderX size={15} /></span>
<span class="status-context-action-copy">
<strong>Ignore folder</strong>
<span>Add this folder and its contents to .gitignore</span>
</span>
</button>
{/if}
{/if}
</div>
{/if}
@@ -558,8 +660,12 @@
display: grid;
place-items: center;
background:
radial-gradient(circle at 50% 38%, rgba(111, 140, 255, 0.1), transparent 55%),
rgba(7, 10, 16, 0.62);
radial-gradient(
circle at 50% 38%,
color-mix(in srgb, var(--color-accent) 13%, transparent),
transparent 55%
),
var(--app-dialog-backdrop);
backdrop-filter: blur(4px);
animation: status-panel-overlay-in 120ms ease;
}
@@ -571,13 +677,13 @@
gap: 12px;
width: min(300px, calc(100% - 32px));
padding: 26px 28px 26px;
border: 1px solid rgba(90, 111, 154, 0.28);
border: 1px solid var(--color-border-input);
border-radius: 16px;
background:
linear-gradient(180deg, rgba(23, 29, 43, 0.92), rgba(12, 17, 27, 0.94)),
var(--color-surface-raised);
var(--app-panel-highlight),
var(--app-dialog-bg);
color: var(--color-ink);
box-shadow: 0 18px 44px rgba(0, 0, 0, 0.34), inset 0 1px 0 rgba(255, 255, 255, 0.05);
box-shadow: var(--app-dialog-shadow);
}
.status-panel-overlay-mark {
@@ -592,7 +698,7 @@
.status-panel-overlay-halo {
position: absolute;
inset: 6px;
border: 1px solid rgba(111, 140, 255, 0.22);
border: 1px solid color-mix(in srgb, var(--color-primary) 34%, transparent);
border-radius: 22px;
transform: rotate(45deg);
}
@@ -601,7 +707,7 @@
}
.status-panel-overlay-halo.halo-two {
inset: 16px;
border-color: rgba(77, 182, 214, 0.24);
border-color: color-mix(in srgb, var(--color-accent) 38%, transparent);
animation: status-panel-overlay-halo-breathe 2.4s ease-in-out infinite reverse;
}
@@ -619,16 +725,16 @@
stroke-linecap: round;
stroke-dasharray: 165;
stroke-dashoffset: 165;
filter: drop-shadow(0 0 6px rgba(77, 182, 214, 0.32));
filter: drop-shadow(0 0 6px color-mix(in srgb, var(--color-accent) 38%, transparent));
animation: status-panel-overlay-trace-draw 2.6s ease-in-out infinite;
}
.status-panel-overlay-traces .trace-main { stroke: #6f8cff; }
.status-panel-overlay-traces .trace-main { stroke: var(--color-primary); }
.status-panel-overlay-traces .trace-branch {
stroke: #4db6d6;
stroke: var(--color-accent);
animation-delay: 0.28s;
}
.status-panel-overlay-traces .trace-cut {
stroke: rgba(177, 186, 208, 0.42);
stroke: color-mix(in srgb, var(--color-ink-muted) 52%, transparent);
stroke-dasharray: 128;
stroke-dashoffset: 128;
animation-delay: 0.55s;
@@ -641,8 +747,8 @@
height: 64px;
object-fit: contain;
filter:
drop-shadow(0 8px 12px rgba(0, 0, 0, 0.5))
drop-shadow(0 0 10px rgba(77, 182, 214, 0.2));
drop-shadow(0 8px 12px color-mix(in srgb, var(--color-ink) 24%, transparent))
drop-shadow(0 0 10px color-mix(in srgb, var(--color-accent) 24%, transparent));
animation: status-panel-overlay-icon-float 2.4s ease-in-out infinite;
}
@@ -663,15 +769,16 @@
height: 4px;
overflow: hidden;
border-radius: 999px;
background: rgba(111, 140, 255, 0.14);
border: 1px solid var(--color-border-subtle);
background: color-mix(in srgb, var(--color-primary) 12%, var(--color-surface-dim));
}
.status-panel-overlay-bar span {
position: absolute;
inset: 0;
width: 46%;
border-radius: inherit;
background: linear-gradient(90deg, transparent, #6f8cff 40%, #4db6d6 74%, transparent);
box-shadow: 0 0 12px rgba(77, 182, 214, 0.26);
background: linear-gradient(90deg, transparent, var(--color-primary) 40%, var(--color-accent) 74%, transparent);
box-shadow: 0 0 12px color-mix(in srgb, var(--color-accent) 32%, transparent);
animation: status-panel-overlay-bar-slide 1.35s ease-in-out infinite;
}
+53 -54
View File
@@ -53,7 +53,7 @@
);
const versionLabel = $derived(
version && currentVersion
? `${currentVersion} -> ${version}`
? `${currentVersion} ${version}`
: version
? `Version ${version}`
: "New version",
@@ -69,34 +69,32 @@
role={state === "error" ? "alert" : "status"}
aria-live={state === "error" ? "assertive" : "polite"}
>
<div class="update-toast-glow" aria-hidden="true"></div>
<div class="update-toast-icon" class:busy={isBusy}>
{#if state === "downloading"}
<LoaderCircle class="spin" size={21} aria-hidden="true" />
{:else if state === "installed"}
<PackageCheck size={21} aria-hidden="true" />
{:else if state === "error"}
<AlertCircle size={21} aria-hidden="true" />
{:else}
<Sparkles size={21} aria-hidden="true" />
{/if}
</div>
<div class="update-toast-content">
<div class="update-toast-top">
<div class="update-toast-copy">
<span class="update-toast-kicker">{versionLabel}</span>
<h2>{title}</h2>
</div>
{#if !isBusy}
<button class="update-toast-close" type="button" onclick={onDismiss} title="Close" aria-label="Close">
<X size={15} aria-hidden="true" />
</button>
<header class="update-toast-header">
<div class="update-toast-icon" class:busy={isBusy}>
{#if state === "downloading"}
<LoaderCircle class="spin" size={18} aria-hidden="true" />
{:else if state === "installed"}
<PackageCheck size={18} aria-hidden="true" />
{:else if state === "error"}
<AlertCircle size={18} aria-hidden="true" />
{:else}
<Sparkles size={18} aria-hidden="true" />
{/if}
</div>
<div class="update-toast-copy">
<span class="update-toast-kicker">Gitty update · {versionLabel}</span>
<h2>{title}</h2>
</div>
{#if !isBusy}
<button class="update-toast-close" type="button" onclick={onDismiss} title="Close" aria-label="Close">
<X size={15} aria-hidden="true" />
</button>
{/if}
</header>
<div class="update-toast-body">
<p>{description}</p>
{#if showProgress}
@@ -115,33 +113,34 @@
</div>
{/if}
<div class="update-toast-actions">
{#if state === "installed"}
<button class="update-toast-secondary" type="button" onclick={onDismiss}>
Got it
</button>
{:else if state === "error"}
<button class="update-toast-secondary" type="button" onclick={onDismiss}>
Close
</button>
<button class="update-toast-primary" type="button" onclick={onInstall}>
<Download size={14} aria-hidden="true" />
Try again
</button>
{:else}
<button class="update-toast-secondary" type="button" onclick={onLater} disabled={isBusy}>
Later
</button>
<button class="update-toast-primary" type="button" onclick={onInstall} disabled={isBusy}>
{#if isBusy}
<LoaderCircle class="spin" size={14} aria-hidden="true" />
Installing
{:else}
<Download size={14} aria-hidden="true" />
Install
{/if}
</button>
{/if}
</div>
</div>
<footer class="update-toast-actions">
{#if state === "installed"}
<button class="update-toast-secondary" type="button" onclick={onDismiss}>
Got it
</button>
{:else if state === "error"}
<button class="update-toast-secondary" type="button" onclick={onDismiss}>
Close
</button>
<button class="update-toast-primary" type="button" onclick={onInstall}>
<Download size={14} aria-hidden="true" />
Try again
</button>
{:else}
<button class="update-toast-secondary" type="button" onclick={onLater} disabled={isBusy}>
Later
</button>
<button class="update-toast-primary" type="button" onclick={onInstall} disabled={isBusy}>
{#if isBusy}
<LoaderCircle class="spin" size={14} aria-hidden="true" />
Installing
{:else}
<Download size={14} aria-hidden="true" />
Install
{/if}
</button>
{/if}
</footer>
</section>
+81 -19
View File
@@ -3,9 +3,9 @@ import { tracedInvoke as invoke } from "./telemetry";
import type {
AiReviewResult,
AiCommitPlan,
CommitAiLocalProfile,
BisectState,
CommitAiProvider,
CommitAiStatus,
CloneOptions,
ConflictFile,
DetectedExternalTool,
ExternalToolCommand,
@@ -14,6 +14,11 @@ import type {
GitBranch,
GitCommit,
GitCommitComparison,
GitIgnoreKind,
GitIntegrationProvider,
GitIntegrationRepository,
IntegrationReviewRequest,
IntegrationReviewAction,
GitLfsStatus,
GitRepositoryFile,
GitRemote,
@@ -27,7 +32,6 @@ import type {
GitStatus,
GitTag,
GitWorktree,
LocalModelOption,
PatchApplyAction,
RepositoryBundle,
StoredCredential,
@@ -53,6 +57,30 @@ export function detectExternalTools(): Promise<DetectedExternalTool[]> {
return invoke<DetectedExternalTool[]>("detect_external_tools");
}
export function listIntegrationRepositories(provider: GitIntegrationProvider, baseUrl: string, accountId?: string): Promise<GitIntegrationRepository[]> {
return invoke<GitIntegrationRepository[]>("list_integration_repositories", { provider, baseUrl, accountId: accountId ?? null });
}
export function listIntegrationReviewRequests(provider: GitIntegrationProvider, baseUrl: string, username: string, token: string, state: "open" | "merged" | "closed"): Promise<IntegrationReviewRequest[]> {
return invoke<IntegrationReviewRequest[]>("list_integration_review_requests", { provider, baseUrl, username, token, state });
}
export function runIntegrationReviewAction(provider: GitIntegrationProvider, baseUrl: string, username: string, token: string, request: IntegrationReviewRequest, action: IntegrationReviewAction): Promise<void> {
return invoke<void>("run_integration_review_action", { provider, baseUrl, username, token, repositoryId: request.repositoryId, repositoryName: request.repositoryName, number: request.number, action });
}
export function getIntegrationReviewDetails(provider: GitIntegrationProvider, baseUrl: string, username: string, token: string, request: IntegrationReviewRequest): Promise<IntegrationReviewRequest> {
return invoke<IntegrationReviewRequest>("get_integration_review_details", { provider, baseUrl, username, token, request });
}
export function addIntegrationReviewComment(provider: GitIntegrationProvider, baseUrl: string, username: string, token: string, request: IntegrationReviewRequest, body: string): Promise<void> {
return invoke<void>("add_integration_review_comment", { provider, baseUrl, username, token, request, body });
}
export function openInBrowser(url: string): Promise<void> {
return invoke<void>("open_in_browser", { url });
}
export function launchExternalTool(path: string, command: ExternalToolCommand, file?: string): Promise<void> {
return invoke<void>("launch_external_tool", { path, file: file ?? null, command });
}
@@ -76,6 +104,7 @@ export function cloneRepository(
username?: string,
password?: string,
commitLimit = 100,
options: CloneOptions = { branch: null, blobless: false, customFlags: "", shallowDepth: null, shallowSince: null, sparse: false, sparsePaths: [] },
): Promise<RepositoryBundle> {
return invoke<RepositoryBundle>("clone_repository", {
remoteUrl,
@@ -84,6 +113,13 @@ export function cloneRepository(
username: username ?? null,
password: password ?? null,
commitLimit,
branch: options.branch,
blobless: options.blobless,
customFlags: options.customFlags,
shallowDepth: options.shallowDepth,
shallowSince: options.shallowSince,
sparse: options.sparse,
sparsePaths: options.sparsePaths,
});
}
@@ -91,6 +127,22 @@ export function getStatus(path: string): Promise<GitStatus> {
return invoke<GitStatus>("get_status", { path });
}
export function getBisectState(path: string): Promise<BisectState> {
return invoke<BisectState>("get_bisect_state", { path });
}
export function startBisect(path: string, good: string, bad: string): Promise<BisectState> {
return invoke<BisectState>("start_bisect", { path, good, bad });
}
export function markBisect(path: string, verdict: "good" | "bad" | "skip"): Promise<BisectState> {
return invoke<BisectState>("mark_bisect", { path, verdict });
}
export function resetBisect(path: string): Promise<GitStatus> {
return invoke<GitStatus>("reset_bisect", { path });
}
export function getGitLfsStatus(path: string): Promise<GitLfsStatus> {
return invoke<GitLfsStatus>("git_lfs_status", { path });
}
@@ -291,6 +343,14 @@ export function unstageFiles(path: string, files: string[]): Promise<GitStatus>
return invoke<GitStatus>("unstage_files", { path, files });
}
export function addToGitignore(path: string, target: string, kind: GitIgnoreKind): Promise<GitStatus> {
return invoke<GitStatus>("add_to_gitignore", { path, target, kind });
}
export function untrackPaths(path: string, targets: string[]): Promise<GitStatus> {
return invoke<GitStatus>("untrack_paths", { path, targets });
}
export function restoreFiles(
path: string,
files: string[],
@@ -354,22 +414,9 @@ export function stashDrop(path: string, selector: string): Promise<GitStatus> {
return invoke<GitStatus>("stash_drop", { path, selector });
}
export function commitAiStatus(): Promise<CommitAiStatus> {
return invoke<CommitAiStatus>("commit_ai_status");
}
export function commitAiLoad(modelId: string): Promise<void> {
return invoke<void>("commit_ai_load", { modelId });
}
export function commitAiLocalModels(): Promise<LocalModelOption[]> {
return invoke<LocalModelOption[]>("commit_ai_local_models");
}
export interface CommitAiGenerateOptions {
notes?: string;
provider: CommitAiProvider;
localProfile?: CommitAiLocalProfile;
model?: string;
apiKey?: string;
baseUrl?: string;
@@ -380,7 +427,6 @@ export function commitAiGenerate(path: string, options: CommitAiGenerateOptions)
path,
notes: options.notes,
provider: options.provider,
localProfile: options.localProfile,
model: options.model,
apiKey: options.apiKey,
baseUrl: options.baseUrl,
@@ -407,8 +453,24 @@ export function commitAiSplit(path: string, options: CommitAiGenerateOptions): P
});
}
export function pull(path: string, username?: string, password?: string, strategy: PullStrategy = "merge", remote?: string, branch?: string): Promise<GitStatus> {
return invoke<GitStatus>("pull", { path, username: username ?? null, password: password ?? null, strategy, remote: remote || null, branch: branch || null });
export function pull(
path: string,
username?: string,
password?: string,
strategy: PullStrategy = "merge",
remote?: string,
branch?: string,
allowUnrelatedHistories = false,
): Promise<GitStatus> {
return invoke<GitStatus>("pull", {
path,
username: username ?? null,
password: password ?? null,
strategy,
remote: remote || null,
branch: branch || null,
allowUnrelatedHistories,
});
}
export function fetchRemote(path: string, username?: string, password?: string, prune = false, remote?: string): Promise<GitStatus> {
+169
View File
@@ -0,0 +1,169 @@
import type {
AzureDevOpsOrganization,
GitIntegrationConfig,
GitIntegrationProvider,
GitIntegrationSource,
GitIntegrationSettings,
} from "./types";
export const gitIntegrationProviders: GitIntegrationProvider[] = [
"github",
"gitlab",
"gitlab-self-hosted",
"azure-devops",
"gitea",
];
const defaults: Record<GitIntegrationProvider, Omit<GitIntegrationConfig, "provider">> = {
github: {
enabled: false,
baseUrl: "https://github.com",
username: "",
tokenStored: false,
},
gitlab: {
enabled: false,
baseUrl: "https://gitlab.com",
username: "",
tokenStored: false,
},
"gitlab-self-hosted": {
enabled: false,
baseUrl: "",
username: "",
tokenStored: false,
},
"azure-devops": {
enabled: false,
baseUrl: "https://dev.azure.com/",
username: "",
tokenStored: false,
},
gitea: {
enabled: false,
baseUrl: "",
username: "",
tokenStored: false,
},
};
export function defaultGitIntegrationSettings(): GitIntegrationSettings {
return {
providers: Object.fromEntries(
gitIntegrationProviders.map((provider) => [provider, { provider, ...defaults[provider] }]),
) as GitIntegrationSettings["providers"],
azureDevOpsOrganizations: [],
};
}
function normaliseAzureOrganization(value: unknown): AzureDevOpsOrganization | null {
if (!value || typeof value !== "object") return null;
const stored = value as Partial<AzureDevOpsOrganization>;
const id = typeof stored.id === "string" && /^[a-zA-Z0-9_-]{1,80}$/.test(stored.id) ? stored.id : "";
if (!id) return null;
return {
id,
name: typeof stored.name === "string" ? stored.name : "",
enabled: stored.enabled === true,
baseUrl: typeof stored.baseUrl === "string" ? stored.baseUrl : "",
username: typeof stored.username === "string" ? stored.username : "",
tokenStored: stored.tokenStored === true,
};
}
export function normaliseGitIntegrationSettings(value: unknown): GitIntegrationSettings {
const fallback = defaultGitIntegrationSettings();
if (!value || typeof value !== "object") return fallback;
const storedProviders = (value as Partial<GitIntegrationSettings>).providers;
if (!storedProviders || typeof storedProviders !== "object") return fallback;
for (const provider of gitIntegrationProviders) {
const stored = storedProviders[provider] as Partial<GitIntegrationConfig> | undefined;
if (!stored || typeof stored !== "object") continue;
fallback.providers[provider] = {
provider,
enabled: stored.enabled === true,
baseUrl: typeof stored.baseUrl === "string" ? stored.baseUrl : fallback.providers[provider].baseUrl,
username: typeof stored.username === "string" ? stored.username : "",
tokenStored: stored.tokenStored === true,
};
}
const storedOrganizations = (value as Partial<GitIntegrationSettings>).azureDevOpsOrganizations;
if (Array.isArray(storedOrganizations)) {
const seen = new Set<string>();
fallback.azureDevOpsOrganizations = storedOrganizations
.map(normaliseAzureOrganization)
.filter((organization): organization is AzureDevOpsOrganization => {
if (!organization || seen.has(organization.id)) return false;
seen.add(organization.id);
return true;
});
} else {
const legacy = fallback.providers["azure-devops"];
const hasLegacyConfiguration = legacy.enabled || legacy.tokenStored || legacy.username.trim().length > 0 || !/^https:\/\/dev\.azure\.com\/?$/i.test(legacy.baseUrl.trim());
if (hasLegacyConfiguration) {
fallback.azureDevOpsOrganizations = [{
id: "default",
name: "Azure DevOps",
enabled: legacy.enabled,
baseUrl: legacy.baseUrl,
username: legacy.username,
tokenStored: legacy.tokenStored,
}];
}
}
return fallback;
}
export function integrationCredentialKey(provider: GitIntegrationProvider, accountId?: string): string {
if (provider === "azure-devops" && accountId && accountId !== "default") {
return `integration:${provider}:${accountId}`;
}
return `integration:${provider}`;
}
export function configuredIntegrationSources(settings: GitIntegrationSettings): GitIntegrationSource[] {
const sources: GitIntegrationSource[] = [];
for (const provider of gitIntegrationProviders) {
if (provider === "azure-devops") continue;
const config = settings.providers[provider];
if (config.enabled && config.tokenStored && config.baseUrl.trim()) {
sources.push({ id: provider, provider, label: providerLabel(provider), baseUrl: config.baseUrl });
}
}
for (const organization of settings.azureDevOpsOrganizations) {
if (!organization.enabled || !organization.tokenStored || !organization.baseUrl.trim()) continue;
sources.push({
id: `azure-devops:${organization.id}`,
provider: "azure-devops",
accountId: organization.id,
label: organization.name.trim() || organizationNameFromUrl(organization.baseUrl) || "Azure DevOps",
baseUrl: organization.baseUrl,
});
}
return sources;
}
export function configuredIntegrationCount(settings: GitIntegrationSettings): number {
return configuredIntegrationSources(settings).length;
}
export function providerLabel(provider: GitIntegrationProvider): string {
return {
github: "GitHub",
gitlab: "GitLab.com",
"gitlab-self-hosted": "GitLab Self-Managed",
"azure-devops": "Azure DevOps",
gitea: "Gitea",
}[provider];
}
export function organizationNameFromUrl(value: string): string {
try {
const url = new URL(value);
return url.pathname.split("/").filter(Boolean)[0] ?? "";
} catch {
return "";
}
}
+4
View File
@@ -11,6 +11,10 @@ export function beginFrontendShutdown() {
frontendShuttingDown = true;
}
export function resumeFrontend() {
frontendShuttingDown = false;
}
// Telemetry must never contain repository contents or identifying local data.
function sanitize(message: string): string {
return message
+122 -15
View File
@@ -7,16 +7,102 @@ export type FileStatusKind =
| "conflicted"
| "unknown";
export type CommitAiPhase = "idle" | "loading" | "ready" | "error";
export type CommitAiProvider = "local" | "openai" | "anthropic" | "custom";
export type CommitAiLocalProfile = "fast" | "balanced" | "detailed";
export type GitIgnoreKind = "file" | "extension" | "folder";
export type CommitAiProvider = "openai" | "anthropic" | "custom";
export type AppTheme = "system" | "light" | "dark";
export type AppAppearance = "modern" | "classic" | "custom";
export type AppLanguage = "en" | "de";
export interface CommitAiStatus {
phase: CommitAiPhase;
model_id: string | null;
error: string | null;
export type GitIntegrationProvider = "github" | "gitlab" | "gitlab-self-hosted" | "azure-devops" | "gitea";
export interface GitIntegrationConfig {
provider: GitIntegrationProvider;
enabled: boolean;
baseUrl: string;
username: string;
tokenStored: boolean;
}
export interface GitIntegrationSettings {
providers: Record<GitIntegrationProvider, GitIntegrationConfig>;
azureDevOpsOrganizations: AzureDevOpsOrganization[];
}
export interface AzureDevOpsOrganization {
id: string;
name: string;
enabled: boolean;
baseUrl: string;
username: string;
tokenStored: boolean;
}
export interface GitIntegrationSecretUpdate {
provider: GitIntegrationProvider;
accountId?: string;
token?: string;
removeToken?: boolean;
}
export interface GitIntegrationSource {
id: string;
provider: GitIntegrationProvider;
accountId?: string;
label: string;
baseUrl: string;
}
export interface GitIntegrationRepository {
id: string;
name: string;
fullName: string;
description: string;
cloneUrl: string;
sshUrl: string;
webUrl: string;
updatedAt: string;
private: boolean;
}
export type IntegrationReviewState = "open" | "draft" | "merged" | "closed";
export type IntegrationReviewAction = "merge" | "approve" | "close" | "reopen";
export interface IntegrationReviewComment {
id: string;
author: string;
body: string;
createdAt: string;
}
export interface IntegrationReviewRequest {
id: string;
number: number;
provider: GitIntegrationProvider;
repositoryId: string;
repositoryName: string;
title: string;
description: string;
author: string;
state: IntegrationReviewState;
sourceBranch: string;
targetBranch: string;
webUrl: string;
createdAt: string;
updatedAt: string;
collaborators: string[];
additions: number | null;
deletions: number | null;
changedFiles: number | null;
mergeStatus: "" | "mergeable" | "conflicts" | "checking" | "blocked";
comments: IntegrationReviewComment[];
}
export interface CustomThemeColors {
background: string;
surface: string;
accent: string;
text: string;
}
export type AiReviewRisk = "low" | "medium" | "high";
@@ -48,16 +134,8 @@ export interface AiCommitPlan {
groups: AiCommitGroup[];
}
export interface LocalModelOption {
id: string;
label: string;
approx_size_mb: number;
}
export interface AiSettings {
provider: CommitAiProvider;
localModelId: string;
localProfile: CommitAiLocalProfile;
openaiModel: string;
anthropicModel: string;
customBaseUrl: string;
@@ -116,6 +194,16 @@ export interface GitRemote { name: string; fetch_url: string; push_url: string;
export type PullStrategy = "merge" | "rebase" | "ff-only";
export type MergeStrategy = "default" | "squash" | "ff-only" | "no-ff";
export interface CloneOptions {
branch: string | null;
blobless: boolean;
customFlags: string;
shallowDepth: number | null;
shallowSince: string | null;
sparse: boolean;
sparsePaths: string[];
}
export interface GitFileStatus {
path: string;
old_path: string | null;
@@ -181,6 +269,24 @@ export interface GitCommit {
has_note: boolean;
}
export interface BisectCommit {
hash: string;
short_hash: string;
summary: string;
author_name: string;
date: string;
}
export interface BisectState {
active: boolean;
finished: boolean;
current: BisectCommit | null;
culprit: BisectCommit | null;
remaining_revisions: number | null;
remaining_steps: number | null;
message: string;
}
export interface GitCommitFile {
path: string;
old_path: string | null;
@@ -228,6 +334,7 @@ export interface RepositoryBundle {
stashes: GitStash[];
commits: GitCommit[];
files: GitRepositoryFile[];
warning: string | null;
}
export interface GitDiffFile {