Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t.sh
286 linesCodeBlameRaw

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9' into claude/cool-newton-7nm8r91//! The window's frame. g1t draws its own title bar, as Slack and Discord
2//! do, so the window is all g1t and looks the same on every system: on
3//! Windows and Linux the system's frame goes and the bar has the menu
4//! button, back and forward, search, and minimize, maximize and close; on
5//! macOS the bar sits under the window's own traffic lights.
6//!
7//! The main window holds two web views, one above the other: the bar, a
8//! page bundled with the app (`launcher/titlebar.html`), across the top,
9//! and the site under it. The site is never drawn under the bar, so no page
10//! of it, or of GitHub's while signing in, needs to know the bar is there.
11//! What the bar shows of the page (its title, its search, whether there is
12//! a way back, its colours) the page's bridge reports (`page`), and what
13//! the bar is asked to do comes back to the page through the app (`act`).
14//!
15//! Settings can give the window back its system frame, and the menu bar
16//! with it, from the next start; the site then has the window to itself.
17
18use std::sync::Mutex;
19
20use serde::{Deserialize, Serialize};
21use tauri::webview::WebviewBuilder;
22use tauri::window::{Color, WindowBuilder};
23use tauri::{AppHandle, Emitter, EventTarget, LogicalPosition, LogicalSize, Manager, Runtime, Webview, WebviewUrl, WebviewWindowBuilder, Window};
24
25use crate::{WINDOW, config, tray};
26
27/// The bar's height, in CSS pixels.
28pub const HEIGHT: f64 = 36.0;
29
30/// The site's web view, in the main window.
31pub const SITE: &str = "site";
32/// The bar's web view, across the top of the main window.
33pub const BAR: &str = "titlebar";
34
35/// What the bar listens for: what to show (`Page`).
36pub const PAGE_EVENT: &str = "g1t-title-bar";
37/// What the site listens for: the bar's search was clicked.
38pub const SEARCH_EVENT: &str = "g1t-search";
39
40/// g1t's rail, dark, which the window is painted while a page loads.
41const RAIL: Color = Color(5, 5, 7, 255);
42
43/// Whether g1t draws its own title bar, as the settings say now.
44pub fn custom(app: &AppHandle) -> bool {
45 !config::settings(app).system_title_bar
46}
47
48/// The title bar g1t started with, which its windows keep until it restarts.
49static STARTED_CUSTOM: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
50
51/// Whether this run draws g1t's own title bar: decided once, at the start.
52pub fn started(app: &AppHandle) -> bool {
53 *STARTED_CUSTOM.get_or_init(|| custom(app))
54}
55
56/// The main window, holding the bar and the site.
57pub fn window(app: &AppHandle) -> Option<Window> {
58 app.get_window(WINDOW)
59}
60
61/// The site's web view.
62pub fn site(app: &AppHandle) -> Option<Webview> {
63 app.get_webview(SITE)
64}
65
66/// The main window, before its web views: g1t's frame, or the system's.
67pub fn main_window<'a, R: Runtime, M: Manager<R>>(app: &'a M, custom: bool) -> tauri::Result<WindowBuilder<'a, R, M>> {
68 #[cfg(target_os = "macos")]
69 let builder = if custom {
70 // The traffic lights stay, over the bar and centred in it.
71 let config = tauri::utils::config::WindowConfig {
72 label: WINDOW.into(),
73 traffic_light_position: Some(tauri::utils::config::LogicalPosition { x: 14.0, y: (HEIGHT - 14.0) / 2.0 }),
74 ..Default::default()
75 };
76 WindowBuilder::from_config(app, &config)?.title_bar_style(tauri::TitleBarStyle::Overlay).hidden_title(true)
77 } else {
78 WindowBuilder::new(app, WINDOW)
79 };
80 #[cfg(not(target_os = "macos"))]
81 let builder = WindowBuilder::new(app, WINDOW).decorations(!custom);
82 Ok(builder.title("g1t").inner_size(1280.0, 840.0).min_inner_size(720.0, 480.0).visible(false))
83}
84
85/// The bar's and the site's web views, in the main window, as tall as the window leaves them.
86pub fn fill(window: &Window, custom: bool, site: WebviewBuilder<tauri::Wry>) -> tauri::Result<()> {
87 let size = logical_size(window);
88 let top = if custom { HEIGHT } else { 0.0 };
89 if custom {
90 window.add_child(
91 WebviewBuilder::new(BAR, WebviewUrl::App("titlebar.html".into())).background_color(RAIL).on_navigation(config::is_launcher),
92 LogicalPosition::new(0.0, 0.0),
93 LogicalSize::new(size.width, HEIGHT),
94 )?;
95 }
96 window.add_child(site.background_color(RAIL), LogicalPosition::new(0.0, top), LogicalSize::new(size.width, (size.height - top).max(0.0)))?;
97 #[cfg(target_os = "linux")]
98 if custom {
99 pack_bar(window);
100 }
101 Ok(())
102}
103
104/// On Linux the web views sit in a GTK box, one under the other, which
105/// shares the window between them equally whatever size they are given:
106/// the bar is told to keep its height, and the site takes the rest.
107#[cfg(target_os = "linux")]
108fn pack_bar(window: &Window) {
109 use gtk::prelude::*;
110 let Ok(vbox) = window.default_vbox() else { return };
111 // The bar is the first web view added; the menu bar, hidden, is not one.
112 if let Some(bar) = vbox.children().into_iter().find(|child| child.type_().name() == "WebKitWebView") {
113 vbox.set_child_packing(&bar, false, true, 0, gtk::PackType::Start);
114 bar.set_size_request(-1, HEIGHT as i32);
115 }
116}
117
118fn logical_size(window: &Window) -> LogicalSize<f64> {
119 let scale = window.scale_factor().unwrap_or(1.0);
120 window.inner_size().map(|size| size.to_logical(scale)).unwrap_or(LogicalSize::new(1280.0, 840.0))
121}
122
123/// The window changed size: the bar keeps its height, and the site takes the rest.
124pub fn layout(window: &Window) {
125 let app = window.app_handle();
126 let size = logical_size(window);
127 let bar = app.get_webview(BAR);
128 let top = if bar.is_some() { HEIGHT } else { 0.0 };
129 if let Some(bar) = bar {
130 let _ = bar.set_position(LogicalPosition::new(0.0, 0.0));
131 let _ = bar.set_size(LogicalSize::new(size.width, HEIGHT));
132 }
133 if let Some(site) = site(app) {
134 let _ = site.set_position(LogicalPosition::new(0.0, top));
135 let _ = site.set_size(LogicalSize::new(size.width, (size.height - top).max(0.0)));
136 }
137}
138
139/// The settings window: g1t's frame, or the system's. Its page draws its
140/// own bar (launcher/settings.html), being g1t's own.
141pub fn dialog<'a, R: Runtime, M: Manager<R>>(builder: WebviewWindowBuilder<'a, R, M>, custom: bool) -> WebviewWindowBuilder<'a, R, M> {
142 if !custom {
143 return builder;
144 }
145 #[cfg(target_os = "macos")]
146 {
147 builder
148 .title_bar_style(tauri::TitleBarStyle::Overlay)
149 .hidden_title(true)
150 .traffic_light_position(LogicalPosition::new(14.0, (HEIGHT - 14.0) / 2.0))
151 }
152 #[cfg(not(target_os = "macos"))]
153 {
154 builder.decorations(false)
155 }
156}
157
158/// The main window's menu bar: shown under the system's frame, and kept
159/// hidden under g1t's, whose menu button opens the same menu (`menu`) and
160/// whose shortcuts still work.
161pub fn menu_bar(window: &Window, custom: bool) {
162 let _ = if custom { window.hide_menu() } else { window.show_menu() };
163 // GTK shows everything in a window each time the window is shown, the
164 // hidden menu bar too, unless the menu bar is told to sit that out.
165 #[cfg(target_os = "linux")]
166 if let Ok(vbox) = window.default_vbox() {
167 use gtk::prelude::*;
168 for child in vbox.children().into_iter().filter(|child| child.is::<gtk::MenuBar>()) {
169 child.set_no_show_all(custom);
170 }
171 }
172}
173
174/// What a window button asks of the window it is in: `minimize`,
175/// `maximize` (which toggles), `close`, `drag`, or `maximized` to ask.
176/// Answers whether the window is maximized.
177pub fn control(app: &AppHandle, window: &Window, action: &str) -> Result<bool, String> {
178 let main = window.label() == WINDOW;
179 match action {
180 "minimize" => window.minimize().map_err(|error| error.to_string())?,
181 "maximize" => {
182 let maximized = window.is_maximized().unwrap_or(false);
183 let _ = if maximized { window.unmaximize() } else { window.maximize() };
184 }
185 "close" if main => tray::close(app),
186 "close" => window.close().map_err(|error| error.to_string())?,
187 "drag" => window.start_dragging().map_err(|error| error.to_string())?,
188 "maximized" => {}
189 other => return Err(format!("the window cannot {other}")),
190 }
191 Ok(window.is_maximized().unwrap_or(false))
192}
193
194/// The menu button: the window's menu, under the button.
195pub fn menu(window: &Window, x: f64, y: f64) -> Result<(), String> {
196 #[cfg(target_os = "macos")]
197 let menu = window.app_handle().menu();
198 #[cfg(not(target_os = "macos"))]
199 let menu = window.menu();
200 let menu = menu.ok_or("the window has no menu")?;
201 window.popup_menu_at(&menu, LogicalPosition::new(x, y)).map_err(|error| error.to_string())
202}
203
204/// The page's colours, as the bar takes them: CSS colours, resolved.
205#[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)]
206pub struct Colors {
207 pub background: String,
208 pub foreground: String,
209 pub muted: String,
210 pub accent: String,
211 /// Whether the page is dark, for the bar's own hover shades.
212 pub dark: bool,
213}
214
215/// What the bar shows of the page in the window under it.
216#[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)]
217#[serde(rename_all = "camelCase")]
218pub struct Page {
219 pub title: String,
220 /// The site's search, by its label ("Search Flagon"), when it has handed one over.
221 pub search: Option<String>,
222 /// Whether there is a page to go back or forward to; absent where the web view cannot tell.
223 pub can_go_back: Option<bool>,
224 pub can_go_forward: Option<bool>,
225 pub colors: Option<Colors>,
Merge branch 'main' into rm-agents226 /// The runner menu the site draws under the bar's dot is open.
227 #[serde(default)]
228 pub runner_menu: bool,
Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9' into claude/cool-newton-7nm8r9229}
230
231/// The page as last reported, kept for a bar that loads after it.
232#[derive(Default)]
233pub struct State(Mutex<Page>);
234
235/// The site's page reported what it is: the bar is told.
236pub fn page(app: &AppHandle, mut page: Page) {
237 page.title.truncate(300);
238 if let Some(search) = page.search.as_mut() {
239 search.truncate(120);
240 }
241 let state = app.state::<State>();
242 let mut kept = state.0.lock().unwrap();
243 // A page that knows no colours of its own keeps the last ones.
244 if page.colors.is_none() {
245 page.colors = kept.colors.clone();
246 }
247 if *kept == page {
248 return;
249 }
250 *kept = page.clone();
251 drop(kept);
252 let _ = app.emit_to(EventTarget::webview(BAR), PAGE_EVENT, page);
253}
254
255/// What the bar shows now: it asks as it loads.
256pub fn current(app: &AppHandle) -> Page {
257 app.state::<State>().0.lock().unwrap().clone()
258}
259
260/// A button on the bar that acts on the page: `back`, `forward`, `search`,
Merge branch 'main' into rm-agents261/// `settings`, `runner` (the dot beside the settings, `at` where it is
262/// across the bar), or `focus`, which hands the keyboard back to the page
263/// after a click on the bar.
264pub fn act(app: &AppHandle, action: &str, at: Option<f64>) -> Result<(), String> {
Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9' into claude/cool-newton-7nm8r9265 let site = site(app).ok_or("there is no page")?;
266 match action {
267 "back" => site.eval("history.back()").map_err(|error| error.to_string())?,
268 "forward" => site.eval("history.forward()").map_err(|error| error.to_string())?,
269 "search" => app.emit_to(EventTarget::webview(SITE), SEARCH_EVENT, ()).map_err(|error| error.to_string())?,
Merge branch 'main' into rm-agents270 // The app's settings, a click away rather than only in its menu:
271 // a dialog the site draws over itself.
The desktop app's settings are a button in its title bar. A Settings button sits at the right of the bar, before the window's own buttons (and at its end on macOS), so the runner and the rest are a click away instead of only in the File menu, the tray or the account menu.272 "settings" => {
273 crate::settings::open(app);
274 return Ok(());
275 }
Merge branch 'main' into rm-agents276 // The runner's menu, hanging under its dot beside the settings button.
The desktop app is 0.3.4, and the runner has a window of its own. Beside the title bar's Settings button a chip says what the runner is doing, live, with a dot in its colour (Runner running, Running a job, Running 2 jobs, Runner starting, Runner updating, Runner stopped, Runner offline, Runner needs you), as Docker Desktop shows its engine; in a narrow window only the dot shows. It opens the Runner window, as the tray's runner row now does: at its top what the whole runner is doing, with what it is running, its version and workspace, and its own Stop or Start; then each kind of job's card as before; then its setup (running jobs here at all, how jobs run, the workspace, the computer with Check for runner updates and View on g1t); and its log, open and following along as it is written. Settings keeps the runner as one line, what it is doing with Open runner, and each window saves only its own settings over what the app has now, so neither undoes the other's. The chip's words are diagnosis.rs's, the Runner window is made off the main thread as Settings now is, and it has a capability of its own with only the commands it uses; the title bar may now ask the runner's status. Two things fixed on the way: a settings row hidden until it applies, Restart to change the title bar, showed all the time, since its row's display beat the hidden attribute; and in a narrow window the title bar's buttons ran over the search, which now shrinks instead. The desktop guide describes the chip, the Runner window and where each runner setting now is.277 "runner" => {
Merge branch 'main' into rm-agents278 crate::settings::runner_menu(app, true, at);
The desktop app is 0.3.4, and the runner has a window of its own. Beside the title bar's Settings button a chip says what the runner is doing, live, with a dot in its colour (Runner running, Running a job, Running 2 jobs, Runner starting, Runner updating, Runner stopped, Runner offline, Runner needs you), as Docker Desktop shows its engine; in a narrow window only the dot shows. It opens the Runner window, as the tray's runner row now does: at its top what the whole runner is doing, with what it is running, its version and workspace, and its own Stop or Start; then each kind of job's card as before; then its setup (running jobs here at all, how jobs run, the workspace, the computer with Check for runner updates and View on g1t); and its log, open and following along as it is written. Settings keeps the runner as one line, what it is doing with Open runner, and each window saves only its own settings over what the app has now, so neither undoes the other's. The chip's words are diagnosis.rs's, the Runner window is made off the main thread as Settings now is, and it has a capability of its own with only the commands it uses; the title bar may now ask the runner's status. Two things fixed on the way: a settings row hidden until it applies, Restart to change the title bar, showed all the time, since its row's display beat the hidden attribute; and in a narrow window the title bar's buttons ran over the search, which now shrinks instead. The desktop guide describes the chip, the Runner window and where each runner setting now is.279 return Ok(());
280 }
Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9' into claude/cool-newton-7nm8r9281 "focus" => {}
282 other => return Err(format!("the bar cannot {other}")),
283 }
284 let _ = site.set_focus();
285 Ok(())
286}

This file's history is long; its oldest lines are credited to the oldest commit read.