Compare commits

...

3 Commits

Author SHA1 Message Date
Chris Punches
36164c01a6 Add working directory selector to config view, suppress GTK baseline warning
- Config view gains a CWD subsection with entry, browse, and apply
- Suppress spurious GtkDropDown baseline warning via custom log writer
- Move extract_path before first use, simplify path status indicators
2026-03-17 01:58:42 -04:00
Chris Punches
e55da16193 various fixes on path validation 2026-03-16 23:25:49 -04:00
Chris Punches
e852b7e182 Add unit validation with red highlighting and status bar feedback
Units that cannot be executed are shown in red on both the Units and
Plans tabs.  Validation checks target existence/executability, shell
definition lookup, working directory existence, rectifier
existence/executability, and environment file existence.

The unit properties dialog applies live validation with GTK error
styling on each field and reports issues to the status bar as the
user edits.  Selecting a unit or task re-runs validation so the
status bar always reflects the selected item.

Also separates plan dirty state from unit editor dirty state so
editing unit properties no longer marks the plan as unsaved.
2026-03-16 03:21:25 -04:00
13 changed files with 441 additions and 60 deletions

View File

@@ -19,10 +19,23 @@
#include <gtk/gtk.h> #include <gtk/gtk.h>
#include <iostream> #include <iostream>
#include <string> #include <string>
#include <cstring>
#include "models/project.h" #include "models/project.h"
#include "models/grex_config.h" #include "models/grex_config.h"
#include "views/main_window.h" #include "views/main_window.h"
// Suppress known GTK4 bug: GtkDropDown's internal arrow GtkImage reports
// INT_MIN baselines instead of -1, triggering a spurious warning.
static GLogWriterOutput grex_log_writer(GLogLevelFlags level, const GLogField* fields,
gsize n_fields, gpointer) {
for (gsize i = 0; i < n_fields; i++) {
if (std::strcmp(fields[i].key, "MESSAGE") == 0 &&
std::strstr(static_cast<const char*>(fields[i].value), "reported baselines") != nullptr)
return G_LOG_WRITER_HANDLED;
}
return g_log_writer_default(level, fields, n_fields, nullptr);
}
static grex::Project* g_project = nullptr; static grex::Project* g_project = nullptr;
static grex::GrexConfig* g_grex_config = nullptr; static grex::GrexConfig* g_grex_config = nullptr;
static grex::MainWindow* g_main_window = nullptr; static grex::MainWindow* g_main_window = nullptr;
@@ -65,6 +78,7 @@ static void on_activate(GtkApplication* app, gpointer) {
} }
int main(int argc, char* argv[]) { int main(int argc, char* argv[]) {
g_log_set_writer_func(grex_log_writer, nullptr, nullptr);
auto* app = gtk_application_new("org.darkhorselinux.grex", G_APPLICATION_HANDLES_COMMAND_LINE); auto* app = gtk_application_new("org.darkhorselinux.grex", G_APPLICATION_HANDLES_COMMAND_LINE);
GOptionEntry entries[] = { GOptionEntry entries[] = {

View File

@@ -18,6 +18,7 @@
#include "models/project.h" #include "models/project.h"
#include <set> #include <set>
#include <unistd.h>
namespace grex { namespace grex {
namespace fs = std::filesystem; namespace fs = std::filesystem;
@@ -50,6 +51,82 @@ fs::path Project::resolved_shells_path() const {
return root / sp; return root / sp;
} }
// Extract the file path portion before any arguments
static std::string extract_path(const std::string& s) {
auto pos = s.find(' ');
return pos == std::string::npos ? s : s.substr(0, pos);
}
bool Project::check_unit_valid(const Unit& u) {
namespace fs = std::filesystem;
if (u.target.empty()) {
report_status("Unit '" + u.name + "': target is not defined");
return false;
}
auto target_path = extract_path(u.target);
if (!fs::exists(target_path)) {
report_status("Unit '" + u.name + "': target does not exist: " + target_path);
return false;
}
if (access(target_path.c_str(), X_OK) != 0) {
report_status("Unit '" + u.name + "': target is not executable: " + target_path);
return false;
}
if (u.is_shell_command) {
bool found = false;
for (auto& s : all_shells()) {
if (s.name == u.shell_definition) { found = true; break; }
}
if (!found) {
report_status("Unit '" + u.name + "': shell definition not found: " + u.shell_definition);
return false;
}
}
if (u.set_working_directory) {
if (u.working_directory.empty()) {
report_status("Unit '" + u.name + "': working directory is not defined");
return false;
}
if (!fs::is_directory(u.working_directory)) {
report_status("Unit '" + u.name + "': working directory does not exist: " + u.working_directory);
return false;
}
}
if (u.rectify) {
if (u.rectifier.empty()) {
report_status("Unit '" + u.name + "': rectifier is not defined");
return false;
}
auto rectifier_path = extract_path(u.rectifier);
if (!fs::exists(rectifier_path)) {
report_status("Unit '" + u.name + "': rectifier does not exist: " + rectifier_path);
return false;
}
if (access(rectifier_path.c_str(), X_OK) != 0) {
report_status("Unit '" + u.name + "': rectifier is not executable: " + rectifier_path);
return false;
}
}
if (u.supply_environment) {
if (u.environment.empty()) {
report_status("Unit '" + u.name + "': environment file is not defined");
return false;
}
auto env_path = extract_path(u.environment);
if (!fs::exists(env_path)) {
report_status("Unit '" + u.name + "': environment file does not exist: " + env_path);
return false;
}
}
return true;
}
Unit* Project::find_unit(const std::string& unit_name) { Unit* Project::find_unit(const std::string& unit_name) {
for (auto& uf : unit_files) { for (auto& uf : unit_files) {
auto* u = uf.find_unit(unit_name); auto* u = uf.find_unit(unit_name);

View File

@@ -50,6 +50,7 @@ public:
std::filesystem::path resolved_units_dir() const; std::filesystem::path resolved_units_dir() const;
std::filesystem::path resolved_shells_path() const; std::filesystem::path resolved_shells_path() const;
bool check_unit_valid(const Unit& u);
Unit* find_unit(const std::string& unit_name); Unit* find_unit(const std::string& unit_name);
UnitFile* find_unit_file(const std::string& unit_name); UnitFile* find_unit_file(const std::string& unit_name);
bool is_unit_name_taken(const std::string& name, const Unit* exclude = nullptr) const; bool is_unit_name_taken(const std::string& name, const Unit* exclude = nullptr) const;

View File

@@ -19,6 +19,7 @@
#include "util/unit_properties_dialog.h" #include "util/unit_properties_dialog.h"
#include <cstdlib> #include <cstdlib>
#include <fstream> #include <fstream>
#include <unistd.h>
namespace grex { namespace grex {
@@ -93,14 +94,22 @@ struct DialogState {
}; };
static void update_sensitivity(DialogState* s); static void update_sensitivity(DialogState* s);
static void validate_fields(DialogState* s);
static std::string extract_path(const std::string& s) {
auto pos = s.find(' ');
return pos == std::string::npos ? s : s.substr(0, pos);
}
static gboolean dlg_switch_state_set_cb(GtkSwitch* sw, gboolean new_state, gpointer data) { static gboolean dlg_switch_state_set_cb(GtkSwitch* sw, gboolean new_state, gpointer data) {
auto* b = static_cast<DlgSwitchBinding*>(data); auto* b = static_cast<DlgSwitchBinding*>(data);
gtk_switch_set_state(sw, new_state); gtk_switch_set_state(sw, new_state);
if (b->state) { if (b->state) {
*b->target = new_state; *b->target = new_state;
if (!b->state->loading) if (!b->state->loading) {
update_sensitivity(b->state); update_sensitivity(b->state);
validate_fields(b->state);
}
} }
return TRUE; return TRUE;
} }
@@ -273,7 +282,7 @@ static GtkWidget* make_file_row(DialogState* s, GtkWidget* grid, int row, const
return; return;
} }
namespace fs = std::filesystem; namespace fs = std::filesystem;
fs::path p(raw); fs::path p(extract_path(raw));
if (p.is_relative()) { if (p.is_relative()) {
auto root = fbd->state->project->resolved_project_root(); auto root = fbd->state->project->resolved_project_root();
if (!root.empty()) if (!root.empty())
@@ -361,6 +370,111 @@ static void update_sensitivity(DialogState* s) {
show(active && s->working_copy.supply_environment, {s->label_environment, s->box_environment}); show(active && s->working_copy.supply_environment, {s->label_environment, s->box_environment});
} }
static void validate_fields(DialogState* s) {
if (s->loading) return;
auto& u = s->working_copy;
namespace fs = std::filesystem;
auto set_valid = [](GtkWidget* w, bool valid) {
if (valid)
gtk_widget_remove_css_class(w, "error");
else
gtk_widget_add_css_class(w, "error");
};
std::string error_msg;
// Target: must be defined, exist, and be executable
{
bool valid = true;
if (u.target.empty()) {
valid = false;
if (error_msg.empty()) error_msg = "Target is not defined";
} else {
auto tp = extract_path(u.target);
if (!fs::exists(tp)) {
valid = false;
if (error_msg.empty()) error_msg = "Target does not exist: " + tp;
} else if (access(tp.c_str(), X_OK) != 0) {
valid = false;
if (error_msg.empty()) error_msg = "Target is not executable: " + tp;
}
}
set_valid(s->entry_target, valid);
}
// Shell definition: if shell command, must reference a known shell
if (u.is_shell_command) {
bool valid = false;
for (auto& sh : *s->shells) {
if (sh.name == u.shell_definition) { valid = true; break; }
}
set_valid(s->dropdown_shell_def, valid);
if (!valid && error_msg.empty())
error_msg = "Shell definition not found: " + u.shell_definition;
} else {
set_valid(s->dropdown_shell_def, true);
}
// Working directory: must exist as a directory if enabled
if (u.set_working_directory) {
bool valid = true;
if (u.working_directory.empty()) {
valid = false;
if (error_msg.empty()) error_msg = "Working directory is not defined";
} else if (!fs::is_directory(u.working_directory)) {
valid = false;
if (error_msg.empty()) error_msg = "Working directory does not exist: " + u.working_directory;
}
set_valid(s->entry_workdir, valid);
} else {
set_valid(s->entry_workdir, true);
}
// Rectifier: must exist and be executable if rectification enabled
if (u.rectify) {
bool valid = true;
if (u.rectifier.empty()) {
valid = false;
if (error_msg.empty()) error_msg = "Rectifier is not defined";
} else {
auto rp = extract_path(u.rectifier);
if (!fs::exists(rp)) {
valid = false;
if (error_msg.empty()) error_msg = "Rectifier does not exist: " + rp;
} else if (access(rp.c_str(), X_OK) != 0) {
valid = false;
if (error_msg.empty()) error_msg = "Rectifier is not executable: " + rp;
}
}
set_valid(s->entry_rectifier, valid);
} else {
set_valid(s->entry_rectifier, true);
}
// Environment file: must exist if supply_environment active
if (u.supply_environment) {
bool valid = true;
if (u.environment.empty()) {
valid = false;
if (error_msg.empty()) error_msg = "Environment file is not defined";
} else {
auto ep = extract_path(u.environment);
if (!fs::exists(ep)) {
valid = false;
if (error_msg.empty()) error_msg = "Environment file does not exist: " + ep;
}
}
set_valid(s->entry_environment, valid);
} else {
set_valid(s->entry_environment, true);
}
if (!error_msg.empty())
s->project->report_status(error_msg);
}
static void populate_and_connect(DialogState* s) { static void populate_and_connect(DialogState* s) {
auto& u = s->working_copy; auto& u = s->working_copy;
@@ -390,6 +504,7 @@ static void populate_and_connect(DialogState* s) {
gtk_editable_set_text(GTK_EDITABLE(s->entry_environment), u.environment.c_str()); gtk_editable_set_text(GTK_EDITABLE(s->entry_environment), u.environment.c_str());
update_sensitivity(s); update_sensitivity(s);
s->loading = false; s->loading = false;
validate_fields(s);
// Entry bindings // Entry bindings
auto bind_entry = [s](GtkWidget* entry, std::string* target) { auto bind_entry = [s](GtkWidget* entry, std::string* target) {
@@ -398,6 +513,7 @@ static void populate_and_connect(DialogState* s) {
g_signal_connect(entry, "changed", G_CALLBACK(+[](GtkEditable* e, gpointer d) { g_signal_connect(entry, "changed", G_CALLBACK(+[](GtkEditable* e, gpointer d) {
auto* eb = static_cast<DlgEntryBinding*>(d); auto* eb = static_cast<DlgEntryBinding*>(d);
*eb->target = gtk_editable_get_text(e); *eb->target = gtk_editable_get_text(e);
validate_fields(eb->state);
}), eb); }), eb);
}; };
bind_entry(s->entry_name, &u.name); bind_entry(s->entry_name, &u.name);
@@ -430,6 +546,7 @@ static void populate_and_connect(DialogState* s) {
auto idx = gtk_drop_down_get_selected(GTK_DROP_DOWN(obj)); auto idx = gtk_drop_down_get_selected(GTK_DROP_DOWN(obj));
if (idx < s->shells->size()) if (idx < s->shells->size())
s->working_copy.shell_definition = (*s->shells)[idx].name; s->working_copy.shell_definition = (*s->shells)[idx].name;
validate_fields(s);
}), s); }), s);
} }

View File

@@ -19,8 +19,12 @@
#include "views/config_view.h" #include "views/config_view.h"
#include "util/unsaved_dialog.h" #include "util/unsaved_dialog.h"
#include "util/json_helpers.h" #include "util/json_helpers.h"
#include <cerrno>
#include <climits>
#include <cstdlib> #include <cstdlib>
#include <cstring>
#include <filesystem> #include <filesystem>
#include <unistd.h>
namespace grex { namespace grex {
@@ -123,6 +127,124 @@ ConfigView::ConfigView(Project& project) : project_(project) {
gtk_grid_set_column_spacing(GTK_GRID(vars_grid_), 16); gtk_grid_set_column_spacing(GTK_GRID(vars_grid_), 16);
gtk_box_append(GTK_BOX(vars_box), vars_grid_); gtk_box_append(GTK_BOX(vars_box), vars_grid_);
// --- Current Working Directory subsection ---
gtk_box_append(GTK_BOX(vars_box), gtk_separator_new(GTK_ORIENTATION_HORIZONTAL));
auto* cwd_label = gtk_label_new(nullptr);
gtk_label_set_markup(GTK_LABEL(cwd_label), "<b>Current Working Directory</b>");
gtk_label_set_xalign(GTK_LABEL(cwd_label), 0.0f);
gtk_box_append(GTK_BOX(vars_box), cwd_label);
auto* cwd_desc = gtk_label_new("Set the working directory for this session. Relative paths in unit validation resolve against this.");
gtk_label_set_xalign(GTK_LABEL(cwd_desc), 0.0f);
gtk_label_set_wrap(GTK_LABEL(cwd_desc), TRUE);
gtk_widget_add_css_class(cwd_desc, "dim-label");
gtk_box_append(GTK_BOX(vars_box), cwd_desc);
auto* cwd_grid = gtk_grid_new();
gtk_grid_set_row_spacing(GTK_GRID(cwd_grid), 8);
gtk_grid_set_column_spacing(GTK_GRID(cwd_grid), 12);
auto* cwd_key = gtk_label_new("Directory");
gtk_label_set_xalign(GTK_LABEL(cwd_key), 1.0f);
gtk_widget_set_size_request(cwd_key, 140, -1);
gtk_widget_add_css_class(cwd_key, "dim-label");
gtk_grid_attach(GTK_GRID(cwd_grid), cwd_key, 0, 0, 1, 1);
auto* cwd_hbox = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 4);
gtk_widget_set_hexpand(cwd_hbox, TRUE);
char cwd_buf[PATH_MAX];
const char* initial_cwd = getcwd(cwd_buf, sizeof(cwd_buf));
cwd_entry_ = gtk_entry_new();
gtk_widget_set_hexpand(cwd_entry_, TRUE);
gtk_editable_set_text(GTK_EDITABLE(cwd_entry_), initial_cwd ? initial_cwd : "");
gtk_box_append(GTK_BOX(cwd_hbox), cwd_entry_);
auto* cwd_browse = gtk_button_new_with_label("Browse...");
gtk_box_append(GTK_BOX(cwd_hbox), cwd_browse);
auto* cwd_apply = gtk_button_new_with_label("Apply");
gtk_box_append(GTK_BOX(cwd_hbox), cwd_apply);
gtk_grid_attach(GTK_GRID(cwd_grid), cwd_hbox, 1, 0, 1, 1);
cwd_status_ = gtk_label_new(nullptr);
gtk_label_set_xalign(GTK_LABEL(cwd_status_), 0.0f);
gtk_widget_add_css_class(cwd_status_, "dim-label");
gtk_grid_attach(GTK_GRID(cwd_grid), cwd_status_, 1, 1, 1, 1);
gtk_box_append(GTK_BOX(vars_box), cwd_grid);
// Apply button — chdir and report
g_signal_connect(cwd_apply, "clicked", G_CALLBACK(+[](GtkButton*, gpointer d) {
auto* self = static_cast<ConfigView*>(d);
auto dir = std::string(gtk_editable_get_text(GTK_EDITABLE(self->cwd_entry_)));
if (dir.empty()) {
self->project_.report_status("Error: working directory is empty");
return;
}
if (chdir(dir.c_str()) == 0) {
char buf[PATH_MAX];
const char* actual = getcwd(buf, sizeof(buf));
gtk_editable_set_text(GTK_EDITABLE(self->cwd_entry_), actual ? actual : dir.c_str());
gtk_label_set_text(GTK_LABEL(self->cwd_status_), "");
self->project_.report_status("Working directory set to: " + std::string(actual ? actual : dir.c_str()));
self->update_resolved_labels();
} else {
auto msg = "Error: cannot chdir to '" + dir + "': " + std::string(strerror(errno));
self->project_.report_status(msg);
gtk_label_set_markup(GTK_LABEL(self->cwd_status_),
("<span foreground=\"#cc0000\">" + msg + "</span>").c_str());
}
}), this);
// Browse button — folder chooser
g_signal_connect(cwd_browse, "clicked", G_CALLBACK(+[](GtkButton*, gpointer d) {
auto* self = static_cast<ConfigView*>(d);
auto* window = GTK_WINDOW(gtk_widget_get_ancestor(self->root_, GTK_TYPE_WINDOW));
auto* dialog = gtk_file_dialog_new();
gtk_file_dialog_set_title(dialog, "Select Working Directory");
struct CwdBrowseCtx { ConfigView* view; };
auto* ctx = new CwdBrowseCtx{self};
gtk_file_dialog_select_folder(dialog, window, nullptr,
+[](GObject* source, GAsyncResult* res, gpointer data) {
auto* ctx = static_cast<CwdBrowseCtx*>(data);
GError* error = nullptr;
auto* file = gtk_file_dialog_select_folder_finish(GTK_FILE_DIALOG(source), res, &error);
if (file) {
auto* path = g_file_get_path(file);
gtk_editable_set_text(GTK_EDITABLE(ctx->view->cwd_entry_), path);
g_free(path);
g_object_unref(file);
} else if (error) {
g_error_free(error);
}
delete ctx;
}, ctx);
}), this);
// Enter in entry triggers apply
g_signal_connect(cwd_entry_, "activate", G_CALLBACK(+[](GtkEntry*, gpointer d) {
auto* self = static_cast<ConfigView*>(d);
auto dir = std::string(gtk_editable_get_text(GTK_EDITABLE(self->cwd_entry_)));
if (dir.empty()) return;
if (chdir(dir.c_str()) == 0) {
char buf[PATH_MAX];
const char* actual = getcwd(buf, sizeof(buf));
gtk_editable_set_text(GTK_EDITABLE(self->cwd_entry_), actual ? actual : dir.c_str());
gtk_label_set_text(GTK_LABEL(self->cwd_status_), "");
self->project_.report_status("Working directory set to: " + std::string(actual ? actual : dir.c_str()));
self->update_resolved_labels();
} else {
auto msg = "Error: cannot chdir to '" + dir + "': " + std::string(strerror(errno));
self->project_.report_status(msg);
gtk_label_set_markup(GTK_LABEL(self->cwd_status_),
("<span foreground=\"#cc0000\">" + msg + "</span>").c_str());
}
}), this);
gtk_frame_set_child(GTK_FRAME(vars_frame), vars_box); gtk_frame_set_child(GTK_FRAME(vars_frame), vars_box);
gtk_box_append(GTK_BOX(config_content_), vars_frame); gtk_box_append(GTK_BOX(config_content_), vars_frame);
@@ -297,14 +419,12 @@ void ConfigView::update_resolved_labels() {
// Status indicator // Status indicator
bool path_ok = (display != "(unresolved)") && bool path_ok = (display != "(unresolved)") &&
(std::filesystem::exists(display) || std::filesystem::is_directory(display)); (std::filesystem::exists(display) || std::filesystem::is_directory(display));
auto* indicator = gtk_label_new(nullptr); auto* indicator = gtk_label_new(path_ok ? "OK" : "ERR");
gtk_label_set_xalign(GTK_LABEL(indicator), 0.5f); gtk_label_set_xalign(GTK_LABEL(indicator), 0.5f);
if (display == "(unresolved)") if (path_ok)
gtk_label_set_markup(GTK_LABEL(indicator), "<span foreground=\"#cc0000\">\u2718</span>"); gtk_widget_add_css_class(indicator, "success");
else if (path_ok)
gtk_label_set_markup(GTK_LABEL(indicator), "<span foreground=\"#4e9a06\">\u2714</span>");
else else
gtk_label_set_markup(GTK_LABEL(indicator), "<span foreground=\"#cc0000\">\u2718</span>"); gtk_widget_add_css_class(indicator, "error");
gtk_grid_attach(GTK_GRID(resolved_grid_), indicator, 1, row, 1, 1); gtk_grid_attach(GTK_GRID(resolved_grid_), indicator, 1, row, 1, 1);
auto* val_label = gtk_label_new(nullptr); auto* val_label = gtk_label_new(nullptr);

View File

@@ -19,6 +19,7 @@
#pragma once #pragma once
#include <gtk/gtk.h> #include <gtk/gtk.h>
#include <vector> #include <vector>
#include <string>
#include "models/project.h" #include "models/project.h"
namespace grex { namespace grex {
@@ -58,6 +59,8 @@ private:
GtkWidget* btn_close_; GtkWidget* btn_close_;
GtkWidget* btn_save_; GtkWidget* btn_save_;
GtkWidget* config_content_; GtkWidget* config_content_;
GtkWidget* cwd_entry_;
GtkWidget* cwd_status_;
void build_config_fields(); void build_config_fields();
void build_variables_section(); void build_variables_section();

View File

@@ -225,16 +225,7 @@ void MainWindow::refresh_visible_page() {
// Refresh the newly visible page // Refresh the newly visible page
if (visible == plan_view_->widget()) { if (visible == plan_view_->widget()) {
project_.load_all_units(); plan_view_->reload_plan_from_disk();
if (!project_.plans.empty()) {
auto& plan = project_.plans[0];
try {
plan = Plan::load(plan.filepath);
} catch (const std::exception& e) {
project_.report_status(std::string("Error reloading plan: ") + e.what());
}
}
plan_view_->refresh();
} else if (visible == units_view_->widget()) { } else if (visible == units_view_->widget()) {
units_view_->refresh(); units_view_->refresh();
} else if (visible == shells_view_->widget()) { } else if (visible == shells_view_->widget()) {

View File

@@ -135,11 +135,13 @@ PlanView::PlanView(Project& project, GrexConfig& grex_config)
gtk_frame_set_child(GTK_FRAME(task_ctrl_frame_), task_ctrl_box); gtk_frame_set_child(GTK_FRAME(task_ctrl_frame_), task_ctrl_box);
gtk_box_append(GTK_BOX(unit_editor_->content_box()), task_ctrl_frame_); gtk_box_append(GTK_BOX(unit_editor_->content_box()), task_ctrl_frame_);
// Name change callback to refresh the task list row label // Callback fired after any unit edit in the dialog
unit_editor_->set_name_changed_callback([](const std::string&, void* data) { unit_editor_->set_unit_edited_callback([](const std::string&, bool name_changed, void* data) {
auto* self = static_cast<PlanView*>(data); auto* self = static_cast<PlanView*>(data);
if (name_changed) {
self->plan_dirty_ = true; self->plan_dirty_ = true;
gtk_widget_add_css_class(self->btn_save_plan_, "suggested-action"); gtk_widget_add_css_class(self->btn_save_plan_, "suggested-action");
}
if (self->current_task_idx_ >= 0) if (self->current_task_idx_ >= 0)
self->refresh_task_row(self->current_task_idx_); self->refresh_task_row(self->current_task_idx_);
}, this); }, this);
@@ -167,7 +169,7 @@ PlanView::PlanView(Project& project, GrexConfig& grex_config)
g_signal_connect(btn_refresh, "clicked", G_CALLBACK(+[](GtkButton*, gpointer d) { g_signal_connect(btn_refresh, "clicked", G_CALLBACK(+[](GtkButton*, gpointer d) {
auto* self = static_cast<PlanView*>(d); auto* self = static_cast<PlanView*>(d);
self->refresh(); self->reload_plan_from_disk();
}), this); }), this);
g_signal_connect(btn_delete_plan, "clicked", G_CALLBACK(on_delete_plan), this); g_signal_connect(btn_delete_plan, "clicked", G_CALLBACK(on_delete_plan), this);
@@ -195,8 +197,17 @@ void PlanView::populate_task_list() {
for (auto& task : plan->tasks) { for (auto& task : plan->tasks) {
auto* row = gtk_list_box_row_new(); auto* row = gtk_list_box_row_new();
auto text = std::string("\u25B6 ") + task.name; auto* label = gtk_label_new(nullptr);
auto* label = gtk_label_new(text.c_str()); auto* escaped = g_markup_escape_text(task.name.c_str(), -1);
Unit* unit = project_.find_unit(task.name);
bool valid = unit && project_.check_unit_valid(*unit);
std::string markup;
if (valid)
markup = std::string("\u25B6 ") + escaped;
else
markup = std::string("<span foreground=\"red\">\u25B6 ") + escaped + "</span>";
g_free(escaped);
gtk_label_set_markup(GTK_LABEL(label), markup.c_str());
gtk_label_set_xalign(GTK_LABEL(label), 0.0f); gtk_label_set_xalign(GTK_LABEL(label), 0.0f);
gtk_widget_set_margin_start(label, 8); gtk_widget_set_margin_start(label, 8);
gtk_widget_set_margin_end(label, 8); gtk_widget_set_margin_end(label, 8);
@@ -217,8 +228,17 @@ void PlanView::refresh_task_row(int idx) {
auto* label = gtk_list_box_row_get_child(GTK_LIST_BOX_ROW(row)); auto* label = gtk_list_box_row_get_child(GTK_LIST_BOX_ROW(row));
if (GTK_IS_LABEL(label)) { if (GTK_IS_LABEL(label)) {
auto text = std::string("\u25B6 ") + plan->tasks[idx].name; auto& task = plan->tasks[idx];
gtk_label_set_text(GTK_LABEL(label), text.c_str()); auto* escaped = g_markup_escape_text(task.name.c_str(), -1);
Unit* unit = project_.find_unit(task.name);
bool valid = unit && project_.check_unit_valid(*unit);
std::string markup;
if (valid)
markup = std::string("\u25B6 ") + escaped;
else
markup = std::string("<span foreground=\"red\">\u25B6 ") + escaped + "</span>";
g_free(escaped);
gtk_label_set_markup(GTK_LABEL(label), markup.c_str());
} }
} }
@@ -237,6 +257,10 @@ void PlanView::select_task(int idx) {
project_.load_all_units(); project_.load_all_units();
Unit* unit = project_.find_unit(task.name); Unit* unit = project_.find_unit(task.name);
if (unit)
project_.check_unit_valid(*unit);
else
project_.report_status("Unit not found: " + task.name);
unit_editor_->load(&task, unit); unit_editor_->load(&task, unit);
update_move_buttons(); update_move_buttons();
@@ -552,24 +576,28 @@ void PlanView::update_move_buttons() {
gtk_widget_set_sensitive(btn_move_down_, current_task_idx_ < count - 1); gtk_widget_set_sensitive(btn_move_down_, current_task_idx_ < count - 1);
} }
void PlanView::refresh() { void PlanView::reload_plan_from_disk() {
// reload units if paths now resolve
project_.load_all_units();
// reload the plan from disk if one is loaded
auto* plan = current_plan(); auto* plan = current_plan();
if (plan) { if (!plan) return;
try { try {
*plan = Plan::load(plan->filepath); *plan = Plan::load(plan->filepath);
project_.report_status("Reloaded plan: " + plan->filepath.filename().string()); project_.report_status("Reloaded plan: " + plan->filepath.filename().string());
} catch (const std::exception& e) { } catch (const std::exception& e) {
project_.report_status("Error reloading plan: " + std::string(e.what())); project_.report_status("Error reloading plan: " + std::string(e.what()));
} }
gtk_label_set_markup(GTK_LABEL(plan_label_), (std::string("<b>Plan:</b> ") + plan->filepath.filename().string()).c_str()); refresh();
} else {
gtk_label_set_markup(GTK_LABEL(plan_label_), "<b>Plan:</b> No plan loaded");
} }
void PlanView::refresh() {
// reload units if paths now resolve
project_.load_all_units();
auto* plan = current_plan();
if (plan)
gtk_label_set_markup(GTK_LABEL(plan_label_), (std::string("<b>Plan:</b> ") + plan->filepath.filename().string()).c_str());
else
gtk_label_set_markup(GTK_LABEL(plan_label_), "<b>Plan:</b> No plan loaded");
populate_task_list(); populate_task_list();
update_plan_buttons(); update_plan_buttons();
plan_dirty_ = false; plan_dirty_ = false;
@@ -577,7 +605,7 @@ void PlanView::refresh() {
} }
bool PlanView::is_dirty() const { bool PlanView::is_dirty() const {
return plan_dirty_ || unit_editor_->is_dirty(); return plan_dirty_;
} }
void PlanView::save_dirty() { void PlanView::save_dirty() {

View File

@@ -30,6 +30,7 @@ public:
PlanView(Project& project, GrexConfig& grex_config); PlanView(Project& project, GrexConfig& grex_config);
GtkWidget* widget() { return root_; } GtkWidget* widget() { return root_; }
void refresh(); void refresh();
void reload_plan_from_disk();
bool is_dirty() const; bool is_dirty() const;
void save_dirty(); void save_dirty();
void revert_dirty(); void revert_dirty();

View File

@@ -417,7 +417,8 @@ void ShellsView::on_delete_file(GtkButton*, gpointer data) {
auto* self = static_cast<ShellsView*>(data); auto* self = static_cast<ShellsView*>(data);
if (!self->selected_file_) return; if (!self->selected_file_) return;
auto name = self->selected_file_->filepath.filename().string(); auto filepath = self->selected_file_->filepath;
auto name = filepath.filename().string();
auto it = std::find_if(self->project_.shell_files.begin(), self->project_.shell_files.end(), auto it = std::find_if(self->project_.shell_files.begin(), self->project_.shell_files.end(),
[&](const ShellsFile& sf) { return &sf == self->selected_file_; }); [&](const ShellsFile& sf) { return &sf == self->selected_file_; });
@@ -426,7 +427,13 @@ void ShellsView::on_delete_file(GtkButton*, gpointer data) {
self->selected_file_ = nullptr; self->selected_file_ = nullptr;
self->populate_file_list(); self->populate_file_list();
self->project_.report_status("Removed shell file from project: " + name + " (file not deleted from disk)");
std::error_code ec;
if (std::filesystem::remove(filepath, ec))
self->project_.report_status("Deleted shell file: " + name);
else
self->project_.report_status("Error: could not delete " + name +
(ec ? ": " + ec.message() : ""));
} }
void ShellsView::on_new_shell(GtkButton*, gpointer data) { void ShellsView::on_new_shell(GtkButton*, gpointer data) {

View File

@@ -191,9 +191,9 @@ void UnitEditor::revert_current() {
clear_dirty(); clear_dirty();
} }
void UnitEditor::set_name_changed_callback(NameChangedCallback cb, void* data) { void UnitEditor::set_unit_edited_callback(UnitEditedCallback cb, void* data) {
name_cb_ = cb; edit_cb_ = cb;
name_cb_data_ = data; edit_cb_data_ = data;
} }
void UnitEditor::on_edit_unit(GtkButton*, gpointer data) { void UnitEditor::on_edit_unit(GtkButton*, gpointer data) {
@@ -205,15 +205,17 @@ void UnitEditor::on_edit_unit(GtkButton*, gpointer data) {
self->project_, self->grex_config_, self->project_.all_shells()); self->project_, self->grex_config_, self->project_.all_shells());
if (result == UnitDialogResult::Save) { if (result == UnitDialogResult::Save) {
// Sync state in case the unit name changed in the dialog
auto new_name = self->current_unit_->name; auto new_name = self->current_unit_->name;
bool name_changed = (new_name != self->current_unit_name_);
self->current_unit_name_ = new_name; self->current_unit_name_ = new_name;
if (name_changed) {
if (self->current_task_) if (self->current_task_)
self->current_task_->name = new_name; self->current_task_->name = new_name;
gtk_label_set_text(GTK_LABEL(self->name_display_), new_name.c_str()); gtk_label_set_text(GTK_LABEL(self->name_display_), new_name.c_str());
if (self->name_cb_) }
self->name_cb_(new_name, self->name_cb_data_);
self->mark_dirty(); self->mark_dirty();
if (self->edit_cb_)
self->edit_cb_(new_name, name_changed, self->edit_cb_data_);
} }
} }
@@ -234,7 +236,7 @@ void UnitEditor::on_select_unit(GtkButton*, gpointer data) {
if (self->current_task_) self->current_task_->name = unit_name; if (self->current_task_) self->current_task_->name = unit_name;
Unit* unit = self->project_.find_unit(unit_name); Unit* unit = self->project_.find_unit(unit_name);
self->load(self->current_task_, unit); self->load(self->current_task_, unit);
if (self->name_cb_) self->name_cb_(unit_name, self->name_cb_data_); if (self->edit_cb_) self->edit_cb_(unit_name, true, self->edit_cb_data_);
}); });
} }

View File

@@ -41,8 +41,8 @@ public:
void save_current(); void save_current();
void revert_current(); void revert_current();
using NameChangedCallback = void(*)(const std::string& new_name, void* data); using UnitEditedCallback = void(*)(const std::string& new_name, bool name_changed, void* data);
void set_name_changed_callback(NameChangedCallback cb, void* data); void set_unit_edited_callback(UnitEditedCallback cb, void* data);
private: private:
Project& project_; Project& project_;
@@ -62,8 +62,8 @@ private:
GtkWidget* btn_save_unit_; GtkWidget* btn_save_unit_;
GtkWidget* entry_comment_; GtkWidget* entry_comment_;
NameChangedCallback name_cb_ = nullptr; UnitEditedCallback edit_cb_ = nullptr;
void* name_cb_data_ = nullptr; void* edit_cb_data_ = nullptr;
bool dirty_ = false; bool dirty_ = false;

View File

@@ -145,8 +145,14 @@ UnitsView::UnitsView(Project& project, GrexConfig& grex_config)
g_signal_connect(btn_move_up_, "clicked", G_CALLBACK(on_move_up), this); g_signal_connect(btn_move_up_, "clicked", G_CALLBACK(on_move_up), this);
g_signal_connect(btn_move_down_, "clicked", G_CALLBACK(on_move_down), this); g_signal_connect(btn_move_down_, "clicked", G_CALLBACK(on_move_down), this);
g_signal_connect(unit_listbox_, "row-activated", G_CALLBACK(on_unit_activated), this); g_signal_connect(unit_listbox_, "row-activated", G_CALLBACK(on_unit_activated), this);
g_signal_connect(unit_listbox_, "row-selected", G_CALLBACK(+[](GtkListBox*, GtkListBoxRow*, gpointer d) { g_signal_connect(unit_listbox_, "row-selected", G_CALLBACK(+[](GtkListBox*, GtkListBoxRow* row, gpointer d) {
static_cast<UnitsView*>(d)->update_move_buttons(); auto* self = static_cast<UnitsView*>(d);
self->update_move_buttons();
if (row && self->selected_file_) {
int idx = gtk_list_box_row_get_index(row);
if (idx >= 0 && idx < (int)self->selected_file_->units.size())
self->project_.check_unit_valid(self->selected_file_->units[idx]);
}
}), this); }), this);
g_signal_connect(btn_refresh, "clicked", G_CALLBACK(+[](GtkButton*, gpointer d) { g_signal_connect(btn_refresh, "clicked", G_CALLBACK(+[](GtkButton*, gpointer d) {
@@ -231,8 +237,15 @@ void UnitsView::populate_unit_list() {
for (auto& u : selected_file_->units) { for (auto& u : selected_file_->units) {
auto* row = gtk_list_box_row_new(); auto* row = gtk_list_box_row_new();
auto text = std::string("\u2022 ") + u.name; auto* label = gtk_label_new(nullptr);
auto* label = gtk_label_new(text.c_str()); auto* escaped = g_markup_escape_text(u.name.c_str(), -1);
std::string markup;
if (project_.check_unit_valid(u))
markup = std::string("\u2022 ") + escaped;
else
markup = std::string("<span foreground=\"red\">\u2022 ") + escaped + "</span>";
g_free(escaped);
gtk_label_set_markup(GTK_LABEL(label), markup.c_str());
gtk_label_set_xalign(GTK_LABEL(label), 0.0f); gtk_label_set_xalign(GTK_LABEL(label), 0.0f);
gtk_widget_set_margin_start(label, 8); gtk_widget_set_margin_start(label, 8);
gtk_widget_set_margin_end(label, 8); gtk_widget_set_margin_end(label, 8);
@@ -403,7 +416,8 @@ void UnitsView::on_delete_file(GtkButton*, gpointer data) {
auto* self = static_cast<UnitsView*>(data); auto* self = static_cast<UnitsView*>(data);
if (!self->selected_file_) return; if (!self->selected_file_) return;
auto name = self->selected_file_->filepath.filename().string(); auto filepath = self->selected_file_->filepath;
auto name = filepath.filename().string();
auto it = std::find_if(self->project_.unit_files.begin(), self->project_.unit_files.end(), auto it = std::find_if(self->project_.unit_files.begin(), self->project_.unit_files.end(),
[&](const UnitFile& uf) { return &uf == self->selected_file_; }); [&](const UnitFile& uf) { return &uf == self->selected_file_; });
@@ -412,7 +426,13 @@ void UnitsView::on_delete_file(GtkButton*, gpointer data) {
self->selected_file_ = nullptr; self->selected_file_ = nullptr;
self->populate_file_list(); self->populate_file_list();
self->project_.report_status("Removed unit file from project: " + name + " (file not deleted from disk)");
std::error_code ec;
if (std::filesystem::remove(filepath, ec))
self->project_.report_status("Deleted unit file: " + name);
else
self->project_.report_status("Error: could not delete " + name +
(ec ? ": " + ec.message() : ""));
} }
void UnitsView::on_new_unit(GtkButton*, gpointer data) { void UnitsView::on_new_unit(GtkButton*, gpointer data) {