git.lucas.co / hou-control
SideFX Houdini customization package
git clone https://git.lucas.co/hou-control.git

commit6614db58b6cbaa14014fe414d5167cbcc7c68a14
parentd0f027dee5
authorLucas Galante <[email protected]>
date2026-09-15 12:31
session: Open Recent command in the HC Panel

A fuzzy picker over Houdini's recent hips, newest first, existing files
only, the current one left out; picking one loads it with the usual
save prompt. HOM has no recent-file accessor, so the list is parsed
from the HIP block of $HOUDINI_USER_PREF_DIR/file.history, the file
Houdini's own Open Recent strip is built from.

Co-Authored-By: Claude Fable 5.1 <[email protected]>

 python3.13libs/hc/hcsession.py | 82 ++++++++++++++++++++++++++++++++++++++++++
 tools/check.py                 | 22 ++++++++++++
 2 files changed, 104 insertions(+)

diff --git a/python3.13libs/hc/hcsession.py b/python3.13libs/hc/hcsession.py
index 01327f2..5d74a8f 100644
--- a/python3.13libs/hc/hcsession.py
+++ b/python3.13libs/hc/hcsession.py
@@ -54,6 +54,36 @@ def floatWindow(window, size=None):
         window.resize(*size)
 
 
+def parseFileHistory(text):
+    """Paths of the HIP block of a file.history, most recent first.
+
+    Houdini writes blocks as ``HIP\n{\n<path>\n...\n}`` and appends the
+    newest path at the end; a path opened again is listed once.
+    """
+    paths = []
+    block = None
+    for line in text.splitlines():
+        line = line.strip()
+        if block is None:
+            if line and line not in ("{", "}"):
+                block = line  # a block name: HIP, OTL, ...
+            continue
+        if line == "{":
+            continue
+        if line == "}":
+            block = None
+            continue
+        if block == "HIP" and line:
+            paths.append(line)
+    seen = set()
+    ordered = []
+    for path in reversed(paths):
+        if path not in seen:
+            seen.add(path)
+            ordered.append(path)
+    return ordered
+
+
 class HCSession:
     def __init__(self):
         return
@@ -724,6 +754,58 @@ class HCSession:
         for callback in callbacks:
             hou.ui.removeEventLoopCallback(callback)
 
+    def recentHipFiles(self):
+        """Houdini's recent hips, most recent first, existing files only.
+
+        HOM exposes no recent-file list (nothing on hou.hipFile or hou.ui),
+        so this reads the HIP block of $HOUDINI_USER_PREF_DIR/file.history,
+        the file Houdini's own File > Open Recent Files strip is built from.
+        """
+        import os
+        base = hou.getenv("HOUDINI_USER_PREF_DIR") or ""
+        try:
+            text = open(os.path.join(base, "file.history")).read()
+        except OSError:
+            return []
+        return [f for f in parseFileHistory(text) if os.path.isfile(f)]
+
+    @command("Open Recent")
+    def openRecent(self):
+        """A fuzzy picker over the recent hips; picking one loads it."""
+        current = hou.hipFile.path()
+        files = [f for f in self.recentHipFiles() if f != current]
+        if not files:
+            hou.ui.setStatusMessage("No other recent files", hou.severityType.Warning)
+            return
+
+        def load(path):
+            try:
+                # Prompts to save unsaved changes first, like File > Open.
+                hou.hipFile.load(path)
+            except hou.OperationInterrupted:
+                return  # cancelled at the save prompt
+            except hou.LoadWarning as e:
+                hou.ui.setStatusMessage(str(e), hou.severityType.Warning)
+            except hou.Error as e:
+                hou.ui.setStatusMessage(f"Cannot open {path}: {e}",
+                                        hou.severityType.Error)
+
+        import os
+        names = [os.path.basename(f) for f in files]
+        list_dict = {}
+        for path, name in zip(files, names):
+            # The directory only when the name alone would be ambiguous.
+            label = path if names.count(name) > 1 else \
+                f"{name}    {os.path.basename(os.path.dirname(path))}"
+            list_dict[label] = (lambda p=path: load(p))
+
+        from .hcwidgets import HCWidgets
+        dialog = HCWidgets.SelectionDialog(
+            "Open Recent", list_dict, anchor_geometry=self.mainWindowGeometry())
+        dialog.show()
+        dialog.raise_()
+        dialog.activateWindow()
+
     @command("Restart Houdini")
     def restartHoudini(self):
         import os
diff --git a/tools/check.py b/tools/check.py
index 8430840..618ec9e 100644
--- a/tools/check.py
+++ b/tools/check.py
@@ -600,6 +600,27 @@ def check_nodegraph_hooks():
     check("one mouseup completer", one_mouseup_completer)
 
 
+def check_open_recent():
+    print("open recent")
+    from hc import hcsession
+
+    def history_parses_newest_first():
+        text = "HIP\n{\n/a/one.hip\n/b/two.hip\n/a/one.hip\n/c/three.hip\n}\nOTL\n{\n/x/lib.hda\n}\n"
+        got = hcsession.parseFileHistory(text)
+        assert got == ["/c/three.hip", "/a/one.hip", "/b/two.hip"], got
+        assert hcsession.parseFileHistory("") == []
+        return "newest first, deduplicated, OTL block ignored"
+
+    check("file.history parser", history_parses_newest_first)
+
+    def recent_files_exist():
+        files = HCSession().recentHipFiles()
+        assert all(Path(f).is_file() for f in files), files
+        return f"{len(files)} recent hips on disk"
+
+    check("recentHipFiles() only lists existing files", recent_files_exist)
+
+
 def check_status_circle():
     """The status circle's menu is built from the main-menu XML files with
     everything Python cannot run filtered out."""
@@ -1753,6 +1774,7 @@ def main():
     check_chrome()
     check_split_handles()
     check_nodegraph_hooks()
+    check_open_recent()
     check_status_circle()
     check_geometry()
     check_node_ops()