1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
//! Left-bottom panel: Global Environments — activate one for substitution,
//! link one to the active collection, edit variables, and load/save `.vars`
//! files. Resolved secret values are shown but never editable (their provider
//! reference is the source of truth).
use eframe::egui::{self, RichText};
use crate::environment::{EnvVar, ValueSource};
use crate::i18n::Strings;
use super::app::{Dialog, GuiApp, OpenKind, PromptKind, SaveKind};
fn source_label(source: ValueSource) -> Option<&'static str> {
match source {
ValueSource::Literal => None,
ValueSource::ProcessEnv => Some("env"),
ValueSource::Ssm => Some("ssm"),
ValueSource::OnePassword => Some("1password"),
ValueSource::Unknown => Some("?"),
}
}
pub fn ui(app: &mut GuiApp, ui: &mut egui::Ui) {
let theme = app.theme;
let ci = app.active_ci();
let (
lbl_environments,
lbl_load,
tip_load,
tip_new,
lbl_no_envs,
lbl_active,
tip_active,
lbl_linked,
tip_linked,
lbl_delete,
lbl_save,
) = {
let s = &app.strings;
(
s.gui_environments,
s.gui_load_ellipsis,
s.gui_load_vars_tooltip,
s.gui_new_environment,
s.gui_no_environments,
s.gui_active,
s.gui_active_tooltip,
s.gui_linked,
s.gui_linked_tooltip,
s.gui_delete,
s.gui_save_ellipsis,
)
};
super::widgets::panel_header(ui, &theme, lbl_environments, |ui| {
if ui.button(lbl_load).on_hover_text(tip_load).clicked() {
super::menu::open_via_picker(app, OpenKind::Environment);
}
if ui
.button(super::icons::PLUS)
.on_hover_text(tip_new)
.clicked()
{
app.dialog = Some(Dialog::Prompt {
kind: PromptKind::NewEnvName,
text: String::new(),
});
}
});
ui.separator();
// Collect ids up front so we can mutate the session inside the loop.
let env_ids: Vec<u64> = app.session.global_envs.iter().map(|e| e.id).collect();
let active = app.session.active_env_id;
let linked = app.session.collections[ci].linked_env_id;
// One-shot: consumed on the frame that shows the row, so a later manual
// collapse isn't fought by a request that never expires.
let reveal_target = app.reveal_env;
let mut activate: Option<u64> = None;
let mut link: Option<u64> = None;
let mut delete: Option<u64> = None;
let mut save: Option<u64> = None;
let mut changed = false;
egui::ScrollArea::vertical()
.auto_shrink([false, false])
.show(ui, |ui| {
// Truncate long environment names so they can't report a content
// width wider than the panel (see the note in `requests.rs` — an
// over-wide panel leaves an unpainted strip while being dragged).
ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Truncate);
if env_ids.is_empty() {
ui.add_space(6.0);
ui.colored_label(theme.dim, lbl_no_envs);
}
for id in env_ids {
let idx = match app.session.global_envs.iter().position(|e| e.id == id) {
Some(i) => i,
None => continue,
};
let is_active = active == Some(id);
let is_linked = linked == Some(id);
let name = app.session.global_envs[idx].name.clone();
let from_git = app.session.global_envs[idx].git_origin.is_some();
// The active environment is marked the way the terminal UI
// marks it — a leading tick in the "ok" colour with the name to
// match — plus a filled band behind the row, because a GUI list
// is sparse enough that colour alone is easy to skim past.
let header = format!(
"{}{}{}",
if is_active {
format!("{} ", super::icons::PASS)
} else {
String::new()
},
if from_git {
format!("{} ", super::icons::GIT)
} else {
String::new()
},
name,
);
let mut text = RichText::new(header);
if is_active {
text = text.color(theme.ok).strong();
} else {
text = text.color(theme.text);
}
// Opening a `.vars` file from the workspace tree reveals it
// here: loading it alone would leave the user looking at a
// collapsed row and no sign anything had happened.
let reveal = reveal_target == Some(id);
let header = super::widgets::tree_header_marked(
ui,
("env", id),
false,
reveal,
text,
is_active.then_some(theme.ok),
|ui| {
// Wrap the action buttons so their fixed widths can't
// overflow (and report an over-wide content size) when
// the panel is dragged narrow — they flow onto a second
// line instead. This is also robust to longer
// translations of the button labels.
ui.horizontal_wrapped(|ui| {
if super::widgets::selectable(ui, is_active, lbl_active)
.on_hover_text(tip_active)
.clicked()
{
activate = Some(id);
}
if super::widgets::selectable(ui, is_linked, lbl_linked)
.on_hover_text(tip_linked)
.clicked()
{
link = Some(id);
}
if ui.button(lbl_save).clicked() {
save = Some(id);
}
if ui
.button(RichText::new(lbl_delete).color(theme.err))
.clicked()
{
delete = Some(id);
}
});
ui.add_space(4.0);
if var_editor(
ui,
&theme,
&app.strings,
&mut app.session.global_envs[idx].vars,
) {
changed = true;
}
},
);
// Scroll the revealed row into view — the panel is short and a
// workspace can hold more environments than fit in it.
if reveal {
header.scroll_to_me(Some(egui::Align::Center));
}
}
});
// Whether or not the row was found, the request is spent after one frame.
app.reveal_env = None;
if let Some(id) = activate {
app.session.set_active_env(Some(id));
}
if let Some(id) = link {
app.session.set_linked_env(ci, Some(id));
}
if let Some(id) = delete {
app.session.delete_environment(id);
}
if let Some(id) = save {
super::menu::save_via_picker(app, SaveKind::Environment(id));
}
if changed {
for col in &mut app.session.collections {
col.invalidate_request_json();
}
}
}
/// Editable variable table for one environment. Literal values are editable in
/// place; secret-backed values show their provider and resolution state and are
/// not directly editable (the `.vars` reference is the source of truth).
fn var_editor(
ui: &mut egui::Ui,
theme: &super::theme::GuiTheme,
s: &Strings,
vars: &mut Vec<EnvVar>,
) -> bool {
let mut changed = false;
let mut remove: Option<usize> = None;
// Give the key ~40% of the free width so it grows with the panel instead of
// staying a fixed sliver next to the filling value (see `split_key_width`).
let key_w = super::widgets::split_key_width(ui, 42.0);
egui::Grid::new("env_vars")
.num_columns(2)
.spacing([8.0, 4.0])
.striped(true)
.min_col_width(0.0)
.show(ui, |ui| {
for i in 0..vars.len() {
let source = vars[i].source;
if super::widgets::sized_key(
ui,
key_w,
&mut vars[i].key,
s.gui_hint_key_upper,
theme.text,
)
.changed()
{
changed = true;
}
// The value cell is the grid's last column so it stretches to
// fill the panel; the remove ✕ is right-aligned inside it (see
// the note in `widgets::kv_editor`).
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if ui
.button(RichText::new(super::icons::CLOSE).color(theme.err))
.clicked()
{
remove = Some(i);
}
match source_label(source) {
None => {
// Literal: editable value.
if ui
.add(
egui::TextEdit::singleline(&mut vars[i].value)
.desired_width(f32::INFINITY)
.hint_text(s.gui_hint_value),
)
.changed()
{
vars[i].raw = vars[i].value.clone();
vars[i].modified = true;
changed = true;
}
}
Some(provider) => {
ui.with_layout(
egui::Layout::left_to_right(egui::Align::Center),
|ui| {
ui.label(
RichText::new(format!("{{{{ {provider} }}}}"))
.color(theme.subst),
);
if vars[i].loading {
ui.spinner();
ui.colored_label(theme.pending, s.gui_resolving);
} else if vars[i].resolved {
ui.colored_label(theme.dim, "••••••");
} else {
ui.colored_label(theme.err, s.gui_unresolved);
}
},
);
}
}
});
ui.end_row();
}
});
if let Some(i) = remove {
vars.remove(i);
changed = true;
}
if ui.button(s.gui_add_variable).clicked() {
vars.push(EnvVar::user(String::new(), String::new()));
changed = true;
}
changed
}