Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
46 commits
Select commit Hold shift + click to select a range
8798c7d
feat(forge): route PRs per provider and name the forge in the UI
leduckhc Aug 10, 2026
164eda8
fix(forge): scope Forgejo credentials to their instance; accept FORGE…
leduckhc Aug 10, 2026
87a0883
fix(forge): stop GitHub's quota ladder throttling Forgejo, and honour…
leduckhc Aug 10, 2026
53be5e2
feat(forge): identify the forge by asking it, not by guessing from th…
leduckhc Aug 10, 2026
5a11d38
feat(forge): detection + UI mockup (no picker needed — automatic)
leduckhc Aug 10, 2026
5719cc2
docs(mockups): where per-repo forge configuration should surface
leduckhc Aug 10, 2026
b3508da
docs(mockups): per-repo settings — inheritance, and the script trust …
leduckhc Aug 10, 2026
445b68c
docs(mockups): redraw repo settings against the real Settings window
leduckhc Aug 10, 2026
6b806b4
docs(mockups): a Settings section per repository, not one Repositorie…
leduckhc Aug 10, 2026
0bae398
docs(spec): SPEC-48 per-repo settings — spec + plan (rev 1, pre-review)
leduckhc Aug 10, 2026
aee8a37
docs(spec): SPEC-48 review round 1 — NOT READY from both reviewers
leduckhc Aug 10, 2026
d3562c2
docs(spec): SPEC-48 rev 2 — both blocking decisions answered, 11 erro…
leduckhc Aug 10, 2026
4cb3415
docs(spec): SPEC-48 rev 2.1 — round-2 findings applied
leduckhc Aug 10, 2026
34c5625
docs(spec): SPEC-48 rev 2.2 — round-3 findings applied (reviewer's wo…
leduckhc Aug 10, 2026
2595d0e
feat(settings): per-repo Settings section + monogram, verified on the…
leduckhc Aug 11, 2026
7facd71
feat(settings): right-aligned value column; mockup reconciled with th…
leduckhc Aug 11, 2026
82883e5
feat(settings): make logo, root path, provider and default branch edi…
leduckhc Aug 11, 2026
3348d49
refactor(settings): cut the provenance badges and the copy button
leduckhc Aug 11, 2026
5a592c6
feat(settings): the git provider can be None
leduckhc Aug 11, 2026
c4358ea
feat(forge): per-repo settings — persistence, resolution, validation,…
leduckhc Aug 11, 2026
c22bde2
feat(app): render the per-repo Settings section from real server data
leduckhc Aug 11, 2026
d33e1bf
feat(app): reachable per-repo Settings sections, wired to the server
leduckhc Aug 11, 2026
2384559
docs(spec): SPEC-48 P1 implemented — results and the live proof
leduckhc Aug 11, 2026
c409d6b
feat(forge): the provider override actually routes
leduckhc Aug 11, 2026
a46d589
feat(git): the default-branch override reaches all three consumers
leduckhc Aug 11, 2026
768670b
feat(app): the chosen logo is actually drawn
leduckhc Aug 11, 2026
5eb72e0
test(app): T6.2 — the per-repo Settings path, mounted for real
leduckhc Aug 11, 2026
4c14ccb
feat(forge): re-pointing a repository that moved on disk (D4')
leduckhc Aug 11, 2026
cb19cb2
docs(spec): SPEC-48 P2 implemented — results and the live proof
leduckhc Aug 11, 2026
7bdced6
feat(forge): New worktree from PR works on Forgejo, not just GitHub
leduckhc Aug 11, 2026
3eee753
docs(spec): SPEC-48 — the PR picker follows the provider on both forges
leduckhc Aug 11, 2026
a681cde
fix(forge): two review findings on the P2 work
leduckhc Aug 11, 2026
dc3a34e
fix: apply ocr review findings (and reject one suggested fix)
leduckhc Aug 11, 2026
78e65d4
fix(forge): apply the server-scoped ocr findings
leduckhc Aug 11, 2026
dd5948a
chore: untrack the local pnpm store
leduckhc Aug 11, 2026
eb38378
style: apply dart format, and brace the if it reflowed
leduckhc Aug 11, 2026
b6e8ccb
Merge origin/main into feat/forgejo-git-provider
leduckhc Aug 11, 2026
2aa8f48
fix(forge): apply the server-side review findings
leduckhc Aug 11, 2026
1fb55c9
fix(app): apply the app-side review findings
leduckhc Aug 11, 2026
38df37b
style: dart format the review fixes
leduckhc Aug 11, 2026
297a30f
fix: apply the second review round
leduckhc Aug 11, 2026
3f8dffa
style: dart format the second-round fixes
leduckhc Aug 11, 2026
d066489
fix: high-severity default-branch resolver bug and status-center inte…
leduckhc Aug 11, 2026
56d0fde
fix(git): a local branch named origin/... is not a remote-tracking ref
leduckhc Aug 11, 2026
58a5ada
fix: four findings I resolved before reading them
leduckhc Aug 11, 2026
d4ce4cb
fix(app): a default-branch override can always be cleared
leduckhc Aug 11, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,7 @@ app/test/sim/frames/

# Local QA artifacts (screenshots, issue logs) from tool/shoot-ports.sh
.qa/
.piano/

# Local pnpm store, created when a store-dir is set for this checkout
.pnpm-store/
24 changes: 23 additions & 1 deletion app/ASSET_ATTRIBUTION.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,4 +20,26 @@ uses a neutral Phosphor glyph for those rather than a fabricated mark.
| File(s) | Role | Source | License |
|---------|------|--------|---------|
| `repo-push.png`, `repo-pull.png` | Composer PR actions | [VS Code codicons](https://github.com/microsoft/vscode-codicons) | CC BY 4.0 |
| `git-pull-request-closed-{thin,light,regular,bold,fill}.svg` | Closed-PR state marker | Original, drawn to match [Phosphor](https://phosphoricons.com) metrics (five weights: light is default) | MIT (this repo) |
| `git-pull-request-closed-{thin,light,regular,bold,fill}.svg` | Closed-PR state marker | Original — [`phosphor_extras`](../../phosphor_extras) | MIT |
| `forgejo-light.svg` | Forgejo forge marker | Original reduction of Forgejo's mark to [Phosphor](https://phosphoricons.com) metrics — [`phosphor_extras`](../../phosphor_extras) | MIT (drawing); Forgejo's mark belongs to the Forgejo project |
| `gitea-light.svg` | Gitea forge marker | Original reduction of Gitea's mark to Phosphor metrics — [`phosphor_extras`](../../phosphor_extras) | MIT (drawing); Gitea's mark belongs to the Gitea project |

Glyphs marked *Original* are authored in the **`phosphor_extras`** repo, which is
the source of truth for their geometry and holds the generator and the invariant
checks. `scripts/sync-icons.sh` vendors the built SVGs here (rather than adding a
dependency) so a fresh clone builds without network access to another repo;
`scripts/sync-icons.sh --check` fails if a vendored copy has drifted.

The Forgejo and Gitea glyphs identify those projects' software in the UI —
nominative use. The MIT grant covers our drawings, not the underlying marks.

## Agent logos in [`assets/agents/`](assets/agents/)

Used to identify which coding agent backs a session. Each is the property of its
project and is included for identification only.

| File | Agent | Owner |
|------|-------|-------|
| `claude.svg` | Claude Code | Anthropic |
| `codex.svg` | Codex | OpenAI |
| `pi.svg` | pi | Earendil Works |
14 changes: 14 additions & 0 deletions app/assets/icons/forgejo-light.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
14 changes: 14 additions & 0 deletions app/assets/icons/gitea-light.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
258 changes: 258 additions & 0 deletions app/integration_test/desktop/settings_repo_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,258 @@
// SPEC-48 T6.2 — the per-repo Settings path, mounted for real.
//
// What this proves that the unit and widget tests cannot: the WHOLE path from the
// live repo snapshot through the dynamic registry to a rendered row, inside the
// real `SettingsWindow`, on a real macOS build.
//
// reposProvider → sectionsFor() → the nav pane → tap → RepositorySettingsPage
// → repoSettingsViewFor() → RepositorySettingsSection → the rows
//
// Every hop above is covered by a unit or widget test in isolation. None of them
// covers the composition, and this repo has already been bitten there once: the
// desktop shell mounts Settings *outside* a GoRouter, so a section that navigated
// with `context.go` threw at runtime while every test stayed green. A test that
// mounts the section directly cannot catch that class of fault; this one can.
//
// Deliberately NOT extended onto the daemon control socket (see the plan's T6.2):
// the daemon-side behaviour is proven by the server tests, and routing this through
// the socket would add infrastructure for no extra coverage. The repo snapshot is
// stubbed at `reposProvider`, which is exactly the seam `SettingsWindow` reads.
//
// Run: app/tool/e2e-desktop-settings.sh
//
// ignore_for_file: depend_on_referenced_packages
import 'dart:io' show Platform;

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:makit/desktop/daemon/daemon_lifecycle.dart';
import 'package:makit/desktop/desktop_app.dart' show desktopControllerProvider;
import 'package:makit/desktop/desktop_controller.dart';
import 'package:makit/desktop/screens/fake_control_client.dart';
import 'package:makit/desktop/settings/sections/repository_section.dart';
import 'package:makit/desktop/settings/server_config.dart';
import 'package:makit/desktop/settings/settings_nav_pane.dart';
import 'package:makit/desktop/settings/settings_window.dart';
import 'package:makit/store/connection.dart';
import 'package:makit/store/models.dart';
import 'package:makit/store/store.dart';
import 'package:makit/ui/home/repo_monogram.dart';
import 'package:shared_preferences/shared_preferences.dart';

/// Two pinned repos and one unpinned, so the pinned filter is exercised by the
/// same fixture that exercises the rows.
///
/// `Diana` carries a worktree-root override and a chosen hue; `makit` inherits
/// everything. The difference is the point: the section has to report each repo's
/// own state, and a bug that reads the wrong repo's settings passes any fixture
/// where both repos look the same.
/// The real home, because the section abbreviates paths against `HOME` at runtime.
/// Hardcoding `/Users/le` made the `~/trees/diana` and `~/.worktrees` assertions hold
/// on exactly one machine.
final String _home = Platform.environment['HOME'] ?? '/root';

final _repos = <RepoInfo>[
RepoInfo.fromJson({
'id': 'p-diana',
'name': 'Diana',
'path': '$_home/Work/XDent/Diana',
'pinned': true,
'isGitRepo': true,
'defaultBranch': 'main',
'currentBranch': 'main',
'worktrees': const <Map<String, dynamic>>[],
'settings': {
'worktreeRoot': {'value': '$_home/trees/diana', 'source': 'override'},
'provider': {'value': 'forgejo', 'source': 'override'},
'defaultBranch': {'value': 'trunk', 'source': 'override'},
'logoHue': 2,
'hasRemote': true,
'forge': {
'software': 'forgejo',
'host': 'forgejo.internal.test',
'authed': true,
},
},
})!,
RepoInfo.fromJson({
'id': 'p-makit',
'name': 'makit',
'path': '$_home/Work/makit',
'pinned': true,
'isGitRepo': true,
'defaultBranch': 'main',
'currentBranch': 'main',
'worktrees': const <Map<String, dynamic>>[],
'settings': {
'worktreeRoot': {'value': '$_home/.worktrees', 'source': 'default'},
'provider': {'value': 'auto', 'source': 'default'},
'hasRemote': true,
},
})!,
RepoInfo.fromJson({
'id': 'p-noticed',
'name': 'noticed',
'path': '/tmp/noticed',
'pinned': false,
'isGitRepo': true,
'worktrees': const <Map<String, dynamic>>[],
})!,
];

late SharedPreferences _prefs;

Widget _app() => ProviderScope(
overrides: [
reposProvider.overrideWithValue(ReposState(_repos)),
serverConfigProvider.overrideWith(
(ref) => ServerConfigController(_prefs, const ServerConfig()),
),
desktopControllerProvider.overrideWithValue(
DesktopController(
client: FakeControlClient(),
lifecycle: DaemonLifecycle(
resolver: MakitCliResolver(shellLookup: () async => null),
),
),
),
connectionProvider.overrideWithValue(MakitConnState()),
],
child: MaterialApp(home: SettingsWindow(onClose: () {})),
);

Future<void> _openRepo(WidgetTester tester, String name) async {
final row = find.descendant(
of: find.byType(SettingsNavPane),
matching: find.text(name),
);
await tester.ensureVisible(row);
await tester.pumpAndSettle();
await tester.tap(row);
await tester.pumpAndSettle();
}

void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();

setUp(() async {
SharedPreferences.setMockInitialValues({});
_prefs = await SharedPreferences.getInstance();
});

testWidgets('a pinned repo gets a reachable section in the real window', (
tester,
) async {
await tester.pumpWidget(_app());
await tester.pumpAndSettle();

// The sidebar lists the pinned repos and not the merely-noticed one.
expect(find.text('Diana'), findsWidgets);
expect(find.text('makit'), findsWidgets);
expect(find.text('noticed'), findsNothing);

await _openRepo(tester, 'Diana');

// The section rendered — not an empty pane, and not the fallback section.
// Group headers are upper-cased by `SettingsSectionHeader`, so these assert the
// rendered string rather than the source one.
expect(find.byType(RepositorySettingsSection), findsOneWidget);
expect(find.text('IDENTITY'), findsOneWidget);
expect(find.text('WORKTREES'), findsOneWidget);
// And the rows themselves, so "the section mounted" is not mistaken for "the
// section rendered its contents".
expect(find.text('Logo'), findsOneWidget);
expect(find.text('Git provider'), findsOneWidget);
expect(find.text('Worktree root'), findsOneWidget);
});

testWidgets('the rows carry THIS repo\'s values, resolved from the snapshot', (
tester,
) async {
await tester.pumpWidget(_app());
await tester.pumpAndSettle();
await _openRepo(tester, 'Diana');

// The override, home-abbreviated, and the badge that distinguishes it from an
// inherited root — the one row where that distinction is the whole feature.
expect(find.text('~/trees/diana'), findsOneWidget);
expect(find.text('overridden'), findsOneWidget);
// The provider override relabels the row rather than reporting detection.
expect(find.textContaining('Set to Forgejo'), findsOneWidget);
// The default-branch override wins over the DTO's git-derived `main`.
expect(find.text('trunk'), findsOneWidget);
});

testWidgets('switching repos re-renders from the newly selected repo', (
tester,
) async {
// The bug this guards: a section built once and cached would keep showing the
// first repo's values under the second repo's title, which reads as correct.
await tester.pumpWidget(_app());
await tester.pumpAndSettle();

await _openRepo(tester, 'Diana');
expect(find.text('overridden'), findsOneWidget);

await _openRepo(tester, 'makit');
expect(
find.text('~/trees/diana'),
findsNothing,
reason: "Diana's root leaked into makit",
);
expect(
find.text('overridden'),
findsNothing,
reason: 'makit inherits, so nothing is overridden',
);
expect(find.text('~/.worktrees'), findsOneWidget);
});

testWidgets('the sidebar draws each repo its own mark, with the chosen hue', (
tester,
) async {
await tester.pumpWidget(_app());
await tester.pumpAndSettle();

final marks = find.descendant(
of: find.byType(SettingsNavPane),
matching: find.byType(RepoMonogram),
);
expect(marks, findsNWidgets(2), reason: 'one mark per pinned repo');
final diana = tester
.widgetList<RepoMonogram>(marks)
.firstWhere((m) => m.name == 'Diana');
expect(diana.hue, 2, reason: 'the stored hue must reach the sidebar');
});

testWidgets('search reaches a repo row and lands on THAT repo', (
tester,
) async {
// The nav pane searches the DYNAMIC sections; on the static list "worktree root"
// found nothing and the result would have been titled `repo:<id>`.
//
// Landing on the RIGHT repo is asserted, not just landing on a repo section: with
// two pinned repos the query matches both, so tapping `.first` and checking only
// that some repo section rendered would pass even if the search sent the user to
// the other repo. The result's subtitle is the repo name, so the tap targets
// Diana specifically and the assertion is on a value only Diana has.
await tester.pumpWidget(_app());
await tester.pumpAndSettle();

await tester.enterText(find.byType(TextField).first, 'worktree root');
await tester.pumpAndSettle();

final results = find.widgetWithText(ListTile, 'Diana');
expect(results, findsOneWidget, reason: 'one matching row for Diana');
await tester.tap(results);
await tester.pumpAndSettle();

expect(find.byType(RepositorySettingsSection), findsOneWidget);
expect(
find.text('~/trees/diana'),
findsOneWidget,
reason: 'landed on Diana, not makit',
);
});
}
60 changes: 60 additions & 0 deletions app/lib/desktop/settings/registry/settings_registry.dart
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ import '../sections/general_section.dart';
import '../sections/notifications_section.dart';
import '../sections/server_devices_section.dart';
import '../sections/shortcuts_section.dart';
import '../../../store/models.dart';
import '../../../ui/home/repo_monogram.dart';
import '../repository_settings_page.dart';
import 'settings_item.dart';
import 'settings_section.dart';

Expand Down Expand Up @@ -302,6 +305,63 @@ class SettingsSearchResult {
final SettingsItem item;
}

/// The section list for a given set of repositories: the fixed app sections, then
/// one per repository under a REPOSITORIES group.
///
/// Only **pinned** repos get a section. That is what bounds the sidebar: a pinned
/// project is one the user added (`manager.ts:215`), where an unpinned one is
/// merely something makit noticed (`:287`). Without the filter the settings
/// sidebar would grow into a file browser.
///
/// Section ids are `repo:<projectId>` — keyed off the persisted id, never the
/// path, so a repo that moves keeps its section and its deep links.
List<SettingsSection> sectionsFor(List<RepoInfo> repos) {
final pinned = repos.where((r) => r.pinned).toList();
return [
...kSettingsSections,
for (final repo in pinned)
SettingsSection(
id: repoSectionId(repo.id),
title: repo.name,
icon: PhosphorIconsLight.folder,
// The repo's own mark, so the sidebar rows are distinguishable — which is
// the place the "two repos look identical" problem actually bites, and the
// place the user looks to confirm a chosen colour took effect (D15/D14′).
// `icon` stays as the fallback for any renderer that ignores `leading`.
leading: RepoMonogram(
name: repo.name,
hue: repo.settings?.logoHue,
size: 20,
),
builder: (_) => RepositorySettingsPage(repoId: repo.id),
// Generated per repo so the existing search field reaches these rows too;
// without them "worktree root" would find nothing.
items: [
SettingsItem(
id: '${repoSectionId(repo.id)}.identity',
title: 'Identity',
help:
'Logo, root path, git provider and default branch for ${repo.name}.',
keywords: const ['logo', 'path', 'provider', 'forge', 'branch'],
),
SettingsItem(
id: '${repoSectionId(repo.id)}.worktrees',
title: 'Worktrees',
help: 'Where new worktrees for ${repo.name} are created.',
keywords: const ['worktree', 'worktree root', 'directory'],
),
],
),
];
}

/// The section id for a repo. One function, so the window, the nav pane and any
/// deep link cannot disagree about the format.
String repoSectionId(String projectId) => 'repo:$projectId';

/// True when [sectionId] addresses a repository section.
bool isRepoSection(String sectionId) => sectionId.startsWith('repo:');

/// A reusable search over an arbitrary list of sections. Defaults to the
/// app-wide [kSettingsSections]. Returns items whose title, keywords, or help
/// contain [query] (case-insensitive); an empty/whitespace query returns no
Expand Down
Loading
Loading