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
1 change: 1 addition & 0 deletions CHANGELOG.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ and this project adheres to http://semver.org/spec/v2.0.0.html[Semantic Versioni
== https://github.com/robotframework/RIDE[Unreleased]

=== Fixed
- Fixed disappering Project Explorer tree elements, after editing directory elements (i.e. variable or keyword).
- Fixed blank Project Explorer panel when docking. Long time existing issue.
- Fixed bad resizing of File Explorer content when opening Test Suites.
- Fix selection of items (variables, test names, keywords) from Project Explorer and highlight at Text Editor.
Expand Down
2 changes: 1 addition & 1 deletion README.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ Likewise, the current version of wxPython, is 4.2.5, but RIDE is known to work w

`pip install -U robotframework-ride`

(3.9 <= python <= 3.14) Install current development version (**2.2.5dev7**) with:
(3.9 <= python <= 3.14) Install current development version (**2.2.5dev8**) with:

`pip install -U https://github.com/robotframework/RIDE/archive/develop.zip`

Expand Down
2 changes: 1 addition & 1 deletion rtest/testdir/Suite.robot
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ Library String

*** Test Cases ***
Test Case 1
Some local keyword with arguments
Some local keyword with arguments # comment
No Operation
Some keyword from resource file 2
Should Be Empty ${EMPTY} 5
Expand Down
7 changes: 3 additions & 4 deletions rtest/testdir/__init__.robot
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
*** Settings ***
Documentation This is suite directory documentation
Force Tags force
Documentation This is suite directory documentation
Force Tags force

*** Variables ***
${SCALAR} 1

