From 74d7590c58f7608e014a02a5f1a0b08b757242a2 Mon Sep 17 00:00:00 2001 From: James Maes Date: Wed, 11 Nov 2026 06:35:45 -0600 Subject: [PATCH 1/3] feat(app): Quick Sync connects to GitHub or Gitea, sends a token only when asked, and the app opens where it was left Step 4 of Gitea support (#201). GitHub and Gitea are equal options on the card the app opens on, with the same two labels as the wizard. - Quick Sync's card asks where the repositories are: GitHub or Gitea, and for Gitea the server's address. On a first run nothing is chosen, and the token box takes a token only once a place is. The title, the token box and the "How to create a token" link follow the place. - Nothing is sent until the user asks. A token that is typed, pasted or filled in from a saved default goes nowhere by itself: the organization list is fetched when Find is pressed, and the repositories when Load is. Both go to the listers of the place on the card. An account's page lists and loads on open as before: its token was saved for its place. - A server address is taken when its box is left, not while it is typed. - Choosing another place empties the token, the organization and the table: the table's clone addresses are on the other host. - The card opens the way the last connection that worked left it (place, address, organization, target folder, in settings.json). Never a token. - Once a connection loaded its repositories, a bar offers to save it as an account; the account is saved on the place the connection was on. - The app opens on the workspace that was open last; with nothing recorded, on the first account; with no account, on Quick Sync. - The connection chip names the place, and the hint about a token that sees no organization is in the words of the forge. - README, ADR 0010, threat model T16, CLAUDE.md. Closes #209 Co-Authored-By: Claude Fable 5.2 Signed-off-by: James Maes --- CLAUDE.md | 6 +- README.md | 23 +- docs/adr/0010-forges-beside-github.md | 28 + docs/images/gclo-connect.png | Bin 38303 -> 58108 bytes docs/security/threat-model.md | 3 +- gclo.Engine.Tests/QuickSyncPlaceTests.cs | 893 ++++++++++++++++++++++ gclo.UiTests/RepoTableTests.cs | 16 +- gclo.UiTests/SmokeTests.cs | 55 +- gclo.ViewModels/AccountWizardSeed.cs | 9 +- gclo.ViewModels/AccountWizardViewModel.cs | 6 +- gclo.ViewModels/AppSettings.cs | 67 ++ gclo.ViewModels/DefaultTokens.cs | 35 + gclo.ViewModels/QuickSyncConnection.cs | 22 + gclo.ViewModels/StartWorkspace.cs | 37 + gclo.ViewModels/WorkspaceViewModel.cs | 403 +++++++++- gclo/ConnectionFormControl.xaml | 56 +- gclo/ConnectionFormControl.xaml.cs | 148 +++- gclo/MainWindow.xaml.cs | 53 +- gclo/WorkspacePage.xaml | 57 +- gclo/WorkspacePage.xaml.cs | 7 +- 20 files changed, 1828 insertions(+), 96 deletions(-) create mode 100644 gclo.Engine.Tests/QuickSyncPlaceTests.cs create mode 100644 gclo.ViewModels/DefaultTokens.cs create mode 100644 gclo.ViewModels/QuickSyncConnection.cs create mode 100644 gclo.ViewModels/StartWorkspace.cs diff --git a/CLAUDE.md b/CLAUDE.md index 6397438..c47248e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -109,7 +109,11 @@ together are the "Core": all logic lives there, under the coverage gate. (over `IProcessRunner`) is the alternative recovery. - **`gclo.ViewModels`** is UI-framework-free (CommunityToolkit.Mvvm, partial-property form). `WorkspaceViewModel` is one workspace (Quick Sync or a saved account): load, - filter and select, sync, retry, path recovery. Engine progress reaches it through + filter and select, sync, retry, path recovery. Quick Sync's place (GitHub, or a + Gitea server) is chosen on its card: no place is preselected, a token is sent + only when the user presses Find or Load and only to the listers of the place + on the card, and choosing another place forgets the token, the organization + and the table (ADR 0010). `StartWorkspace` picks the workspace the app opens on. Engine progress reaches it through `BatchedRepoProgress` (a few deliveries a second, the latest report per repository; one dispatcher callback per report froze the window on large runs). The table the view binds is `FilteredRepos`, kept in order row by row (`PlaceInTable`, diff --git a/README.md b/README.md index 2760e8c..b51da1c 100644 --- a/README.md +++ b/README.md @@ -85,32 +85,33 @@ gclo authenticates to GitHub with a personal access token — it is used for bot ## Using the GUI -On launch, gclo shows a brief branded splash, then the **Quick Sync** connect card: +On launch, gclo shows a brief branded splash and then opens where you left off: the workspace that was open last, or your first account, or, when there is no account yet, the **Quick Sync** connect card:

gclo startup splash    - Quick Sync connect card: personal access token, organization dropdown, target folder with subfolder toggle and path preview, and Load repositories + Quick Sync connect card: the choice between GitHub and Gitea with the server address, the access token, the organization box with its Find button, the target folder, and Load repositories

-1. **Paste your token first.** After a moment, the **Organization or account** dropdown fills with everything the token can sync — your personal account is listed first, then your organizations alphabetically. If the token cannot list organizations (a fine-grained PAT, or a classic PAT without `read:org`), the status line says so; the dropdown is editable, so just type the name. -2. **Pick the organization or account** from the dropdown. -3. **Choose a target folder** — type a path or click **Browse...**. Tick **Create a `` subfolder** to keep several organizations under one root; the live preview line underneath always shows exactly where repositories will land (e.g. `C:\src\acme\my-repo`). -4. **Press Load repositories.** The table fills with every repository of the organization — name, status, default branch, and an Archived marker — all selected. Click a column header to sort by it (click again to flip, a third time to return to the default order); use the funnel on any column to filter by name, status, branch, or archived state. +1. **Choose where the repositories are:** **GitHub** or **Gitea**. For Gitea, enter the address of the server (HTTPS only; a Forgejo server works the same way). On a first run nothing is chosen for you, and the token box takes a token only once you chose. Later the card opens the way the last connection that worked left it: place, server address, organization and target folder. A token is never kept by Quick Sync. +2. **Enter your token.** Nothing is sent yet. Press **Find** beside **Organization or account** to list everything the token can sync: your personal account first, then your organizations alphabetically. That, and **Load repositories**, are the moments a token leaves your machine, and it goes to the place chosen on the card and nowhere else. If the token cannot list organizations (on GitHub a fine-grained PAT, or a classic PAT without `read:org`; on Gitea a token without `read:organization`), the status line says so; the box is editable, so just type the name. +3. **Pick the organization or account** from the dropdown. +4. **Choose a target folder** — type a path or click **Browse...**. Tick **Create a `` subfolder** to keep several organizations under one root; the live preview line underneath always shows exactly where repositories will land (e.g. `C:\src\acme\my-repo`). +5. **Press Load repositories.** The table fills with every repository of the organization — name, status, default branch, and an Archived marker — all selected. Click a column header to sort by it (click again to flip, a third time to return to the default order); use the funnel on any column to filter by name, status, branch, or archived state.

Loaded repository table: 59 repositories all selected, with sortable, filterable Name / Status / Branch / Archived columns and a Sync 59 repos button

-5. **Uncheck anything you don't want.** The header checkbox selects or clears every row at once. -6. **Set parallelism** if you like: the gear button (**Sync options**) in the toolbar has **Parallel clones** (1–64, default 8), how many git operations run at once. -7. **Press Sync** (the button names how many repositories are selected: **Sync 45 repos**). Each row moves through its states live — Queued, Cloning (with a per-row progress bar and transfer percentage), Pulling, Done, Failed, or Canceled — while the overall progress bar and completed/total counter track the run, which ends in a summary (`Finished: 3 cloned, 41 updated, 1 failed, 0 canceled of 45.`). By default the table follows the sync, so what is happening is always in view: repositories in progress are on top, then the failed ones, then the finished ones with the latest first, then the ones still waiting. When the sync is over, the failed ones come first and the rest are in name order. A column you sorted by yourself stays as you set it. +6. **Uncheck anything you don't want.** The header checkbox selects or clears every row at once. +7. **Set parallelism** if you like: the gear button (**Sync options**) in the toolbar has **Parallel clones** (1–64, default 8), how many git operations run at once. +8. **Press Sync** (the button names how many repositories are selected: **Sync 45 repos**). Each row moves through its states live — Queued, Cloning (with a per-row progress bar and transfer percentage), Pulling, Done, Failed, or Canceled — while the overall progress bar and completed/total counter track the run, which ends in a summary (`Finished: 3 cloned, 41 updated, 1 failed, 0 canceled of 45.`). By default the table follows the sync, so what is happening is always in view: repositories in progress are on top, then the failed ones, then the finished ones with the latest first, then the ones still waiting. When the sync is over, the failed ones come first and the rest are in name order. A column you sorted by yourself stays as you set it.

Finished sync: every row Done, with the summary 58 cloned, 0 updated, 1 failed, 0 canceled of 59

-8. **Cancel** at any time: in-flight repos stop cleanly, unstarted repos are marked Canceled, and the summary still appears. After a run, the result bar offers **Retry failed** when something failed (it re-runs exactly the failed rows and leaves your selection alone), otherwise **Open folder**. The folder button in the toolbar opens the target folder in Explorer whenever no sync is running. +9. **Cancel** at any time: in-flight repos stop cleanly, unstarted repos are marked Canceled, and the summary still appears. After a run, the result bar offers **Retry failed** when something failed (it re-runs exactly the failed rows and leaves your selection alone), otherwise **Open folder**. The folder button in the toolbar opens the target folder in Explorer whenever no sync is running. **When something goes wrong:** failures are isolated per repository — one broken repo never stops the rest, and a failed row shows its error message inline. Failed clones are cleaned up, and a clone whose checkout never finished (a crash, a kill, a cleanup that could not delete the folder) is flagged inside its `.git`, so a partial working tree is never mistaken for an up-to-date repo on the next run — gclo completes the checkout instead. What it completes is its own. On Windows, a file that is already where the repository has one is replaced only when it is what the unfinished checkout wrote there (its content, or the beginning of it, line endings aside); for anything else the row fails and names the file: move it and sync again. The same check guards a repository that was empty when it was cloned and gets its first files later. Such a clone follows the origin's default branch when nothing has been put into its folder (otherwise it keeps the branch its HEAD names, and stays empty until origin has that branch); a clone that has branches of its own is left as it is when its HEAD names a branch without commits (a branch you started there yourself). If you have worked in an unfinished clone with git in the meantime, gclo finishes it only when every file there is still the repository's own and nothing you added with `git add` would be removed; otherwise the row fails and names the file. While a canceled clone or update is still ending, syncing that repository again fails with a message to try again when it has finished. Repositories with Windows-impossible paths are the deliberate exception: gclo checks every path in the incoming tree against Windows file-system rules *before* checkout — invalid characters, reserved device names like `CON`, trailing spaces or dots, names that differ only by case — and when it finds any, the repo is marked Failed with a per-path list of reasons, but the fully fetched `.git` is **kept** (nothing was ever checked out). Recovery happens in place, with no re-download: click the row's **Resolve…** link to rename each offending path (safe names are pre-suggested) or skip it, and gclo materializes the working tree right there; the CLI does the same automatically with [`--sanitize-paths`](docs/CLI.md#windows-invalid-paths-and---sanitize-paths). This applies to a repository gclo could not check out when it cloned it. A repository that was already checked out normally is never rewritten: when an update brings it such a path, it fails and nothing in it is changed, and **Resolve…** (or `--sanitize-paths`) then says how to get a fresh clone with the renames instead of touching it. When WSL is installed with git in its default distribution, the same dialog offers **Clone in WSL instead**: the repository is cloned unchanged into `~/gclo//` inside WSL (reachable from Explorer as `\wsl.localhost\\home\...`), the token travels only through the environment, and the Windows copy is left as it is. Long paths beyond the classic 260-character limit are handled automatically (`core.longpaths`). @@ -124,7 +125,7 @@ On launch, gclo shows a brief branded splash, then the **Quick Sync** connect ca ### Saved accounts -Quick Sync is ad-hoc — nothing is saved. For organizations you sync regularly, **Add account** stores a reusable profile: a name, where the repositories are, the organization, target folder, and parallelism, with the token kept in the Windows Credential Manager (never in a file). The first step asks where the repositories are: **GitHub** or **Gitea**, and for Gitea the address of the server (HTTPS only; a Forgejo server works the same way). The token of the next step is checked with that place and sent nowhere else, and an account stays on the place it was added for: to move one, delete it and add it again. Quick Sync connects to GitHub. Each account gets its own entry in the navigation pane and its own workspace; **Sync all** runs every saved account in turn. A status bar above the workspace is there for the whole pass, whichever workspace is on screen: it says which account is syncing, which of how many, and how far along it is, takes you to that account, and holds the result when the pass is over. The pane shows each account's live status as well. **Cancel sync all** stops the pass after the account that is syncing, and the result says how many accounts were not started. An account that could not be synced (it has no token, or its repositories could not be loaded) is marked in the pane and counted as needing attention, so a pass in which it was left out does not read as a success. An account's token is changed with **Edit account…** on the account's page (**Edit…** in the pane's menu); the token box on an account's page takes no input. Sync all syncs the repositories each account's page lists: **Refresh** on that page picks up repositories created since it was loaded. +Quick Sync is ad-hoc — no token is saved. Once a connection has loaded its repositories, a bar above the table offers **Save as account…**. For organizations you sync regularly, **Add account** stores a reusable profile: a name, where the repositories are, the organization, target folder, and parallelism, with the token kept in the Windows Credential Manager (never in a file). The first step asks where the repositories are: **GitHub** or **Gitea**, and for Gitea the address of the server (HTTPS only; a Forgejo server works the same way). The token of the next step is checked with that place and sent nowhere else, and an account stays on the place it was added for: to move one, delete it and add it again. Quick Sync connects to GitHub. Each account gets its own entry in the navigation pane and its own workspace; **Sync all** runs every saved account in turn. A status bar above the workspace is there for the whole pass, whichever workspace is on screen: it says which account is syncing, which of how many, and how far along it is, takes you to that account, and holds the result when the pass is over. The pane shows each account's live status as well. **Cancel sync all** stops the pass after the account that is syncing, and the result says how many accounts were not started. An account that could not be synced (it has no token, or its repositories could not be loaded) is marked in the pane and counted as needing attention, so a pass in which it was left out does not read as a success. An account's token is changed with **Edit account…** on the account's page (**Edit…** in the pane's menu); the token box on an account's page takes no input. Sync all syncs the repositories each account's page lists: **Refresh** on that page picks up repositories created since it was loaded.

Add account wizard, step 1 of 4: account name, optional description, and where the repositories are, with a Gitea server chosen and its address entered diff --git a/docs/adr/0010-forges-beside-github.md b/docs/adr/0010-forges-beside-github.md index dc3d273..776c876 100644 --- a/docs/adr/0010-forges-beside-github.md +++ b/docs/adr/0010-forges-beside-github.md @@ -142,6 +142,34 @@ were the two lister implementations and the failure type they throw. - **Texts follow the place.** The page a token is made on is the one of the chosen place (`ForgeTexts`); step 2 names the place the token is checked with. +### Quick Sync and the start screen (#209) + +GitHub and Gitea are equal options wherever the app asks for a place, with the +same two labels. Quick Sync, the card the app first opens on, is where that +matters most. + +- **The place is chosen on the card, and nothing is chosen for the user.** On a + first run neither GitHub nor Gitea is selected, and the token box takes a token + only once one is. A token cannot be pasted for a place nobody chose. +- **Nothing is sent until the user asks.** A token that is typed, pasted, or + filled in from a saved default goes nowhere by itself. It leaves the machine + when the user presses Find (the organization list) or Load repositories, and + it goes to the listers of the place that is on the card at that moment. An + account's page still lists and loads when it is opened: its token was saved for + its place. +- **A server address is taken when its box is left**, not while it is typed, so no + token is sent to half an address; the card then shows it as it was normalized. +- **What belongs to one place is forgotten when another is chosen:** the token, + the organization, and the table. The repositories of the table have clone + addresses on the other host; they are not left there to be synced with a token + of the new place. +- **The card opens the way the last connection that worked left it:** place, + server address, organization, target folder, kept in `settings.json`. Never a + token. An older gclo that rewrites `settings.json` drops these fields; the card + then opens as on a first run, which costs a choice and nothing else. +- **The app opens where it was left:** the workspace that was open last; with + nothing recorded, the first account; with no account, Quick Sync. + ## Alternatives considered - **A Gitea client package.** One more dependency to track and to trust with the diff --git a/docs/images/gclo-connect.png b/docs/images/gclo-connect.png index fbf9ebb97e2dd4551f6c5c7970d07e1a18928664..e9c0236205f4ebe966f02a5bcdf9bdd191d434ae 100644 GIT binary patch literal 58108 zcmeFacUY6z);Am#2S!JV1&}H*iVh$my(J0=Ix1KZ2!aq15UCL`w4j23l<24sq(oo_ z9BCpYK!BhKQ38pG(n2o@3JE2W5YpZ|>U_@`@jTb}Uf1)y<-F$)uNbrMeeb>YT5JDS zHR*)C-KGr+8$ck?rXz=KPJuvTUBI7gi64M}`Q!5T4&Y-A{FI#)sEnla0r(>3een1} z5U2vRk>{}<`2J(iVP`l9wE3#&&zj*&s@FiE@w-QC4xYK@Hp}05KlFpMYFLDqPWeyw z`WA-L{H5#FkM0PaDQkKYdqN}W2Mvo09w|HJcc}kx>u6T&Hm?(&xX!g2TcnQYU)&LQ z#9O{~&7iy|@lEWN!R_)&&r3Y+d)&~zaq?+uP>S#TMQTUHZA2E^f*j-U{?GEJ^4upY z^y-)=PrT>&49bVOY<7A09FqcG)PRJD14{*!@M~N}pCHzcz$QVU(XCbnqEC}}=}n?f zdo`PNz(?$9iU0iP!5V4$cJlJ8p#;c8KMoRc+ljiJeC?96s%Vn>S{=241jtN5xRA

w!u!;#aR@)7=XR0N+P1r{#hGVoL#9I9caJBB%T zJ3a42k!3TK8;f<6a^pkMfj9PZ9OC+r^T zjqXN9EJnU#L=VyCbQEW773+p5YK7(efpVQCB8!OU5jptOBDQ`^+b89b1!b?s%H=u? zx2`d4Voon`nB?{0sg$=~S&Kyu`=M(Uc{zn_(vJzpbBDZ!4<&xkL@sI`1?UwEl^g3b zAK{ROvW7nu-u$|D8xxkUfFr!R@31*>fB4(@TRy4+8S)_iIxT9P!G_SPH3e_%8UxrY z{HJLKxnT*3GktpT_lfV6Ahbs^;~bma&9^T#0@A%tN%<~ z(oRp>(_8+~TCG11kue1v@d$g?p^rc8ji=g(T^w2Ho4~pA-y;+~MWi z$z()DhZDG`F-dJe-tuZL-;@}kr@UdDG(e*Fc&JKl_qZte<`#pC)K*gfjxlFj_wfx~ zFC3j}&d!XETUU8U^B8RK%$8hMLq_xpBe`i&moC6zqJ<1Rw;#DIWDADyOJ2yai^C09 zN+ORX8ja-2;|Yf!St#rlGb6|iJ75TA;XU*M5B$y}MSctshJxesn;KA|KI)kS5T`bXZQt>)uNpWXga`OfLQj!5~d*Zl>oMb7o$fn}rHr0=5OJWq8*6 zs=1)8E6din)Xq!Ofjv!x;c1zw$Z-^}qL-d~jf|RWVsnw#*aOA_3Qo988LsA11Nmh` zHv@TChs%1p>D!5fdhbnL`KKY_ZFY81lBSFMyG%zmblKk7A3n^i$95}4Wbc~u7MN2u zSk@>-f^9DtVwJn2lqz@@NUho1I25SrI zN_h&mqtPg@84H!j5{^YVOJCJpTVFS$oa_k7a(6(C5Cl1)NWwxYVuAgU2WS!F-ip(1 z!Y7FzcHQ#vi1R+bt|}-`Z9tWu=*@^B+r`W>!jY5f93m$$g4;tvx==`4ONWJS+(W%rt+5EFP-RTn3RPkV^1yT#tuJ*R4E)%6jskg5z zmG*aw3FVoJ5z{Pt&L-B>msvMmsv8)|zY_;SrL`hfUl?cJp|05Dx{u#gcR{gS}o zyh3p%hkE(%14sDn<-E!*3z6>@Ln;`~R#g&PYs`j=;!P?|N1fs|plZa&m~en|e)%L} z`tG&=B)+oy#=TO}0&CXZuam`>7Y;Idz*my^4~r^vp$Cs;SC&rXV((HEyYr;|Lvvm@ zCZ-4achI!2oG)kyOh_~Atzp5nJ}J$Rx*Ixr40G9`p=kw1!f-S;%WuMo2?j_$XrMRr zQkrf%OxnJtZ%&ZN4a3j0u=#gU5p;$W@||C}RLlTWBz8n%YyCMj8=Ox?&e<}^}X%}Bx@KQ-LETNiGg8r@0Q z5+ae#-XqLOt&!5XM=>RG`ZhRG2OLj?JbWJ$us#G(8R<1FT}<-(;4-Cpd(+1Q5vgTv z_w-WvCgcjLi)5E0A(O3}Zo12c@(`2pnp^XvbA~zr+=#V%JnN6B4dgwYebYb;0oc>d zLc^-+96g4nidf2p@Yf?_*qMPb*Vv}+^M*JGGd|FqV|f`-_n9XHl1Qw<>nQukV{mZ` z-7RWu5~iLCcv{d-8*}KI=k(1KVqV*0)eT)C7oK@#XDOXHgo8yAa#5wAoxO`oiT?ia zrp!fzsvQnKy`S_x?}Bff6SFSKi|YTfp`o+SvFV4((iwvqLsg1nq7lmjtXa@#iZ)|8 z!Tn>7PqHq-aWEv!3yxD058>1jOfBEFxh9X$m>7y^S4VOqKK?l*x;5_InZ4t*E4_2Z zeW&-2uSw53a<1m7xhz{Emk;4a76O|tlTku_8ujRL)?*lGJ$T1Ln08h#{#QW) zM%bMBqQZ$PC+yO_zv3m*of^3dvqSiW1otLxukei)hP55Te1LP{-*c}Abj`}MA>l^{ zWb5m>lUW%_p`Js7!+Ig|Ybs@anOgBmKCmYt2;kzN97|?q5o8NKYok=x3jpF|;zqa$ zkO;Q9@XdJ~k}uUb`v@Q^pvf$XtKcbm>RP=XiBF$U4j1)dEKY3%W|*ec#Q9WSYwK4- za4Orez8loh{zL8PK2w)6hUy)2i7mo$nt0*3u3$W=p(E7DO>RM-Y)<4oKo9d7;TdKk zdTD!mZ2aTIIzwzrQwUqVGqB<+bwgL)>QYut_ogEeYXd{Z{3P%bmW0KSUNQu~Jub25EoE~j`o zJ|`@jr>E|@Ec0u;gpIn*2c@`|f+?sOp){KrqZ-*Tk$D*|ZQPUOJM31H!Opw+e#TRt z<}2Byz9R*s?WT1Sg(*m_IChLvy_Kdzvd%8AaI3m^^C&>wQR*w_KC3-j>&BQ09!GMi zAdNz$s1Bx`^nNVPFuyo!`B@yA+N}K|+rnkmf{;Z?V#;|qAJe#|-~m*PdpdTpmt&eWs7b zU-Z#RP=kh`^~6<+Qk`7Z|9rlKya&4eAkB1G=1nVEhSd71QlhslL)HeJZr2zJoVGey zRioXp+i7p%d%rzI;#y#Z#&%yt6LU_|x@K}8OcLCtlQd!K39&H*NETgBq^MdmtN2lDpmH5CUh6N6nJU?Jgu|r+7?=?p0Q;HZ6qrqHI4)Z6 zU##R&d%I@sucEhP$Lrj;x~q7r`0!`Vd*$_)4FLl_tKR1He>MPk7*xCWs{qT3zLh0^ z9V=4du`}vlX)IF2qxD~T*h4h%-;b5$ds|Fw$Dx)JQGL5Z_|V8r+*2UiekVqP{Bdb3=h7r zf%%`H^R1=X2K-1ew=*^$IUsR6SneVrQkN3GA7JmKp`?rnJUKm-nAVP7|J zWc~E7A|7q7_`-CI-`8H)YRL*RSG z;rUyDyJZ^1h!N0`f^-{2%;Fx*sR8kTz5n$T!hT$@d+j-cEa->eBA3S!0*_S5_)oE} zXC3`Jt%slVHc$<5a{|WPFrSYXvPA<&K4nt=n^tvQT&0iy3dMWH07yUsUN)AzJ~s33 z4AFSD0*+bOj`>Q{{8YJy(e-~sk5d27z=7`Fv8)ukc$l^jc44?pd>X5SCJj;m#~`{G}4C8E@7O`f^5geij|tUkf^$_`}sZs|LsW91#ECI<(~|c;R$7=2Ne5j1fA* z+=$DF{^*@SqF3ZbExsPg`|xK`?f}0I!lt!HG)O)n*=xJ-!h!xJ2BKn+fexpgDk2x8d^udCXaxzNhK-!)( z4XY#Mj0v+>tLNOF->UOa?@-@8TD+ZV^rwpgufg{12T#YHm$7JN_O&tlZ6#zq1`wKA#*`aF&HnCwolLLp6__sg2Ni6Ndbf?$trv2FE zS-<7FhHE-+?zb=8{}mJ6i@2-iGWZLkhnCZt_*8y-je>I6UC;Z@V!n0Y=1OU&(Tt7nWpoi2r!0Z zTskS+x%vS+Ep;9#5L2T&3fOIx{fnHGdH=y5%&e-M_SQghi;V&*Iw`j&5;?HC%4}ZF?sjtNi?uw!T0`%|)d(eMYaf9)Sxo zpF(DQ#s}G7ZH}CG$s4-V`;ta?TK(~!S=qMxiFH9$Ktg2-OTmL8feJA(nv4*b7k013 zA=!j!mQw$p2}C+ex4V{WkYrZVm8Iov}w)93$`{mY|PIs$2*n9r;@OO)k z@qigdjw+r89JtHo11Uy}tkJOV$cA$Ml&*a3EXZG;ES(&!IP!3NTjlGJW}|=yDG^b) zOWNkSw6NVair0taQ{`r+0&A4i2Kwx0yYs|3s0;f^Q+*lBrfX|WpDJSiu#H3*T#zI> zF8P=ja{6G%33fwZ(8)#o(a%VX^woB>|9B|DN5&vk=KT4!RcdPs`-&vcZCej^Eoa)H zT4PWg*zYcqor@#PFxydC3N+paW)3|#ti86v1Pi-8H8Iyf_K5SV%WZjPy(FWM<|}r& z?s)C))mrR})&u>A)*H6zd#iH*aQQjGJ~`zg-`n^Yk38IP?w}z2$Y(%=_=EI9L!j_?# zCYoHBF83XIFQ81bmkx4VOC&ly#YSuC2IK_BDxzF|mE&BNyHJ`JtVsV@clnL;s&#G4 zU!y$vEgPRvQ+Ts_3;#VsHvxEZ(3(`m2Nbi@9&6fDNKb z-2*&}udms=2uj41|I+Cq4}-1Uf=8GB8&TvFECsynmw)sW)F%lE?{^!3Vn?=Ol11Bp zKt2}K|Cbda@<$Y8E#Hxs>h4tCu?7^7f6eG&m(SJv|L9v7vPq;m+5h!(ZtBzgb|tpJRrk~ZAjMx+l*qBtl?tHN3mECtw7>0F5iqQp!D~OuU-N5$-TH@*`lhGExYlTo;70 z1YjUWVqzL!&LP0zZ5C4q(7B{-uv$YomnR5#cAlpJT|(z@?}lBj$bD`6|isljT~qO{*m1szg`*77GX1 zr1RBd)+yQwYqCfmKd2xk?-r4#93NeMXhMM1|3Wjj>bo`+w;Y`tUQD}LnN?&lryhDO zHRAm9rCr|&3iX+FRh@>yX|YSi-Y$pwY90~->)w;16Jp2Jhdy#fXQ_M0xWmeB1#ahp z8dphV-K?~KQ+#)2$=57OX5@b+JQ&HKE;(PQkMXNV0XW=WtJheN*m9+o=w78_F8L7EldG9y<^`5d5+#bl73bb;3s% zn@Fb7Ic`iBkka6%#5GfeWoMY~c<8>4+29l?S!>Tdm8#TEBBYav7O8$u&5ED}5ZWF{ zc~EXX-Fk+KNMLCcmPWr$L!{nvRMK>hY?9;g}d!Ow;HX^WuSTlsh3+ z-W!&0DZi=LdA+>>#jI8xJ2gw;xhT4)OP}UtV`V6qA~ZgNpKAm`y=p(LWIaoC3U9JC zt$J8g2X?2JxOX|en}{OyvZ`c!2@STW#;^(ox~*Ei9OJZiK%N^_T(0#9vPzw2Y|roP z?wKy`?kKqE=+POa*gJTT{QQjM<`D6yT!3TUlnB5v8P3i1yuu-}U`T2jzvoq^4!k)e z_oN>5`ny?25?2kr?^*Sb#jifO;XW90B$x`vhx$>K(x+hAf#NB2!psFRV<`8IJFA9y6QD zVv7%cD=)`0q;<&V$x3G?IUWpca?XdkEx9+J20gxM;ZO%trh716#%ajjqz+_rjnd$(InTz5KVvPK&&F8vJyb&XpWCm=O0cTc~vG`fNQmd*`e=^fy zV-geGdD<<|sj&^)lT%K2F05=INu8ey%Jnlw$ts<8y=j7k7SQZ*KQzBzt?c%TlwEFM zf_XBcooNqcRM{`*NiJGUExmAFdrP#w;u=ZQsyXlAfx1jUYg!QFD;dn3pSr02IYy-e zoq5M{AsreOj@=0Bswl917Q_M3=&@8k=n_8M^VdQekVVItoaCq42^p|4f zHST&T(MVjJ_92dRIu~0#7zL&H=FD3wPa@i9;$gr_N(Rg(-!x%beQB3+7aK~aY63Jn zNcCiRvtF+ho#{*=k>Ct(&h`MkspySNq`2OV%Ta3%q#Vma&6vgcK)Der31#6<^bzMn zXxZMK;+F}o>qalHLQbM1MvKxdF(Up#G(P>?tGk7`{F@kryq0T2c`f$uzA?0mN!!f*XNp&`V=hg!)@c;>MR@{ddUxycuOu zBzsD8tT6lNn>$-D$Q%4A;K@0HQ4D&j|)fdeAFPyJ~ z8cmv_@|9<%_IZH?P7oH5LeZ>~K2(1`#0{Lju{d@|7Rqu!g=>5x5fF)l4viSk+p(ca zG!yY+nG;3l$$I`dCRdJ5daC2Jn{J5};H=A1 zHNq54SCP4jc(Q-Mal};zNpsKkgD=&X5|8yd*StWGxxPSh?lHYUuoN=_*i&g3frIW% z^>xz~zXd(rq5AxFl%h8;8-;9&M&xE-A%#wtmgpr3ZWx51hht)Z%$00pggm<6+sO<$ zTyNOP-5(e^BkvHIsuD@J=NkLW;4#)Bt+a~8yVYzI^Yw8{`{tG-Q*OPUiJR88OT2H+ zn^2=8I6*GcA-jJ^7d5z+kb=ecgvQfvHEq@bYW(y!)>J|Ad#na&WLUntN58q&1Tfpw6fD0rmV*bvx`RdyFr08J)DooJaCv?DZMtH zq#35V@+QGPO-;Z>EWXwZmjW+eaF&c^t`d9TW^)m{sCQ_0I;{~1>RD#WL6{8^WX}j@ z)`+&!fb`f2$wc54&$i{a`t5T&hvY&%e*ugdu? z*e8ch$X27Nlv!IKg3+otc7kUa5SqMa?Omm&Ly^q=GNV%`_7qB-X^Uwk>jlQhp5<8( z>BwN>j3j?*?=hYdp5{A@H=%J9s1&aWwDl}fiB=mAeB};6L?WgHEDqXeuKlA zv^7C7tJKq8bDOM|jFZVHJW>pj_o)k1m~G5Aw$nyEst2ukmN!yN8xp5rnbJ&Hoy^4! zwNu9`0y>htY-{zJ#5}IR#2gb|Bs&?ikQVsMV#hRgWV4Y0ksan?SrglXD>_Y(8b3e8 z!cWrnK$Qv3lw{wOUsIS{iK(>l&NXkZr484DJ+$Ui=&4?|LAAiC3sU`4g96{d`Vp$O zP?(U`J6l_(aY2!gZe1m_7B%rxh5hVYLuVJsqzNc75cA1R_#tRvHHZsKlvS5Nue*b8 z!-m*ilV%bcB=)uK#_R*mV1G)puh^D}ImAnc#CcCiHP71v4IO>9g2FdWy1J1M7nEv! z-~s01YS?F;5QAyY$s`+to>q5D`8E>wOy7a>Ej*qT2@pImV!pH8Q2Cjk%w zfq~;D*rhVT(1T|Z;TZD!o~D3!pJG4hw#3@5FiGMqSCsFz_S6Gc#1dq*1C{*}jU>-Y zfdW!jc}67F29yrl1nol^Y-H-(Fy-|<@qh1-)fS;Qwa;4;UsKpXS4-0bg4}7rN8N_Y zdF;{vcu5w@qdLbG;@?hjsn*Xn>LgA@ljv3>7NM!DPYXQuJEMN`+I>n`4s!-{#-_m4()cMkw{$ApfRn{gjigP>q`*=!z#O z@8Z@G=$9o$aXs9;<;RZIgwA+1z27%SD-F;e{53JGhk1W0>{ewBHgvc}>1o=@+omTr zAbuXu?#T*VH#E>&Dy>s?Fs5-6ZF`bz?-eC)u$0G4ZN4mR-<;hQV~ihYZ`yYV<03Eh z0JR6n99!Oh9gPXxG`PX0=A*_Nv#sEGpwOvW27M)7DdEa`tqG^Lm?^#z3D+r2={Puf z*}N|1ujR;{2A@;AtdXdsb|z+1oZhcGBlVdy!W@*Vz+_6ik{Kusz-O;}Tk6+%78P2< z65c80uODRrj?urnRJ(WDD2_Zexslc{@3plcIOI{q2KkWaL6_|ZUoWS>THd~fpfu2W z-@0m_?Yhp9s_kn|N)}(z*r%w0YV#>V;1^?tKN?xb8OZdc1cqBw0S7mOXb&-O`7D*P ziUb8b!9dse#Z$yp$~Ah*NgtV-R$fKp!X|WrZGNiXwwVr!Vy~ZTONDzyIlMWu<=NS2 zF?d&7Ex3Z-hnE4h8LISJq|iI*X@TJWJ!m6OPH5Lityz2btR+NK zE2eD(o~5zxHHA$66UY)UMfCUA?hFDl3{Iym)v%b3*zIE^C-0Op7k}^Km~`fS|7!TL zNWIny5>1O0t@X1)ULSffBzU+@q81UEkeejSw7nopBy(#*H!Vs$S4vlYLI|@vAb+|_ znIg|Mwry1&$d%soebP=`O7_)i5Q`=5_?8`Q6K%x-jASPB?STMLY8~LD&RKPn03)wU zZ;G{FrJMfkK@b0&OZmUr<^Mq`-*Ucr9Z;?E&S5B;<&9)9bOI4X1`{G=h6AOen8?NO zcd!^Y@x3pG$`L;7cF59BTqJ&WCY+6@L&K$bt#f9a?MORIDlHUL3tn}uSAXhq;|}!Z z#$9q>I_n-fu7zLJa%kYw1Cc9ipoB1a3a%KWq8m9~8O9cJ0{a_Tlo(I;C=5~=IfN5% zhh_vT;$h{&Ea63Vz3}5YP0@=8Ar(;p>tc&nOHz?EeM%R}VN`-=D#HRJDU>jGrlET! z_lEBBjUmO6<+dTsm~l-811}T|`MEJ4-oq@2Hm5B|Bt%ri1Z*LVx0crjhLbV;jkw+z zngiX9-H3l0$!GKL4QUGB1kM6^Ugu?&(5l4N#R|6)3`K189P+{l&s@UxZ@*7?}h{U3FjeL;B~@~ zA3G(JreKXukb&3!>w*kdLAwvq`K=PqbcKt$BheGaF%yh#2){c#d?@tXj~#zKsi&S= zd0Xb9s&EMjW8VrFeq?Nd%;#L7Z z;Uq?TO~0Ne-$!$RNQu>9^Xu?6k^B)}5iTKS8M$|?PZdcHVEfMX)4`kt_krlQ!Vl4! zkcqj+G%pz6ht`CoWf6@)wGpdW!xexd?i=Pw;Z4lR%uA+Q%~8NBm|p9$cQtL@(X?+g zPg5}E2KdAw^_1uV6b~Pcna6YUhKK^=k=n>H%*yQ{yXcR!9A4CijF=Y2gz)3YFn>Wa zX5wO9;|_WfvQ9+E)9yx~piQgP5D*8T7vooo9hyR!eNdBg`8hti1D%u`{eDfHq#;E? z4@7s(fb|IbDDIq~E^wINb*|wO+XFu_(#L0w2q+kqhj3-CkAF7<{4|o2A$&iiiJXJ{ za&SOVz$$0BF*_lyHS3$-uZq)90e7Cb%C!sZ=>il(C2Icp=37Izfj6hJm_}sv9>As` zgwG*`+;A9BOji{4*#JRVviiD-mYm6ZnoGubPUjz~+J&0A*Ye+y&5%fXzN9LuDCBA2kW=oQbM4+F>7|hcu3(h0&7GBsb9n7;BTeUz z24+`bun1Y~M74PHXS@ixrw!C5i1Nq*mJ=-(+c2er!!Mo_wEsEs0#WjHu@21n7O6S# z@^h(Nv8^&^Uh4N)#f6CCOb#znDrOz;$PnWP8NMF9zRYtZ-0<=6xpPqyj7@Wb>PC=u zZYXQPoo$I55oV+IU<|RmChx`z>}K~BNQU?Chj3jn!&KjZUO%bW2@%&LWu1YFv9G#O zWR(R|rsR+dav9RI>NcMTtLbe3vKd$29JwQdr*XVxG!kp9O1oc_byLf;!yB_0h9iqG zfK{h^*)PhC1@nQkx$IGlYuW*#XC1A9>M#rD+p#czvffAuR*rBHSqX!rUKJ2QWTFYQ zp8=&(Uy|MnvWP!*c~VJ zkD1>lSRvD6dVw>Tf>YHhc-F%A+@sKP?lAI94dN9&ioPbuLgc+7tBDG^zQoDv0ZWSx zZJL7GzbI3RJs&433Yp2I;9EPBiP0QP5@2+Zg74=u(B7 zg{~G0Qn-xpfkt&O?jeJaTV1ZrLsrIy5~BMO9*{E=c*Vkx_=sn20a^oOip6AD6U*}= zv`QI^Y};BeMD0D%fih~F(JZVD{nVf_4k`og?{C*urZ!+LWqI{XqU`m9#|Uu_@H+sp z)zOKu>T8o1EQ%|x9D`eCVH>xlmwwc1HG*wDWg(?SZ4FDaOwR%$lws!XkrPH;(g*$C zY3xYellxqj_;O~K8w4hhxLA;m-53OPfB?>0@|*LL1YkR!_gV|5ka+HSJ4^Cmv+l-eEB%LbO%gKiD^L1F?=PV=l>o{4UG3HUU@Q9 z>c!D1)|I_R(u^7icGsQ{M)EE;HcHbI(zu@2DV%}i0bXe?a|OPvpay;C$&v3$$BGAa zf8N)(d<oRy${+F(9W+{$cYI1UBfaYh#B9-(Z ztbrx!K~bBdUkErA_&`Q7?3l zZ^M48W6-5iPBG|8ant4S!L*FPi3#H&{=07n1^AO`?&V$BOAy5L)C{qq$~|2v&KW8 zZlJyB@qlOFPp?(u$bVAkU6-D8%lw^s8;f<#4IV$Nff z_n}lT?2Qx&RnMV?@+53LFTP!Fdx@4q67Ew{eN&8X)6+me9G)_UZJ5m? z4>Sq_1^0%m9eBjX9kcgRAi0+sn%ZVyDL1n4jrvsDUjp={ z(&?|m0etUdZUfo;L&L~N*Hqz{Gqi&8dh2V3HlcTf;wGK`azADABxLVd{|L zFkd$;a2YBrxdro{za?D3BXP2dS>m(ankKUg@sT+ zuE$rsqCUBZLMgMuWRL$T@0HGao5H;v?x;{!7Ml0#M>yAI@jvdim#6 zO*m_B^zxUavVl_sD3QZHDh65|`#(;Q_P;iIT|5l4@+YRAJ4+VK4$)#5GzM4*7%D#- zB;yNIm`t-E8AeQ}HGtXv)a@VQdb#6qLjRHa$Wh&x-q+-1^N)Iv@^#His}V08NWKc% zz6zlJ+a~?rW0U@iXLWrq(eF4=TYuM1rC(IdLrMK0!m|v~-x*^Gs(XoU;Xn@7%h#r_ zmUQ%57waTJg9_h^ds?nc7ifB%i1U#tj3=l>USSK^cqE%@jIYy14gtkMql>HTNWc)% zdGYMdjIpTjbMJTk_nr|z=;A295UBEMo?0Eb=m!dE+BiU)tDF^HrMPO;{G_QbfCN8B zK?3;M8Qb$t#f9j#N?_6BtT%&hRolpJibw*BMtjye$E5$RF?f5*vk~oy3BjrLWU&o_ z-YW6IS~EU=+Lhb#Q(>y7l_F>Dj=>X+1S$kbp#Db~bb+}*P}SvbrUP|KNY{H&0iJbE zU{xugyTmHJ2>9=lV=u)Wm)SaXLE9#A>vM<116Ks{o>lOJ_fmldF<@~| zQyU^dbxGz-+cOxSvka^2>bbUSNJ+h|1}J97O!O@VuWJrnrENqBY)=i_uSgPXr|;o? zE<`2Mmdy(hSDocO-dkMOw#u)Q9Xsn5D0URKmC~0`deYxphHIM3m!+h_a@f)GV|~k! zp|cb6A&KcIjT_~q{9L>7)EX+M#Y8H>${JJ)WT<|V{R*Vu-IB;qf3w|VK;o@>RC~ee z7xH%v1*E*UKKg|mR;!P0LLi6L)-Whhxwv0T#;i553TPEzU{xh^#ee;2gn2^KH8s^h zL1dL2O~2fnrDpX^=6-9~IFCj-LpWS(VrSa1E_UYdDo=iqwEruOYWvTwa_K!b=6dxy zF=UNmTXfY!+t>#!rPgiwuho~6m(1{x;HJF&F}APtkw+&T6Ae;iob{#-Tu%8Zd58b3otS?`V}%q!%ns&G3tZ{QJEO$x z8{yQpG~_JM|0rm5GV0k*%#qEH4+c-5+iES>As#wVU7b_ch#r?GMVNahdKG}JUZUIR6$Sj|$l;7Gp#6gF z+BO4Hq@?7zm=!<=fK=>Nc?18>!v5@{`F`5C=r%c|8_~t^(v9qAyf2SgVhl&l z4Go2lM1z_)!O52FSvSFA#+(~(TrPvWA^hR0;0_SCWz^4<-IMC9l;{Q$^Sz>eNIXDi z;O6#XwljWSEf+vpsIXf}6%G63EZDILH0Zj}Kf2!zs3`s>C0-Os4j407{0a1GocUbK zZQ=G}|Cs%%_<<-M9(5q*eX-kFu*o`*F-6Or(|)hZhf~>-^IfmnHR=(;o1Z|^<8Uj% zY*5iEoGOA8%0G5paQ`NGWF5$CbN=(DzZC_DXnOT$o14+AQt_vOjvd@@GgLrtfXZst zHwhtOto&j6Bu-TaiNDk7@(I*q9ckQq*pgxpN-4?^%-eIDSk{ zrq5>ImTu>@4`+Nr`KVAFdBT~+RCkvH0Z3vM8O@=JSZgt#3aL7k@vx54XfTSREVzoT z7ZB=)Xbak0#NG0Zv5eL4{s@4FwQ|^+R{y(thnl6Uoi4?xAY9(tJu-Ze;-hJmtj8BY z{nv*dAt2+d*X}ESxDUAQVrdnYzVVuE=}F8F&Z6Olm&%@%N(=*zFumV&#IKu9Cr!Kp z+S%<=g)32_a3A{IfDEtzm9rV-qgV7XI5bf1Oybl6Ny5FM!>9$W)84s2r})#%h=jGB zUADPYBM0+9HO##Q7w1M5rm0)wUQij(BxCk(l`a8g5s9(~ZFnlzTOOwd!7O0E+(?$j zC!AExBRobO@Qd49LGm%bku0&#O7esH>~`WyK}wipyiW!FeT&fUu|Y3Y_oqab?GAmb z%!TK9Y32qJTV-B3O;*a_qaG#N2X z)9ANOqI`#`wLQUUgb?^)LktrL-U?K%pV2lSfUfsVAWb-vD7AZ`1@`jje2OT#UKI30)JknuXx+t*b1aT-GXaj^9#z0hV&AeQ6)+#Y4MY zQ&W;jbm|=+`GcKGYt!Cns`|<2dAJEJ)LfL9J=3)^7dop=#5LaaZ@BZULJBUi^|a*k zn)HpP%P!}IcgRPY1Qng7O#IS7X6a|LIJ;>fAl^`b8E5Rj73HjAmHep{$2S!lG#&j* z(#cG969ueP+Kwr0H9Ikr;(a>@UOj(%8G@zyD+?X{iN4(pvp{eeU6ZzxWIYQcdBfV( zT!0c=H5c%$sKH7LU3W49SyT8P(aznMZPdEo8&NvR&-NEFl=&)#iu0*XeZN?YnBX{S z?C4ieHcX-2^iLhU?J~@MIzTQ(KneJ>l+E4O()!a8{SvWlrN46yvMa81BtP9}3u^_Z z-E}$-AdY@me@}J)K!RFfWiHmb{6Pbz<>Pm$tzlE_|MD3W%Z-;oPe#ZC+Y0x^J;<2IaWliN^Y`=m60sf4wsUhr-D zBjjC88uHy@V#O7UquLnMm6)(TiJP7;`_T}HaezE+1t_*|7&Osj3MPXtTe#D!n0G~o zN`|JpYZdo?`2J4eTL8M`G2kGhwHd)$!x9H=(K71$k`S;+xcrvtWpiD9t8|GF^$QBz z%}APzON&IIBX)C|jCVzEM2?smI>958l@)2~;`vZO57OvWy}$NF zu z<`7%ErRgOB%%3WTy4;Qd9j|B}Gs`U!XitTLs*pt4@xc^|G9j2O^?XDj)bK{NjC9)w ztPXIr$^3BsYN9OVVfjH`u3elz(4`@&ZGk+K#yY14G~!Yss+t8BH9EF|r+Pp5iq7-c zYB^EJAF_w$5la=uj+P}z9QAY=q9P8@(k@@6gr>K&%uG9qR1Og4|3>9lRecNtQgFQg zY~B5b#V;SU9m&7qehjcm3>)rUFS3(XUAU$7%4!3pxB1)2a|&bawegF)z`8TLfC@b0 zobOEj1ez$T<{W1`!MUQ>s$V_s>};%eV55~m?2N;=TM!k{eec#b06o(9j%0Q{jy^uA?&Wc9)le8#qb$F_ut>cbda zcB(_sTrAQY6tu3D7K?49`)gu0E_%nCud3$(K^)-@vK{p7K1XI()?S= zC$p^aXxl{DH53U}8_!3JrIk`+`^I=}olHL9(@J|UXZpR`Vr8J3>8`iN?86Mj>*rMd z4DI1)9KKEOTtaMh4sm2UgI#X==?>IHwm$5w|p;}kZ)len`O_Cf-!9;ZM#p9-YvHF?IlNR~R* zSXEhb%@QKi3MFuk1#OQ0sgzR_?BEnrkn8(3qr1N6o*a-L+IH|w?T}uIt^&Fx4;>Gdi;0rFRj))rWI%P(wu-qVvm}R@9G~S9Lz!maja} z`~EQC0IF)xEhvq$u>Q}d4M24)?XxXNO%R}DE)-LCJ7hru-gl_I^96ideRu3q{gb5q zqkLcN%RQ9No_eF-Mb*_n_bTQ=CD(WtKfQ8RJT~jtYDIm^N27VWT7SCf#pEFEwZ8`^ zdzGj{?3zOdcu!YYB$<#ymNBXYwYG<4^UlVd(Hr-xQl3#!IBE2fXL8FaChn|;Hj%49 zk2jUhvTopHnGp6*)WRC;N+ssn;6sic{ZAx8#+l!{iP}nkORzn0=&_+y$+9_+U-II2 z)1O5Lpe0CKLARCO(SukOar%8_j-~GSrAN7-$x}h2O_GjGj8>fiYD=3sxb>HH3pw&_ z*sCNDD45aJBmV&VPmkmhV7mi1`@ak3hyG`RS3uQKWLL6VRb)TWttN7**u4=4vnOa`@%esmd2QtLqd2pEeF~7)@Hkc~Jr;2KfvEV3frYprhs_ z{D7E`%;TYG_Ry4mOU_pAeTe>ZufN;V_WjVsM#JLNm+=pAY zLWHCdj=5K6kdP1jUo68T+(?`|x7yv@nI%dE3rEt+`PI){uK{g7tGJmMPUgKS&)ffl zy*G_&D(l)sqfi!Q3o3}TB4rDR0YUndfKXK;Xj#&QMu$R_yZ8!}HJhZz`0& zHXB1hDN5=b+<}Scf$lKcnWKlF*ua|HW{uagjb4k?agIZ`EMxZ)x{=P%9^_4j0z=J>% zuao43i?=*7e3R$>CmM5aMo~iI{2P3DbIZ;7YwY}R9WD1u8H9rN?Pq#9RN({1=p+cb zG1+Wv{`q+F+NA%v*LoVG@mE|U(>n8ztM+3$C)HZmM{Fh{aq}t7E z@m6famSqkguz#?Rgg_b@@ihI(sf4$8Uw%}H7%a?X-O&eGY*e@D*$y&~tYC-+WzFX0UW!^?e1?}3l=D8gs%iu?3E=w1IR{$`QuqI(RQM|=z{Q6ux$x@Kd z7gHJ&XTMDd4$aqJELj;nKM*HA2fD9~M97{jUeXFeB-CtU&LLMmxj2ot)8@o|g`aNz ziJQbHPFK&;^JgQ~&}y{)px+w2D3SYNchr4?QRe;DY*(uwg`lzc)Kh!-EII%8 z{;~$zL{+g_P(5yF%JtCNO4p+a8A~e6G(R%b?!&hC*)Lo})q46XO)>%^C;4*ki*T;6 zaeK?mn?e&Rx;!>>hLDEpq-;fXK5&irFqHAsu-0-$zrufWEf5ra(H!kf;rgqA_^iX0 zS)V^&XRr5dB*Y_83iJT=_m6fSqp1-`Yj@Y|1f{~1@gMw>4xsC{J?IR4z#uLWaTbF? zlPvnn+8%lrYV^(}?fzF+SoG3HH-NwUa3iR~+GamlpZbEaB~Yo!93Nn238 za$YuyZTy-ToH@&_d3$g?eMzI^22N;6WBtZ-!xeHN9-&erU*Vgg^!$yReNh(>gCZ=X z|A4Y+)FIp*TT*?;3_3FK;7-6Joa!`mi^4BmwC_56@ArpTl~j*Vqrt=ntMj8nIYSMb zB3;vMRhb}r1ei+Y9Y8fCI=0LV!pM~x=`!V_^mFHRl2m9as)<0i!9(+%ThbxZr~!kk z+4V|Sdg$mb^0G$EUib3I4{mTU&I-53P}Ewx=jo??(6Lnx%QLU4Ynt~AQm+inSziV3 zN#oUBC}aMN7;&D%D?UtrgW}?!^-Z~b^-alW((cieg}Dgo;<@?51vH~%9qrPaE&G!Z z@S@@V3&F@5*bI`(GcPUA%;v!$Jn1Urk-3>;~+!ysSK0l z)36g_IJsVv+e!27=(qsqL?pSuSya>HyQZTm6szP5;4&(z>lh-0Q?vSrs1#k7GubVB zD_Z;FE1>oJ{O8SA?CLVJSX-Z;OtREY+bQZFQ4Uk|O`J|Czo%df9^emSkk&(|{DF+y z=BI0D-8ipmMA55c6Vu*Y4AGy7&ufnUb;xayR0k3%-STs7c}G0L=wR z_}up?w;((~res-8ztrBhJNs5lQs5VfippzDpgCZA6Afh~1-RG{o%I()pcSSxT@AoK z3f8!*&lH|Ett|2Ng%#DxS0Z~d<{MHVCM6!hI-)y`i@Klezl&hT^);&S0KxtC5{hNeKV%cJ5Z&d4()@cX#Pc44WUI(-y9M`({k1 z=7a8j$Fvw#pA))C9_2t`YiLG}c%z%_R;ym5aqOJ#(LrTV_PBgt z+NdhWXLI`aZDcSa$-8ROH6$doBbsohmF%Xg`mV=>RF#fHbabSjPDLd7f_9-L{gf&v z;UbW%HyX^F9m|&2YbKFF%fBKeMugf4lOwOUUM`RZwl9G6K&#phY6jn`Aet<$>J8qM z-^m?gs2xpF0Z{R&$a6} zT*;r+#(rXkPa6Ba=B$*DpY~0zRrO_YE*h90NtE?Qz|gM7qYJK~Bu6dl*67~h{#npF z*Cko%gaf5_c2PC!|F{6x?hemZ5E88K-X?6*Iw5-mY{rSXAjw0O&faHNf{eKmf?ea?wK%93@QiLCKPuev<;B4xk z;(TRD&UACSMULh=Sdh@H+Jly853kZR{eHdwa70a(O<*&S|YY&S<`Y9LR+3k=cb5m{aChd#4%~uQtQ<@PfYr5oc@AlL#p4pmz7m!CdZDrm|L6AB>LBUqOzCua6_+-Ks zv#wO{#rMb+4Wq(h_;;$6fbH_Pyg4YMY)=-60iD-&`P3H3jsq{ABCLy)?_+vKir=g4 zyfc$4HIqO9Qi*`|pscuPUQT$wv{ys@HY?THYx=jWp}N^plaV@%QDJJXR26`Pw<7Sd zEI4}?)+W<(JQboB0`KG>*AXmV`f@MO2IEl3?$sV}_#yl}q%KZCx(n_TG7;Mg<$N%c zit&IW__oAk@2-Z&{L_@YV!&4V+eQcc>&v9MK+F1D{RA-Lx&s;CrTm}$5a_cuHIi2Djgk^Mv;CDaStwD%5|zJ5y*tkmd9v{0 zn}u^XhdFUn&hHo+GGxUun#mlAcs2~mBq@Tnuy(M}7;~d7@ zKu)p?-wI?@grmmf(3P1`ef~H9&{WA{G%13ik8aMuWXkK6$dSk_O~6-ojAH^8`;<$< zn2X~$MX!(_!)Scn5Pjdod$p+JJ|FPZc_7D9+MryM2~due*i@>jxoX72X`zv&L09OtOfbL-eQ%LvqUY zv4|xFrSx;VY*CZ^{uS5p>|J^@e!<8*wgP~oHt!aORa(DR_{&z6T?crPjH792gSAf; zE~W_&(>1l=!LjA4j7ZWtwmX?P7hkhe$aXJ`@I-j~=DQ{sS1=CR=2kxMr_GCYaw()6 z^B9X4m3;~A^XYyZ0PGDz8G5Q>OYh`p7^v!=4TwzoD|zNXD$=yp$cw^Wpst{>$Rl2C zbgX~0dKz~tFTJ*C7!>MUDpv*iVSnB_eZlm{T$!3wjq6I1^(nFp5;@cmnwNM^`k++q z_^z1~Amh1D%T0Hs8YZsZla^|XSojEvneWEvn$s1FJU2@1d<-Mo4c!N*`2)}=7Zxq8 zv()5l{Y@t7ZLIVSuUK_+hl$RN=2QAW>&`>JIZInbgJIEQBWEkVHK1aD`Y7Jzd|)d{tTBlNSGq zYz#=!BhQojhH$}c(#2~$o0|#3s>;@?^eXS@rUAMYZWNU+LclmbL{Ey9-_@CA z&ST^gv1n*0LRMt@BW)$(V!ztM_(@8_Bx=)}7tl4`IFfz3yq=RnM7p2v;CkQ)l8;(> zrm_$!-ap7EQVbOS7(->;39L^@TYb+mOAaX3hDBZ|ZngNcrpGx~5W6{#yihMw z(4JuXC|)+Kn;w+gN*4t86$>@QiVwQg-ju&c^+{Tb=D<2R+5o-1H|vwfQ0p(ZjYXFl zbzB(&qH_c518;-{v-w~sM1#sjR##j53Gy5h&Cp4)-1*wUfoFLAY9hak-6^u+6^!MP z!CF96z%ys{JH^noulw!txQk{R;o_R#2u5_4**^4AdF8b@&vCzLJqnyc_0j%O44`!W z2pys)*)zd8i5sHc&&uT{4`R$Y?W?$Qz}r?|QV37K|i1XPm=vC+jIV#XWA&u3p3wQ^PZ)aBV=IfrV(SZMT zY~Vq(|LRAz$E>`boqT%tuiZX0zzzfXZ0D{prgM#^Iy+b~uRLUySSpV~9(hC8OM#r1^(%{}g!De`Ia&4Qih-y|8i1a3?tv?u^3ui=;jEps1UKUfyX7g@tHpF%0>KD_* z0XnI38snz!Ur|XFEDm-FFTk(#J?Xkl3j=djK7&XrNt=)U{R4d(K3z**fOBFh3PqUP zAP5>JV&N1=&3_z6m`31HFS|JitNU&!zuITps4VT9$AU;UNsw1GRFzJ>lo0ZZa{f3- zWVf7C++QAyDM6Tb)15g9wPpqNB*m?1hu_zH@>N{liSp{L&L;;Dhp2R6Y3&TA!-Nn$ zfHNjgd*oCJA>EeeF(WVNB5Li7UnbKVf1it}8JfW4(atD|f49G?jvjtSOng_f^pbM{ zS?FBETP$xG8gc%I$=$#q2F)TCB+gdfY$*VUnnbuzJ??cu`4O$bc4?qzSfaPr6`Y=T zlX^*Z?L1tmxT#(SW>;=|+c+~dBeI-e6cm`v+$<13LBrs{-ZZbcEvkcXr+9ei3M&Yy zxPCNVwYZFYRFe#Co6n=2thsB??6Ui?)we!iUxFReXLEw&_2do~b%iY!ODY<_*X%j$ z&2dfi5LX3Hx_I!bXzYdb!$#Cr6Me0TETgS@S1T6HGHS_3e=LY_M6lS+*s74;jQS9_ zk!B!s*vno_=N)fkV?%8%=}2?$yK|P=zUQGtdz(J%O|;C-LDm%rop?NX?okhWhNB3 z_F~9?Tt^UFJ}F!MI^a=gpWnG+lJK?VfuQcDU zd0W&j?vhkePJ7jWz>PRcZnAF|kUsyZug7`6S4zlEBDbFk>IhV08#h8CoxyAXdAVqP zv(XZ8ndzjuL$~V^{kw;E!}%bjtz%w*)HRXy^0e$S$7cWNAcX-I}imsgo0swh}ICn6E`CX;;5jqR8ciiy6n%T+|8` z0;GE}fpT}qs*;rdZoBlQ*%qj#{%@gRF0pOUiKy8e^_uJP4ai%({i2xBL;3Vl8~eMT zvm`HKs2S-{6;~kxA*_Oc-UyJCto+n+q;31V9*+*LN2VP4i})66E9DaqwBTvPPF59T zj{#^aOG;}}0&KW(P6Z^z)syzv^E`8w33i{3e0xUw(yfKXjnCvVcJIpJqE8uz?z@VU`@IMp*nzTvrzOrbEhrXf zl6%a+YC(wEsFi?y3MAgF+O{)QP_`|KJ&WadwW8y>RWyIoj4CXqcij;9N=Qe%>in@*~5!P(f2h+dK_+Fh!W$a>AoQ%D*+ zj8qoGW+YP5%eS$JVC|V$WPR20oLw(S8+?LG9l)2ibnl+L>7E2*8@JjWsC`P@O!@pe>L&X_+MuB7`;iI4A%r zNQjFXK{vxM{6kF;(kL{o)BU7^V|zIGLEKDOO5kSExF08afF4s8XypaWLQM)H`7-%u zDngvp5;<=^zG=`3iOnNyqiLu5QP8iS2~I7;zw5SBt~2_1vgS^J!mg0?(hdr=7C7M0 zr1=QPul;1^%4RlGkHRv&qyiMasVoOmeb}J-gCXY@BULT;kkAvGpV+;{E8a+7a6Gr1@AkzDAzC?=RJ{x|3%?}fgx$aGNv`O+60*hUMP6S)Hphg^feFxAXtJ~{VW7LF%cq%h{)(^>AZSMgC z3tb3Gvq;?)m}G-~`Mb>{H`!CdECt)c#&*}cv}=o$cx|YBgo+PiXatnJ-1M-U5OPt6EdEJ02&h&>Mz5Hr&cUK3NO{4criG@sO0!X1hZUQI6pQ)I zE2AsFrV0xx+gGVfFaYUeb@}|f4|$_hAQJR@RH12vt-G$~bGLHt%4oeI+5ak#WPLlj za=wEx>bRN{+ zTX|43Z_V4?P1E-(Eq1>9ot69rG6!#R$SrTB?w+xC?&`Ik%bE$0so$e$ulP1Pg2Ydf z3tVIi1%|7A{|m(ZHe0?+d}a&9qdHaUX#l|4a`K|XF80#nWJJZl@l{duy(W8a3zmCZ zp^V8QsZ2IF!7gr7+x9XlapGMzGNN0`_9YnkcNyQEC&EM*JB=CxRdR6}-(1uNGHv%A>uiKHppH1LTHIlfEKQBep_b_G7@v?sy-#t=Thgo&##t(vy z*r$#M%uGc#$IhN)eLlJGihBwK17&8c;Sx--?j`wuXN%_uqFv!KK4<9ZAQR8 z$-qu!`tepBEdX2dzM$P}YvgFtM4JQ&jP(H%(w+}^1WYiDA!ADi(m>%~^_=CRL;>R` zo+m%X#jODKm4^hbjOFJyujDH0JPl~(CzC><;d+eFEb}HyNUi#|mwB^Gfy$s?NFEoe z!jsXQ`{BR(CFnC9y9e>Fvf_TapVh8cs8APA1xbTfEQOZ#1~z}-OVbkzydW4KMIY?9 z8_L;VUDb{@VtrFT5Ca$gPPo@@1)dK@7TBRS|K$s_i&7FP zjHd509Nfvp`q5%gkg11=wfBW2*e1Bv;1^+WkSVC-O9WdiB$#82n8`{o_*t*S_0afn z-lN2YM^5yc2Y{qMB4J2Lr3cPhpf{oH&BM4KJW_eK@ipcldg(yL`eRlEYX-%tt&ela zn%5<)x>nJE)d=0H%b*2uudHZqf89alro9QMpcTKGHWM~P#|j(J%EoqgkDoJ6_KE)N z*+~;`J?2t@+DX~^0A8?z6QMC?OckkFXD|Z^SD!;w^cidZbFdd3YUGH!%U>720rjEB z&H^4BL9Su<5!E#=y~ViGseZd1QUepn?&U{IqRp!uWiJZEasx$O0p=6HlNH+a@90R2 zC#LQCipjUjqtdcpix8vVE@STmu<7jPdM$f-i zFUJ_ajnajwvRrc*`RPwXk}j|#sJQrn^_yKNvKK*y>O57vd?YyD++X$#Y?%#P{`}*! z!)u=``pGEN9`85OHz$~9VlM`3V3c^r_bc_tjoS4q9dk=_&*k{lFM*aG+5=z;SKA9E zVtwuW^vyGrq>7oq_PD*VF z3631GD~E-2cM`6Wmpgfz*iqBd1m>!naQ!Ww>=0c9A4T6EnxRA2c+~#KqiMM-U!m8rIXDTUP|{W0=UYGTh6L4?0;Oin9B1*R##OiFT);lP4)vWZ zf8L$hC$o}l`orM*Dm&Hr)D6zlGa*K{s@{hWlcIL9t~MCZ&AEo%fWVyiU8{#C?PsRh z-pd^k4!h7gd?z2pr1S-4zgi&q$JD18Y(*`q^V+7hb#rC#0wR9MG3BuSi<&Ag*^sKZ zNls>5>_=Y2s;5jv5FpSf8m`TidqgI{$uzkqWSJQP;CG~qQBun1;zNM7JW97sjX=~x zw+5Hz%9IeBE|zJzr?eosC)PP`d*0tqH3?O4ATM{(%m4}4dO3n-GG3>_&KVv1$2!3g zVb{T?bj-y88m_xM0=|;A+q}cD+gjEedR6!8(|W5H`0UNYH%$4hb7WdoV%ArdL!Ue< z<@vxZDdVY#I-AvO7%WnT!Macvl~;2$J;?9A(a|!f^$E#CAs1NWaCcU@)K}$@_c3 z(%`m$ZDn__i*_#cR7eu4dpyb!=pzC3T;@)(73sMl41pT@O$9Z+bjzj!`Y49^0o*#2tyO%9=Ye^foU6avp}OxExT*S>o5UJ zT5HQpbvJ4 zVL)(s27F=S=pKDQ=Xmvgu~~v7GR9YxbCV#6y9u3;i_GJ@&C!jby^6X1{4RMW=xMq48og*nv-gj&egdAKt6(G?Os#8FCIsD{m zf^`67becX)>0Ym~Hio8_^Vd4Qnv-f|2DT8F%fbx2^DmMMRyNZ4-Ivh9ir!6lDl z!pbsTi;_xp{F~anz5ed{H}~YzL;g<*WEWQk8xvia^spt(2|51my(itoiAC*^RPSuYUL2?%faNeeYObJ+AAZUA0w*SM!%+$C97g&V_R5 zcr`tTlNUd0A|@&kO=7Y0-21Q@QY?|5hkU+AOJ!T1Zg8<;&fvqf4ts17Af$z~{4e-# z4p8Yc=M#mW>Ot}2J4xAMt3=y78FcA^!Sp~I9fV~SsbLf3a_o2A*`*hJRB>;5_bQ$P zs7{XsCS~TaQ~bm`Koy&4mDKSk)8fVrw-nB`?bEPY7GMjNB<*YpT)oQ)AsZlXHLQ>9 zed5+{r*j1V(>K{ar;v|00lJtvLLsVM{k)_liB~T8DnmUG($XPc1U=pL!r1-6x?jUch=zD-rj4JRR& z0fgu9YxUD~=YXKQ>kk8a3M??NP2D1sGHXz_rk^X%O&2jxBW#I|gcYUMAD@bUA}p=~{uK9Y+Sp{nnkd87o=BD{tlP!s#?Gr!0@Lp{9wpau{C%ZLti5`mg z)9CPC5qWRmDfwI1m?SG)1q$-ZA3q}Y+sgcqLbf&9zzn!vV*D6#2F#6J%1q+>%{3dn z-*e5Dnv^Q#J_RkI0ENojFm%sXzHYERTAy#+2&(u#IvkF;F!FQCI)LQf3{tA?UVjqx z%Nn4}4Dd_dN}FeZfaH%)`2JdERHvcFQd%P;+}*-TnfuBmu4x@wfU!_f>1fx{RQF_f zdLv8G$R%?@0K36sGIUdpf%0pLJNsVbSbH$}TH6oXybIk{ z?#LCqSJZMR(co#?UdYQ?&rg->XTE~$r{^Jm>>?C+C&pivsK_?R$ z0`fB-xJ}t7cr)%D^B!hxIgmR!8L;Z%<7KTFN-V&`~V0dnzV|fBxuxs(RhNXnW^%CW0s2RF_TNQ=Tga zujLjyJuYA4vF;d#s zJCy6wKXtl)IJU^|rR%IdO9y4?9G2#5DN@EwkYo=t_$M@scQ5#oO54rC8)wT`e!SsnUr4PVQ6y~W_7!vM z(DQRvUPkBLkJr^`bP(BKaIGe-JPkQG#4SX9$i6IWRe5AiA6kK3 z+0mZBA#viu7KO@VkI>}istPsl-uH?t6$qJgKoaBcC!6~YpC3og{$43(xBG}$%xLB7 zKlXc9708{N-u!3WL+GR9Z?^Vu7m5lZ&D(4Fo-2+wVDqnk2+sH4QFI+MB)En6J+fYX z_#M6i?RkjorHSpN=&==r&e6P4 zq98P`kO7?Ew_UrWO@@7Tl$~L%1t|bpa{v0wIgd7WgHtlpBJTaR!hrugL8fEIhFu9$ zS7k0Az4`a)=sE+qt3Vc6(@J=sRs)&Dq=?81T;pidFp8VSLN;HdNS|F_CX{Ib3QK?6 z+Q$#r)JUWWws-?a84gy>Kh8g5UKHzT@$60W7?=Shsz+*Hv;b!z)tqk;2XuGh=l;qf z^;$o5QQ)S2xi63#kg`_E85=iorZ5Ja$oU9?+y!EmTipH@w=X^AZi?DtxU~?-l-Az| zO4tkHTyygK?vA=^x>g%^S)CP@1EwfeR420u5~V10>Ti?eiGPwhktH5p*1!K7`0sJ} zhljTMI26EBW2gg;+;Kgp?ibA_}i7)=9Q&nejLXfmoO@+hfL+s7aV0} zp%rR~VD96b(P~6uh1cGin`|XYC7p!?louB-4;!v%JKl_Srj%ktj^uF!8@%UUv~Ee9 zYu(}Foc+HoKyJmc(KN&=o@E6 z*k#7YV1%-kcbX1)2Rlu$zPrFN?^>N*&{8JQbWPHM&eXvV%xJeUPRCBX9s4lY0N8W9 zy&h+Yxg<<0EEd&T8tG`Fhaz*51e4Vk`QHo^=bcY(L>%r0SUGkKDzo;f;!jm^4%CdksyP zcAEA4?Bff&!U>rZp$lFK$#ikHbUd8i#`5Q+slQz#5$K)YMB8xs~Ww=LR)1mj- zIP6d4;cML53CwmDW7kn+eJHmkmO5qxEjYzJP@C1mK;NP4UB-cSO)j&hk37v875a7CGnuZ#W@*ROkGI$AgtvK%c0^uFDH`b=9AXNQPF{NJUpNJPPZDpKW z#$L(hfAF6u2&-VE)A41UWc%r zAjLV%n}fI&J0hr*m5yB+l^p)W|6~@F5hV1=eKKdZyq^Y}Dqizy@A>?rDjfkz6-(U( zC;fEKuuklyL@PRxRJ2t)8_PKNn z`h?@V=||rJPvt!B6OI)lmWBzV&DR+%!zl+63xq0EUb*;G;?X`(U*}a>=4wT+*T{vl z%jxVqW{P{k$`H9D4fJ*4*3XE?JNZ}7mWM9Ymow`*{`DGck5J1Un3$##I#;iRQakOB z+EhJNPj_Zybm38Bq)G~Ooc0lnxONpulw)ZYQReF3dADPfG;S1zk_^1cbe@MewC#+P z$9`YFsBa-?$!4Y0^a-~#{TNCfOIJ_-u`^Py^8_?;F-()`p*pb$`(WyAtjw(qvU!?e z#5d?S((1!kRdzYMV~24c@oqm?IZWAfdaJ(8x43}MWTwVv@cM+8dVj2#sNZ6A&eT0I z#2h<62;mShEXKOe^m^i-#?@!b_s#0ehHW}MkwA%lEY1tb=W5%Ff5D!i2VHA&EaRa4 zXARwH@F$FROAebp2ds9^v@xPdQ@sbX5z~1^TLT?gd`om59aDqF*D^U5xgMIPgIUF>a=clIs}(T!#&t&@AByDH zV3_u793IYY#^g;bJSj#Jz0jDLQgIYAuTRCU4X*}E4I>(!yNMb%T0V$K8H;ERr7j*S zr9-0BBTZE7U9sHm=mDO8sW|3)@*Q!bm(06>SbO{>Zodc9xwDX>oe{lBIDv7`s+n~5_2?`#^vD5w zpVyYt)C|$-KLYknXWTL}F$r>|4|abUCVQrtVSMD!gfe2qmkG@fS)9exyE9Sxz2s0# z%}~g4OnLF04Wf$QiZRai6n<$fx@LIzbFrXzFr={cewZhGJU|tR%Q#>wqXFC29l)P* zHFcjV2;|mc$#5ir?9HhRISFeZbB$acB{yIRA56XGsG@*8?^dLCqV^>?y%vqeh3l zxfTj7yqvs^)fG!Vn!*dp5xr_&)yAfRc;iZFiOO%ijdV}ehc+U9QLVaogxs(^6j4wq^5~1k4gsqgt+!e)H&Ig@ zUyh&K`Jam|{KyEL?GWw2Rd;wdNc%A5(aU`wK*UEN7YP|>JhT}lnx1T~@=lBUSj!J`WXu5 z*ACIxTtvE;XKjXM>x2H;;A}*Js42PPBv#b6K(9J~w}8cgrc@%-q5P?g7sVG!=@Zen zcxp!7WorJxu-Rhc`O}qaUN-RJ93}>hwjtbRaRmhTR7?eB>c_MLglCTO&djCX_l+_}iv#$7TbjOW?%qjVRVPg4ap zA(QJm?b^#aS{8E>$6zir?kZs|@H;9pE7Wu0%=J)uKTG8>oeK>jBvf#tEf-9^+f5LS zRXB?oOlM+Dx#k$TU2WTxru&`|57Vg|Nb5cek(r`y&4d^1C z3VT}J{raQ*rjv1n9c?kyKIOs-cQ5v)*lOSDL8l4gQC8uwiD?Yn9v}Rx?~+!|BTxA= zSvUlit<=G3@Ld+UdgW@Y*y&}QDY%;*X?X}UUyQhuk@LV)kFaBf&c=MNsqM~CWK*lu z^B*(|R!?~(Sq)cWQnRKDU%4!SB@IGDhk(`Ae9XjG^ z^YqFd_cDZgqBFg)K7W`N;Mf@9Ib^2!jy~6lWB>jxz|q^LY~yTE0TYLCy_N#poj3-R z7gW|C(OJDtIeHU)%qs#caXRa$s164Uh0>-XU0)+P0VFuAL#ve|@69OWYd3OHOQyFl zLqb+ov3o;Aa0p6AEuT5uz!kZ-TZm#RVVp8ivpv*%k>oy$4_Wyz^br2lO%mYKj>~Jj zbBV8t9-9P3jeI}QOE+t2+I=pSm5lc~{s|wuQNG|esreZN{iSwcxETcprVrH*ucRlQ zk?X$faxe06UrheK1pkqoEd^JlF8VCbJE`#Cj77zCiEIJ`TtHF6PK+V0VpJSffd^zD zby7~eL_g+#&3~_DZ7XbB80II;zUwJmzLdMJCHLgtfdYU5$5=Sup6&`w5WbTD>SeHd z4TaEJWkf#zY#(okT}6H-ysjVj?i~n^-lZPKIW~?psckti)MwVhbo~1pNsz** z@24aJ@2PfHZN+}v&voV(;)f=`h0Q%r^$TBMG%t&G;Ioy4s4tKRIqH}AL8_i`%X|l1DekFD*jf5(KI5FW%%84@fXiMPxv4!aI?Xqpt9_&1 zsVY6Ypyxdc(>x(~%r+gQdCbOSOvFqLJC=e*(N^60R6g5vwCYx3GoWNG@*CJufb1I7 zh>m}_?o?G_v>xc%HW!+ zd2)O_88}`qyD>x@kO=YG_xC~1d9=CPVArim_EL7yos4tA`5NglB$u9rez$e@1E(k! zrrtNqxHYac6+1N}w$aebaVt*8Tc3D-X+x(Gz-$VSIZvJ_Pu!$t@ac7>;q9BzLS@{V zlaoC$Y2o)R9HAtS3ziyCCk?I#AHQTWNGrv0U%f?GKJq7Q>oIuq(`+6M6IWGPm2dQY z>b%h8h6$M)F=fgqQ<*Mjgy>e^XaZjCh}nhuVUf)y-;o$g&HTzIQ#L8=8ZRfPQ=0#9 zp@nnRaPe$XYW(Al(U@>pD2fvhT+drTZ}`+84OL-MoR9DSZOt!7INgUUkG*_i{PDmz zc5I?%F2R#^+R*D-vXQ(y(!cfAS=n!Q-fn9UhU9J3_Ki2Sq^tV0H!P|cjFITO5bB6^ zcetNLCYrd}m)!|=xREeCb=H9SxQB09SU+qM!4-5yGyX8>y**xljz`X$k@Jy=CQN5| zqbQGJz^yki^FBMA9OH0i9ezH(^9*0M+*g;I&^BvIimg3E$*joVcA0T}H3JPODh|uI z0eevtaNAI~)aP{&;~Xoe*7&V`SJWnrjPb|#hE6@?k6RyD%N`ZPuk){3292d+HfF57 zr)^qNEI5v%7EVMl{4(fh+zfbHaU6d>&2L)pLgjF~8F!`!&wW>#IZ+p`y7M>sM-|S~ktITdF&$2s3}}2X z#m0%$aQ}S9XSWdY;w*k)#07Iq?#B_73~{}*$#3k=$Qc#IKPeJqe6)}JZ2MNJCqtvK zaaPbQvMm24Nd@`oV((MNprz08d+&alLXx&Pm;1*%OzjVLMK;EJs;5_46+~i-MMF=T zIwK~m_%l@nw4o3>vPI_GDcFYSZ^##jvE&8PtF)Yi@wGHL(})L775>At;ReQFNASMPu3dYwOb013Rt(++NMpgH7`^rykt<_96921A zY5z=2zE|VAigXbj+QF+3{{}NhK8?6UdeX4v-3OCC?(|@oTYCfo>=hSj5%2RTw4ZfV zh3$*nTEmBB%x|JVn~GKZZMm&QVHso-m1PxqzdFSpn@l{3kyVAH7nMI_6$|0|e$`Mn-mt9ns zpvtAO6@>J2(;wo_Vl-cev!6!Yc_ zAXBp3n~B&5Ylz=T!85A^m;e6ne+BFQ&x{TjeSYGKSTkYVKVkeG0<=JFePks%pghLb zT$yVHq-Gka&XJ4X&?1y_&j7~y54OWAr-WzU>^OYV<55@>9jSboV=rYmAnCFt?ybxg zP;wZuLFe3)Ixltuj$r_yrT*Qyk)(D6DIS{+oNI{sT<6sRvCv;_WyOCk)(sHxbpwPL}4^ps0>Tn`JZ9#VcF+LfzwnAxRW<+f_%FB3g@rA9-vTe`(gXB6G@&>?;C!n#gqOpu`g-vXpjQE z%|c=0@l)G{b5H*hip?Z<6%_x-FLoednW3g>MKh7ZXcGZQcLW`Qdu~* zLPsUXaH7iL(2DZcACs$o3Tb(@ig@kup}V_B-5L4JTc5@QNU_#6a(C#1A^Fe>%m`6c z_Zs1ME$1ok8z@mlc|(42W4kjlC37N-iip~NZY+!@7@x(!xbtOf2d*HclH0EVU)@|r zYC&i6%;MYodjB|R{eV~&7@q`XY?}w?X|707vfn}MjOosy*y_RDZ@Fm zrQt3(C5|t6X&+vkiH)FRnjKd+-)JTnGxw?bZMczpN)zlng$niIJ@=T8ADqG8`1t_r z)2`aE?=?Ft*5hBqP+U4yy;0su9}TU`PT4#P+VOc5=qapAzZkl735YsQ> z6Ig&uiTM87eYxi8^v}fwSAJ=N>u54MPwnT|xXTojw$}{VCH<^0qITW!Y-i&3qQ2RE z9lT0rm&iY0hp?^Lo|`Ht|FW)Wv)em(!!b@Z{Sg|4Ms=yTk)AH=qA;;g6z+PlJdZ9& z!T7h-Ge@{z*?~ApMNN7c)_No6s~dnqQPcgT0H?Cm4Vo;d@HEJB?(nUrtfdLgma5xr5RJ;%j zmy%^fM8m2fAqY`XKmr7396V4^83n-*P$V27@q!>n&?O|S7m6U73`C3&k`OZeInGXk z*8XSJZWUFj>Z+vs`?_Cuzt?&F`@ILe5S7PfEei`}etwYP;ly4qEFN+xTS@& zBfovS-$S$nlaUvZyGzL}dckInGIYa;$NS7WC8F^E+ap4ur28O>9zLbPUvN zekCK|VulR5N?8j!Yt-W&Ndc6=YpN{$&3sLG@%2#;#u1QkX3s2&x>_&mjTQzp?8%8a zG$#@b{9Z%l5PKx1#0x`K#hOswJoE8@Sk~3OV;fQGovx<%V#)#VzT-WTv!hC@ao%wb z@yX%Fyi1z+Vf;_Sw3ccwBk1|MxfCISN7C{*>Os)+Ynn2JAn076287oLf)3r#?4}L` z#ckCbI%vVu#2Xtx(5-Enf&&#+lxlVfe6dp#bv@aMKk?_2l9-gl=l0e6&1S{;7$R_0u1Xv3Nz^`=G_Sz3(;bUR=zLtP+2Zh z$_v0PPmdTJc26&P)z$}xgkdAPM2bU9IBk@MHjXGlH1%ZPh)`UPAd>)yPI#pVT+jeW z#9F@PKd2Hh>(*m`5UxT)8#DZ$?HK9sPyzhj`qn(+HN%I802x86h8H>)rPFl=vTJav z_{~B6&cOqu(6b0b+t;!^LXHw0oL?2?5C{8YD7QuqA)I6ndzAjN7z#IUbEHK8z1v3b z5_j4|r(UY@o+L^Y%&wpeg~t-cj~|U@Wld;PVwsuY>%^#^iUoXb{opstlV2`31xVrn zz9P>%z45W%gf1>MO$+silA6kI>vk^jkl%Neh;wLE!DlPuQ%IT8M`@)tvo&g6#T@`EXvJ7lk;`O*~BzVZ1A!-u4A3_%d8zs%O~o}2>R z`4=}Q&wTCXu3K8@yJ^vAGk3LfWrp1HL9;B7DiQkn;1&*%V)!q4wWl3Ym~LeRr6ahd zmg7`4=g$GR*TiAqsR*W^uHN!>^Qw-X_2tDr)>S-?z^Sst>_Se#(z80DsbO_>&jyjW zu)8ZQ=Ho$LbIt9*-edV&^QtC6xs7gVwbZtsTGterKbAZf+er>^zl#te_9qKZsPCtm#wcHk^+UD}NgBEX8`%h79Dg^6c&~ zgIybY)_-Sw2{!!U9}T~#G{by}xvtC|8Tb=(4u1z9MYK>g5`@)>~i-C0C z(RhI1FZBH(#n|YN84vn+u27UXd6!x|K)Ea>V?Zcg$j!zO(xPkrDp?fU3f=N*_=iYt{xJ2D* zAw?ym?y49JmbhDBW0xOc9qhYK07RYL znJw^LP75a_#yMz@kp0zeKdBSL<$Z#!NoIqMUIB>0eBCWVe;q2u#}8?-;h-7A#W+Ki z4cWywMgZ8d&w%wwY{UC`{szZUkLWtv1fqlT@VzMg#ub*fEftMI0@=QRcD?L&kWowg zFmTCLY@_+}W8%mo_5J1(Mdp(}lYyl$7CueLwdit-NcD6Xz27G+w+)p(yLlguQee@^RLxmVL1V6@gWXE*pxDIv>BAY>-sbT{k39>@C=x)V#94 zZOMcc=W4NGo8+q&-x*%X4{ni}TUky`C4yZp+p%GJxjj5O;=U#DhBpRZ0(3Gjqq35a z4#~eyVY}%!1WV5C&!0N6bB2f4q@so4@@Sm?HecH6SBb?@Z)Z!st*YB>EBNQ^HJ4d> zWf-Sg3@>4iYKQ9Ahi(Ic1H?;1SG4u12$yGE+&gs&pA3AX-HEn?GpKJ%k|U!Hd{Fxq z)^o8V11CCE0KFFW+|oD_c54m92aGNpdQwrkx;H=otFG#FX?q7ITex z+6q{NOB5N~F*%7TiUIrSS&l&Y8o(Y^Mb*SAcV5Gn5q_W%F@ literal 38303 zcmd43cU+U%wl^NTqaZ{?DFOqGGD=ZUdYh5rSSX4Zq$&~V1c-D(m{B3r*eKE>s30wf z0-**A5CtJ3C6E9SB0&=Z1|$Ij$@|2LbMCqCJ@=mb{(gV>!6eV#d+oK>Ugf*ieiCf0 z4~uS+*#rWCM34Moei8&)+YJJ(xxH~c@Qp;so14I&Rq&IC4}q{;ja*O&2Z7QK9x?y@RJbc^8Bv9Za2bf;(@h~2@8750 zavheFIF+~8V%6N^1NOg3ifwmX-DbXR!;xKTWAm|k>yxGLTua?t`je`7&^gzGx1~FYn`D6~jhzS1JaF7RRD= zarpUtkLRMgEh0pLO_|-FfU98hNWm7vQT_OWKP@&V?cVM`i8dTm9-ed*^%A^DLB`Bp zrnogNw84-R`t*_;(r(U+F_%rU++MC$akQa-eHBUI#^=!U z-2`v@lZ8`0*e$l)rb2W)6IbQSHVuk!?A2Z+q#49_xLoCle9!D651uhk;u#5eTx7WK zA>mIBkGw8)cVU+kiekp;vdzmde=FhFprz3>h7H>lhNUq^whC*7-|#<_<+?<@-i!|g zwzIHMzClP)tm#nnd=WAR&MFRTx-grKSz>+t!N}K>6o6p64Me^XZ{{^L;}%+x{C&C# z%Y$wMdkPnZsmmYxmxL4VPh=)~nR0F@j0}a6e09(bETTXD1AQNLPho;LG_QWA@FA%u z)lO~~oy~c#OqU1fHpUAc*#bsel#Zb9#?+t@a~INkc7TE(Ln8xPYQxnE6EM)cOq_<8 zaH#cgH<(oyCTQ(ME_mWUzT7dil-Nm7m{02z%1P57MEc%nYs@#OrMBhyZQ6qhzT+}sf&1S0Keam8pS~*M~!6g#}8Y{ z&bDbWg7+P2Jj)+6<#{^MGB^VSlThZBT#B?CZGzkADcF3z{g~^_^~37AFWt9eTd>@INf0?}q1)^}>t@8BI)I#M+c&(F!sGMlBe3vU zx)pUHOsARq9vQV8-^`W8N6osxB1m+bk@;O>HVU7qe_FgQ8@1m~b9W4u<`_SD*b*ai zS}D%_qW(qw9jt9N=P-J4mAMf^ukDkMRaW?W0Vd+j$JPaJ^M?lbL~nPpLCnDhr9mt~ z<0&96KnM>zk^IXUISIdUo(bMy{P@q1^LILt0twUVh^gTUV+krjDCigOp(EOsRgIDF zB5NkDE)<Rvd_$00BVR&%EY=?YX#= zzu1T}72Gbg$8fx}Wkae6)UKnPV{F5s)lE5jMlF${TYDygUJ}FxNugPbLvLvzE_OXQ zG%hN*QVp;q!*Bl^fnj$U^Lx2{s(Dc3DOdlNVc@r0&#FERM{ z$8-hH2!d9+0&*Uj-5?m!^&gqbm;PHpI%?Pt`M{$Zgua{M33bE^kwpS9AFWEOt&esj2Gw}!`Gf$T=L%|Tf zYLy0VAGEUdChBOwL4Bp%Wt@6qLczk1C!_56JiNyIx6Owws*rPUnn#x2 zXXDS$HX|op#CY(E2pV0$PM{6|XOJ#vSZLFYnZ6}hpe_l7`(jmT!qo0);M%2ZD;j;2 z9}gYeBj0gjPG)^!Vcj2KNk$}D5J&-?z!meITk%%w(ij6YFq-sRuWN!Nvusu2`Wrq} zd=(j*o?nQF-V5qO9Srd!BkmXG74@Go_btLu1Ts%bpxh9Rf==U#ViV7hx)5nQG|8Le z4~u#UNao#+iL**%cQ8xaNqHj2qDrOd;;ah(L)YPm${28^_uj6e zjq&QIi#5W0WD8qDt2pLcXac6Q^H{U zmK{cd6a0@UVVD%;dr0)NFC;H*1hX1jBk51AVCJQ>M14;9@pb7k-CNGUd;{Fvom15O ze=Rw&`{lYkGY-59JF%yVuo$UKo}2WQgD8`G?Stw@Lh0kay`>#I#5>rUsq)Sil_|9OX!qvwN z1NPpWJb=r!`(2}OSTen^rb0fcZaL=>GomElhoNo2%}NfclOsK<2yssFg7ePc_7&>y zF0e9oEh?ty=wfqmQ>K8JQZV02$dTRj8*Dl0NC*oB3+vZxx0<7B>_A@2{F#P4*V$v}*gVEizQ zVCe9|j^?EbU4?~r`}|_)(4bx9OWEk?k2{EfV!N{Ys8!XCE?S9i6Qb9~N3NDv8P~Up z3U;F%iMw-ZO}EE=itQcC`;^>2)q2nNwEywCMDuMX?ZlHVQ|gMEYF~_yi$8jtK&`+3NN7Yv+FjrR%X&?O$9doDW*4luVqyjem2*Wx6_RY9ZfZy3ZKtxAu5sCXd+n1;Mk5};PFf;tw{6D;Pte`DgzU9 zqa?-E?g&41PLl@5*yQjhd1xe|LbrqtzpiUjC6*v6k`Uj=zXzAcp^H(RMN%_=644Tj znzmUuimh`pfM!F(r=zI654BLPEd`S_zcBHX=5J7)X*_13{Fl5P@;Q@p?%m1A3dceN2n&X(2^W(f7x zpd^|&L3JW&yf^SCV3EM&%?Vy1UTZ8xw4xolVSI{zp4F4$o;XSS%X)}xmBBs($eR}KjmQx56u5*WKxqH^q-JZsKRT>l9CZk+> z3A2pKy7yYwTOTm1drNv&19R=9-BlvvRnzIKUle;$i~Ka!x)AvnZYNbpPmHxcLisM_ zuLJim>|sUjRAzxm9^_`HsJ@E`yy9Mo_2y3-ypiY?am*brb~=QCqmA=ITVswl9d@i8 zSxy;my$J5oO0WK1UA_WmnwJPjb}@YIlL1Vm(a9051|bPJ!Y3M-cad)Lyqv!Ld&*{uLCYA~q8*FnH*+NTTO?_ce6%DIE zS+EgmsxYUWihs3)T0SpU*nSlohJ@G4T$(8Uq>GjUoq0ldKn(@ql6# z%A*lzL?ZN>+0HwmS@qNiKibinoPB5y7ZqTR$C_Q*1y8OQuHGg55Fy@Ot7U#rIClZc z%UN0pFteb*6Xpl8pG`*Xub;jFO)3@$H}hEjf8`^jZAZ-LjsX0d&GFmh_o9MCreR{$H`+xyu8@iVsCAOaxv(OeP`_JB09Q0ql3 zgy!PxPXHa;ga$Wu7s~6g?I-T0{|wCc$>=!z^}qSxj;-DDf_Varzl0IYW<#17_Aagq z?T|eG^DZV2Tt5l#7;rj6iN;*N7>*}0bk?wW*3iXWdLMG31D7sVmVp97$j4g&+q25~ zKiS07YSSpDx|2#VsOaeDO#>?j)W0w6)J60FS<1&k-H-&2mWPa)Kj%IrZ4OVX_ch0& zIAc4S!)N~#i#|J>)I6UAEX+QF30Zg?nIM#s+UOxA8KGo=|Nr0k6bP8^F2m`e=t-yo zWvOd=$vVR!PHa2K@!KZ1Z;lyPb*qc$D|PMLl^)b%rR%S*;IQwrWSsvnU-Lz|kBN83 z=A%BX|2rUOC84H2SPSS&;%8o%A3Xc{6#UyB#11cR1?G?cx6XgL3CyYlP77UlB3>*^c)5}eOf&pv>R7~&89QXRC&gVS-Fc|sjFqDfYa7}Oc}uMsnPFsL0p1;OpREUqnc}o zF`P$Y&88GQYgi?H2X9*k7TuXJ8rd%mVOzw$ROBcZ+~sD__TF#K_$;(w*5R0s%@l(p z#zfS1oL8H8Ws%ADkvVKwt}(u)JO`Ym6lxKk$c1)rJv%mT*JMT3Fdr&8$~U0A>R4c9 zQ$T;@ssr@wzX=Gt{3;507NF5c+3|AT#-jj@rG;bcji!5)PUTKDTAU|Kwrf3b>Gv-r zNp#PI_pt-*d%tQ$-3N+3w~2bjyasi6&uTJOmDF|24UKud<)OAe^H_V> z;pkW8xVgDG_dr0J(Z}VkvBU?pT7FZE2eaECw=DiBr8$9#ClT75g zc9afjz3M}yV;t&zWubhBt|1<#lLZ}FFT?DbYpE4U(rl3onA1nSpKX64HM2}&%#1dE zC+`(SUwP_$Suxr^L;BSWg1n_}Mm0{v!Y;_qCwf_*t3GCj=Q>)Jh8ouyN2tEX|CZJ> zuG3%2ooO0mu#@N0aerPgPkQgZyny$KNb+}qOddAM>yZl<8U3ztb+1s{pZtnFT|z7E z5b;uc)~&ee*SO>(+Nt*pVx}hQRNI1$HHY(8skgFyDz+p+Gnp?`-;-5W9r&4ETL-;{ z>!Z(t2|wp`bcDzJ{7s96bDrB>;k8M-_im#)`{8PQAjNXP}P4r!i@e z6Ri59Or@Q{)sVKD* zGIiWyV@@QxzV5gFRHD;wE(vWOrQgWf1NPrLq9*<7kl(Q~

6Hrs)N_v}UFRTi*#T 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.helper`) and an existing WSL destination is pulled only when its stored origin is the listed URL (#139) | Mitigated | | T15 | Logs and terminal | Spoofing / Tampering | Text from a repository or a server (a file path may contain a newline, an escape sequence, or a character that reorders or hides text) forges an entry in the activity log or drives the terminal the CLI prints to | Low | Medium | `DisplayText` shows control, separator and format characters (direction marks and overrides, invisible characters, tag characters) and unpaired surrogates as visible `\uXXXX` text: every log message is one line, and an exception's lines are indented under their entry so none can start like one. The CLI writes every line through one place, `Terminal`, which also shows any character the console's code page cannot represent in that form, because a legacy code page turns some printable characters into control bytes; a test fails if any other CLI file writes to the console. The JSON modes rely on the JSON encoder (#143) | Mitigated for the log and the CLI. Not covered: the desktop app's own surfaces (row error text, the path-recovery dialog), where a newline is harmless but a direction override in a path the user is asked to approve is not; and text that merely looks like something else (look-alike letters, an escape written out literally) | -| T16 | Forge server (Gitea) | Information disclosure / Spoofing | The address of a server is typed by a user. A wrong address, a server that is hostile or misconfigured, or something between gclo and the server gets the account's token sent elsewhere, or answers in a way that sends clones (and the token with them) to another host, or makes gclo read without end | Low | Critical | `GiteaServerUrl`: HTTPS only, no credentials or query in the address, no setting for `http://`. Certificates are validated by the platform and that cannot be switched off. Every request goes to the configured address with the token in a header; redirects are not followed; the address of a next page is never taken from an answer; the client keeps no cookies. An address that is refused is not repeated in a message (it may hold a password or a token), and neither is a token. Clone addresses are built from the configured address, the owner and the name, and the server's `clone_url` is not read, so the git client's rule (the token only to the host of the clone address, T14) binds the token to the account's server. Answers are bounded in size, lists in pages and items, requests in time (reading the answer included). A server's own text reaches a message only as the reason of a refusal, on one line and cut to a length (T15). Accounts (#204): the forge and server of an account cannot be changed after it is added, so a saved token is not sent to an address somebody edited; `gclo sync --account` takes no other place from the command line; the default token (a GitHub token) is never handed to an account on another forge, and each forge has its own default token variable; accounts that are not on GitHub are kept in a file of their own, which a gclo from before forges neither reads nor rewrites, so that version cannot turn one into a GitHub account by writing back what it knows; the token of such an account is found only by that account at the address it was saved for (T13); an account whose stored forge or server cannot be used, or whose forge this version does not know, asks no server anything. The app's wizard (#206): the address is checked by the same rule, and the step that asks for the token names it in full as it was normalized; the wizard holds the lister of the chosen place only; a token typed for one place is emptied when another place is chosen, and the default token is not offered for, or sent to, a Gitea server. | Mitigated. The server the user names sees the token: that is the purpose. A machine that trusts an intercepting certificate authority is outside this (as for GitHub) | +| T16 | Forge server (Gitea) | Information disclosure / Spoofing | The address of a server is typed by a user. A wrong address, a server that is hostile or misconfigured, or something between gclo and the server gets the account's token sent elsewhere, or answers in a way that sends clones (and the token with them) to another host, or makes gclo read without end | Low | Critical | `GiteaServerUrl`: HTTPS only, no credentials or query in the address, no setting for `http://`. Certificates are validated by the platform and that cannot be switched off. Every request goes to the configured address with the token in a header; redirects are not followed; the address of a next page is never taken from an answer; the client keeps no cookies. An address that is refused is not repeated in a message (it may hold a password or a token), and neither is a token. Clone addresses are built from the configured address, the owner and the name, and the server's `clone_url` is not read, so the git client's rule (the token only to the host of the clone address, T14) binds the token to the account's server. Answers are bounded in size, lists in pages and items, requests in time (reading the answer included). A server's own text reaches a message only as the reason of a refusal, on one line and cut to a length (T15). Accounts (#204): the forge and server of an account cannot be changed after it is added, so a saved token is not sent to an address somebody edited; `gclo sync --account` takes no other place from the command line; the default token (a GitHub token) is never handed to an account on another forge, and each forge has its own default token variable; accounts that are not on GitHub are kept in a file of their own, which a gclo from before forges neither reads nor rewrites, so that version cannot turn one into a GitHub account by writing back what it knows; the token of such an account is found only by that account at the address it was saved for (T13); an account whose stored forge or server cannot be used, or whose forge this version does not know, asks no server anything. The app's wizard (#206): the address is checked by the same rule, and the step that asks for the token names it in full as it was normalized; the wizard holds the lister of the chosen place only; a token typed for one place is emptied when another place is chosen, and the default token is not offered for, or sent to, a Gitea server. Quick Sync (#209): no place is chosen for the user, and its token box takes a token only once one is chosen; a token that is typed, pasted or filled in is sent nowhere until the user presses Find or Load repositories, and then to the listers of the place on the card; a server address is taken when its box is left, not while it is typed; choosing another place empties the token, the organization and the table (whose clone addresses are on the other host); what is remembered of a connection is its place, organization and folder, never a token. | Mitigated. The server the user names sees the token: that is the purpose. A machine that trusts an intercepting certificate authority is outside this (as for GitHub) | | T13 | CLI / data directory | Tampering | A malicious `accounts.json`, `accounts-forges.json` or `settings.json` redirects targets, or names another place for an account | Low | Medium | Files live under the user profile; sanitized on load; target paths resolved and contained. The token of an account on a Gitea server is stored under a name made from the account's id, forge and server address as the file states them, so an account whose address was changed in the file finds no token for the new address, and a record that names another account's id finds none of that account's. What remains for someone who can write these files: a record made to look like a GitHub account can name the entry of a Gitea token (the name can be computed from the file), and that token is then offered to github.com, not to a host of their choosing. The app resolves a token for the place a workspace was opened for, so a file that is changed while the app runs does not bring the token of one place to the listers of another | Accepted (same-user boundary: whoever can write these files can read the tokens) | ## 7. Accepted risks @@ -135,3 +135,4 @@ flowchart LR | 2026-11-11 | v1.2.0 (in work) | James Maes (@KofTwentyTwo) | T16 added with the Gitea listers of the engine (ADR 0010, #202): a server at an address the user names is a new component and a new place a token is sent to. The rules that keep the token on that server are stated there. Accounts, the CLI and the app do not use a Gitea server yet. | | 2026-11-11 | v1.2.0 (in work) | James Maes (@KofTwentyTwo) | T16 extended for accounts and the command line (#204): where an account lives is fixed when it is added; tokens do not cross forges by default (the default token, the environment variables); accounts on another forge are in `accounts-forges.json`, which an older gclo does not rewrite; their token is bound to the account and its server address. T13 restated for it. | | 2026-11-11 | v1.2.0 (in work) | James Maes (@KofTwentyTwo) | T16 extended for the desktop app's Add account wizard (#206): the place is chosen before the token is typed, the token is checked with that place only, and nothing typed for one place is carried to another. | +| 2026-11-11 | v1.2.0 (in work) | James Maes (@KofTwentyTwo) | T16 extended for Quick Sync (#209): GitHub and Gitea are equal on the card the app opens on; nothing is preselected and nothing is sent without the user asking; `settings.json` now also holds the place Quick Sync last connected to (a server address, no token). | diff --git a/gclo.Engine.Tests/QuickSyncPlaceTests.cs b/gclo.Engine.Tests/QuickSyncPlaceTests.cs new file mode 100644 index 0000000..ff7a921 --- /dev/null +++ b/gclo.Engine.Tests/QuickSyncPlaceTests.cs @@ -0,0 +1,893 @@ +/* + * Copyright (c) 2026 James Maes (KofTwentyTwo) + * SPDX-License-Identifier: MIT + */ + +using System.Collections.Concurrent; +using gclo.ViewModels; + + +namespace gclo.Engine.Tests; + + +///

+/// Quick Sync's place, chosen on its card (#209): GitHub, or Gitea and the server's +/// address. The rules these tests are built around: nothing is sent anywhere +/// before the user chose a place and asked (a token that is typed, pasted or filled +/// in goes nowhere by itself); a token is handed only to the listers of the place +/// the card is on; and what was entered or loaded for one place is not carried to +/// another. Each lister here records what it was given +/// under the place it is the lister of. Also what the app remembers of a Quick Sync +/// connection between runs (never a token), and which workspace it opens on. +/// +public sealed class QuickSyncPlaceTests : IDisposable +{ + private const string Server = "https://git.example.test/"; + + private static readonly ForgeEndpoint s_gitea = Place(Server); + + private readonly string _root = Path.Combine(Path.GetTempPath(), "gclo-tests", Guid.NewGuid().ToString("N")); + + /// Every token a lister was given: the place the lister is for, what was asked, and the token. + private readonly ConcurrentQueue<(string Place, string Asked, string Token)> _sent = new(); + + private readonly List _workspaces = []; + + /// How many organizations a lister lists; one means "only your own account". + private int _organizations = 2; + + + + public void Dispose() + { + foreach(WorkspaceViewModel workspace in _workspaces) + { + workspace.Dispose(); + } + GitTestHelpers.TryDeleteDirectory(_root); + } + + + + private static ForgeEndpoint Place(string server) + { + Assert.True(ForgeEndpoint.TryCreate(ForgeKind.Gitea, server, out ForgeEndpoint? place, out _)); + return place; + } + + + + /// The listers of a place: they record what they are given and answer with things named after the place. + private (IRepositoryLister Repositories, IOrganizationLister Organizations) ListersOf(ForgeEndpoint place) + { + var repositories = new RecordingRepositoryLister(place.DisplayName, _sent); + var organizations = new FakeOrganizationLister + { + Handler = (token, _) => + { + _sent.Enqueue((place.DisplayName, "organizations", token)); + IReadOnlyList listed = _organizations == 1 ? ["me"] : ["me", $"org-at-{place.DisplayName}"]; + return Task.FromResult(listed); + }, + }; + return (repositories, organizations); + } + + + + private WorkspaceViewModel QuickSync( + Func? defaultTokenOf = null, + Func? listersOf = null) + { + var workspace = new WorkspaceViewModel( + git: new FakeGitClient(), + orgLookupDebounce: TimeSpan.FromMilliseconds(1), + log: new NullActivityLog(), + wsl: new FakeWslCloner(), + listersOf: listersOf ?? ListersOf, + defaultTokenOf: defaultTokenOf); + _workspaces.Add(workspace); + return workspace; + } + + + + /// Long enough for a lookup that was going to be sent to have been sent (the debounce is 1 ms). + private static Task ALookupWouldHaveGoneOutAsync() => Task.Delay(150); + + + + /// A Quick Sync on which GitHub was chosen. + private WorkspaceViewModel QuickSyncOnGitHub(Func? defaultTokenOf = null) + { + WorkspaceViewModel workspace = QuickSync(defaultTokenOf); + workspace.PlaceIndex = 0; + return workspace; + } + + + + /// Types a token and asks for the organizations it lists, as the Find button does. + private static async Task TypeTokenAsync(WorkspaceViewModel workspace, string token) + { + workspace.Token = token; + Assert.True(workspace.FindOrganizationsCommand.CanExecute(null)); + await workspace.FindOrganizationsCommand.ExecuteAsync(null); + Assert.NotEmpty(workspace.Organizations); + } + + + + // ---------------------------------------------------------------- the choice + + [Fact] + public async Task QuickSync_StartsWithNoPlaceChosen_AndNothingCanBeSent() + { + WorkspaceViewModel workspace = QuickSync(defaultTokenOf: _ => "ghp_default_0123456789"); + + Assert.True(workspace.CanChoosePlace); + Assert.False(workspace.IsPlaceChosen); + Assert.Equal(-1, workspace.PlaceIndex); + Assert.False(workspace.IsOnGitea); + Assert.False(workspace.AsksForServer); + Assert.Equal("Connect", workspace.ConnectTitle); + Assert.Equal("", workspace.PlaceName); + Assert.Equal("Access token", workspace.TokenHeader); + Assert.Equal("", workspace.TokenPlaceholder); + Assert.Null(workspace.TokenPage); + Assert.False(workspace.SaveAsAccountOffered); + + // No place was chosen: no default token is filled in, and one that is put in the box goes nowhere. + workspace.FillInTheDefaultToken(); + Assert.Equal("", workspace.Token); + + workspace.Token = "a-token-0123456789"; + workspace.Organization = "typed-by-hand"; + workspace.Server = Server; // an address without the choice is no place either + await ALookupWouldHaveGoneOutAsync(); + + Assert.Empty(_sent); + Assert.False(workspace.IsPlaceChosen); + Assert.False(workspace.FindOrganizationsCommand.CanExecute(null)); + Assert.False(workspace.LoadReposCommand.CanExecute(null)); + } + + + + [Fact] + public void ChoosingGitHub_IsAChoiceToo() + { + WorkspaceViewModel workspace = QuickSync(defaultTokenOf: place => place.Kind == ForgeKind.GitHub ? "ghp_default_0123456789" : null); + var changed = new List(); + workspace.PropertyChanged += (_, e) => changed.Add(e.PropertyName); + + workspace.PlaceIndex = 0; + + Assert.True(workspace.IsPlaceChosen); + Assert.Equal(0, workspace.PlaceIndex); + Assert.False(workspace.IsOnGitea); + Assert.Equal("Connect to GitHub", workspace.ConnectTitle); + Assert.Equal("Personal access token", workspace.TokenHeader); + Assert.Equal("ghp_…", workspace.TokenPlaceholder); + Assert.Equal(new Uri("https://github.com/settings/tokens"), workspace.TokenPage); + Assert.Contains(nameof(WorkspaceViewModel.IsPlaceChosen), changed, StringComparer.Ordinal); + Assert.Contains(nameof(WorkspaceViewModel.ConnectTitle), changed, StringComparer.Ordinal); + + // The default token saved for the place that was chosen is filled in. It is not sent. + Assert.Equal("ghp_default_0123456789", workspace.Token); + Assert.Empty(_sent); + } + + + + [Fact] + public async Task ATokenThatIsTypedPastedOrFilledIn_GoesNowhere_UntilTheUserAsks() + { + WorkspaceViewModel workspace = QuickSyncOnGitHub(); + + workspace.Token = "ghp_token_0123456789"; + workspace.Organization = "typed-by-hand"; + await ALookupWouldHaveGoneOutAsync(); + + Assert.Empty(_sent); + Assert.Empty(workspace.Organizations); + + // Find: the organizations are asked for, now, of the place that is on the card. + await workspace.FindOrganizationsCommand.ExecuteAsync(null); + + Assert.Equal([("GitHub", "organizations", "ghp_token_0123456789")], _sent); + Assert.Equal(["me", "org-at-GitHub"], workspace.Organizations); + Assert.Equal("typed-by-hand", workspace.Organization); // what was typed stays + + // Another token: the list of the other one goes, and again nothing is sent by itself. + workspace.Token = "ghp_other_0123456789"; + await ALookupWouldHaveGoneOutAsync(); + + Assert.Empty(workspace.Organizations); + Assert.Single(_sent); + + // Too short to be a token: there is nothing to ask with. + workspace.Token = "ghp_short"; + Assert.False(workspace.FindOrganizationsCommand.CanExecute(null)); + } + + + + [Fact] + public void PlaceIndex_IsTheChoiceAsARadioGroupReadsIt_AndNoSelectionChangesNothing() + { + WorkspaceViewModel workspace = QuickSync(); + var changed = new List(); + workspace.PropertyChanged += (_, e) => changed.Add(e.PropertyName); + + workspace.PlaceIndex = -1; // a radio group with no selection: still nothing chosen + Assert.False(workspace.IsPlaceChosen); + + workspace.PlaceIndex = 1; + + Assert.True(workspace.IsOnGitea); + Assert.True(workspace.AsksForServer); + Assert.Equal(1, workspace.PlaceIndex); + Assert.Contains(nameof(WorkspaceViewModel.PlaceIndex), changed, StringComparer.Ordinal); + Assert.Contains(nameof(WorkspaceViewModel.AsksForServer), changed, StringComparer.Ordinal); + + workspace.PlaceIndex = -1; // a radio group that has no selection for a moment + + Assert.True(workspace.IsOnGitea); + + workspace.PlaceIndex = 0; + + Assert.False(workspace.IsOnGitea); + Assert.False(workspace.AsksForServer); + } + + + + [Fact] + public async Task Gitea_WithoutAnAddressYet_IsNoPlace_AndNothingIsSent() + { + WorkspaceViewModel workspace = QuickSync(); + + workspace.IsOnGitea = true; + + Assert.Equal("Connect to Gitea", workspace.ConnectTitle); + Assert.Equal("Access token", workspace.TokenHeader); + Assert.Equal("", workspace.TokenPlaceholder); + Assert.Null(workspace.TokenPage); // no server, no page of it + Assert.Equal("", workspace.ServerError); // nothing was entered yet: nothing to complain about + + workspace.Token = "a-token-typed-too-early"; + workspace.Organization = "typed-by-hand"; + await ALookupWouldHaveGoneOutAsync(); + + Assert.Empty(_sent); + Assert.Empty(workspace.Organizations); + Assert.False(workspace.LoadReposCommand.CanExecute(null)); + } + + + + // ---------------------------------------------------------------- where the token goes + + [Fact] + public async Task AConnectionToAGiteaServer_SendsItsTokenThere_AndNowhereElse() + { + WorkspaceViewModel workspace = QuickSync(); + QuickSyncConnection? connected = null; + workspace.Connected = connection => connected = connection; + var changed = new List(); + workspace.PropertyChanged += (_, e) => changed.Add(e.PropertyName); + + workspace.IsOnGitea = true; + workspace.Server = " git.example.test "; // as it is typed + + // The address reads as it was normalized: it is the one a token goes to. + Assert.Equal(Server, workspace.Server); + Assert.Equal("", workspace.ServerError); + Assert.Equal("Connect to git.example.test", workspace.ConnectTitle); + Assert.Equal(new Uri("https://git.example.test/user/settings/applications"), workspace.TokenPage); + Assert.All( + (string[]) + [ + nameof(WorkspaceViewModel.Server), nameof(WorkspaceViewModel.PlaceName), nameof(WorkspaceViewModel.ConnectTitle), + nameof(WorkspaceViewModel.TokenHeader), nameof(WorkspaceViewModel.TokenPlaceholder), nameof(WorkspaceViewModel.TokenPage), + ], + name => Assert.Contains(name, changed, StringComparer.Ordinal)); + Assert.Empty(_sent); + + await TypeTokenAsync(workspace, "gitea-token-0123456789"); + + Assert.Equal(["me", "org-at-git.example.test"], workspace.Organizations); + Assert.Equal("Found 2 organizations and accounts.", workspace.StatusText); + + workspace.Organization = "org-at-git.example.test"; + workspace.TargetFolder = @"C:\mirror"; + workspace.CreateOrgSubfolder = true; + await workspace.LoadReposCommand.ExecuteAsync(null); + + Assert.Equal( + [("git.example.test", "organizations", "gitea-token-0123456789"), ("git.example.test", "repositories", "gitea-token-0123456789")], + _sent); + Assert.True(workspace.HasLoadedRepos); + + // The connection works: offered as an account, and told to whoever remembers it. No token in what is told. + Assert.True(workspace.SaveAsAccountOffered); + Assert.Equal(new QuickSyncConnection(s_gitea, "org-at-git.example.test", @"C:\mirror", CreateOrgSubfolder: true), connected); + } + + + + [Theory] + [InlineData("http://git.example.test", "The server address cannot be used: it has to begin with https://.")] + [InlineData( + "https://someone:s3cret-token@git.example.test", + "The server address cannot be used: it contains a user name or a password; the token is entered separately.")] + public async Task AnAddressThatCannotBeUsed_IsSaidSo_AndNothingIsSent(string typed, string said) + { + WorkspaceViewModel workspace = QuickSync(); + workspace.IsOnGitea = true; + + workspace.Server = typed; + workspace.Token = "gitea-token-0123456789"; + workspace.Organization = "typed-by-hand"; + await ALookupWouldHaveGoneOutAsync(); + + Assert.Equal(said, workspace.ServerError); + Assert.DoesNotContain("s3cret", workspace.ServerError, StringComparison.Ordinal); // a refused address is not repeated + Assert.Equal(typed, workspace.Server); // left as it was typed, to be corrected + Assert.Equal("Connect to Gitea", workspace.ConnectTitle); + Assert.Empty(_sent); + Assert.False(workspace.LoadReposCommand.CanExecute(null)); + + // Corrected, the message goes away and the place is taken. What was typed before there was a place is not kept for it. + workspace.Server = "git.example.test"; + + Assert.Equal("", workspace.ServerError); + Assert.Equal("Connect to git.example.test", workspace.ConnectTitle); + Assert.Equal("", workspace.Token); + Assert.False(workspace.LoadReposCommand.CanExecute(null)); + + workspace.Token = "gitea-token-0123456789"; + workspace.Organization = "typed-by-hand"; + + Assert.True(workspace.LoadReposCommand.CanExecute(null)); + Assert.True(workspace.FindOrganizationsCommand.CanExecute(null)); + Assert.Empty(_sent); + } + + + + [Fact] + public async Task TheOrganizationHint_IsInTheWordsOfTheForge() + { + _organizations = 1; // a token that sees no organization + WorkspaceViewModel onGitHub = QuickSyncOnGitHub(); + WorkspaceViewModel onGitea = QuickSync(); + onGitea.IsOnGitea = true; + onGitea.Server = Server; + + onGitHub.Token = "ghp_token_0123456789"; + onGitea.Token = "gitea-token-0123456789"; + await onGitHub.FindOrganizationsCommand.ExecuteAsync(null); + await onGitea.FindOrganizationsCommand.ExecuteAsync(null); + + Assert.Equal( + "Only your personal account is visible — add read:org (classic) for organizations, or type an org name manually.", + onGitHub.StatusText); + Assert.Equal( + "Only your personal account is visible — allow the token read:organization for organizations, or type an org name manually.", + onGitea.StatusText); + } + + + + // ---------------------------------------------------------------- nothing is carried to another place + + [Fact] + public async Task WhatWasEnteredAndLoadedForOnePlace_IsForgotten_WhenAnotherIsChosen() + { + WorkspaceViewModel workspace = QuickSyncOnGitHub(); + await TypeTokenAsync(workspace, "ghp_token_0123456789"); + workspace.Organization = "org-at-GitHub"; + await workspace.LoadReposCommand.ExecuteAsync(null); + Assert.Equal(2, workspace.Repos.Count); + Assert.True(workspace.SaveAsAccountOffered); + + // The other kind of forge: everything of GitHub goes at once, before there is an address. + workspace.IsOnGitea = true; + + Assert.Equal("", workspace.Token); + Assert.Equal("", workspace.Organization); + Assert.Empty(workspace.Organizations); + Assert.Empty(workspace.Repos); // their clone addresses are on github.com: not to be synced with a token of the server + Assert.Empty(workspace.FilteredRepos); + Assert.False(workspace.HasLoadedRepos); + Assert.Null(workspace.LoadedOrganization); + Assert.False(workspace.SaveAsAccountOffered); + Assert.False(workspace.SyncCommand.CanExecute(null)); + + workspace.Server = Server; + await TypeTokenAsync(workspace, "gitea-token-0123456789"); + workspace.Organization = "org-at-git.example.test"; + await workspace.LoadReposCommand.ExecuteAsync(null); + int sentSoFar = _sent.Count; + + // From one server to another: the same. + workspace.Server = "https://other.example.test"; + + Assert.Equal("", workspace.Token); + Assert.Equal("", workspace.Organization); + Assert.Empty(workspace.Repos); + Assert.Equal("Connect to other.example.test", workspace.ConnectTitle); + await ALookupWouldHaveGoneOutAsync(); + Assert.Equal(sentSoFar, _sent.Count); // nothing went to the new server: no token was typed for it + + Assert.DoesNotContain(_sent, sent => string.Equals(sent.Place, "git.example.test", StringComparison.Ordinal) && sent.Token.StartsWith("ghp_", StringComparison.Ordinal)); + Assert.DoesNotContain(_sent, sent => string.Equals(sent.Place, "GitHub", StringComparison.Ordinal) && sent.Token.StartsWith("gitea", StringComparison.Ordinal)); + } + + + + [Fact] + public async Task TheSameServer_SpelledAnotherWay_IsTheSamePlace_AndNothingIsForgotten() + { + WorkspaceViewModel workspace = QuickSync(); + workspace.IsOnGitea = true; + workspace.Server = "git.example.test"; + await TypeTokenAsync(workspace, "gitea-token-0123456789"); + workspace.Organization = "org-at-git.example.test"; + + workspace.Server = "HTTPS://GIT.example.test"; + + Assert.Equal(Server, workspace.Server); + Assert.Equal("gitea-token-0123456789", workspace.Token); + Assert.Equal("org-at-git.example.test", workspace.Organization); + } + + + + [Fact] + public async Task APlaceWhoseListersCannotBeMade_IsNotTaken_AndNothingGoesToTheListersOfTheOther() + { + WorkspaceViewModel workspace = QuickSync( + listersOf: place => string.Equals(place.Server?.Host, "broken.example.test", StringComparison.Ordinal) ? throw new InvalidOperationException("no listers for that place") : ListersOf(place)); + workspace.IsOnGitea = true; + workspace.Server = Server; + await TypeTokenAsync(workspace, "gitea-token-0123456789"); + int sentSoFar = _sent.Count; + + Assert.Throws(() => workspace.Server = "https://broken.example.test"); + + // The card shows the new address, and there are no listers for it: the token that is still there can go nowhere. + workspace.Token = "gitea-token-0123456789-again"; + workspace.Organization = "typed-by-hand"; + + Assert.False(workspace.FindOrganizationsCommand.CanExecute(null)); + Assert.False(workspace.LoadReposCommand.CanExecute(null)); + Assert.Equal(sentSoFar, _sent.Count); + } + + + + [Fact] + public async Task WhileTheWorkspaceIsBusy_ThePlaceStaysAsItIs() + { + var gate = new TaskCompletionSource>(TaskCreationOptions.RunContinuationsAsynchronously); + WorkspaceViewModel workspace = QuickSync(listersOf: place => (new GatedRepositoryLister(gate.Task), ListersOf(place).Organizations)); + workspace.PlaceIndex = 0; + await TypeTokenAsync(workspace, "ghp_token_0123456789"); + workspace.Organization = "org-at-GitHub"; + Task loading = workspace.LoadReposCommand.ExecuteAsync(null); + Assert.False(workspace.CanEditInputs); + var changed = new List(); + workspace.PropertyChanged += (_, e) => changed.Add(e.PropertyName); + + workspace.IsOnGitea = true; + workspace.Server = Server; + + // Refused, and said: a control that asked shows the choice as it is. + Assert.False(workspace.IsOnGitea); + Assert.Equal("", workspace.Server); + Assert.Contains(nameof(WorkspaceViewModel.IsOnGitea), changed, StringComparer.Ordinal); + Assert.Contains(nameof(WorkspaceViewModel.Server), changed, StringComparer.Ordinal); + Assert.Equal("ghp_token_0123456789", workspace.Token); + + gate.SetResult([new RepoDescriptor("alpha", "https://github.com/org-at-GitHub/alpha.git", "main", false)]); + await loading; + + Assert.Single(workspace.Repos); + } + + + + [Fact] + public async Task TheAnswerToALookup_ThatArrivesAfterAnotherPlaceWasChosen_IsNotShown() + { + var answer = new TaskCompletionSource>(TaskCreationOptions.RunContinuationsAsynchronously); + WorkspaceViewModel workspace = QuickSync(listersOf: place => place.Server is not null + ? ListersOf(place) + : (ListersOf(place).Repositories, new FakeOrganizationLister { Handler = (_, _) => answer.Task })); + workspace.PlaceIndex = 0; + workspace.Token = "ghp_token_0123456789"; + Task asking = workspace.FindOrganizationsCommand.ExecuteAsync(null); + + workspace.IsOnGitea = true; + workspace.Server = Server; + answer.SetResult(["me", "an-organization-on-github"]); + await asking; + + Assert.Empty(workspace.Organizations); // GitHub's organizations are not offered for the server + Assert.False(workspace.IsLoadingOrgs); + } + + + + // ---------------------------------------------------------------- the default token of a place + + [Fact] + public async Task TheDefaultTokenOfAPlace_IsFilledInForThatPlaceOnly() + { + var asked = new List(); + WorkspaceViewModel workspace = QuickSync(defaultTokenOf: place => + { + asked.Add(place.DisplayName); + return place.Kind == ForgeKind.GitHub ? "ghp_default_0123456789" : null; + }); + + workspace.PlaceIndex = 0; + + Assert.Equal("ghp_default_0123456789", workspace.Token); + await ALookupWouldHaveGoneOutAsync(); + Assert.Empty(_sent); // filled in, not sent + + // On a Gitea server there is none: the box is empty, and GitHub's default token goes nowhere near it. + workspace.IsOnGitea = true; + workspace.Server = Server; + await ALookupWouldHaveGoneOutAsync(); + + Assert.Equal("", workspace.Token); + Assert.Contains("git.example.test", asked, StringComparer.Ordinal); + Assert.DoesNotContain(_sent, sent => string.Equals(sent.Place, "git.example.test", StringComparison.Ordinal)); + + // Back on GitHub it is filled in again. + workspace.IsOnGitea = false; + + Assert.Equal("ghp_default_0123456789", workspace.Token); + } + + + + [Fact] + public void TheDefaultToken_DoesNotReplaceATokenThatWasTyped_AndIsNotForAnAccount() + { + WorkspaceViewModel workspace = QuickSync(defaultTokenOf: _ => "ghp_default_0123456789"); + workspace.Token = "ghp_typed_0123456789"; + + workspace.PlaceIndex = 0; + workspace.FillInTheDefaultToken(); + + Assert.Equal("ghp_typed_0123456789", workspace.Token); + + var account = new Account { Id = Guid.NewGuid(), Name = "Work", Organization = "acme", TargetRoot = @"C:\repos" }; + using var ofAnAccount = new WorkspaceViewModel( + new FakeRepositoryLister(), new FakeGitClient(), new FakeOrganizationLister(), account: account, + listersOf: ListersOf, defaultTokenOf: _ => "ghp_default_0123456789"); + + ofAnAccount.FillInTheDefaultToken(); + ofAnAccount.Restore(new QuickSyncConnection(s_gitea, "platform", @"C:\mirror", CreateOrgSubfolder: false)); + ofAnAccount.IsOnGitea = true; + ofAnAccount.Server = Server; + + // An account's place and token are its own: none of this is for it. + Assert.False(ofAnAccount.CanChoosePlace); + Assert.Equal("", ofAnAccount.Token); + Assert.False(ofAnAccount.IsOnGitea); + Assert.Equal("", ofAnAccount.Server); + Assert.Equal("acme", ofAnAccount.Organization); + Assert.Equal("Connect to GitHub", ofAnAccount.ConnectTitle); + } + + + + [Fact] + public void AChangeOfTheDefaultTokenInSettings_ReachesQuickSync_OnlyWhileItIsOnGitHub() + { + WorkspaceViewModel workspace = QuickSync(); + + workspace.OnDefaultTokenChanged(previous: null, current: "ghp_default_0123456789"); + Assert.Equal("", workspace.Token); // no place was chosen yet + + workspace.PlaceIndex = 0; + workspace.OnDefaultTokenChanged(previous: null, current: "ghp_default_0123456789"); + Assert.Equal("ghp_default_0123456789", workspace.Token); + + workspace.IsOnGitea = true; + workspace.Server = Server; + workspace.OnDefaultTokenChanged(previous: null, current: "ghp_another_0123456789"); // the box is empty, as "previous" says + + Assert.Equal("", workspace.Token); // a GitHub token: not for this place + + workspace.Server = "http://not-usable"; + workspace.OnDefaultTokenChanged(previous: null, current: "ghp_another_0123456789"); + + Assert.Equal("", workspace.Token); + } + + + + // ---------------------------------------------------------------- opening the way it was left + + [Fact] + public async Task Restore_PutsTheCardBackAsAConnectionHadIt_WithoutAToken_AndSendsNothing() + { + WorkspaceViewModel workspace = QuickSync(); + + workspace.Restore(new QuickSyncConnection(s_gitea, "platform", @"C:\mirror", CreateOrgSubfolder: true)); + await ALookupWouldHaveGoneOutAsync(); + + Assert.True(workspace.IsOnGitea); + Assert.Equal(Server, workspace.Server); + Assert.Equal("Connect to git.example.test", workspace.ConnectTitle); + Assert.Equal("platform", workspace.Organization); + Assert.Equal(@"C:\mirror", workspace.TargetFolder); + Assert.True(workspace.CreateOrgSubfolder); + Assert.Equal("", workspace.Token); + Assert.False(workspace.HasLoadedRepos); // nothing is loaded until the user asks + Assert.Empty(_sent); + } + + + + [Fact] + public void Restore_OfAGitHubConnection_FillsInTheDefaultToken_AndLeavesAFolderThatWasNotRememberedAlone() + { + WorkspaceViewModel workspace = QuickSync(defaultTokenOf: place => place.Kind == ForgeKind.GitHub ? "ghp_default_0123456789" : null); + workspace.TargetFolder = @"C:\from-settings"; + + workspace.Restore(new QuickSyncConnection(ForgeEndpoint.GitHub, "acme", TargetFolder: "", CreateOrgSubfolder: true)); + + Assert.False(workspace.IsOnGitea); + Assert.Equal("acme", workspace.Organization); + Assert.Equal(@"C:\from-settings", workspace.TargetFolder); + Assert.False(workspace.CreateOrgSubfolder); + Assert.Equal("ghp_default_0123456789", workspace.Token); + Assert.Throws(() => workspace.Restore(null!)); + } + + + + [Fact] + public void AQuickSyncThatWasGivenNoListersOfAPlace_OffersNoChoice() + { + using var workspace = new WorkspaceViewModel(new FakeRepositoryLister(), new FakeGitClient(), new FakeOrganizationLister()); + var changed = new List(); + workspace.PropertyChanged += (_, e) => changed.Add(e.PropertyName); + + workspace.IsOnGitea = true; + workspace.Server = Server; + workspace.Restore(new QuickSyncConnection(s_gitea, "platform", @"C:\mirror", CreateOrgSubfolder: false)); + + Assert.False(workspace.CanChoosePlace); + Assert.True(workspace.IsPlaceChosen); // its place is given: GitHub + Assert.Equal(0, workspace.PlaceIndex); + Assert.False(workspace.IsOnGitea); + Assert.False(workspace.AsksForServer); + Assert.Equal("", workspace.Server); + Assert.Equal("", workspace.Organization); + Assert.Equal("Connect to GitHub", workspace.ConnectTitle); + Assert.Contains(nameof(WorkspaceViewModel.IsOnGitea), changed, StringComparer.Ordinal); // said all the same: the control shows what is + } + + + + // ---------------------------------------------------------------- saved as an account + + [Fact] + public async Task AConnectionSavedAsAnAccount_IsAnAccountOnThePlaceItWasOn() + { + WorkspaceViewModel workspace = QuickSync(); + workspace.IsOnGitea = true; + workspace.Server = Server; + await TypeTokenAsync(workspace, "gitea-token-0123456789"); + workspace.Organization = "org-at-git.example.test"; + workspace.TargetFolder = @"C:\mirror"; + + AccountWizardSeed seed = workspace.ToAccountSeed(); + + Assert.Equal(s_gitea, seed.Place); + Assert.Equal("gitea-token-0123456789", seed.Token); + Assert.DoesNotContain("gitea-token", seed.ToString(), StringComparison.Ordinal); + Assert.Contains("Place = git.example.test", seed.ToString(), StringComparison.Ordinal); + Assert.Contains("Place = GitHub", (seed with { Place = null }).ToString(), StringComparison.Ordinal); + + // The wizard that saves it: on that place, with that place's lister, and never on the default token (a GitHub token). + var vault = new InMemoryVault(); + var store = new AccountsStore(vault, _root); + var wizard = new AccountWizardViewModel( + store, ListersOf(s_gitea).Organizations, seed, defaultToken: () => "gitea-token-0123456789"); + Assert.False(wizard.CanChoosePlace); + Assert.Equal("This account is on git.example.test.", wizard.PlaceNote); + Assert.False(wizard.UseDefaultToken); + Assert.False(wizard.OffersTokenChoice); + + wizard.Name = "Platform"; + while(wizard.Step < 4) + { + Assert.True(await wizard.TryAdvanceAsync(), $"stopped at step {wizard.Step}"); + } + await wizard.SaveAsync(); + + Account saved = Assert.Single(new AccountsStore(vault, _root).GetAll()); + Assert.Equal(ForgeKind.Gitea, saved.Forge); + Assert.Equal(Server, saved.Server); + Assert.Equal("gitea-token-0123456789", vault.TryRetrieve(saved.TokenEntryId)); + Assert.DoesNotContain(_sent, sent => string.Equals(sent.Place, "GitHub", StringComparison.Ordinal)); + } + + + + [Fact] + public async Task AnAccountsPage_IsNotOfferedToBeSavedAsAnAccount() + { + var account = new Account { Id = Guid.NewGuid(), Name = "Work", Organization = "acme", TargetRoot = @"C:\repos" }; + var vault = new InMemoryVault(); + vault.Store(account.Id, "ghp_token_0123456789"); + using var workspace = new WorkspaceViewModel( + new FakeRepositoryLister { Repositories = [new RepoDescriptor("alpha", "https://github.com/acme/alpha.git", "main", false)] }, + new FakeGitClient(), new FakeOrganizationLister(), orgLookupDebounce: TimeSpan.FromMilliseconds(1), log: new NullActivityLog(), + account: account, tokenVault: vault); + bool told = false; + workspace.Connected = _ => told = true; + + await workspace.LoadReposCommand.ExecuteAsync(null); + + Assert.True(workspace.HasLoadedRepos); + Assert.False(workspace.SaveAsAccountOffered); + Assert.False(told); + } + + + + // ---------------------------------------------------------------- what is remembered between runs + + [Fact] + public void AQuickSyncConnection_IsRememberedWithoutAToken_AndGivenBackAsItWas() + { + Directory.CreateDirectory(_root); + var settings = new AppSettings(); + settings.RememberQuickSync(new QuickSyncConnection(s_gitea, " platform ", @" C:\mirror ", CreateOrgSubfolder: true)); + settings.LastWorkspace = StartWorkspace.QuickSync; + Assert.True(settings.Save(directory: _root)); + + var loaded = AppSettings.Load(directory: _root); + + Assert.Equal(new QuickSyncConnection(s_gitea, "platform", @"C:\mirror", CreateOrgSubfolder: true), loaded.RememberedQuickSync()); + Assert.Equal(StartWorkspace.QuickSync, loaded.LastWorkspace); + string file = File.ReadAllText(Path.Combine(_root, "settings.json")); + Assert.Contains("\"QuickSyncForge\": \"Gitea\"", file, StringComparison.Ordinal); + Assert.Contains("\"QuickSyncServer\": \"https://git.example.test/\"", file, StringComparison.Ordinal); + + // GitHub is remembered as no forge and no server. + loaded.RememberQuickSync(new QuickSyncConnection(ForgeEndpoint.GitHub, "acme", "", CreateOrgSubfolder: false)); + + Assert.Null(loaded.QuickSyncForge); + Assert.Null(loaded.QuickSyncServer); + Assert.Equal(new QuickSyncConnection(ForgeEndpoint.GitHub, "acme", "", CreateOrgSubfolder: false), loaded.RememberedQuickSync()); + Assert.Throws(() => loaded.RememberQuickSync(null!)); + } + + + + [Fact] + public void OnAFirstRun_NothingIsRemembered_SoNoPlaceIsChosenForTheUser() + { + var settings = new AppSettings(); + + Assert.Null(settings.RememberedQuickSync()); + Assert.Null(settings.LastWorkspace); + } + + + + [Theory] + [InlineData("Gitea", "http://git.example.test")] // a file edited by hand + [InlineData("Gitea", null)] + [InlineData("GitLab", "https://gitlab.example.test/")] // a forge of a later version + [InlineData(null, "https://git.example.test/")] // GitHub has no server + public void WhatIsRemembered_ThatNamesNoUsablePlace_IsNotGivenBack(string? forge, string? server) + { + var settings = new AppSettings { QuickSyncForge = forge, QuickSyncServer = server, QuickSyncOrganization = "platform" }; + + Assert.Null(settings.RememberedQuickSync()); + } + + + + [Fact] + public void ASettingsFileThatSaysNullForWhatIsRemembered_ReadsAsNothing() + { + Directory.CreateDirectory(_root); + File.WriteAllText(Path.Combine(_root, "settings.json"), """{ "QuickSyncOrganization": null, "QuickSyncTargetFolder": null }"""); + + var loaded = AppSettings.Load(directory: _root); + + Assert.Equal("", loaded.QuickSyncOrganization); + Assert.Equal("", loaded.QuickSyncTargetFolder); + } + + + + // ---------------------------------------------------------------- which workspace the app opens on + + [Fact] + public void TheAppOpens_WhereItWasLeft_AndOtherwiseOnTheFirstAccount_OrQuickSync() + { + Account first = new() { Id = Guid.NewGuid(), Name = "Alpha", Organization = "a", TargetRoot = "t" }; + Account second = new() { Id = Guid.NewGuid(), Name = "Beta", Organization = "b", TargetRoot = "t" }; + + // A first run, and any run without accounts: Quick Sync. + Assert.Equal(StartWorkspace.QuickSync, StartWorkspace.Choose([], last: null)); + Assert.Equal(StartWorkspace.QuickSync, StartWorkspace.Choose([], last: second.Id)); // the account that was open is gone + + // Accounts, and nothing recorded yet: the first of them. + Assert.Equal(first.Id, StartWorkspace.Choose([first, second], last: null)); + Assert.Equal(first.Id, StartWorkspace.Choose([first], last: null)); + + // Where it was left: an account, or Quick Sync. + Assert.Equal(second.Id, StartWorkspace.Choose([first, second], last: second.Id)); + Assert.Equal(StartWorkspace.QuickSync, StartWorkspace.Choose([first, second], last: StartWorkspace.QuickSync)); + + // The account that was open last was removed: the first of those that are left. + Assert.Equal(first.Id, StartWorkspace.Choose([first], last: second.Id)); + Assert.Throws(() => StartWorkspace.Choose(null!, last: null)); + } + + + + // ---------------------------------------------------------------- the default token saved for a place + + [Fact] + public void TheDefaultTokenInSettings_IsGitHubs_AndNoneForAnotherPlace() + { + var vault = new InMemoryVault(); + + Assert.Null(DefaultTokens.For(vault, ForgeEndpoint.GitHub)); + + vault.Store(AppSettings.DefaultTokenVaultId, "ghp_default_0123456789"); + + Assert.Equal("ghp_default_0123456789", DefaultTokens.For(vault, ForgeEndpoint.GitHub)); + Assert.Null(DefaultTokens.For(vault, s_gitea)); + Assert.Throws(() => DefaultTokens.For(null!, ForgeEndpoint.GitHub)); + Assert.Throws(() => DefaultTokens.For(vault, null!)); + } + + + + // ---------------------------------------------------------------- fakes + + /// Lists two repositories on the place it is for, and records the token it was given. + private sealed class RecordingRepositoryLister(string place, ConcurrentQueue<(string Place, string Asked, string Token)> sent) : IRepositoryLister + { + public Task> ListOrganizationRepositoriesAsync( + string organization, string token, CancellationToken cancellationToken = default) + { + sent.Enqueue((place, "repositories", token)); + IReadOnlyList listed = + [ + new("alpha", $"https://{place}/{organization}/alpha.git", "main", IsArchived: false), + new("bravo", $"https://{place}/{organization}/bravo.git", "main", IsArchived: false), + ]; + return Task.FromResult(listed); + } + } + + + + /// Answers when the test lets it. + private sealed class GatedRepositoryLister(Task> answer) : IRepositoryLister + { + public Task> ListOrganizationRepositoriesAsync( + string organization, string token, CancellationToken cancellationToken = default) + => answer; + } +} diff --git a/gclo.UiTests/RepoTableTests.cs b/gclo.UiTests/RepoTableTests.cs index 6b42cf0..b76cf56 100644 --- a/gclo.UiTests/RepoTableTests.cs +++ b/gclo.UiTests/RepoTableTests.cs @@ -135,11 +135,21 @@ public sealed class RepoTableTests : UiTestBase return; } - AutomationElement tokenBox = Session.WaitForElement("ConnectTokenBox"); - Session.EnterText(tokenBox, FixtureToken); - // The debounced org lookup is answered by the fixture (one organization); the + // Quick Sync asks where the repositories are first, and takes no token before + // that (#209). With the place chosen and the token entered nothing is sent yet: + // Find lists the organizations, answered by the fixture (one organization). The // log line is the oracle, then the org is picked from the combo box's list so // the selection commits to the view model's Organization, which enables Load. + Session.WaitForElement("ConnectPlaceGitHub").AsRadioButton().IsChecked = true; + AutomationElement tokenBox = Session.WaitFor( + () => Session.FindInApp(cf => cf.ByAutomationId("ConnectTokenBox")) is { IsEnabled: true } box ? box : null, + "the token box to take a token once a place is chosen"); + Session.EnterText(tokenBox, FixtureToken); + Assert.False(Session.LogContains("Organization lookup started"), "entering a token must not send it anywhere by itself"); + Session.WaitFor( + () => Session.FindInApp(cf => cf.ByAutomationId("ConnectFindOrgsButton")) is { IsEnabled: true } button ? button : null, + "the Find button to enable"); + Session.ClickButton("Find"); Session.WaitForLogLine("Organization lookup finished: 1 organizations", TimeSpan.FromSeconds(20)); ComboBox orgBox = Session.WaitForElement("ConnectOrgBox").AsComboBox(); Session.WaitFor( diff --git a/gclo.UiTests/SmokeTests.cs b/gclo.UiTests/SmokeTests.cs index f483e34..f84c926 100644 --- a/gclo.UiTests/SmokeTests.cs +++ b/gclo.UiTests/SmokeTests.cs @@ -197,7 +197,11 @@ public sealed partial class SmokeTests : UiTestBase [Fact] public void ConnectTokenBox_TypingText_KeepsAppAliveAndOrgBoxPresent() { - AutomationElement tokenBox = Session.WaitForElement("ConnectTokenBox"); + // The box takes a token once a place was chosen (#209). + Session.WaitForElement("ConnectPlaceGitHub").AsRadioButton().IsChecked = true; + AutomationElement tokenBox = Session.WaitFor( + () => Session.FindInApp(cf => cf.ByAutomationId("ConnectTokenBox")) is { IsEnabled: true } box ? box : null, + "the token box to take a token once a place is chosen"); Session.EnterText(tokenBox, "ghp_short"); Assert.NotNull(Session.WaitForElement("ConnectOrgBox")); @@ -288,7 +292,54 @@ public sealed partial class SmokeTests : UiTestBase - // (10) Resizing below the minimum window size is intentionally not covered: + // (10) Quick Sync on a Gitea server (#209). The card asks where the repositories + // are; with Gitea chosen and an address entered its title names the server. + // Entering the token sends nothing: the organizations are listed when Find + // is pressed, and they are the ones of that server (the fixture answers for + // the place that was asked, offline). Nothing is loaded, and the card is put + // back on GitHub with an empty token box, so the other tests of this + // session find it as they expect. + [Fact] + public void QuickSync_OnAGiteaServer_ListsThatServersOrganizations_WhenAsked() + { + try + { + Session.WaitForElement("ConnectPlaceGitea").AsRadioButton().IsChecked = true; + Session.EnterText(Session.WaitForElement("ConnectServerBox"), "git.example.test"); + AutomationElement tokenBox = Session.WaitFor( + () => Session.FindInApp(cf => cf.ByAutomationId("ConnectTokenBox")) is { IsEnabled: true } box ? box : null, + "the token box to take a token once a place is chosen"); + Session.EnterText(tokenBox, "gclo-uitest-fixture-token"); + + // The address was taken with the token, as it was normalized, and the title names the server. + Session.WaitForText("Connect to git.example.test"); + + Session.WaitFor( + () => Session.FindInApp(cf => cf.ByAutomationId("ConnectFindOrgsButton")) is { IsEnabled: true } button ? button : null, + "the Find button to enable"); + Session.ClickButton("Find"); + ComboBox orgBox = Session.WaitForElement("ConnectOrgBox").AsComboBox(); + Session.WaitFor( + () => + { + orgBox.Expand(); + return orgBox.Items.FirstOrDefault(i => string.Equals(i.Name, "fixture-gitea-org", StringComparison.Ordinal)); + }, + "the fixture's Gitea organization in the organization list"); + orgBox.Collapse(); + Assert.False(Session.App.HasExited); + } + finally + { + // Back to GitHub: choosing another place empties the token and the organization list. + Session.WaitForElement("ConnectPlaceGitHub").AsRadioButton().IsChecked = true; + Session.WaitForText("Connect to GitHub"); + } + } + + + + // (11) Resizing below the minimum window size is intentionally not covered: // driving OS-level resize through UIA transforms is flaky across DPI // scales, and the minimum is already enforced by OverlappedPresenter. diff --git a/gclo.ViewModels/AccountWizardSeed.cs b/gclo.ViewModels/AccountWizardSeed.cs index bc3f506..5d79964 100644 --- a/gclo.ViewModels/AccountWizardSeed.cs +++ b/gclo.ViewModels/AccountWizardSeed.cs @@ -3,6 +3,9 @@ * SPDX-License-Identifier: MIT */ +using gclo.Engine; + + namespace gclo.ViewModels; @@ -16,15 +19,17 @@ namespace gclo.ViewModels; /// The workspace's target folder (not the org subfolder). /// Whether clones go under TargetRoot\Organization. /// The workspace's parallel clone count. +/// Where the connection is: the place the token was used with. Null means GitHub. public sealed record AccountWizardSeed( string Token, string Organization, string TargetRoot, bool CreateOrgSubfolder, - int MaxConcurrency) + int MaxConcurrency, + ForgeEndpoint? Place = null) { /// Redacts the token: this record must never print it. public override string ToString() => $"AccountWizardSeed {{ Token = [redacted], Organization = {Organization}, TargetRoot = {TargetRoot}, " - + $"CreateOrgSubfolder = {CreateOrgSubfolder}, MaxConcurrency = {MaxConcurrency} }}"; + + $"CreateOrgSubfolder = {CreateOrgSubfolder}, MaxConcurrency = {MaxConcurrency}, Place = {Place ?? ForgeEndpoint.GitHub} }}"; } diff --git a/gclo.ViewModels/AccountWizardViewModel.cs b/gclo.ViewModels/AccountWizardViewModel.cs index 52f9d8c..73ae639 100644 --- a/gclo.ViewModels/AccountWizardViewModel.cs +++ b/gclo.ViewModels/AccountWizardViewModel.cs @@ -144,10 +144,14 @@ public sealed partial class AccountWizardViewModel : ObservableObject Name = ""; Description = ""; Token = seed.Token; + // The account is where the connection was: its token was used with that place. + _chosen = seed.Place ?? ForgeEndpoint.GitHub; + // A Quick Sync connection that runs on the default token becomes a // default-token account; any other token is the account's own. Compared in - // memory only; neither value is logged. + // memory only; neither value is logged. The default token is a GitHub token. UseDefaultToken = HasDefaultToken + && _chosen.Kind == ForgeKind.GitHub && string.Equals(seed.Token.Trim(), _defaultToken?.Invoke()?.Trim(), StringComparison.Ordinal); Organization = seed.Organization; TargetRoot = seed.TargetRoot; diff --git a/gclo.ViewModels/AppSettings.cs b/gclo.ViewModels/AppSettings.cs index 1d15691..00d47a1 100644 --- a/gclo.ViewModels/AppSettings.cs +++ b/gclo.ViewModels/AppSettings.cs @@ -37,6 +37,32 @@ public sealed class AppSettings /// public static readonly Guid DefaultTokenVaultId = new("d0f0e0c0-9c40-4a5e-8f3a-5b1e6f7a2c11"); + /// + /// The workspace that was open last: an account's id, or . + /// Null when nothing is recorded yet. See . + /// + public Guid? LastWorkspace { get; set; } + + /// + /// The forge Quick Sync last connected to, by name; null for GitHub. With + /// , , + /// and + /// it is what gives back. No token is among them. + /// + public string? QuickSyncForge { get; set; } + + /// The address of the Gitea server Quick Sync last connected to; null for GitHub. + public string? QuickSyncServer { get; set; } + + /// The organization Quick Sync last loaded; empty when none. + public string QuickSyncOrganization { get; set; } = ""; + + /// The target folder of Quick Sync's last connection; empty when none. + public string QuickSyncTargetFolder { get; set; } = ""; + + /// Whether Quick Sync's last connection cloned under an organization subfolder. + public bool QuickSyncCreateOrgSubfolder { get; set; } + /// Folder pre-filled as the sync target. Empty means "no default". public string DefaultTargetFolder { get; set; } = ""; @@ -170,10 +196,51 @@ public sealed class AppSettings + /// + /// Records a Quick Sync connection that worked, so the card opens the same way + /// next time. The caller saves. Never a token. + /// + public void RememberQuickSync(QuickSyncConnection connection) + { + ArgumentNullException.ThrowIfNull(connection); + QuickSyncForge = connection.Place.Kind == ForgeKind.GitHub ? null : connection.Place.Kind.ToString(); + QuickSyncServer = connection.Place.Server?.AbsoluteUri; + QuickSyncOrganization = connection.Organization.Trim(); + QuickSyncTargetFolder = connection.TargetFolder.Trim(); + QuickSyncCreateOrgSubfolder = connection.CreateOrgSubfolder; + } + + + + /// + /// The Quick Sync connection that was recorded. Null when none was (a first + /// run: a connection that worked has an organization), and when what is stored + /// names a place that cannot be used (a file edited by hand, or a forge of a + /// later version). The card then opens with no place chosen. + /// + public QuickSyncConnection? RememberedQuickSync() + { + if(QuickSyncOrganization.Length == 0) + { + return null; + } + + ForgeKind? kind = QuickSyncForge is null ? ForgeKind.GitHub + : string.Equals(QuickSyncForge, nameof(ForgeKind.Gitea), StringComparison.OrdinalIgnoreCase) ? ForgeKind.Gitea + : null; + return kind is ForgeKind known && ForgeEndpoint.TryCreate(known, QuickSyncServer, out ForgeEndpoint? place, out _) + ? new QuickSyncConnection(place, QuickSyncOrganization, QuickSyncTargetFolder, QuickSyncCreateOrgSubfolder) + : null; + } + + + private void Sanitize() { // The JSON deserializer can assign null despite the non-nullable annotation. DefaultTargetFolder ??= ""; + QuickSyncOrganization ??= ""; + QuickSyncTargetFolder ??= ""; DefaultMaxConcurrency = Math.Clamp(DefaultMaxConcurrency, MinConcurrency, MaxConcurrency); // A file written before this setting existed has no such field, and the // property then keeps its default; whatever a file does say is brought into range. diff --git a/gclo.ViewModels/DefaultTokens.cs b/gclo.ViewModels/DefaultTokens.cs new file mode 100644 index 0000000..e63e13e --- /dev/null +++ b/gclo.ViewModels/DefaultTokens.cs @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2026 James Maes (KofTwentyTwo) + * SPDX-License-Identifier: MIT + */ + +using gclo.Engine; + + +namespace gclo.ViewModels; + + +/// +/// The default token saved in Settings for a place, which Quick Sync fills in when +/// it is on that place. A default token belongs to one place and is never offered +/// for another. +/// +public static class DefaultTokens +{ + /// + /// The default token for , or null when none is saved for + /// it. Today Settings holds one for GitHub only. + /// + public static string? For(ITokenVault vault, ForgeEndpoint place) + { + ArgumentNullException.ThrowIfNull(vault); + ArgumentNullException.ThrowIfNull(place); + if(place.Kind != ForgeKind.GitHub) + { + return null; + } + + string? token = vault.TryRetrieve(AppSettings.DefaultTokenVaultId); + return string.IsNullOrEmpty(token) ? null : token; + } +} diff --git a/gclo.ViewModels/QuickSyncConnection.cs b/gclo.ViewModels/QuickSyncConnection.cs new file mode 100644 index 0000000..8fa75f7 --- /dev/null +++ b/gclo.ViewModels/QuickSyncConnection.cs @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2026 James Maes (KofTwentyTwo) + * SPDX-License-Identifier: MIT + */ + +using gclo.Engine; + + +namespace gclo.ViewModels; + + +/// +/// What a Quick Sync connection that worked consisted of, without its token: the +/// place, the organization and where the clones go. It is what the app remembers +/// between runs, so Quick Sync opens the way it was left. A token is never part of +/// it: Quick Sync keeps none. +/// +/// GitHub, or the Gitea server. +/// The organization (or user) that was loaded. +/// The target folder; empty when none was chosen. +/// Whether clones go under TargetFolder\Organization. +public sealed record QuickSyncConnection(ForgeEndpoint Place, string Organization, string TargetFolder, bool CreateOrgSubfolder); diff --git a/gclo.ViewModels/StartWorkspace.cs b/gclo.ViewModels/StartWorkspace.cs new file mode 100644 index 0000000..83aeea0 --- /dev/null +++ b/gclo.ViewModels/StartWorkspace.cs @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2026 James Maes (KofTwentyTwo) + * SPDX-License-Identifier: MIT + */ + +namespace gclo.ViewModels; + + +/// +/// Which workspace the app opens on. The start screen is the thing the user most +/// likely came to do: where they left off, and when nothing says where that was, +/// their first account; Quick Sync when there is no account. +/// +public static class StartWorkspace +{ + /// The id that stands for Quick Sync, which has no account. + public static readonly Guid QuickSync = Guid.Empty; + + + + /// + /// The workspace to open: when it is Quick Sync or an + /// account that still exists; otherwise the first of + /// (they come sorted by name); otherwise Quick Sync. + /// + /// The saved accounts, in the order the pane lists them. + /// The workspace that was open last; null when nothing is recorded. + public static Guid Choose(IReadOnlyList accounts, Guid? last) + { + ArgumentNullException.ThrowIfNull(accounts); + if(last is Guid id && (id == QuickSync || accounts.Any(a => a.Id == id))) + { + return id; + } + return accounts.Count > 0 ? accounts[0].Id : QuickSync; + } +} diff --git a/gclo.ViewModels/WorkspaceViewModel.cs b/gclo.ViewModels/WorkspaceViewModel.cs index f3ef79f..0addf0a 100644 --- a/gclo.ViewModels/WorkspaceViewModel.cs +++ b/gclo.ViewModels/WorkspaceViewModel.cs @@ -23,7 +23,8 @@ namespace gclo.ViewModels; /// public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable { - private readonly IRepositoryLister _lister; + /// The lister of the place this workspace is on. Quick Sync replaces it when another place is chosen. + private IRepositoryLister _lister; private readonly IGitClient _git; @@ -32,7 +33,36 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable /// Result of the one-time WSL probe; null until the first path recovery asks for it. private WslAvailability? _wslAvailability; - private readonly IOrganizationLister _orgLister; + /// The organization lister of the place this workspace is on; replaced together with . + private IOrganizationLister _orgLister; + + /// The listers of a place, for a Quick Sync whose place can be chosen; null when the place is fixed. + private readonly Func? _listersOf; + + /// The default token saved for a place, which Quick Sync fills in; null when there is no such source. + private readonly Func? _defaultTokenOf; + + /// Quick Sync's place, as it was last taken from the choice on its card. The listers are the ones of this place. + private ForgeEndpoint _quickSyncPlace = ForgeEndpoint.GitHub; + + /// + /// False while the choice on Quick Sync's card names no place that can be used: + /// nothing was chosen yet, or a Gitea server without an address, or with one + /// that is refused. Nothing is sent anywhere meanwhile. + /// + private bool _placeIsUsable = true; + + /// + /// Whether a place was chosen. A Quick Sync whose place is chosen on its card + /// starts with none: no place is taken for granted, so no token can be sent to a + /// place nobody chose. An account's place, and that of a Quick Sync without the + /// choice, is given. + /// + private bool _placeChosen = true; + + private bool _isOnGitea; + + private string _server = ""; private readonly Func, IProgress> _progressFactory; @@ -113,6 +143,11 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable /// (), so one that was replaced or removed since /// (the default token in Settings, the account's own in the wizard) takes effect /// without a restart. + /// makes this a Quick Sync whose place is chosen on + /// its card (): it gives the listers of a place, and a + /// token is only ever handed to the listers of the place the card is on. + /// gives the default token saved for a place, + /// which such a Quick Sync fills in when it is on that place. /// public WorkspaceViewModel( IRepositoryLister? lister = null, @@ -124,13 +159,24 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable Account? account = null, ITokenVault? tokenVault = null, AccountsStore? accountsStore = null, - IWslCloner? wsl = null) + IWslCloner? wsl = null, + Func? listersOf = null, + Func? defaultTokenOf = null) { + // An account's place is its own and fixed. Quick Sync starts on GitHub, and takes the listers of whatever place is chosen. + _listersOf = account is null ? listersOf : null; + _defaultTokenOf = account is null ? defaultTokenOf : null; + _placeChosen = _listersOf is null; + _placeIsUsable = _placeChosen; + (IRepositoryLister Repositories, IOrganizationLister Organizations)? ofGitHub = _listersOf?.Invoke(ForgeEndpoint.GitHub); + // Without listers of its own, a workspace gets those of its account's forge: Quick Sync is GitHub. - _lister = lister ?? (account is null ? new GitHubRepositoryLister() : AccountListers.RepositoryListerFor(account)); + _lister = lister ?? ofGitHub?.Repositories + ?? (account is null ? new GitHubRepositoryLister() : AccountListers.RepositoryListerFor(account)); _git = git ?? new LibGit2GitClient(); _wsl = wsl ?? new WslCloner(); - _orgLister = orgLister ?? (account is null ? new GitHubOrganizationLister() : AccountListers.OrganizationListerFor(account)); + _orgLister = orgLister ?? ofGitHub?.Organizations + ?? (account is null ? new GitHubOrganizationLister() : AccountListers.OrganizationListerFor(account)); _progressFactory = progressFactory ?? DefaultProgress; _orgLookupDebounce = orgLookupDebounce ?? TimeSpan.FromMilliseconds(600); _log = log ?? new FileActivityLog(GcloPaths.LogDirectory); @@ -146,6 +192,7 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable ResultMessage = ""; LoadErrorTitle = ""; LoadErrorMessage = ""; + ServerError = ""; AllSelected = true; CanEditInputs = true; @@ -174,27 +221,291 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable public string DisplayName => _account?.Name ?? "Quick Sync"; /// - /// How the place this workspace connects to is named: "GitHub" for Quick Sync - /// and a GitHub account, the server's host for an account on a Gitea server. For - /// an account whose stored place cannot be used, what says. + /// How the place this workspace connects to is named: "GitHub", or the server's + /// host. For Quick Sync it follows the choice on its card, and is "Gitea" until an + /// address is there that can be used. For an account whose stored place cannot + /// be used, what says. /// - public string PlaceName => _account is null ? ForgeEndpoint.GitHub.DisplayName : _account.DescribePlace(); + public string PlaceName + => _account is not null ? _account.DescribePlace() + : !_placeChosen ? "" + : _placeIsUsable ? _quickSyncPlace.DisplayName + : nameof(ForgeKind.Gitea); - /// What the token of this workspace's forge is called: the header of its token box. - public string TokenHeader => ForgeTexts.TokenName(_account?.Forge ?? ForgeKind.GitHub); + /// The kind of forge this workspace is on; for Quick Sync, the one chosen on its card. + private ForgeKind PlaceKind => _account?.Forge ?? (_isOnGitea ? ForgeKind.Gitea : ForgeKind.GitHub); - /// The title of the connect card: where this workspace connects to. - public string ConnectTitle => $"Connect to {PlaceName}"; + /// What the token of this workspace's forge is called: the header of its token box. Before a place is chosen, the plain name. + public string TokenHeader => _placeChosen ? ForgeTexts.TokenName(PlaceKind) : "Access token"; + + /// What Quick Sync's empty token box says: the shape of a GitHub token, and nothing for another forge or before a place is chosen. + public string TokenPlaceholder => _placeChosen && PlaceKind == ForgeKind.GitHub ? "ghp_…" : ""; + + /// + /// Whether a place was chosen. False for a Quick Sync that was just opened for + /// the first time: its card then asks first, and takes no token before. + /// + public bool IsPlaceChosen => _placeChosen; + + /// + /// Whether this workspace's card asks where the repositories are: Quick Sync, + /// when it was given the listers of a place. An account's place is fixed. + /// + public bool CanChoosePlace => _listersOf is not null; + + /// + /// The choice on Quick Sync's card: false is GitHub, true is a Gitea server whose + /// address is . Choosing the other kind empties what was + /// entered or loaded for this one: the token, the organization, the table. It + /// cannot be changed while the workspace is busy (). + /// + public bool IsOnGitea + { + get => _isOnGitea; + set + { + if((value != _isOnGitea || !_placeChosen) && _listersOf is { } listersOf && CanEditInputs) + { + bool otherKind = value != _isOnGitea; + _isOnGitea = value; + _placeChosen = true; + if(otherKind) + { + ForgetTheOtherPlace(); + } + TakeThePlace(listersOf); + } + + // Always said, also when nothing was taken: a control that asked for a change that was refused shows the choice as it is. + OnPropertyChanged(); + OnPropertyChanged(nameof(PlaceIndex)); + OnPropertyChanged(nameof(AsksForServer)); + OnPropertyChanged(nameof(IsPlaceChosen)); + } + } + + /// + /// as the selected index of a two-item radio group: + /// 0 = GitHub, 1 = Gitea, and -1 while nothing was chosen. Setting any other + /// index than 0 or 1 (a radio group reports -1 while it has no selection) + /// changes nothing. + /// + public int PlaceIndex + { + get => !_placeChosen ? -1 : _isOnGitea ? 1 : 0; + set + { + if(value is 0 or 1) + { + IsOnGitea = value == 1; + } + } + } + + /// Whether the card shows the box for the server's address. + public bool AsksForServer => CanChoosePlace && _placeChosen && _isOnGitea; + + /// + /// The address of the Gitea server on Quick Sync's card. It is taken when it is + /// set, so the shell sets it when the box is left, not while it is typed: no + /// token is sent to half an address. Once taken it reads as it was normalized, + /// which is the address a token goes to. + /// + public string Server + { + get => _server; + set + { + string typed = value ?? ""; + if(!string.Equals(typed, _server, StringComparison.Ordinal) && _listersOf is { } listersOf && CanEditInputs) + { + _server = typed; + TakeThePlace(listersOf); + } + OnPropertyChanged(); + } + } + + /// What is wrong with ; empty when it can be used, and while none was entered yet. + [ObservableProperty] + public partial string ServerError { get; set; } + + /// + /// True once Quick Sync loaded repositories: the connection works, and the page + /// offers to save it as an account (where its token is kept). Set back when the + /// offer is dismissed, and when another place is chosen. + /// + [ObservableProperty] + public partial bool SaveAsAccountOffered { get; set; } + + /// + /// Told about a Quick Sync connection that just loaded its repositories, so the + /// shell can remember it for the next start. Never told a token. + /// + public Action? Connected { get; set; } + + /// The title of the connect card: where this workspace connects to; just "Connect" before a place is chosen. + public string ConnectTitle => _placeChosen ? $"Connect to {PlaceName}" : "Connect"; /// The page a token for this workspace is made on; null when the account's place is not known. public Uri? TokenPage => ForgeTexts.TokenPage(Place); - /// The place this workspace connects to; null for an account whose stored forge and server cannot be used. + /// + /// The place this workspace connects to. Null for an account whose stored forge + /// and server cannot be used, and for Quick Sync while its card names no usable place. + /// private ForgeEndpoint? Place - => _account is null ? ForgeEndpoint.GitHub + => _account is null ? (_placeIsUsable ? _quickSyncPlace : null) : _account.TryGetEndpoint(out ForgeEndpoint? place, out _) ? place : null; + + + /// + /// Takes the place the card names, after the choice or the address changed. A + /// place that can be used and is another one than before brings its own listers, + /// and what belonged to the other place is forgotten first: the token typed for + /// it is not handed to the new listers, and the repositories loaded from it (whose + /// clone addresses are on its host) do not stay in the table to be synced with a + /// token of the new place. + /// + private void TakeThePlace(Func listersOf) + { + ForgeEndpoint? place = null; + string error = ""; + if(!_isOnGitea) + { + place = ForgeEndpoint.GitHub; + } + else if(!string.IsNullOrWhiteSpace(_server) && !ForgeEndpoint.TryCreate(ForgeKind.Gitea, _server, out place, out string? reason)) + { + error = char.ToUpperInvariant(reason[0]) + reason[1..] + "."; + } + ServerError = error; + + /////////////////////////////////////////////////////////////////////////////// + // Until the place is taken below, the card names no place that can be used: // + // if the listers of the new place cannot be made, nothing is sent to the // + // listers of the old one while the card already shows the new address. // + /////////////////////////////////////////////////////////////////////////////// + _placeIsUsable = false; + if(place is not null && !place.Equals(_quickSyncPlace)) + { + (IRepositoryLister repositories, IOrganizationLister organizations) = listersOf(place); + ForgetTheOtherPlace(); + _lister = repositories; + _orgLister = organizations; + _quickSyncPlace = place; + } + + _placeIsUsable = _placeChosen && place is not null; + if(place?.Server is not null) + { + _server = place.Server.AbsoluteUri; + OnPropertyChanged(nameof(Server)); + } + OnPropertyChanged(nameof(PlaceName)); + OnPropertyChanged(nameof(ConnectTitle)); + OnPropertyChanged(nameof(TokenHeader)); + OnPropertyChanged(nameof(TokenPlaceholder)); + OnPropertyChanged(nameof(TokenPage)); + LoadReposCommand.NotifyCanExecuteChanged(); + FindOrganizationsCommand.NotifyCanExecuteChanged(); + FillInTheDefaultToken(); + } + + + + /// + /// Empties what was entered or loaded for the place Quick Sync was on: the token + /// (which also ends a lookup that is on its way and empties the organization + /// list), the organization, the table, and the offer to save the connection. + /// + private void ForgetTheOtherPlace() + { + Token = ""; + Organization = ""; + SaveAsAccountOffered = false; + + foreach(RepoItemViewModel existing in Repos) + { + existing.PropertyChanged -= OnItemPropertyChanged; + } + Repos.Clear(); + _itemsByName.Clear(); + TotalCount = 0; + CompletedCount = 0; + _runSet = []; + SortColumn = null; + SortDescending = false; + _sampleRepoName = null; + RebuildReposIndex(); + LoadedOrganization = null; + HasLoadedRepos = false; + StatusText = ""; + ResetRunResult(); + LoadErrorOpen = false; + NotifyTargetPathChanged(); + RecomputeSelectedCount(); + RebuildFilteredRepos(); + SyncCommand.NotifyCanExecuteChanged(); + RetryFailedCommand.NotifyCanExecuteChanged(); + RecomputeHasFailedRepos(); + } + + + + /// + /// Quick Sync, on a place that can be used and with an empty token box: fills in + /// the default token saved for that place, if there is one. Nothing otherwise. + /// The token of one place is never filled in for another: the source is asked + /// for the place the card is on. + /// + public void FillInTheDefaultToken() + { + if(_account is null && _placeIsUsable && Token.Length == 0 && _defaultTokenOf?.Invoke(_quickSyncPlace) is { Length: > 0 } saved) + { + Token = saved; + } + } + + + + /// + /// Puts Quick Sync back the way a connection that worked had it: the place, the + /// organization, the target folder. No token: one is filled in only from a saved + /// default (), and nothing is loaded until + /// the user asks. Nothing happens for an account, or for a Quick Sync whose + /// place cannot be chosen. + /// + public void Restore(QuickSyncConnection connection) + { + ArgumentNullException.ThrowIfNull(connection); + if(_listersOf is null) + { + return; + } + + IsOnGitea = connection.Place.Server is not null; + Server = connection.Place.Server?.AbsoluteUri ?? ""; + Organization = connection.Organization; + if(connection.TargetFolder.Length > 0) + { + TargetFolder = connection.TargetFolder; + CreateOrgSubfolder = connection.CreateOrgSubfolder; + } + FillInTheDefaultToken(); + } + + + + /// + /// What the account wizard needs to save this Quick Sync connection as an + /// account, the place included: the token was used with that place. + /// + public AccountWizardSeed ToAccountSeed() + => new(Token, Organization, TargetFolder, CreateOrgSubfolder, MaxConcurrency, _quickSyncPlace); + /// /// True for an account workspace whose token is the default token from Settings /// (): the pane tags it, and Sync All names @@ -564,11 +875,27 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable ? $"Token cleared in workspace '{DisplayName}'." : $"Token entered in workspace '{DisplayName}' ({value.Length} characters)."); } - _ = RefreshOrganizationsAsync(); + FindOrganizationsCommand.NotifyCanExecuteChanged(); + _ = RefreshOrganizationsAsync(asked: false); } + private bool CanFindOrganizations() => _placeIsUsable && Token.Trim().Length >= 10; + + + + /// + /// Lists the organizations the token can see, because the user asked. On a Quick + /// Sync whose place is chosen on its card this is the only way the list is + /// fetched: a token that was typed, pasted or filled in is sent nowhere until + /// the user presses this (or Load repositories). + /// + [RelayCommand(CanExecute = nameof(CanFindOrganizations))] + private Task FindOrganizationsAsync() => RefreshOrganizationsAsync(asked: true); + + + /// /// Takes an account workspace's token from its source again (its own vault entry, /// or the default token from Settings) and uses it from here on. Called before the @@ -665,8 +992,9 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable { RefreshAccountToken(); } - else if(string.Equals(Token, previous ?? "", StringComparison.Ordinal)) + else if(_placeIsUsable && _quickSyncPlace.Kind == ForgeKind.GitHub && string.Equals(Token, previous ?? "", StringComparison.Ordinal)) { + // The default token is a GitHub token: a Quick Sync that is on another place is not given it. SetTokenFromItsSource(current ?? ""); } } @@ -760,8 +1088,14 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable - /// Debounced: each token edit cancels the previous lookup. - private async Task RefreshOrganizationsAsync() + /// + /// Looks the organizations of the token up; each token edit cancels the previous + /// lookup. says the user asked for it. Without being + /// asked it runs (after a pause) for an account, whose token was saved for its + /// place, and never for a Quick Sync whose place is chosen on its card: there a + /// token leaves the machine only on the user's word. + /// + private async Task RefreshOrganizationsAsync(bool asked) { _orgLoadCts?.Cancel(); _orgLoadCts?.Dispose(); @@ -777,8 +1111,14 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable /////////////////////////////////////////////////////////////////////////////// bool quiet = _readingTokenFromItsSource; + /////////////////////////////////////////////////////////////////////////////// + // The lister is taken with the token, here: the token of this moment goes // + // to the lister of this moment, whatever Quick Sync's place is by the time // + // the pause below is over. // + /////////////////////////////////////////////////////////////////////////////// + IOrganizationLister lister = _orgLister; string token = Token.Trim(); - if(token.Length < 10) + if(token.Length < 10 || !_placeIsUsable || (_listersOf is not null && !asked)) { // The same capture and restore as below: the editable box loses its text // when its list changes, and an account's organization must survive its @@ -795,10 +1135,14 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable try { - await Task.Delay(_orgLookupDebounce, lookupToken); // debounce keystrokes / rapid pastes + if(!asked) + { + await Task.Delay(_orgLookupDebounce, lookupToken); // debounce keystrokes / rapid pastes + } + lookupToken.ThrowIfCancellationRequested(); // superseded just as the pause ended: nothing is sent IsLoadingOrgs = true; _log.Info("Organization lookup started."); - IReadOnlyList orgs = await _orgLister.ListOrganizationsAsync(token, lookupToken); + IReadOnlyList orgs = await lister.ListOrganizationsAsync(token, lookupToken); lookupToken.ThrowIfCancellationRequested(); _log.Info($"Organization lookup finished: {orgs.Count} organizations and accounts visible."); @@ -820,9 +1164,11 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable LoadErrorOpen = false; // a lookup that works clears a stale lookup failure // The production lister always lists the token's own account first, so a // single entry means no organizations were visible. - StatusText = orgs.Count <= 1 - ? "Only your personal account is visible — add read:org (classic) for organizations, or type an org name manually." - : $"Found {orgs.Count} organizations and accounts."; + // What a token that sees no organization lacks is called differently on each forge. + StatusText = orgs.Count > 1 ? $"Found {orgs.Count} organizations and accounts." + : PlaceKind == ForgeKind.GitHub + ? "Only your personal account is visible — add read:org (classic) for organizations, or type an org name manually." + : "Only your personal account is visible — allow the token read:organization for organizations, or type an org name manually."; } } catch(OperationCanceledException) @@ -964,6 +1310,7 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable !IsRunning && !IsLoadingRepos && !IsResolvingPaths + && _placeIsUsable && !string.IsNullOrWhiteSpace(Organization) && !string.IsNullOrWhiteSpace(Token); @@ -1016,6 +1363,12 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable HasLoadedRepos = true; StatusText = Repos.Count == 1 ? "1 repository loaded." : $"{Repos.Count} repositories loaded."; _log.Info($"Loaded {Repos.Count} repositories for organization '{organization}'."); + if(_account is null) + { + // The connection works: worth keeping as an account, and worth opening on next time. + SaveAsAccountOffered = true; + Connected?.Invoke(new QuickSyncConnection(_quickSyncPlace, organization, TargetFolder.Trim(), CreateOrgSubfolder)); + } } catch(Exception ex) { diff --git a/gclo/ConnectionFormControl.xaml b/gclo/ConnectionFormControl.xaml index 7c90afd..c9a1d1f 100644 --- a/gclo/ConnectionFormControl.xaml +++ b/gclo/ConnectionFormControl.xaml @@ -10,17 +10,51 @@ IsTabStop="False" Loaded="OnLoaded"> - + + + + + + + + + - + private async Task ShowSeededAccountWizardAsync(AccountWizardSeed seed) { - // A Quick Sync connection is a GitHub connection, and so is the account it is saved as. + // The account is saved where the connection was: its token is checked with that place. + ForgeEndpoint place = seed.Place ?? ForgeEndpoint.GitHub; var viewModel = new AccountWizardViewModel( - _accountsStore, UiTestFixture.WrapOrganizationLister(new GitHubOrganizationLister()), seed, _log, + _accountsStore, UiTestFixture.WrapOrganizationLister(place.CreateOrganizationLister(), place), seed, _log, defaultToken: () => _tokenVault.TryRetrieve(AppSettings.DefaultTokenVaultId)); var dialog = new AccountWizardDialog( viewModel, () => WinRT.Interop.WindowNative.GetWindowHandle(this)) @@ -877,6 +902,10 @@ public sealed partial class MainWindow : Window await DialogGuard.ShowAsync(dialog); if(dialog.Saved) { + if(_workspaces.TryGetValue(Guid.Empty, out WorkspaceEntry? quickSync)) + { + quickSync.ViewModel.SaveAsAccountOffered = false; // it was taken up + } OnAccountAdded(viewModel); } else diff --git a/gclo/WorkspacePage.xaml b/gclo/WorkspacePage.xaml index 5a10252..8a9b5cf 100644 --- a/gclo/WorkspacePage.xaml +++ b/gclo/WorkspacePage.xaml @@ -84,20 +84,36 @@ load that fails (connect card still showing) and a refresh that fails (table showing) get the same red, dismissable surface with a Retry — not caption text at the bottom of the window. --> - - - private WorkspaceViewModel? _followed; + /// True while checks the radio buttons itself: that is not the user choosing. + private bool _showingThePlace; + + + + /// + /// Checks the radio button of the place the view model is on, and neither while + /// no place was chosen. + /// + private void ShowThePlace() + { + if(ViewModel is null) + { + return; + } + + _showingThePlace = true; + try + { + int index = ViewModel.PlaceIndex; + PlaceGitHub.IsChecked = index == 0; + PlaceGitea.IsChecked = index == 1; + } + finally + { + _showingThePlace = false; + } + } + + + + /// + /// A radio button was checked by the user (click, Space, an arrow key, or UI + /// automation): that is the choice of the place. Focus alone checks nothing. If + /// the view model does not take the choice (the workspace is busy), the buttons + /// go back to what it is on. + /// + private void Place_Checked(object sender, RoutedEventArgs e) + { + if(_showingThePlace || ViewModel is null) + { + return; + } + + ViewModel.PlaceIndex = ReferenceEquals(sender, PlaceGitea) ? 1 : 0; + ShowThePlace(); + } + /// diff --git a/gclo/MainWindow.xaml b/gclo/MainWindow.xaml index 648c391..6a38ef6 100644 --- a/gclo/MainWindow.xaml +++ b/gclo/MainWindow.xaml @@ -69,7 +69,10 @@ x:Name="WorkspaceNav" AutomationProperties.AutomationId="WorkspaceNav" Grid.Row="2" - PaneDisplayMode="Left" + PaneDisplayMode="Auto" + ExpandedModeThresholdWidth="0" + CompactModeThresholdWidth="0" + PaneTitle="Workspaces" IsBackButtonVisible="Collapsed" IsSettingsVisible="False" OpenPaneLength="240" -- 2.54.0 From 1dce803d63673f4a353af8d9b77cd5407000d56c Mon Sep 17 00:00:00 2001 From: James Maes Date: Wed, 11 Nov 2026 07:09:53 -0600 Subject: [PATCH 3/3] fix(app): Quick Sync uses no address that is being edited or cannot be used, and forgets what was typed before a place was chosen From the review of #210. - An address that was edited but not taken yet (the box was not left: a keyboard shortcut moves no focus) was still the place of a load started with Ctrl+R. The card now names no usable place while its address box holds another text than the address that was taken. - An address made unusable after a load left the old place's table syncable, and a token typed next went to the old server's clone addresses. Sync, Retry failed and path recovery need a usable place, as Load and Find do; the commands check for themselves, whatever started them; no account is offered from a card that names no place. - A token or organization put on the card before a place was chosen is not kept for the place that is: the view model no longer relies on the shell's disabled token box. - The form follows its view model only while it is in the tree, and its own changes of the token box are not taken for typing. - The workspace that was open is written when the window closes, not on every switch, and what the app remembers by itself is not written over a settings file that could not be read. - The offer to save the connection is made once; a failure to remember the connection does not fail the load. - README, ADR 0010, T13. Refs #209 Co-Authored-By: Claude Fable 5.2 Signed-off-by: James Maes --- README.md | 2 +- docs/adr/0010-forges-beside-github.md | 5 +- docs/security/threat-model.md | 2 +- gclo.Engine.Tests/QuickSyncPlaceTests.cs | 207 ++++++++++++++++++++++- gclo.ViewModels/AppSettings.cs | 24 +++ gclo.ViewModels/WorkspaceViewModel.cs | 113 ++++++++++--- gclo/ConnectionFormControl.xaml | 4 +- gclo/ConnectionFormControl.xaml.cs | 99 ++++++++--- gclo/MainWindow.xaml.cs | 17 +- gclo/WorkspacePage.xaml.cs | 5 +- 10 files changed, 414 insertions(+), 64 deletions(-) diff --git a/README.md b/README.md index b51da1c..3c58236 100644 --- a/README.md +++ b/README.md @@ -125,7 +125,7 @@ On launch, gclo shows a brief branded splash and then opens where you left off: ### Saved accounts -Quick Sync is ad-hoc — no token is saved. Once a connection has loaded its repositories, a bar above the table offers **Save as account…**. For organizations you sync regularly, **Add account** stores a reusable profile: a name, where the repositories are, the organization, target folder, and parallelism, with the token kept in the Windows Credential Manager (never in a file). The first step asks where the repositories are: **GitHub** or **Gitea**, and for Gitea the address of the server (HTTPS only; a Forgejo server works the same way). The token of the next step is checked with that place and sent nowhere else, and an account stays on the place it was added for: to move one, delete it and add it again. Quick Sync connects to GitHub. Each account gets its own entry in the navigation pane and its own workspace; **Sync all** runs every saved account in turn. A status bar above the workspace is there for the whole pass, whichever workspace is on screen: it says which account is syncing, which of how many, and how far along it is, takes you to that account, and holds the result when the pass is over. The pane shows each account's live status as well. **Cancel sync all** stops the pass after the account that is syncing, and the result says how many accounts were not started. An account that could not be synced (it has no token, or its repositories could not be loaded) is marked in the pane and counted as needing attention, so a pass in which it was left out does not read as a success. An account's token is changed with **Edit account…** on the account's page (**Edit…** in the pane's menu); the token box on an account's page takes no input. Sync all syncs the repositories each account's page lists: **Refresh** on that page picks up repositories created since it was loaded. +Quick Sync is ad-hoc — no token is saved. Once a connection has loaded its repositories, a bar above the table offers **Save as account…**. For organizations you sync regularly, **Add account** stores a reusable profile: a name, where the repositories are, the organization, target folder, and parallelism, with the token kept in the Windows Credential Manager (never in a file). The first step asks where the repositories are: **GitHub** or **Gitea**, and for Gitea the address of the server (HTTPS only; a Forgejo server works the same way). The token of the next step is checked with that place and sent nowhere else, and an account stays on the place it was added for: to move one, delete it and add it again. Each account gets its own entry in the navigation pane and its own workspace; **Sync all** runs every saved account in turn. A status bar above the workspace is there for the whole pass, whichever workspace is on screen: it says which account is syncing, which of how many, and how far along it is, takes you to that account, and holds the result when the pass is over. The pane shows each account's live status as well. **Cancel sync all** stops the pass after the account that is syncing, and the result says how many accounts were not started. An account that could not be synced (it has no token, or its repositories could not be loaded) is marked in the pane and counted as needing attention, so a pass in which it was left out does not read as a success. An account's token is changed with **Edit account…** on the account's page (**Edit…** in the pane's menu); the token box on an account's page takes no input. Sync all syncs the repositories each account's page lists: **Refresh** on that page picks up repositories created since it was loaded.

Add account wizard, step 1 of 4: account name, optional description, and where the repositories are, with a Gitea server chosen and its address entered diff --git a/docs/adr/0010-forges-beside-github.md b/docs/adr/0010-forges-beside-github.md index 776c876..e170610 100644 --- a/docs/adr/0010-forges-beside-github.md +++ b/docs/adr/0010-forges-beside-github.md @@ -130,7 +130,7 @@ were the two lister implementations and the failure type they throw. was normalized, in full, where the token is asked for: that is the address the token goes to. An edit shows where the account is and that it stays there. A Quick Sync connection saved as an account - is a GitHub account: Quick Sync itself connects to GitHub only. + is an account on the place the connection was on (#209). - **The wizard has the lister of one place at a time.** The shell gives it a lister per place; step 1 takes the lister of the place that was chosen, and step 2 has no other to send the token to. A wizard that was given one lister (an @@ -159,6 +159,9 @@ matters most. its place. - **A server address is taken when its box is left**, not while it is typed, so no token is sent to half an address; the card then shows it as it was normalized. + While the box holds another text than the address that was taken, and while + the card names no usable address, nothing can be looked up, loaded or synced, + whatever starts it (a button, a keyboard shortcut). - **What belongs to one place is forgotten when another is chosen:** the token, the organization, and the table. The repositories of the table have clone addresses on the other host; they are not left there to be synced with a token diff --git a/docs/security/threat-model.md b/docs/security/threat-model.md index e67ad54..60022ab 100644 --- a/docs/security/threat-model.md +++ b/docs/security/threat-model.md @@ -101,7 +101,7 @@ flowchart LR | T14 | PAT handling | Information disclosure | The token is offered to a host other than the one the repository was listed on: an existing folder whose `origin` points elsewhere, or a server named by the repository's own configuration during the WSL clone (for example an LFS endpoint) | Low | Critical | The git credential callback answers only a remote whose URL is HTTPS on the listed URL's host and port, and refuses every other remote with a clear error (a redirect that the listed host itself issues is followed by libgit2; that host already holds the token); a folder whose origin names another repository fails before any request; the WSL credential helper is scoped to the listed host (`credential.https://.helper`) and an existing WSL destination is pulled only when its stored origin is the listed URL (#139) | Mitigated | | T15 | Logs and terminal | Spoofing / Tampering | Text from a repository or a server (a file path may contain a newline, an escape sequence, or a character that reorders or hides text) forges an entry in the activity log or drives the terminal the CLI prints to | Low | Medium | `DisplayText` shows control, separator and format characters (direction marks and overrides, invisible characters, tag characters) and unpaired surrogates as visible `\uXXXX` text: every log message is one line, and an exception's lines are indented under their entry so none can start like one. The CLI writes every line through one place, `Terminal`, which also shows any character the console's code page cannot represent in that form, because a legacy code page turns some printable characters into control bytes; a test fails if any other CLI file writes to the console. The JSON modes rely on the JSON encoder (#143) | Mitigated for the log and the CLI. Not covered: the desktop app's own surfaces (row error text, the path-recovery dialog), where a newline is harmless but a direction override in a path the user is asked to approve is not; and text that merely looks like something else (look-alike letters, an escape written out literally) | | T16 | Forge server (Gitea) | Information disclosure / Spoofing | The address of a server is typed by a user. A wrong address, a server that is hostile or misconfigured, or something between gclo and the server gets the account's token sent elsewhere, or answers in a way that sends clones (and the token with them) to another host, or makes gclo read without end | Low | Critical | `GiteaServerUrl`: HTTPS only, no credentials or query in the address, no setting for `http://`. Certificates are validated by the platform and that cannot be switched off. Every request goes to the configured address with the token in a header; redirects are not followed; the address of a next page is never taken from an answer; the client keeps no cookies. An address that is refused is not repeated in a message (it may hold a password or a token), and neither is a token. Clone addresses are built from the configured address, the owner and the name, and the server's `clone_url` is not read, so the git client's rule (the token only to the host of the clone address, T14) binds the token to the account's server. Answers are bounded in size, lists in pages and items, requests in time (reading the answer included). A server's own text reaches a message only as the reason of a refusal, on one line and cut to a length (T15). Accounts (#204): the forge and server of an account cannot be changed after it is added, so a saved token is not sent to an address somebody edited; `gclo sync --account` takes no other place from the command line; the default token (a GitHub token) is never handed to an account on another forge, and each forge has its own default token variable; accounts that are not on GitHub are kept in a file of their own, which a gclo from before forges neither reads nor rewrites, so that version cannot turn one into a GitHub account by writing back what it knows; the token of such an account is found only by that account at the address it was saved for (T13); an account whose stored forge or server cannot be used, or whose forge this version does not know, asks no server anything. The app's wizard (#206): the address is checked by the same rule, and the step that asks for the token names it in full as it was normalized; the wizard holds the lister of the chosen place only; a token typed for one place is emptied when another place is chosen, and the default token is not offered for, or sent to, a Gitea server. Quick Sync (#209): no place is chosen for the user, and its token box takes a token only once one is chosen; a token that is typed, pasted or filled in is sent nowhere until the user presses Find or Load repositories, and then to the listers of the place on the card; a server address is taken when its box is left, not while it is typed; choosing another place empties the token, the organization and the table (whose clone addresses are on the other host); what is remembered of a connection is its place, organization and folder, never a token. | Mitigated. The server the user names sees the token: that is the purpose. A machine that trusts an intercepting certificate authority is outside this (as for GitHub) | -| T13 | CLI / data directory | Tampering | A malicious `accounts.json`, `accounts-forges.json` or `settings.json` redirects targets, or names another place for an account | Low | Medium | Files live under the user profile; sanitized on load; target paths resolved and contained. The token of an account on a Gitea server is stored under a name made from the account's id, forge and server address as the file states them, so an account whose address was changed in the file finds no token for the new address, and a record that names another account's id finds none of that account's. What remains for someone who can write these files: a record made to look like a GitHub account can name the entry of a Gitea token (the name can be computed from the file), and that token is then offered to github.com, not to a host of their choosing. The app resolves a token for the place a workspace was opened for, so a file that is changed while the app runs does not bring the token of one place to the listers of another | Accepted (same-user boundary: whoever can write these files can read the tokens) | +| T13 | CLI / data directory | Tampering | A malicious `accounts.json`, `accounts-forges.json` or `settings.json` redirects targets, or names another place for an account | Low | Medium | Files live under the user profile; sanitized on load; target paths resolved and contained. The token of an account on a Gitea server is stored under a name made from the account's id, forge and server address as the file states them, so an account whose address was changed in the file finds no token for the new address, and a record that names another account's id finds none of that account's. What remains for someone who can write these files: a record made to look like a GitHub account can name the entry of a Gitea token (the name can be computed from the file), and that token is then offered to github.com, not to a host of their choosing. The app resolves a token for the place a workspace was opened for, so a file that is changed while the app runs does not bring the token of one place to the listers of another. `settings.json` also names the place Quick Sync opens on: one that is changed there opens the card on another server with the organization filled in, and no token (Quick Sync keeps none, and a default token is found only for the place it was saved for); the title of the card names the server before anything is sent | Accepted (same-user boundary: whoever can write these files can read the tokens) | ## 7. Accepted risks diff --git a/gclo.Engine.Tests/QuickSyncPlaceTests.cs b/gclo.Engine.Tests/QuickSyncPlaceTests.cs index ff7a921..cb78f6e 100644 --- a/gclo.Engine.Tests/QuickSyncPlaceTests.cs +++ b/gclo.Engine.Tests/QuickSyncPlaceTests.cs @@ -33,6 +33,9 @@ public sealed class QuickSyncPlaceTests : IDisposable private readonly List _workspaces = []; + ///

The git client of every Quick Sync made by : what it was asked to clone. + private readonly FakeGitClient _git = new(); + /// How many organizations a lister lists; one means "only your own account". private int _organizations = 2; @@ -80,7 +83,8 @@ public sealed class QuickSyncPlaceTests : IDisposable Func? listersOf = null) { var workspace = new WorkspaceViewModel( - git: new FakeGitClient(), + git: _git, + progressFactory: handler => new Progress(handler), orgLookupDebounce: TimeSpan.FromMilliseconds(1), log: new NullActivityLog(), wsl: new FakeWslCloner(), @@ -150,6 +154,31 @@ public sealed class QuickSyncPlaceTests : IDisposable Assert.False(workspace.IsPlaceChosen); Assert.False(workspace.FindOrganizationsCommand.CanExecute(null)); Assert.False(workspace.LoadReposCommand.CanExecute(null)); + Assert.Null(workspace.ToAccountSeed()); // no connection to save + + // And when something runs the commands without asking whether they can: still nothing. + await workspace.LoadReposCommand.ExecuteAsync(null); + await workspace.FindOrganizationsCommand.ExecuteAsync(null); + + Assert.Empty(_sent); + Assert.False(workspace.HasLoadedRepos); + } + + + + [Fact] + public void WhatWasPutOnTheCardBeforeAPlaceWasChosen_IsNotKeptForThePlaceThatIs() + { + // The shell's token box takes no token before a place is chosen; the view model does not rely on that. + WorkspaceViewModel workspace = QuickSync(); + workspace.Token = "a-token-0123456789"; + workspace.Organization = "typed-by-hand"; + + workspace.PlaceIndex = 0; + + Assert.Equal("", workspace.Token); + Assert.Equal("", workspace.Organization); + Assert.Empty(_sent); } @@ -428,6 +457,143 @@ public sealed class QuickSyncPlaceTests : IDisposable + [Fact] + public async Task AnAddressThatIsBeingTyped_IsNoPlace_UntilItIsTakenAgain() + { + // The card shows another text than the address that was taken: a keyboard shortcut moves no focus, so the + // box was not left. Nothing uses the address that is no longer on screen. + WorkspaceViewModel workspace = QuickSync(); + workspace.IsOnGitea = true; + workspace.Server = Server; + await TypeTokenAsync(workspace, "gitea-token-0123456789"); + workspace.Organization = "org-at-git.example.test"; + workspace.TargetFolder = @"C:\mirror"; + await workspace.LoadReposCommand.ExecuteAsync(null); + int sentSoFar = _sent.Count; + Assert.True(workspace.SyncCommand.CanExecute(null)); + + workspace.ServerIsBeingEdited(); + + Assert.False(workspace.LoadReposCommand.CanExecute(null)); + Assert.False(workspace.FindOrganizationsCommand.CanExecute(null)); + Assert.False(workspace.SyncCommand.CanExecute(null)); + Assert.Null(workspace.ToAccountSeed()); + Assert.False(workspace.SaveAsAccountOffered); + Assert.Equal("Connect to Gitea", workspace.ConnectTitle); + + await workspace.LoadReposCommand.ExecuteAsync(null); + await workspace.FindOrganizationsCommand.ExecuteAsync(null); + await workspace.SyncCommand.ExecuteAsync(null); + + Assert.Equal(sentSoFar, _sent.Count); + Assert.Empty(_git.CloneCalls); + + // The same address after all: the place is taken again, and nothing was forgotten. + workspace.Server = Server; + + Assert.Equal("gitea-token-0123456789", workspace.Token); + Assert.Equal("Connect to git.example.test", workspace.ConnectTitle); + Assert.True(workspace.SyncCommand.CanExecute(null)); + Assert.NotNull(workspace.ToAccountSeed()); + + workspace.ServerIsBeingEdited(); + workspace.ServerIsBeingEdited(); // said for every keystroke + workspace.Server = "https://other.example.test"; // another address after all + + Assert.Equal("", workspace.Token); + Assert.Empty(workspace.Repos); + } + + + + [Fact] + public async Task AfterALoad_AnAddressThatCannotBeUsedAnyMore_LeavesNothingOfTheOldPlaceToRun() + { + WorkspaceViewModel workspace = QuickSync(); + workspace.IsOnGitea = true; + workspace.Server = Server; + await TypeTokenAsync(workspace, "gitea-token-0123456789"); + workspace.Organization = "org-at-git.example.test"; + workspace.TargetFolder = @"C:\mirror"; + await workspace.LoadReposCommand.ExecuteAsync(null); + workspace.Repos[0].Status = SyncStatus.Failed; + workspace.RetryFailedCommand.NotifyCanExecuteChanged(); + Assert.True(workspace.SyncCommand.CanExecute(null)); + Assert.True(workspace.RetryFailedCommand.CanExecute(null)); + int sentSoFar = _sent.Count; + + workspace.Server = "http://x"; // no address that can be used; the table still shows the old server's repositories + + Assert.False(workspace.SyncCommand.CanExecute(null)); + Assert.False(workspace.RetryFailedCommand.CanExecute(null)); + Assert.False(workspace.ResolvePathsCommand.CanExecute(workspace.Repos[0])); + Assert.False(workspace.LoadReposCommand.CanExecute(null)); + Assert.Null(workspace.ToAccountSeed()); + Assert.False(workspace.SaveAsAccountOffered); + + // A token typed now is for no place: nothing clones the old server's repositories with it. + workspace.Token = "a-token-typed-now-0123456789"; + await workspace.SyncCommand.ExecuteAsync(null); + await workspace.RetryFailedCommand.ExecuteAsync(null); + await workspace.ResolvePathsCommand.ExecuteAsync(workspace.Repos[0]); + + Assert.Empty(_git.CloneCalls); + Assert.Equal(sentSoFar, _sent.Count); + } + + + + [Fact] + public async Task ALookupThatIsOnItsWay_IsEnded_WhenTheAddressIsEdited() + { + var answer = new TaskCompletionSource>(TaskCreationOptions.RunContinuationsAsynchronously); + WorkspaceViewModel workspace = QuickSync(listersOf: place => (ListersOf(place).Repositories, new FakeOrganizationLister + { + Handler = (_, canceled) => + { + canceled.Register(() => answer.TrySetCanceled(canceled)); + return answer.Task; + }, + })); + workspace.IsOnGitea = true; + workspace.Server = Server; + workspace.Token = "gitea-token-0123456789"; + Task asking = workspace.FindOrganizationsCommand.ExecuteAsync(null); + + workspace.ServerIsBeingEdited(); + await asking; + + Assert.True(answer.Task.IsCanceled); + Assert.Empty(workspace.Organizations); + Assert.False(workspace.IsLoadingOrgs); + } + + + + [Fact] + public async Task TheOfferToSaveTheConnection_IsMadeOnce_AndRememberingItFailingDoesNotFailTheLoad() + { + WorkspaceViewModel workspace = QuickSyncOnGitHub(); + workspace.Connected = _ => throw new InvalidOperationException("the settings could not be written"); + await TypeTokenAsync(workspace, "ghp_token_0123456789"); + workspace.Organization = "org-at-GitHub"; + + await workspace.LoadReposCommand.ExecuteAsync(null); + + Assert.True(workspace.HasLoadedRepos); + Assert.False(workspace.LoadErrorOpen); + Assert.Equal("2 repositories loaded.", workspace.StatusText); + Assert.True(workspace.SaveAsAccountOffered); + + // Dismissed, it stays dismissed when the list is refreshed. + workspace.SaveAsAccountOffered = false; + await workspace.LoadReposCommand.ExecuteAsync(null); + + Assert.False(workspace.SaveAsAccountOffered); + } + + + [Fact] public async Task TheSameServer_SpelledAnotherWay_IsTheSamePlace_AndNothingIsForgotten() { @@ -561,9 +727,9 @@ public sealed class QuickSyncPlaceTests : IDisposable public void TheDefaultToken_DoesNotReplaceATokenThatWasTyped_AndIsNotForAnAccount() { WorkspaceViewModel workspace = QuickSync(defaultTokenOf: _ => "ghp_default_0123456789"); + workspace.PlaceIndex = 0; workspace.Token = "ghp_typed_0123456789"; - workspace.PlaceIndex = 0; workspace.FillInTheDefaultToken(); Assert.Equal("ghp_typed_0123456789", workspace.Token); @@ -692,7 +858,7 @@ public sealed class QuickSyncPlaceTests : IDisposable workspace.Organization = "org-at-git.example.test"; workspace.TargetFolder = @"C:\mirror"; - AccountWizardSeed seed = workspace.ToAccountSeed(); + AccountWizardSeed seed = Assert.IsType(workspace.ToAccountSeed()); Assert.Equal(s_gitea, seed.Place); Assert.Equal("gitea-token-0123456789", seed.Token); @@ -817,6 +983,34 @@ public sealed class QuickSyncPlaceTests : IDisposable + [Fact] + public void WhatTheAppRemembersByItself_IsNotWrittenOverASettingsFileThatCouldNotBeRead() + { + Directory.CreateDirectory(_root); + string file = Path.Combine(_root, "settings.json"); + File.WriteAllText(file, "{ \"Theme\": \"Dark\", this is where the file went wrong"); + + var settings = AppSettings.Load(directory: _root); + settings.RememberQuickSync(new QuickSyncConnection(ForgeEndpoint.GitHub, "acme", "", CreateOrgSubfolder: false)); + + Assert.True(settings.CouldNotBeRead); + Assert.False(settings.SaveWhatWasRemembered(directory: _root)); + Assert.Contains("this is where the file went wrong", File.ReadAllText(file), StringComparison.Ordinal); // left for the user to mend + + // The Settings dialog's Save is the user's own doing, and writes. + Assert.True(settings.Save(directory: _root)); + Assert.DoesNotContain("went wrong", File.ReadAllText(file), StringComparison.Ordinal); + + // A file that was read, or was not there, is written. + var read = AppSettings.Load(directory: _root); + Assert.False(read.CouldNotBeRead); + read.LastWorkspace = StartWorkspace.QuickSync; + Assert.True(read.SaveWhatWasRemembered(directory: _root)); + Assert.Equal(StartWorkspace.QuickSync, AppSettings.Load(directory: _root).LastWorkspace); + } + + + // ---------------------------------------------------------------- which workspace the app opens on [Fact] @@ -883,6 +1077,13 @@ public sealed class QuickSyncPlaceTests : IDisposable + private sealed class Progress(Action handler) : IProgress + { + public void Report(RepoProgress value) => handler(value); + } + + + /// Answers when the test lets it. private sealed class GatedRepositoryLister(Task> answer) : IRepositoryLister { diff --git a/gclo.ViewModels/AppSettings.cs b/gclo.ViewModels/AppSettings.cs index 00d47a1..5842654 100644 --- a/gclo.ViewModels/AppSettings.cs +++ b/gclo.ViewModels/AppSettings.cs @@ -112,6 +112,7 @@ public sealed class AppSettings { // Settings must never prevent startup; fall through to defaults. log?.Error($"Settings could not be loaded ({ex.Message}); the defaults are used.", ex); + return new AppSettings { CouldNotBeRead = true }; } return new AppSettings(); @@ -119,6 +120,29 @@ public sealed class AppSettings + /// + /// True when the settings file was there and could not be read, so these are the + /// defaults in its place. What the app remembers by itself is then not written + /// (): the file is left for the user to mend, + /// as it was before the app remembered anything. Saving the Settings dialog + /// still writes. + /// + [System.Text.Json.Serialization.JsonIgnore] + public bool CouldNotBeRead { get; private set; } + + + + /// + /// Writes the settings because the app remembered something by itself (the + /// workspace that was open, Quick Sync's last connection), not because the user + /// saved them. Does nothing when the file could not be read at start: that file + /// is not written over with defaults on the app's own account. + /// + public bool SaveWhatWasRemembered(IActivityLog? log = null, string? directory = null) + => !CouldNotBeRead && Save(log, directory); + + + /// How long a save tries to put the new file in place while something has the old one open. private static readonly TimeSpan s_savePatience = TimeSpan.FromSeconds(1); diff --git a/gclo.ViewModels/WorkspaceViewModel.cs b/gclo.ViewModels/WorkspaceViewModel.cs index 0addf0a..8787401 100644 --- a/gclo.ViewModels/WorkspaceViewModel.cs +++ b/gclo.ViewModels/WorkspaceViewModel.cs @@ -266,13 +266,14 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable { if((value != _isOnGitea || !_placeChosen) && _listersOf is { } listersOf && CanEditInputs) { - bool otherKind = value != _isOnGitea; + /////////////////////////////////////////////////////////////////////// + // Another kind of forge, or the first choice at all: what is on the // + // card was entered for the other place, or for no place. Neither is // + // carried to the place that is chosen now. // + /////////////////////////////////////////////////////////////////////// _isOnGitea = value; _placeChosen = true; - if(otherKind) - { - ForgetTheOtherPlace(); - } + ForgetTheOtherPlace(); TakeThePlace(listersOf); } @@ -317,7 +318,7 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable set { string typed = value ?? ""; - if(!string.Equals(typed, _server, StringComparison.Ordinal) && _listersOf is { } listersOf && CanEditInputs) + if((!string.Equals(typed, _server, StringComparison.Ordinal) || !_placeIsUsable) && _listersOf is { } listersOf && CanEditInputs) { _server = typed; TakeThePlace(listersOf); @@ -326,6 +327,50 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable } } + + + /// + /// Told by the shell that the address box is being typed into and holds another + /// text than . Until the address is set again (the box is + /// left, or Enter is pressed), the card names no place that can be used: nothing + /// can be loaded, looked up or synced with the address that is no longer the one + /// on screen, whatever starts it (a button, a keyboard shortcut). + /// + public void ServerIsBeingEdited() + { + if(AsksForServer && _placeIsUsable) + { + _placeIsUsable = false; + SayWhatThePlaceAllows(); + } + } + + + + /// + /// Says what follows from the place the card names: its texts, and what can be + /// done with it. While it names no usable place, a lookup that is on its way is + /// ended and the connection is not offered as an account. + /// + private void SayWhatThePlaceAllows() + { + if(!_placeIsUsable) + { + _orgLoadCts?.Cancel(); + SaveAsAccountOffered = false; + } + OnPropertyChanged(nameof(PlaceName)); + OnPropertyChanged(nameof(ConnectTitle)); + OnPropertyChanged(nameof(TokenHeader)); + OnPropertyChanged(nameof(TokenPlaceholder)); + OnPropertyChanged(nameof(TokenPage)); + LoadReposCommand.NotifyCanExecuteChanged(); + FindOrganizationsCommand.NotifyCanExecuteChanged(); + SyncCommand.NotifyCanExecuteChanged(); + RetryFailedCommand.NotifyCanExecuteChanged(); + ResolvePathsCommand.NotifyCanExecuteChanged(); + } + /// What is wrong with ; empty when it can be used, and while none was entered yet. [ObservableProperty] public partial string ServerError { get; set; } @@ -404,13 +449,7 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable _server = place.Server.AbsoluteUri; OnPropertyChanged(nameof(Server)); } - OnPropertyChanged(nameof(PlaceName)); - OnPropertyChanged(nameof(ConnectTitle)); - OnPropertyChanged(nameof(TokenHeader)); - OnPropertyChanged(nameof(TokenPlaceholder)); - OnPropertyChanged(nameof(TokenPage)); - LoadReposCommand.NotifyCanExecuteChanged(); - FindOrganizationsCommand.NotifyCanExecuteChanged(); + SayWhatThePlaceAllows(); FillInTheDefaultToken(); } @@ -494,17 +533,17 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable TargetFolder = connection.TargetFolder; CreateOrgSubfolder = connection.CreateOrgSubfolder; } - FillInTheDefaultToken(); } /// /// What the account wizard needs to save this Quick Sync connection as an - /// account, the place included: the token was used with that place. + /// account, the place included: the token was used with that place. Null while + /// the card names no place that can be used: there is no connection to save. /// - public AccountWizardSeed ToAccountSeed() - => new(Token, Organization, TargetFolder, CreateOrgSubfolder, MaxConcurrency, _quickSyncPlace); + public AccountWizardSeed? ToAccountSeed() + => _placeIsUsable ? new AccountWizardSeed(Token, Organization, TargetFolder, CreateOrgSubfolder, MaxConcurrency, _quickSyncPlace) : null; /// /// True for an account workspace whose token is the default token from Settings @@ -721,7 +760,8 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable /// /// True once this workspace has completed its first successful repository load; - /// drives the connect-card vs workspace visibility switch. Never reset. + /// drives the connect-card vs workspace visibility switch. Set back only when + /// Quick Sync is put on another place, which empties the table. /// [ObservableProperty] public partial bool HasLoadedRepos { get; set; } @@ -1320,6 +1360,13 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable [RelayCommand(CanExecute = nameof(CanLoadRepos))] private async Task LoadReposAsync() { + if(!_placeIsUsable) + { + // Whatever started it: there is no place to ask. No token is sent to a place nobody chose. + return; + } + + bool firstLoad = !HasLoadedRepos; IsLoadingRepos = true; ResetRunResult(); LoadErrorOpen = false; @@ -1365,9 +1412,21 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable _log.Info($"Loaded {Repos.Count} repositories for organization '{organization}'."); if(_account is null) { - // The connection works: worth keeping as an account, and worth opening on next time. - SaveAsAccountOffered = true; - Connected?.Invoke(new QuickSyncConnection(_quickSyncPlace, organization, TargetFolder.Trim(), CreateOrgSubfolder)); + /////////////////////////////////////////////////////////////////////// + // The connection works: worth keeping as an account, said once (a // + // refresh does not bring a dismissed offer back), and worth opening // + // on next time. Remembering it is not part of loading: it failing // + // does not make the load a failure. // + /////////////////////////////////////////////////////////////////////// + SaveAsAccountOffered = SaveAsAccountOffered || firstLoad; + try + { + Connected?.Invoke(new QuickSyncConnection(_quickSyncPlace, organization, TargetFolder.Trim(), CreateOrgSubfolder)); + } + catch(Exception ex) + { + _log.Error($"The connection could not be remembered for the next start: {ex.Message}", ex); + } } } catch(Exception ex) @@ -1398,6 +1457,7 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable !IsRunning && !IsLoadingRepos && !IsResolvingPaths + && _placeIsUsable && !IsTableStale && !string.IsNullOrWhiteSpace(TargetFolder) && Repos.Any(r => r.IsSelected); @@ -1408,6 +1468,12 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable [RelayCommand(CanExecute = nameof(CanSync), IncludeCancelCommand = true)] private async Task SyncAsync(CancellationToken cancellationToken) { + if(!_placeIsUsable) + { + // Whatever started it: the table is of a place the card no longer names. + return; + } + IsRunning = true; ResetRunResult(); try @@ -1525,6 +1591,7 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable !IsRunning && !IsLoadingRepos && !IsResolvingPaths + && _placeIsUsable && Repos.Any(r => r.Status == SyncStatus.Failed); @@ -1604,7 +1671,7 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable private bool CanResolvePaths(RepoItemViewModel item) - => item is not null && item.HasPathIssue && !IsRunning && !IsResolvingPaths; + => item is not null && item.HasPathIssue && !IsRunning && !IsResolvingPaths && _placeIsUsable; @@ -1615,7 +1682,7 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable [RelayCommand(CanExecute = nameof(CanResolvePaths))] private async Task ResolvePathsAsync(RepoItemViewModel item) { - if(item is null || RecoveryInteraction is not { } interaction) + if(item is null || !_placeIsUsable || RecoveryInteraction is not { } interaction) { return; } diff --git a/gclo/ConnectionFormControl.xaml b/gclo/ConnectionFormControl.xaml index ec1610f..6a14f66 100644 --- a/gclo/ConnectionFormControl.xaml +++ b/gclo/ConnectionFormControl.xaml @@ -8,7 +8,8 @@ xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" mc:Ignorable="d" IsTabStop="False" - Loaded="OnLoaded"> + Loaded="OnLoaded" + Unloaded="OnUnloaded">