diff --git a/CHANGELOG.md b/CHANGELOG.md index 4ab2920..56a4126 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,19 @@ # Changelog +## 0.11.0 — 2026-10-26 + +Settings in a window. + +- Start menu *ADO Lens - settings* (or `adolens setup --window`) opens a window instead of a menu in a terminal: + - General: who uses it (mode), tools at start, language, download folder; + - Connections: the list with their PATs, add one (address or link, PAT, default project - checked before anything + is saved), remove, make default, project, team, project with the code, required label languages; + - Environments: per connection, the map in flow order as a table (repository, branch or release stage, name), + "Find stages and branches", reorder, save for you or for the whole team (wiki page "ADO Lens"); + - Status: the same check as `adolens status`; + - "Open in a terminal" starts the menu of `adolens setup`, which stays as it was. +- The program is now built for Windows with Windows Forms (net9.0-windows); the program file is larger. + ## 0.10.1 — 2026-10-26 - A Polish installation shows Polish texts people read: the descriptions of the skills in Claude Code and in the ZIPs diff --git a/README.md b/README.md index 1ea4e4f..6450bcd 100644 --- a/README.md +++ b/README.md @@ -52,8 +52,10 @@ called (for you or the whole team); it installs the skills and registers ADO Len English) is chosen at the start and kept for the settings; an update skips the questions. Until the program is signed, Windows SmartScreen may show "Windows protected your PC" for a file downloaded in a browser: *More info → Run anyway* (or use the one-line install above, which avoids it). -After installing, the Start menu has an **ADO Lens** folder: *settings* (a menu: mode, environments — change, add, -remove, for you or the team —, add or remove a connection, status, language), *status* (is everything working) and the skills for Claude Desktop; `adolens` works in +After installing, the Start menu has an **ADO Lens** folder: *settings* (a window with tabs: mode, tools at start, +language, download folder; connections — add, remove, default, project, team, project with the code, label languages; +environments — find, edit, order, save for you or the team; status — and a button to the same menu in a terminal, +`adolens setup`), *status* (is everything working) and the skills for Claude Desktop; `adolens` works in any new terminal. Uninstall from *Settings → Apps → ADO Lens* (or the Start menu): it removes the program, its Claude registrations and skills, and asks whether to remove the stored PATs and settings too (by default they stay). diff --git a/README.pl.md b/README.pl.md index 3bc200b..6e8a379 100644 --- a/README.pl.md +++ b/README.pl.md @@ -53,8 +53,10 @@ starcie i zostaje też dla ustawień; aktualizacja pomija pytania. Dopóki program nie jest podpisany, Windows SmartScreen może przy pliku pobranym przeglądarką pokazać „System Windows ochronił ten komputer”: *Więcej informacji → Uruchom mimo to* (albo użyj instalacji jedną linijką powyżej, która tego unika). -Po instalacji w menu Start jest folder **ADO Lens**: *ustawienia* (menu: tryb, środowiska — zmiana, dodanie, usunięcie, -dla ciebie lub zespołu —, dodanie lub usunięcie połączenia, status, język), *status* (czy wszystko działa) i skille dla Claude Desktop; `adolens` działa w każdym nowym terminalu. +Po instalacji w menu Start jest folder **ADO Lens**: *ustawienia* (okno z zakładkami: tryb, narzędzia na start, +język, folder pobierania; połączenia — dodanie, usunięcie, domyślne, projekt, zespół, projekt z kodem, języki etykiet; +środowiska — wykrycie, edycja, kolejność, zapis dla ciebie lub zespołu; status — oraz przycisk do tego samego menu w +terminalu, `adolens setup`), *status* (czy wszystko działa) i skille dla Claude Desktop; `adolens` działa w każdym nowym terminalu. Odinstalowanie: *Ustawienia → Aplikacje → ADO Lens* (albo z menu Start) — usuwa program, jego rejestracje w Claudzie i skille, i pyta, czy usunąć też zapisane PAT-y i ustawienia (domyślnie zostają). diff --git a/packaging/inno/ado-lens.iss b/packaging/inno/ado-lens.iss index 4068807..42cfd91 100644 --- a/packaging/inno/ado-lens.iss +++ b/packaging/inno/ado-lens.iss @@ -61,7 +61,9 @@ Source: "{#DesktopSkills}\*.zip"; DestDir: "{app}\skills-for-claude-desktop"; Fl [Icons] ; Start menu: settings and status open in a console that stays open (adolens commands work there too). -Name: "{group}\{cm:IconSettings}"; Filename: "{cmd}"; Parameters: "/k """"{app}\bin\adolens.exe"" setup"""; WorkingDir: "{app}"; IconFilename: "{app}\bin\adolens.exe" +; The settings window; its button "Open in a terminal" starts the menu of 'adolens setup'. Minimized: the console the +; program starts with is closed at once, so it does not flash. +Name: "{group}\{cm:IconSettings}"; Filename: "{app}\bin\adolens.exe"; Parameters: "setup --window"; WorkingDir: "{app}"; Flags: runminimized Name: "{group}\{cm:IconStatus}"; Filename: "{cmd}"; Parameters: "/k """"{app}\bin\adolens.exe"" status"""; WorkingDir: "{app}"; IconFilename: "{app}\bin\adolens.exe" Name: "{group}\{cm:IconSkills}"; Filename: "{app}\skills-for-claude-desktop" Name: "{group}\{cm:UninstallProgram,ADO Lens}"; Filename: "{uninstallexe}"; IconFilename: "{app}\bin\adolens.exe" diff --git a/src/AdoLens/AdoLens.csproj b/src/AdoLens/AdoLens.csproj index c600bf8..dbdd98e 100644 --- a/src/AdoLens/AdoLens.csproj +++ b/src/AdoLens/AdoLens.csproj @@ -2,7 +2,9 @@ Exe - net9.0 + net9.0-windows + + true enable enable adolens @@ -23,7 +25,7 @@ ADO Lens for Azure DevOps Azure DevOps (work items, boards, wiki, Git and TFVC, pipelines) served to AI assistants over MCP Copyright © 2026 WillowDev - 0.10.1 + 0.11.0 diff --git a/src/AdoLens/Cli.cs b/src/AdoLens/Cli.cs index 73152ec..fa03557 100644 --- a/src/AdoLens/Cli.cs +++ b/src/AdoLens/Cli.cs @@ -79,7 +79,8 @@ recipe list | show | save --file | delete +/// The settings after installation in a window (Start menu "ADO Lens - settings", adolens setup --window): the +/// same things as the menu of adolens setup, which "Open in a terminal" starts. Settings are changed through the +/// same classes as the menu; signing in and the status run adolens.exe itself, as the installer does. +/// +public sealed class SettingsWindow : Form +{ + readonly string path; + readonly string version; + Settings settings; + + readonly ComboBox mode = Combo(), loading = Combo(), language = Combo(); + readonly TextBox downloads = new() { Dock = DockStyle.Fill }; + + readonly ListView connections = new() { View = View.Details, FullRowSelect = true, MultiSelect = false, HideSelection = false, Dock = DockStyle.Fill }; + readonly TextBox project = Field(), team = Field(), codeProject = Field(), labelLanguages = Field(); + + readonly ComboBox envConnection = Combo(); + readonly DataGridView envGrid = new() + { + Dock = DockStyle.Fill, AllowUserToAddRows = true, AllowUserToResizeRows = false, RowHeadersVisible = false, + AutoSizeColumnsMode = DataGridViewAutoSizeColumnsMode.Fill, SelectionMode = DataGridViewSelectionMode.FullRowSelect, BackgroundColor = SystemColors.Window, + }; + readonly Label envInfo = new() { Dock = DockStyle.Fill, AutoEllipsis = true }; + + readonly TextBox status = new() { Multiline = true, ReadOnly = true, ScrollBars = ScrollBars.Both, WordWrap = false, Dock = DockStyle.Fill, Font = new Font(FontFamily.GenericMonospace, 9) }; + + static readonly string[] Modes = ["po", "dev", "full"]; + static readonly string[] Loadings = ["auto", "demand", "all"]; + static readonly string?[] Languages = ["pl", "en", null]; + + public SettingsWindow(string settingsPath, string version) + { + path = settingsPath; + this.version = version; + settings = Settings.Load(path); + Text = Ui.T($"ADO Lens {version} - settings", $"ADO Lens {version} - ustawienia"); + try { Icon = Icon.ExtractAssociatedIcon(Environment.ProcessPath ?? ""); } catch (Exception) { } + StartPosition = FormStartPosition.CenterScreen; + AutoScaleMode = AutoScaleMode.Dpi; + Font = new Font("Segoe UI", 9); + ClientSize = new Size(820, 540); + MinimumSize = new Size(700, 460); + + var tabs = new TabControl { Dock = DockStyle.Fill }; + tabs.TabPages.Add(GeneralPage()); + tabs.TabPages.Add(ConnectionsPage()); + tabs.TabPages.Add(EnvironmentsPage()); + tabs.TabPages.Add(StatusPage()); + tabs.SelectedIndexChanged += async (_, _) => { if (tabs.SelectedIndex == 2 && envGrid.Rows.Count <= 1) await LoadEnvironmentsAsync(detect: false); }; + + var terminal = new Button { Text = Ui.T("Open in a terminal", "Otwórz w terminalu"), AutoSize = true }; + terminal.Click += (_, _) => Start("setup", shell: true); + var close = new Button { Text = Ui.T("Close", "Zamknij"), AutoSize = true, DialogResult = DialogResult.Cancel }; + close.Click += (_, _) => Close(); + CancelButton = close; + var bottom = new FlowLayoutPanel { Dock = DockStyle.Bottom, FlowDirection = FlowDirection.RightToLeft, AutoSize = true, Padding = new Padding(6) }; + bottom.Controls.Add(close); + bottom.Controls.Add(terminal); + + Controls.Add(tabs); + Controls.Add(bottom); + Shown += (_, _) => { WindowState = FormWindowState.Normal; Activate(); }; + LoadGeneral(); + LoadConnections(); + } + + // ---- entry ------------------------------------------------------------------------------------------------------ + + [DllImport("kernel32.dll")] static extern bool FreeConsole(); + [DllImport("kernel32.dll")] static extern uint GetConsoleProcessList(uint[] list, uint count); + + /// Shows the window on its own UI thread; a console opened only for this program (Start menu) is closed. + public static int Run(string settingsPath, string version) + { + if (GetConsoleProcessList(new uint[2], 2) == 1) FreeConsole(); + Exception? failed = null; + var thread = new Thread(() => + { + try + { + Application.SetHighDpiMode(HighDpiMode.PerMonitorV2); + Application.EnableVisualStyles(); + Application.SetCompatibleTextRenderingDefault(false); + Application.Run(new SettingsWindow(settingsPath, version)); + } + catch (Exception ex) { failed = ex; } + }); + thread.SetApartmentState(ApartmentState.STA); + thread.Start(); + thread.Join(); + if (failed is not null) { Console.Error.WriteLine(failed.Message); return 1; } + return 0; + } + + // ---- general ---------------------------------------------------------------------------------------------------- + + TabPage GeneralPage() + { + mode.Items.AddRange([Ui.T("Product Owner - work items, sprints, board, wiki, checking delivered work", "Product Owner - work itemy, sprinty, tablica, wiki, sprawdzanie wykonanej pracy"), + Ui.T("Developer - work items, wiki, code, pull requests, pipelines", "Programista - work itemy, wiki, kod, pull requesty, pipeline'y"), + Ui.T("Everything", "Wszystko")]); + loading.Items.AddRange([Ui.T("auto - on demand in Claude Code, all at start in Claude Desktop", "auto - na żądanie w Claude Code, wszystkie od startu w Claude Desktop"), + Ui.T("demand - core at start, the rest when needed (fewest tokens)", "demand - podstawowe na start, reszta, gdy potrzebna (najmniej tokenów)"), + Ui.T("all - every tool of the mode at start", "all - wszystkie narzędzia trybu od startu")]); + language.Items.AddRange(["polski", "English", Ui.T("as Windows", "jak Windows")]); + var browse = new Button { Text = "...", Width = 32 }; + browse.Click += (_, _) => + { + using var dialog = new FolderBrowserDialog { SelectedPath = Downloads.BaseDir(settings) }; + if (dialog.ShowDialog(this) == DialogResult.OK) downloads.Text = dialog.SelectedPath; + }; + var folder = new TableLayoutPanel { Dock = DockStyle.Fill, ColumnCount = 2, AutoSize = true }; + folder.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100)); + folder.ColumnStyles.Add(new ColumnStyle(SizeType.AutoSize)); + folder.Controls.Add(downloads, 0, 0); + folder.Controls.Add(browse, 1, 0); + + var save = new Button { Text = Ui.T("Save", "Zapisz"), AutoSize = true }; + save.Click += (_, _) => Guard(SaveGeneral); + var grid = Grid( + (Ui.T("Who uses it", "Kto używa"), mode), + (Ui.T("Tools at start", "Narzędzia na start"), loading), + (Ui.T("Language", "Język"), language), + (Ui.T("Download folder (empty = Downloads\\ADO Lens)", "Folder pobierania (puste = Pobrane\\ADO Lens)"), folder)); + grid.Controls.Add(Note(Ui.T("Changes apply to new conversations with Claude.", "Zmiany obowiązują w nowych rozmowach z Claude.")), 1, grid.RowCount); + grid.Controls.Add(save, 1, grid.RowCount + 1); + return Page(Ui.T("General", "Ogólne"), grid); + } + + void LoadGeneral() + { + mode.SelectedIndex = Math.Max(0, Array.IndexOf(Modes, settings.Mode)); + loading.SelectedIndex = Math.Max(0, Array.IndexOf(Loadings, settings.ToolLoading)); + language.SelectedIndex = Math.Max(0, Array.IndexOf(Languages, settings.Language)); + downloads.Text = settings.DownloadDir ?? ""; + } + + void SaveGeneral() + { + settings = Settings.Load(path); + var newMode = Modes[mode.SelectedIndex]; + var newLanguage = Languages[language.SelectedIndex]; + var skillsChange = newMode != settings.Mode || newLanguage != settings.Language; + settings.Mode = newMode; + settings.ToolLoading = Loadings[loading.SelectedIndex]; + settings.Language = newLanguage; + settings.DownloadDir = string.IsNullOrWhiteSpace(downloads.Text) ? null : downloads.Text.Trim().Trim('"'); + settings.Save(path); + var said = Ui.T("Saved - new conversations with Claude use it.", "Zapisane - obowiązuje w nowych rozmowach z Claude."); + if (skillsChange) + { + var polish = newLanguage == "pl" || newLanguage is null && Ui.Polish; + said += "\n\n" + Skills.Sync(newMode, polish: polish); + if (Skills.DesktopZips(polish) is { Length: > 0 } zips) said += "\n" + zips; + } + MessageBox.Show(this, said, Text, MessageBoxButtons.OK, MessageBoxIcon.Information); + } + + // ---- connections ------------------------------------------------------------------------------------------------ + + TabPage ConnectionsPage() + { + connections.Columns.Add(Ui.T("Connection", "Połączenie"), 110); + connections.Columns.Add(Ui.T("Organization", "Organizacja"), 120); + connections.Columns.Add(Ui.T("Project", "Projekt"), 120); + connections.Columns.Add(Ui.T("Code in", "Kod w"), 100); + connections.Columns.Add(Ui.T("Team", "Zespół"), 100); + connections.Columns.Add("PAT", 90); + connections.SelectedIndexChanged += (_, _) => ShowConnection(); + + var add = Button(Ui.T("Add a connection...", "Dodaj połączenie..."), () => { using var d = new LoginDialog(path, version); if (d.ShowDialog(this) == DialogResult.OK) LoadConnections(); }); + var makeDefault = Button(Ui.T("Make default", "Ustaw jako domyślne"), () => + { + if (Selected() is not { } name) return; + settings = Settings.Load(path); + settings.DefaultConnection = name; + settings.Save(path); + LoadConnections(); + }); + var remove = Button(Ui.T("Remove...", "Usuń..."), () => + { + if (Selected() is not { } name) return; + if (MessageBox.Show(this, Ui.T($"Remove the connection '{name}' with its PAT?", $"Usunąć połączenie '{name}' razem z PAT-em?"), Text, + MessageBoxButtons.YesNo, MessageBoxIcon.Question) != DialogResult.Yes) return; + RemoveConnection(name); + LoadConnections(); + }); + var buttons = new FlowLayoutPanel { Dock = DockStyle.Fill, AutoSize = true }; + buttons.Controls.AddRange([add, makeDefault, remove]); + + var save = Button(Ui.T("Save changes", "Zapisz zmiany"), SaveConnection); + var details = Grid( + (Ui.T("Default project (work items)", "Projekt domyślny (work itemy)"), project), + (Ui.T("Team", "Zespół"), team), + (Ui.T("Project with the code, PRs and pipelines (empty = the same)", "Projekt z kodem, PR-ami i pipeline'ami (puste = ten sam)"), codeProject), + (Ui.T("Required label languages (e.g. en-US, pl; empty = all)", "Wymagane języki etykiet (np. en-US, pl; puste = wszystkie)"), labelLanguages)); + details.Controls.Add(save, 1, details.RowCount); + + var layout = new TableLayoutPanel { Dock = DockStyle.Fill, ColumnCount = 1, RowCount = 3 }; + layout.RowStyles.Add(new RowStyle(SizeType.Percent, 45)); + layout.RowStyles.Add(new RowStyle(SizeType.AutoSize)); + layout.RowStyles.Add(new RowStyle(SizeType.Percent, 55)); + layout.Controls.Add(connections, 0, 0); + layout.Controls.Add(buttons, 0, 1); + layout.Controls.Add(details, 0, 2); + return Page(Ui.T("Connections", "Połączenia"), layout); + } + + void LoadConnections() + { + settings = Settings.Load(path); + connections.Items.Clear(); + foreach (var (name, c) in settings.Connections.OrderBy(kv => kv.Key, StringComparer.OrdinalIgnoreCase)) + { + var item = new ListViewItem(name + (name.Equals(settings.DefaultConnection, StringComparison.OrdinalIgnoreCase) ? Ui.T(" (default)", " (domyślne)") : "")) { Tag = name }; + item.SubItems.AddRange([c.Organization, c.Project ?? "-", c.CodeProject ?? "", c.Team ?? "", CredentialStore.Read(name) is null ? Ui.T("missing", "brak") : Ui.T("stored", "zapisany")]); + connections.Items.Add(item); + } + if (connections.Items.Count > 0) connections.Items[0].Selected = true; + envConnection.Items.Clear(); + envConnection.Items.AddRange(settings.Connections.Keys.Order(StringComparer.OrdinalIgnoreCase).Cast().ToArray()); + if (envConnection.Items.Count > 0) + envConnection.SelectedItem = settings.DefaultConnection is { } d && envConnection.Items.Contains(d) ? d : envConnection.Items[0]; + ShowConnection(); + } + + string? Selected() => connections.SelectedItems.Count > 0 ? connections.SelectedItems[0].Tag as string : null; + + void ShowConnection() + { + var c = Selected() is { } name && settings.Connections.TryGetValue(name, out var conn) ? conn : null; + foreach (var box in new[] { project, team, codeProject, labelLanguages }) box.Enabled = c is not null; + project.Text = c?.Project ?? ""; + team.Text = c?.Team ?? ""; + codeProject.Text = c?.CodeProject ?? ""; + labelLanguages.Text = c is null ? "" : string.Join(", ", c.LabelLanguages); + } + + void SaveConnection() + { + if (Selected() is not { } name) return; + settings = Settings.Load(path); + if (!settings.Connections.TryGetValue(name, out var c)) return; + c.Project = Empty(project.Text); + c.Team = Empty(team.Text); + c.CodeProject = Empty(codeProject.Text); + c.LabelLanguages = labelLanguages.Text.Split([',', ';', ' '], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList(); + settings.Save(path); + LoadConnections(); + MessageBox.Show(this, Ui.T("Saved - new conversations with Claude use it.", "Zapisane - obowiązuje w nowych rozmowach z Claude."), Text); + } + + /// The PAT, the settings entry and the metadata cache of a connection (as 'adolens logout --remove'). + void RemoveConnection(string name) + { + CredentialStore.Delete(name); + settings = Settings.Load(path); + if (settings.Connections.Keys.FirstOrDefault(k => k.Equals(name, StringComparison.OrdinalIgnoreCase)) is not { } key) return; + settings.Connections.Remove(key); + if (string.Equals(settings.DefaultConnection, key, StringComparison.OrdinalIgnoreCase)) + settings.DefaultConnection = settings.Connections.Count == 1 ? settings.Connections.Keys.First() : null; + settings.Save(path); + var meta = Path.Combine(Settings.DataDir, "meta"); + foreach (var f in Directory.Exists(meta) ? Directory.GetFiles(meta, key + ".db*") : []) + try { File.Delete(f); } catch (IOException) { } // in use by an open Claude session + } + + // ---- environments ----------------------------------------------------------------------------------------------- + + TabPage EnvironmentsPage() + { + envGrid.Columns.Add(new DataGridViewTextBoxColumn { Name = "repo", HeaderText = Ui.T("Repository (empty = all)", "Repozytorium (puste = wszystkie)"), FillWeight = 25 }); + envGrid.Columns.Add(new DataGridViewTextBoxColumn { Name = "key", HeaderText = Ui.T("Branch or release stage (Definition/Stage)", "Gałąź albo etap release'u (Definicja/Etap)"), FillWeight = 40 }); + envGrid.Columns.Add(new DataGridViewTextBoxColumn { Name = "name", HeaderText = Ui.T("Environment, as people say it (empty = skip)", "Środowisko, jak o nim mówicie (puste = pomiń)"), FillWeight = 35 }); + envConnection.SelectedIndexChanged += async (_, _) => await LoadEnvironmentsAsync(detect: false); + + var top = new FlowLayoutPanel { Dock = DockStyle.Fill, AutoSize = true, WrapContents = false }; + top.Controls.Add(new Label { Text = Ui.T("Connection:", "Połączenie:"), AutoSize = true, Padding = new Padding(0, 6, 0, 0) }); + envConnection.Width = 160; + top.Controls.Add(envConnection); + top.Controls.Add(Button(Ui.T("Find stages and branches", "Wykryj etapy i gałęzie"), async () => await LoadEnvironmentsAsync(detect: true))); + top.Controls.Add(Button("↑", () => MoveRow(-1))); + top.Controls.Add(Button("↓", () => MoveRow(+1))); + top.Controls.Add(Button(Ui.T("Remove row", "Usuń wiersz"), () => { foreach (DataGridViewRow r in envGrid.SelectedRows) if (!r.IsNewRow) envGrid.Rows.Remove(r); })); + + var bottom = new FlowLayoutPanel { Dock = DockStyle.Fill, AutoSize = true }; + bottom.Controls.Add(Button(Ui.T("Save for me", "Zapisz dla mnie"), async () => await SaveEnvironmentsAsync(team: false))); + bottom.Controls.Add(Button(Ui.T("Save for the whole team (wiki page \"ADO Lens\")", "Zapisz dla zespołu (strona wiki \"ADO Lens\")"), async () => await SaveEnvironmentsAsync(team: true))); + + var layout = new TableLayoutPanel { Dock = DockStyle.Fill, ColumnCount = 1, RowCount = 5 }; + layout.RowStyles.Add(new RowStyle(SizeType.AutoSize)); + layout.RowStyles.Add(new RowStyle(SizeType.AutoSize)); + layout.RowStyles.Add(new RowStyle(SizeType.Percent, 100)); + layout.RowStyles.Add(new RowStyle(SizeType.AutoSize)); + layout.RowStyles.Add(new RowStyle(SizeType.AutoSize)); + layout.Controls.Add(top, 0, 0); + layout.Controls.Add(Note(Ui.T("In flow order: DEV first, production last. A repository with rows of its own uses only those (the same branch may mean something else, or nothing, in another repository).", + "W kolejności przepływu: najpierw DEV, na końcu produkcja. Repozytorium z własnymi wierszami używa tylko ich (ta sama gałąź w innym repozytorium może znaczyć co innego albo nic).")), 0, 1); + layout.Controls.Add(envGrid, 0, 2); + layout.Controls.Add(envInfo, 0, 3); + layout.Controls.Add(bottom, 0, 4); + return Page(Ui.T("Environments", "Środowiska"), layout); + } + + void MoveRow(int by) + { + if (envGrid.CurrentRow is not { IsNewRow: false } row) return; + var to = row.Index + by; + if (to < 0 || to >= envGrid.Rows.Count - 1) return; + envGrid.Rows.Remove(row); + envGrid.Rows.Insert(to, row); + envGrid.ClearSelection(); + row.Selected = true; + envGrid.CurrentCell = row.Cells[0]; + } + + async Task LoadEnvironmentsAsync(bool detect) + { + if (envConnection.SelectedItem is not string name) return; + UseWaitCursor = true; + envInfo.Text = detect ? Ui.T("Looking for release stages and branches...", "Szukam etapów release'ów i gałęzi...") : ""; + try + { + var lens = new Lens(Settings.Load(path), version, Settings.DataDir, Environment.GetEnvironmentVariable, CredentialStore.Read, settingsPath: path); + var session = lens.For(name); + var project = session.Project(null); + var (map, source) = await EnvironmentFlow.EffectiveAsync(session, project, default); + envGrid.Rows.Clear(); + foreach (var (key, label) in map) AddRow(key, label); + var found = 0; + if (detect || map.Count == 0) + foreach (var (key, guess) in await EnvironmentFlow.DetectAsync(session, project, default)) + if (!map.ContainsKey(key)) { AddRow(key, guess ?? ""); found++; } + envInfo.Text = $"{project}: " + (map.Count > 0 ? Ui.T($"in use: {source}", $"w użyciu: {source}") : Ui.T("no map yet", "brak mapy")) + + (found > 0 ? Ui.T($"; {found} found (check the names)", $"; znalezione: {found} (sprawdź nazwy)") : ""); + } + catch (Exception ex) when (ex is InvalidOperationException or AdoApiException or HttpRequestException) + { + envInfo.Text = ex.Message; + } + finally { UseWaitCursor = false; } + } + + void AddRow(string key, string label) => + envGrid.Rows.Add(EnvMap.RepoOf(key) ?? "", EnvMap.BranchOf(key), label); + + /// The rows as a map in their order; a row without a name is left out. + internal static Dictionary MapOf(IEnumerable<(string? Repo, string? Key, string? Name)> rows) + { + var map = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var (repo, key, name) in rows) + { + if (string.IsNullOrWhiteSpace(key) || string.IsNullOrWhiteSpace(name)) continue; + var k = key.Trim(); + if (!string.IsNullOrWhiteSpace(repo) && !EnvMap.IsStage(k)) k = repo.Trim() + ":" + k; + map[k] = name.Trim().Replace(';', ','); + } + return map; + } + + async Task SaveEnvironmentsAsync(bool team) + { + if (envConnection.SelectedItem is not string name) return; + envGrid.EndEdit(); + var map = MapOf(envGrid.Rows.Cast().Where(r => !r.IsNewRow) + .Select(r => (r.Cells[0].Value as string, r.Cells[1].Value as string, r.Cells[2].Value as string))); + if (map.Count == 0) { MessageBox.Show(this, Ui.T("Name at least one environment.", "Nazwij co najmniej jedno środowisko."), Text); return; } + UseWaitCursor = true; + try + { + var lens = new Lens(Settings.Load(path), version, Settings.DataDir, Environment.GetEnvironmentVariable, CredentialStore.Read, settingsPath: path); + var session = lens.For(name); + var project = session.Project(null); + string said; + if (team) + { + var url = await session.Team.SaveAsync(project, map, default); + // A local map would hide the team's one. + var s = Settings.Load(path); + if (s.Connections.TryGetValue(name, out var c) && c.Environments.Count > 0) + { + c.Environments = new Dictionary(StringComparer.OrdinalIgnoreCase); + s.Save(path); + } + said = Ui.T($"Saved on the wiki for the whole team:\n{url}", $"Zapisane na wiki dla całego zespołu:\n{url}"); + } + else + { + var s = Settings.Load(path); + s.Connections[name].Environments = map; + s.Save(path); + said = Ui.T("Saved in your settings.", "Zapisane w twoich ustawieniach."); + } + MessageBox.Show(this, said + "\n\n" + string.Join(" -> ", map.Values), Text, MessageBoxButtons.OK, MessageBoxIcon.Information); + } + catch (Exception ex) when (ex is InvalidOperationException or AdoApiException or HttpRequestException) + { + MessageBox.Show(this, ex.Message, Text, MessageBoxButtons.OK, MessageBoxIcon.Warning); + } + finally { UseWaitCursor = false; } + } + + // ---- status ----------------------------------------------------------------------------------------------------- + + TabPage StatusPage() + { + var check = Button(Ui.T("Check", "Sprawdź"), async () => + { + status.Text = Ui.T("checking...", "sprawdzam..."); + status.Text = (await RunAsync("status")).Replace("\n", "\r\n"); + }); + var layout = new TableLayoutPanel { Dock = DockStyle.Fill, ColumnCount = 1, RowCount = 3 }; + layout.RowStyles.Add(new RowStyle(SizeType.AutoSize)); + layout.RowStyles.Add(new RowStyle(SizeType.AutoSize)); + layout.RowStyles.Add(new RowStyle(SizeType.Percent, 100)); + layout.Controls.Add(Note(Ui.T($"Settings: {path}", $"Ustawienia: {path}")), 0, 0); + layout.Controls.Add(check, 0, 1); + layout.Controls.Add(status, 0, 2); + return Page("Status", layout); + } + + /// Runs adolens.exe with and returns what it printed. + internal static async Task RunAsync(string args, Dictionary? env = null) + { + var info = new ProcessStartInfo(Environment.ProcessPath ?? "adolens.exe", args) + { + UseShellExecute = false, CreateNoWindow = true, RedirectStandardOutput = true, RedirectStandardError = true, + StandardOutputEncoding = System.Text.Encoding.UTF8, StandardErrorEncoding = System.Text.Encoding.UTF8, + }; + foreach (var (k, v) in env ?? []) info.Environment[k] = v; + using var p = Process.Start(info)!; + var output = p.StandardOutput.ReadToEndAsync(); + var errors = p.StandardError.ReadToEndAsync(); + await p.WaitForExitAsync(); + return ((await output) + (await errors)).Trim(); + } + + static void Start(string args, bool shell) => + Process.Start(new ProcessStartInfo(Environment.ProcessPath ?? "adolens.exe", args) { UseShellExecute = shell }); + + // ---- layout helpers --------------------------------------------------------------------------------------------- + + static ComboBox Combo() => new() { DropDownStyle = ComboBoxStyle.DropDownList, Dock = DockStyle.Fill }; + static TextBox Field() => new() { Dock = DockStyle.Fill }; + static string? Empty(string text) => string.IsNullOrWhiteSpace(text) ? null : text.Trim(); + static Label Note(string text) => new() { Text = text, AutoSize = true, MaximumSize = new Size(760, 0), ForeColor = SystemColors.GrayText, Padding = new Padding(0, 4, 0, 4) }; + + Button Button(string text, Action click) + { + var b = new Button { Text = text, AutoSize = true }; + b.Click += (_, _) => Guard(click); + return b; + } + + Button Button(string text, Func click) + { + var b = new Button { Text = text, AutoSize = true }; + b.Click += async (_, _) => + { + try { await click(); } + catch (Exception ex) { MessageBox.Show(this, ex.Message, Text, MessageBoxButtons.OK, MessageBoxIcon.Warning); } + }; + return b; + } + + void Guard(Action action) + { + try { action(); } + catch (Exception ex) { MessageBox.Show(this, ex.Message, Text, MessageBoxButtons.OK, MessageBoxIcon.Warning); } + } + + static TableLayoutPanel Grid(params (string Label, Control Control)[] rows) + { + var grid = new TableLayoutPanel { Dock = DockStyle.Top, ColumnCount = 2, AutoSize = true, Padding = new Padding(4) }; + grid.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 40)); + grid.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 60)); + for (var i = 0; i < rows.Length; i++) + { + grid.Controls.Add(new Label { Text = rows[i].Label, AutoSize = true, Anchor = AnchorStyles.Left, Padding = new Padding(0, 6, 0, 0) }, 0, i); + grid.Controls.Add(rows[i].Control, 1, i); + } + grid.RowCount = rows.Length; + return grid; + } + + static TabPage Page(string title, Control content) + { + var page = new TabPage(title) { Padding = new Padding(8) }; + page.Controls.Add(content); + return page; + } +} + +/// Adding a connection: the address, the PAT (kept in Windows Credential Manager) and the default project. +sealed class LoginDialog : Form +{ + readonly string path; + readonly string version; + readonly TextBox address = new() { Dock = DockStyle.Fill, PlaceholderText = "https://dev.azure.com/contoso/Project" }; + readonly TextBox name = new() { Dock = DockStyle.Fill }; + readonly TextBox pat = new() { Dock = DockStyle.Fill, UseSystemPasswordChar = true }; + readonly ComboBox project = new() { Dock = DockStyle.Fill, DropDownStyle = ComboBoxStyle.DropDown }; + readonly Label info = new() { AutoSize = true, MaximumSize = new Size(520, 0) }; + readonly Button connect = new() { AutoSize = true }; + + public LoginDialog(string settingsPath, string version) + { + path = settingsPath; + this.version = version; + Text = Ui.T("Add a connection", "Dodaj połączenie"); + StartPosition = FormStartPosition.CenterParent; + FormBorderStyle = FormBorderStyle.FixedDialog; + MaximizeBox = MinimizeBox = false; + AutoScaleMode = AutoScaleMode.Dpi; + Font = new Font("Segoe UI", 9); + ClientSize = new Size(560, 270); + + var tokens = new LinkLabel { Text = Ui.T("Create a PAT (Work Items, Code, Build, Release, Wiki: read & write as needed)", "Utwórz PAT (Work Items, Code, Build, Release, Wiki: odczyt i zapis wg potrzeb)"), AutoSize = true }; + tokens.LinkClicked += (_, _) => + { + var org = Settings.ProjectFromUrl(address.Text) is not null || address.Text.Contains('/') ? TryOrg(address.Text) : address.Text.Trim(); + Process.Start(new ProcessStartInfo(string.IsNullOrEmpty(org) ? "https://dev.azure.com" : $"https://dev.azure.com/{org}/_usersSettings/tokens") { UseShellExecute = true }); + }; + connect.Text = Ui.T("Check and save", "Sprawdź i zapisz"); + connect.Click += async (_, _) => await ConnectAsync(); + var cancel = new Button { Text = Ui.T("Cancel", "Anuluj"), AutoSize = true, DialogResult = DialogResult.Cancel }; + AcceptButton = connect; + CancelButton = cancel; + + var grid = new TableLayoutPanel { Dock = DockStyle.Fill, ColumnCount = 2, Padding = new Padding(10) }; + grid.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 35)); + grid.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 65)); + void Row(string label, Control c, int r) + { + grid.Controls.Add(new Label { Text = label, AutoSize = true, Anchor = AnchorStyles.Left, Padding = new Padding(0, 6, 0, 0) }, 0, r); + grid.Controls.Add(c, 1, r); + } + Row(Ui.T("Azure DevOps address or link", "Adres Azure DevOps albo link"), address, 0); + Row(Ui.T("Connection name (optional)", "Nazwa połączenia (opcjonalnie)"), name, 1); + Row("PAT", pat, 2); + grid.Controls.Add(tokens, 1, 3); + Row(Ui.T("Default project", "Projekt domyślny"), project, 4); + grid.Controls.Add(info, 1, 5); + var buttons = new FlowLayoutPanel { FlowDirection = FlowDirection.RightToLeft, Dock = DockStyle.Bottom, AutoSize = true, Padding = new Padding(6) }; + buttons.Controls.Add(cancel); + buttons.Controls.Add(connect); + Controls.Add(grid); + Controls.Add(buttons); + } + + static string TryOrg(string text) + { + try { return Settings.NormalizeOrganization(text); } + catch (ArgumentException) { return ""; } + } + + async Task ConnectAsync() + { + string org, connection; + try + { + org = Settings.NormalizeOrganization(address.Text); + connection = Settings.NormalizeConnectionName(string.IsNullOrWhiteSpace(name.Text) ? org : name.Text); + } + catch (ArgumentException ex) { info.Text = ex.Message; return; } + if (string.IsNullOrWhiteSpace(pat.Text)) { info.Text = Ui.T("The PAT is required.", "PAT jest wymagany."); return; } + + connect.Enabled = false; + UseWaitCursor = true; + info.Text = Ui.T($"checking access to https://dev.azure.com/{org} ...", $"sprawdzam dostęp do https://dev.azure.com/{org} ..."); + try + { + var client = new AdoClient(AdoClient.CreateHttpClient(org, pat.Text.Trim(), version), org); + var user = await client.WhoAmIAsync(); + var projects = (await client.GetListAsync("_apis/projects?$top=500")).Select(p => Json.Str(p, "name")).OfType() + .Order(StringComparer.OrdinalIgnoreCase).ToList(); + var chosen = Empty(project.Text) ?? Settings.ProjectFromUrl(address.Text) ?? (projects.Count == 1 ? projects[0] : null); + if (chosen is null && projects.Count > 1) + { + project.Items.Clear(); + project.Items.AddRange(projects.Cast().ToArray()); + project.DroppedDown = true; + info.Text = Ui.T($"Signed in as {user}. Pick the default project, then save.", $"Zalogowano jako {user}. Wybierz projekt domyślny i zapisz."); + return; + } + var settings = Settings.Load(path); + var conn = settings.Connections.TryGetValue(connection, out var stored) ? stored : new Connection(); + conn.Organization = org; + conn.Project = chosen is null ? conn.Project : projects.FirstOrDefault(p => p.Equals(chosen, StringComparison.OrdinalIgnoreCase)) ?? chosen; + CredentialStore.Write(connection, org, pat.Text.Trim()); + settings.Connections[connection] = conn; + if (settings.DefaultConnection is null || !settings.Connections.ContainsKey(settings.DefaultConnection)) settings.DefaultConnection = connection; + settings.Save(path); + DialogResult = DialogResult.OK; + Close(); + } + catch (Exception ex) when (ex is AdoApiException or HttpRequestException or TaskCanceledException) + { + info.Text = Ui.T($"Failed, nothing was saved: {ex.Message}", $"Nie udało się, nic nie zapisano: {ex.Message}"); + } + finally + { + connect.Enabled = true; + UseWaitCursor = false; + } + } + + static string? Empty(string text) => string.IsNullOrWhiteSpace(text) ? null : text.Trim(); +} diff --git a/tests/AdoLens.Tests/AdoLens.Tests.csproj b/tests/AdoLens.Tests/AdoLens.Tests.csproj index b7862a4..bc181fd 100644 --- a/tests/AdoLens.Tests/AdoLens.Tests.csproj +++ b/tests/AdoLens.Tests/AdoLens.Tests.csproj @@ -1,7 +1,8 @@ - net9.0 + net9.0-windows + true enable enable false diff --git a/tests/AdoLens.Tests/SettingsWindowTests.cs b/tests/AdoLens.Tests/SettingsWindowTests.cs new file mode 100644 index 0000000..66b4614 --- /dev/null +++ b/tests/AdoLens.Tests/SettingsWindowTests.cs @@ -0,0 +1,67 @@ +namespace AdoLens.Tests; + +public class SettingsWindowTests : IDisposable +{ + readonly string dir = Path.Combine(Path.GetTempPath(), $"adolens-window-{Guid.NewGuid():N}"); + + public void Dispose() + { + if (Directory.Exists(dir)) Directory.Delete(dir, recursive: true); + } + + [Fact] + public void The_settings_window_builds_with_every_tab() + { + Directory.CreateDirectory(dir); + var path = Path.Combine(dir, "adolens.json"); + var s = new Settings { DefaultConnection = "contoso", Mode = "dev" }; + s.Connections["contoso"] = new Connection { Organization = "contoso", Project = "Demo", CodeProject = "ERP" }; + s.Save(path); + + Exception? failed = null; + var tabs = new List(); + var thread = new Thread(() => + { + try + { + using var window = new SettingsWindow(path, "0.0.0"); + window.CreateControl(); + var shots = Environment.GetEnvironmentVariable("ADOLENS_SHOTS"); + if (shots is { Length: > 0 }) + { + window.StartPosition = FormStartPosition.Manual; + window.Location = new Point(-3000, -3000); + window.Show(); + } + var control = window.Controls.OfType().Single(); + foreach (TabPage page in control.TabPages) + { + tabs.Add(page.Text); + // ADOLENS_SHOTS=: a picture of each tab, to look at the layout. + if (shots is { Length: > 0 }) + { + control.SelectedTab = page; + Application.DoEvents(); + using var bitmap = new Bitmap(window.Width, window.Height); + window.DrawToBitmap(bitmap, new Rectangle(0, 0, window.Width, window.Height)); + bitmap.Save(Path.Combine(shots, $"tab{tabs.Count}.png")); + } + } + } + catch (Exception ex) { failed = ex; } + }); + thread.SetApartmentState(ApartmentState.STA); + thread.Start(); + thread.Join(); + Assert.Null(failed); + Assert.Equal(4, tabs.Count); + } + + [Fact] + public void Environment_rows_become_the_map_in_their_order() + { + var map = SettingsWindow.MapOf([("ERP", "dev", "DEV (sbx01)"), ("", "Deploy/Stage 1", "UAT; SCM"), ("ERP", "staging", ""), (null, "master", "PROD")]); + Assert.Equal(["ERP:dev", "Deploy/Stage 1", "master"], map.Keys); + Assert.Equal("UAT, SCM", map["Deploy/Stage 1"]); + } +}