${SCALAR} 1
2 changes: 1 addition & 1 deletion rtest/testdir/resources/resu.txt
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
*** Keywords ***
Some keyword from resource file
[Arguments] ${argument}
[Documentation] This is documentation
[Documentation] This is documentation changed
[Timeout] 1 second
No Operation
Log Something
Expand Down
2 changes: 2 additions & 0 deletions src/robotide/application/CHANGELOG.html
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"><html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en"><head><meta http-equiv="Content-Type" content="text/html; charset=UTF-8" /><title>Changelog</title><link rel="stylesheet" type="text/css" href="docbook-xsl.css" /><meta name="generator" content="DocBook XSL Stylesheets Vsnapshot" /></head><body><div xml:lang="en" class="article" lang="en"><div class="titlepage"><div><div><h2 class="title"><a id="id1337">_</a>Changelog</h2></div></div><hr /></div><p>All notable changes to this project will be documented in this file.</p><p>The format is based on <a class="ulink" href="http://keepachangelog.com/en/1.0.0/" target="_top">Keep a Changelog</a>
and this project adheres to <a class="ulink" href="http://semver.org/spec/v2.0.0.html" target="_top">Semantic Versioning</a>.</p><div class="section"><div class="titlepage"><div><div><h2 class="title" style="clear: both"><a id="_ulink_url_https_github_com_robotframework_ride_unreleased_ulink">_</a>1. <a class="ulink" href="https://github.com/robotframework/RIDE" target="_top">Unreleased</a></h2></div></div></div><div class="section"><div class="titlepage"><div><div><h3 class="title"><a id="_fixed">_</a>1.1. Fixed</h3></div></div></div><div class="itemizedlist"><ul class="itemizedlist" style="list-style-type: disc; "><li class="listitem">
Fixed disappering Project Explorer tree elements, after editing directory elements (i.e. variable or keyword).
</li><li class="listitem">
Fixed blank Project Explorer panel when docking. Long time existing issue.
</li><li class="listitem">
Fixed bad resizing of File Explorer content when opening Test Suites.
Expand Down
3 changes: 2 additions & 1 deletion src/robotide/application/releasenotes.py
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,7 @@ def set_content(self, html_win, content):
</ul>
<p><strong>New Features and Fixes Highlights</strong></p>
<ul class="simple">
<li>Fixed disappering Project Explorer tree elements, after editing directory elements (i.e. variable or keyword).</li>
<li>Fixed blank Project Explorer panel when docking. Long time existing issue.</li>
<li>Fixed bad resizing of File Explorer content when opening Test Suites.</li>
<li>Improved spaces detection in test suites reader.</li>
Expand Down Expand Up @@ -236,7 +237,7 @@ def set_content(self, html_win, content):
<pre class="literal-block">python -m robotide.postinstall -install</pre>
<p>or</p>
<pre class="literal-block">ride_postinstall.py -install</pre>
<p>RIDE {VERSION} was released on 01/August/2026.</p>
<p>RIDE {VERSION} was released on 07/August/2026.</p>
<br/>
<!--
<h3>Celebrate the bank holiday, 1st December, Restoration of the Independence of Portugal (from Spain in 1640)!!</h3>
Expand Down
84 changes: 77 additions & 7 deletions src/robotide/ui/treeplugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,9 @@
RideDataFileRemoved, RideDataChangedToDirty, RideDataDirtyCleared, RideVariableRemoved,
RideVariableAdded, RideVariableMovedUp, RideVariableMovedDown, RideVariableUpdated,
RideOpenResource, RideSuiteAdded, RideSelectResource, RideDataFileSet, RideItemNameChanged,
RideSettingsChanged)
RideSaving, RideSettingsChanged)
from ..controller.ctrlcommands import MoveTo
from ..controller.filecontrollers import TestDataDirectoryController
from ..pluginapi import Plugin
from ..action import ActionInfo
from ..widgets import PopupCreator
Expand Down Expand Up @@ -74,6 +75,7 @@ def __init__(self, application):
self.settings = self._app.settings.config_obj['Plugins']['Tree']
self._parent = None
self._tree = self.tree
self._last_selection_path = []
"""
self._tree.SetBackgroundColour(Colour(200, 222, 40))
self._tree.SetOwnBackgroundColour(Colour(200, 222, 40))
Expand Down Expand Up @@ -111,6 +113,7 @@ def enable(self):
doc=_('Show Test Suites tree panel'),
position=2))
self.subscribe(self.on_tree_selection, RideTreeSelection)
self.subscribe(self.on_saving, RideSaving)
self.subscribe(self.reload_tree, RideSettingsChanged)
# self.save_setting('opened', True)
# DEBUG: Add toggle checkbox to menu View/Hide Tree
Expand Down Expand Up @@ -169,11 +172,11 @@ def disable(self):
def is_focused(self):
return self._tree.HasFocus()

def populate(self, model):
def populate(self, model, select_first=True):
if model: # DEBUG: Always populate ... and model != self._model:
self._model = model
# print(f"DEBUG: Populating model... {self._model}\n\n")
self._tree.populate(self._model)
self._tree.populate(self._model, select_first=select_first)

def set_editor(self, editor):
self._tree.set_editor(editor)
Expand Down Expand Up @@ -260,9 +263,28 @@ def set_float_docked(self, state: bool):
self.save_setting('docked', state) # Docked == True

def on_tree_selection(self, message):
# Applying editor changes to a directory can drop nodes from the tree before it
# is repopulated, so the selection is remembered here, while it is still valid,
# rather than read back in on_saving when it may already be gone.
path = self._tree.get_label_path(message.node)
if path:
self._last_selection_path = path
if self.is_focused():
self._tree.tree_node_selected(message.item)

def on_saving(self, message):
if not isinstance(message.datafile, TestDataDirectoryController):
return
# Saving a directory's __init__.robot invalidates every controller, so the whole
# tree has to be rebuilt. The selection is restored by node labels because the
# controller objects it pointed at do not survive the rebuild.
selection_path = self._last_selection_path or self._tree.get_label_path()
# Only let populate() select the first node when there is nothing to restore,
# otherwise its selection would land after ours and undo it.
wx.CallAfter(self.populate, self._model, not selection_path)
if selection_path:
wx.CallAfter(self._tree.select_node_by_label_path, selection_path)

def _update_tree(self, event=None):
__ = event
# print(f"DEBUG: treeplugin.py TreePlugin _update_tree called model={self._model}")
Expand Down Expand Up @@ -520,10 +542,10 @@ def _get_icon_index_for(self, controller):
return SKIPPED_IMAGE_INDEX
return ROBOT_IMAGE_INDEX

def populate(self, model):
def populate(self, model, select_first=True):
self._clear_tree_data()
self._populate_model(model)
self.refresh_view()
self.refresh_view(select_first=select_first)
self.SetFocus() # Needed for keyboard shortcuts

def _clear_tree_data(self):
Expand Down Expand Up @@ -579,15 +601,16 @@ def select_controller_node(self, controller):
def _suite_added(self, message):
self.add_datafile(message.parent, message.suite)

def refresh_view(self):
def refresh_view(self, select_first=True):
self.Show()
self.Refresh()
# print(f"DEBUG: Called Tree._refresh_view {self.GetParent().GetClassName()}")
if self._resource_root:
self.Expand(self._resource_root)
if self.datafile_nodes:
self._expand_and_render_children(self.datafile_nodes[0])
wx.CallAfter(self.SelectItem, self.datafile_nodes[0])
if select_first:
wx.CallAfter(self.SelectItem, self.datafile_nodes[0])
self.Update()
# print(f"DEBUG: Called Tree._refresh_view parent={self.GetParent().GetClassName()} self={self}")

Expand Down Expand Up @@ -816,6 +839,53 @@ def select_node_by_data(self, controller):
self.SelectItem(node)
return node

def get_label_path(self, node=None):
"""Returns the labels of ``node``'s ancestors and of the node itself.

``node`` defaults to the current selection. The list is ordered from the topmost
node down and is empty when there is no such node. It identifies a node without
holding on to any controller, so it stays valid across a repopulate. The dirty
marker is stripped because a node normally becomes clean between capturing the
path and restoring it."""
node = node if node is not None else self.GetSelection()
path = []
while node and node != self.root:
path.insert(0, self._undirty_label(self.GetItemText(node)))
node = self.GetItemParent(node)
return path

def select_node_by_label_path(self, path):
"""Selects the node reached by following ``path``, a list of node labels.

