Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 61 additions & 8 deletions src/chat/option-picker.c
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ typedef struct
char *label;
char *description;
GtkLabel *row_label;
GtkLabel *description_label;
GtkImage *check;
GtkListBoxRow *row;
} Choice;
Expand Down Expand Up @@ -130,6 +131,7 @@ append_choice (XdOptionPicker *self,
choice->label = g_strdup (label);
choice->description = g_strdup (description);
choice->row_label = GTK_LABEL (gtk_label_new (label));
choice->description_label = GTK_LABEL (detail);
choice->check = GTK_IMAGE (
gtk_image_new_from_icon_name ("object-select-symbolic"));
choice->row = GTK_LIST_BOX_ROW (row);
Expand Down Expand Up @@ -159,28 +161,64 @@ append_choice (XdOptionPicker *self,
g_ptr_array_add (self->choices, choice);
}

static void
update_choice (Choice *choice,
const char *label,
const char *description)
{
if (g_strcmp0 (choice->label, label) != 0)
{
g_free (choice->label);
choice->label = g_strdup (label);
gtk_label_set_label (choice->row_label, label);
}

if (g_strcmp0 (choice->description, description) != 0)
{
g_free (choice->description);
choice->description = g_strdup (description);
gtk_label_set_label (choice->description_label, description);
}
}

void
xd_option_picker_set_choices (XdOptionPicker *self,
const char *const *labels,
const char *const *descriptions)
{
GtkWidget *child;
guint old_selected;
guint length = 0;

g_return_if_fail (XD_IS_OPTION_PICKER (self));
g_return_if_fail (labels != NULL);
g_return_if_fail (descriptions != NULL);

old_selected = self->selected;

while ((child = gtk_widget_get_first_child (GTK_WIDGET (self->list))) != NULL)
gtk_list_box_remove (self->list, child);
g_ptr_array_set_size (self->choices, 0);
self->selected = 0;
/*
* Keep existing rows alive. Remote metadata can arrive while this popover is
* open; replacing every row then makes GTK dismiss it. Updating rows in
* place also avoids rebuilding identical static pickers.
*/
for (; labels[length] != NULL; length++)
{
if (length < self->choices->len)
update_choice (g_ptr_array_index (self->choices, length),
labels[length], descriptions[length]);
else
append_choice (self, labels[length], descriptions[length]);
}

for (guint i = 0; labels[i] != NULL; i++)
append_choice (self, labels[i], descriptions[i]);
while (self->choices->len > length)
{
Choice *choice =
g_ptr_array_index (self->choices, self->choices->len - 1);

gtk_list_box_remove (self->list, GTK_WIDGET (choice->row));
g_ptr_array_remove_index (self->choices, self->choices->len - 1);
}

self->selected = 0;
sync_selection (self);

if (old_selected != 0)
Expand Down Expand Up @@ -273,6 +311,7 @@ xd_option_picker_init (XdOptionPicker *self)
GtkWidget *button_content = gtk_box_new (GTK_ORIENTATION_HORIZONTAL, 6);
GtkWidget *popover = gtk_popover_new ();
GtkWidget *panel = gtk_box_new (GTK_ORIENTATION_VERTICAL, 0);
GtkWidget *scroller = gtk_scrolled_window_new ();

self->choices =
g_ptr_array_new_with_free_func ((GDestroyNotify) choice_free);
Expand All @@ -292,7 +331,21 @@ xd_option_picker_init (XdOptionPicker *self)
g_signal_connect (self->list, "row-activated",
G_CALLBACK (on_row_activated), self);

gtk_box_append (GTK_BOX (panel), GTK_WIDGET (self->list));
/*
* A repository can have dozens of worktrees. Let short pickers keep their
* natural height, but cap long ones so the popover can fit on screen and
* scroll instead of being dismissed by the display server.
*/
gtk_scrolled_window_set_policy (GTK_SCROLLED_WINDOW (scroller),
GTK_POLICY_NEVER, GTK_POLICY_AUTOMATIC);
gtk_scrolled_window_set_max_content_height (
GTK_SCROLLED_WINDOW (scroller), 420);
gtk_scrolled_window_set_propagate_natural_height (
GTK_SCROLLED_WINDOW (scroller), TRUE);
gtk_scrolled_window_set_child (GTK_SCROLLED_WINDOW (scroller),
GTK_WIDGET (self->list));

gtk_box_append (GTK_BOX (panel), scroller);
gtk_widget_add_css_class (panel, "xd-menu");
gtk_popover_set_child (GTK_POPOVER (popover), panel);
gtk_popover_set_has_arrow (GTK_POPOVER (popover), FALSE);
Expand Down
40 changes: 37 additions & 3 deletions src/remote/remote-tree.c
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,21 @@ static void set_root_state (XdRemoteTree *self, XdNodeState state);

/* --- reconciling ---------------------------------------------------------- */

/*
* A row with no daemon id is an inline editor owned by the client.
*
* The sidebar inserts one while a new folder or chat is being named. A tree
* reply knows nothing about that placeholder, so treating the reply as an
* exhaustive list would remove the entry while the user is typing.
*/
static gboolean
is_client_placeholder (XdNode *node)
{
return xd_node_get_kind (node) == XD_NODE_FOLDER
? xd_node_get_folder_id (node) == NULL
: xd_node_get_chat_id (node) == NULL;
}

/*
* Brings @store to exactly @desired, moving as little as possible.
*
Expand All @@ -62,10 +77,29 @@ reconcile_children (GListStore *store,
GPtrArray *desired)
{
GListModel *model = G_LIST_MODEL (store);
g_autoptr (GPtrArray) target =
g_ptr_array_new_with_free_func (g_object_unref);

for (guint i = 0; i < desired->len; i++)
g_ptr_array_add (target, g_object_ref (g_ptr_array_index (desired, i)));

/*
* Keep client placeholders at their current positions while reconciling
* every daemon-owned row around them. Their row, entry text and focus then
* survive a refresh that finishes while the user is naming something.
*/
for (guint i = 0; i < g_list_model_get_n_items (model); i++)
{
g_autoptr (XdNode) node = g_list_model_get_item (model, i);

if (is_client_placeholder (node))
g_ptr_array_insert (target, MIN (i, target->len),
g_steal_pointer (&node));
Comment on lines +95 to +97

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Reconcile daemon rows without relocating placeholders

When a daemon snapshot adds, removes, or reorders a folder before an inline new-chat row, inserting the placeholder into target at its pre-refresh absolute index does not keep the row alive. Reconciliation first moves the changed daemon row, shifting the placeholder, and then the generic loop removes and reinserts the placeholder to restore this target index; GTK consequently unbinds the active editor, which can commit it through on_editor_focus_left() or discard its focus before the user submits or cancels. Preserve placeholders independently of daemon-row movement rather than assigning them an absolute target index.

Useful? React with 👍 / 👎.

}

for (guint i = 0; i < target->len; i++)
{
XdNode *wanted = g_ptr_array_index (desired, i);
XdNode *wanted = g_ptr_array_index (target, i);
guint at;

if (i < g_list_model_get_n_items (model))
Expand All @@ -82,8 +116,8 @@ reconcile_children (GListStore *store,
g_list_store_insert (store, i, wanted);
}

while (g_list_model_get_n_items (model) > desired->len)
g_list_store_remove (store, desired->len);
while (g_list_model_get_n_items (model) > target->len)
g_list_store_remove (store, target->len);
}

static int
Expand Down
9 changes: 8 additions & 1 deletion src/tree/sidebar.c
Original file line number Diff line number Diff line change
Expand Up @@ -1719,7 +1719,14 @@ on_selection_changed (GtkSingleSelection *selection,
if (row != NULL)
node = gtk_tree_list_row_get_item (row);

if (node == self->selected)
/*
* Reconciliation can remove and reinsert the selected row in one main-loop
* turn. GtkSingleSelection reports a brief empty selection between those
* operations. Empty selection does not close the current chat, so it must
* not erase the node identity used to recognize the same row when it comes
* back.
*/
if (node == NULL || node == self->selected)
return;
Comment on lines +1729 to 1730

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Distinguish explicit unselection from reconciliation gaps

When the user explicitly clears the current selection, which is supported because this model enables can_unselect, this return leaves self->selected pointing at the old node. Reselecting that same row is then also suppressed by the identity check, so no node-selected signal is emitted and the pending restore_chat_id is not cleared; if a saved remote chat finishes loading afterward, restoration can unexpectedly replace the user's renewed selection. Ignore only the transient deselection caused by reconciliation, while recording genuine unselection.

Useful? React with 👍 / 👎.


/* A real selection made while a remote is still connecting wins over what
Expand Down
15 changes: 14 additions & 1 deletion src/xd-window.c
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,20 @@ on_node_selected (XdSidebar *sidebar,
XdNode *node,
gpointer user_data)
{
show_chat (user_data, node);
XdWindow *self = user_data;

/*
* Model changes can produce more than one selection notification around the
* same node. Reopening the chat takes focus back to the composer, dismissing
* an open picker or finishing an inline editor in the sidebar.
*
* Activation remains separate below, so deliberately opening the row still
* does what the user asked.
*/
if (xd_chat_view_get_chat (self->chat_view) == node)
return;

show_chat (self, node);
}

/*
Expand Down
44 changes: 44 additions & 0 deletions tests/test-remote.c
Original file line number Diff line number Diff line change
Expand Up @@ -1045,6 +1045,49 @@ test_folders_and_chats_are_managed_from_the_client (void)
daemon_stop (&daemon);
}

/*
* A tree refresh can finish while the sidebar is showing an inline editor for
* a new folder or chat. Those rows have no daemon id yet and must survive the
* authoritative server snapshot until the user submits or cancels them.
*/
static void
test_tree_refresh_keeps_client_placeholders (void)
{
Daemon daemon = { 0 };
g_autoptr (XdRemoteClient) client = NULL;
g_autoptr (XdRemoteTree) tree = NULL;
g_autoptr (XdNode) folder_placeholder = NULL;
g_autoptr (XdNode) chat_placeholder = NULL;
XdNode *root;
XdNode *folder;
Wait loading = { 0 };

daemon_start (&daemon);

client = xd_remote_client_new ("127.0.0.1", daemon.port);
tree = paired_tree (&daemon, client);
root = xd_remote_tree_get_root (tree);
folder = child_at (root, 0);

folder_placeholder = xd_node_new_folder (NULL, "New Folder", NULL);
xd_node_set_parent (folder_placeholder, root);
g_list_store_insert (xd_node_get_children (root), 0, folder_placeholder);

chat_placeholder =
xd_node_new_chat (NULL, "New Chat", folder);
g_list_store_insert (xd_node_get_children (folder), 0, chat_placeholder);

g_signal_connect_swapped (tree, "loaded", G_CALLBACK (on_done), &loading);
xd_remote_tree_refresh (tree);
wait_for (&loading);
g_signal_handlers_disconnect_by_data (tree, &loading);

g_assert_true (child_at (root, 0) == folder_placeholder);
g_assert_true (child_at (folder, 0) == chat_placeholder);

daemon_stop (&daemon);
}

static void
set_remote_agent_option (XdRemoteClient *client,
const char *chat_id,
Expand Down Expand Up @@ -3802,6 +3845,7 @@ main (int argc, char *argv[])
ADD ("/remote/client-pairs-and-reads-the-tree", test_client_pairs_and_reads_the_tree);
ADD ("/remote/token-reconnects-and-strangers-are-turned-away", test_token_reconnects_and_strangers_are_turned_away);
ADD ("/remote/folders-and-chats-are-managed-from-the-client", test_folders_and_chats_are_managed_from_the_client);
ADD ("/remote/tree-refresh-keeps-client-placeholders", test_tree_refresh_keeps_client_placeholders);
ADD ("/remote/new-chat-inherits-last-changed-agent", test_remote_new_chat_inherits_last_changed_agent);
ADD ("/remote/folder-context-is-managed-from-the-client", test_folder_context_is_managed_from_the_client);
ADD ("/remote/agent-secrets-are-managed-without-reading-values", test_agent_secrets_are_managed_without_reading_values);
Expand Down
Loading