Children are rendered on demand while descending. If the path cannot be
followed to its end, the deepest node that did match is selected, so a node
that was removed from the data leaves its parent selected."""
node = self.root
for label in path:
if node != self.root:
self._expand_and_render_children(node)
child = self._find_child_with_label(node, label)
if not child:
break
node = child
if node == self.root:
return None
self.EnsureVisible(node)
self.SelectItem(node)
return node

def _find_child_with_label(self, node, label):
item, cookie = self.GetFirstChild(node)
while item:
if utils.eq(self._undirty_label(self.GetItemText(item)), label):
return item
item, cookie = self.GetNextChild(node, cookie)
return None

@staticmethod
def _undirty_label(text):
return text[1:] if text.startswith('*') else text

def select_user_keyword_node(self, uk):
parent_node = self._get_datafile_node(uk.parent.parent)
if not parent_node:
Expand Down
2 changes: 1 addition & 1 deletion src/robotide/version.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,4 @@
#
# Automatically generated by `tasks.py`.

VERSION = 'v2.2.5dev7'
VERSION = 'v2.2.5dev8'
95 changes: 95 additions & 0 deletions utest/ui/test_tree_restore_selection.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
# Copyright 2008-2015 Nokia Networks
# Copyright 2016- Robot Framework Foundation
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

from utest.ui.test_tree import _BaseSuiteTreeTest


class TestRestoringSelectionAcrossPopulate(_BaseSuiteTreeTest):
"""Saving a directory __init__.robot rebuilds the tree, dropping every controller.

TreePlugin.on_saving restores the selection through the node labels only, so these
tests exercise that round trip."""

def test_label_path_of_nested_node(self):
self._select_node('Sub Suite 1 Fake Test 2')
assert self._tree.get_label_path() == \
['Top Suite', 'Sub Suite 1', 'Sub Suite 1 Fake Test 2']

def test_label_path_of_an_explicitly_given_node(self):
node = self._get_node('Sub Suite 0 Fake UK 1')
assert self._tree.get_label_path(node) == \
['Top Suite', 'Sub Suite 0', 'Sub Suite 0 Fake UK 1']

def test_label_path_is_empty_without_selection(self):
self._tree.UnselectAll()
assert self._tree.get_label_path() == []

def test_selection_survives_repopulate(self):
self._select_node('Sub Suite 1 Fake Test 2')
path = self._tree.get_label_path()
self._tree.populate(self._model, select_first=False)
assert self._get_selected_label() != 'Sub Suite 1 Fake Test 2'
self._tree.select_node_by_label_path(path)
assert self._get_selected_label() == 'Sub Suite 1 Fake Test 2'

def test_keyword_selection_survives_repopulate(self):
self._select_node('Sub Suite 2 Fake UK 3')
path = self._tree.get_label_path()
self._tree.populate(self._model, select_first=False)
self._tree.select_node_by_label_path(path)
assert self._get_selected_label() == 'Sub Suite 2 Fake UK 3'

def test_restores_children_that_were_not_rendered_yet(self):
"""populate() only renders the children of datafile_nodes[0], so the target
node does not exist as a tree item until the path walk expands it."""
self._select_node('Sub Suite 1 Fake Test 2')
path = self._tree.get_label_path()
self._tree.populate(self._model, select_first=False)
assert self._tree.controller.find_node_with_label(
self._tree.root, 'Sub Suite 1 Fake Test 2') is None
self._tree.select_node_by_label_path(path)
assert self._get_selected_label() == 'Sub Suite 1 Fake Test 2'

def test_label_path_ignores_the_dirty_marker(self):
self._select_node('Sub Suite 1 Fake Test 2')
self._tree.controller.mark_node_dirty(self._get_node('Top Suite'))
assert self._tree.get_label_path() == \
['Top Suite', 'Sub Suite 1', 'Sub Suite 1 Fake Test 2']

def test_selection_survives_repopulate_of_a_dirty_datafile(self):
"""The node is dirty when the path is captured and clean once saved and
repopulated, so neither end may depend on the '*' marker."""
self._select_node('Sub Suite 1 Fake Test 2')
self._tree.controller.mark_node_dirty(self._get_node('Top Suite'))
path = self._tree.get_label_path()
self._tree.populate(self._model, select_first=False)
self._tree.select_node_by_label_path(path)
assert self._get_selected_label() == 'Sub Suite 1 Fake Test 2'

def test_restores_a_node_that_is_still_dirty(self):
self._select_node('Sub Suite 1 Fake Test 2')
path = self._tree.get_label_path()
self._tree.populate(self._model, select_first=False)
self._tree.controller.mark_node_dirty(self._get_node('Top Suite'))
self._tree.select_node_by_label_path(path)
assert self._get_selected_label() == 'Sub Suite 1 Fake Test 2'

def test_falls_back_to_deepest_match_when_node_is_gone(self):
path = ['Top Suite', 'Sub Suite 1', 'Removed Test']
self._tree.select_node_by_label_path(path)
assert self._get_selected_label() == 'Sub Suite 1'

def test_returns_none_when_nothing_matches(self):
assert self._tree.select_node_by_label_path(['No Such Suite']) is None
Loading