Add filter functionality to PatchVisualizer and ConfirmingPage

- Implemented a case-insensitive wildcard matching function in PatchVisualizer to filter visible nodes based on user input.
- Enhanced the ConfirmingPage to include a TextInput for filtering, allowing users to search for specific nodes in the PatchVisualizer.
- Updated the rendering logic to display appropriate messages based on the filter state, improving user experience and clarity.
This commit is contained in:
Joe 2026-02-02 15:34:08 -06:00
parent ff96d98d79
commit c092bb975c
6 changed files with 295 additions and 30 deletions

View file

@ -15,6 +15,38 @@ local e = Roact.createElement
local DomLabel = require(script.DomLabel) local DomLabel = require(script.DomLabel)
-- Convert wildcard pattern (with *) to case-insensitive match
-- e.g., "player*sync" matches "PlayerDataSync"
local function matchesWildcard(name: string, pattern: string): boolean
if pattern == "" then
return true
end
-- Escape Lua pattern special chars except *
local escaped = pattern:gsub("([%.%+%-%?%^%$%(%)%[%]%%])", "%%%1")
-- Convert * to .* (match any characters)
local luaPattern = "^" .. escaped:gsub("%*", ".*") .. "$"
return string.match(name:lower(), luaPattern:lower()) ~= nil
end
-- Check if node or any of its descendants match the filter
local function nodeOrDescendantMatches(node, pattern: string): boolean
if matchesWildcard(node.name or "", pattern) then
return true
end
if node.children then
for _, child in node.children do
if type(child) == "table" and nodeOrDescendantMatches(child, pattern) then
return true
end
end
end
return false
end
local PatchVisualizer = Roact.Component:extend("PatchVisualizer") local PatchVisualizer = Roact.Component:extend("PatchVisualizer")
function PatchVisualizer:init() function PatchVisualizer:init()
@ -36,6 +68,10 @@ function PatchVisualizer:shouldUpdate(nextProps)
return true return true
end end
if self.props.filterText ~= nextProps.filterText then
return true
end
local currentPatch, nextPatch = self.props.patch, nextProps.patch local currentPatch, nextPatch = self.props.patch, nextProps.patch
if currentPatch ~= nil or nextPatch ~= nil then if currentPatch ~= nil or nextPatch ~= nil then
return not PatchSet.isEqual(currentPatch, nextPatch) return not PatchSet.isEqual(currentPatch, nextPatch)
@ -58,23 +94,73 @@ function PatchVisualizer:render()
end end
end end
-- Recusively draw tree -- Recursively draw tree
local scrollElements, elementHeights, elementIndex = {}, {}, 0 local scrollElements, elementHeights, elementIndex = {}, {}, 0
local filterText = self.props.filterText or ""
if patchTree then if patchTree then
local elementTotal = patchTree:getCount() -- First pass: collect visible nodes in proper tree order (depth-first, alphabetical)
local visibleNodes = {}
local function collectVisibleNodes(node, depth)
local shouldShow = filterText == "" or nodeOrDescendantMatches(node, filterText)
if shouldShow then
table.insert(visibleNodes, { node = node, depth = depth })
-- Process children in alphabetical order (depth-first)
if node.children then
local sortedChildren = {}
for _, child in node.children do
if type(child) == "table" then
table.insert(sortedChildren, child)
end
end
table.sort(sortedChildren, function(a, b)
return (a.name or "") < (b.name or "")
end)
for _, child in ipairs(sortedChildren) do
collectVisibleNodes(child, depth + 1)
end
end
end
end
-- Collect from root children in alphabetical order
local rootChildren = {}
for _, child in patchTree.ROOT.children do
if type(child) == "table" then
table.insert(rootChildren, child)
end
end
table.sort(rootChildren, function(a, b)
return (a.name or "") < (b.name or "")
end)
for _, child in ipairs(rootChildren) do
collectVisibleNodes(child, 1)
end
local elementTotal = #visibleNodes
local depthsComplete = {} local depthsComplete = {}
local function drawNode(node, depth)
local function drawNode(node, depth, nodeIndex)
elementIndex += 1 elementIndex += 1
local parentNode = patchTree:getNode(node.parentId) -- Check if this is the final visible child among siblings
-- Look ahead to see if there's another node at the same depth with the same parent
local isFinalChild = true local isFinalChild = true
if parentNode then for i = nodeIndex + 1, #visibleNodes do
for _id, sibling in parentNode.children do local nextEntry = visibleNodes[i]
if type(sibling) == "table" and sibling.name and sibling.name > node.name then if nextEntry.depth < depth then
isFinalChild = false -- Went back up the tree, we're done checking
break break
end end
if nextEntry.depth == depth and nextEntry.node.parentId == node.parentId then
-- Found a sibling at the same depth
isFinalChild = false
break
end end
end end
@ -112,14 +198,16 @@ function PatchVisualizer:render()
end end
end end
patchTree:forEach(function(node, depth) -- Draw all visible nodes in order
for i, entry in ipairs(visibleNodes) do
local depth = entry.depth
depthsComplete[depth] = false depthsComplete[depth] = false
for i = depth + 1, #depthsComplete do for j = depth + 1, #depthsComplete do
depthsComplete[i] = nil depthsComplete[j] = nil
end end
drawNode(node, depth) drawNode(entry.node, depth, i)
end) end
end end
return Theme.with(function(theme) return Theme.with(function(theme)
@ -132,7 +220,9 @@ function PatchVisualizer:render()
}, { }, {
CleanMerge = e("TextLabel", { CleanMerge = e("TextLabel", {
Visible = #scrollElements == 0, Visible = #scrollElements == 0,
Text = "No changes to sync, project is up to date.", Text = if filterText ~= ""
then "No items match the filter."
else "No changes to sync, project is up to date.",
FontFace = theme.Font.Main, FontFace = theme.Font.Main,
TextSize = theme.TextSize.Medium, TextSize = theme.TextSize.Medium,
TextColor3 = theme.TextColor, TextColor3 = theme.TextColor,

View file

@ -7,6 +7,7 @@ local Roact = require(Packages.Roact)
local Theme = require(Plugin.App.Theme) local Theme = require(Plugin.App.Theme)
local PatchTree = require(Plugin.PatchTree) local PatchTree = require(Plugin.PatchTree)
local TextButton = require(Plugin.App.Components.TextButton) local TextButton = require(Plugin.App.Components.TextButton)
local TextInput = require(Plugin.App.Components.TextInput)
local StudioPluginGui = require(Plugin.App.Components.Studio.StudioPluginGui) local StudioPluginGui = require(Plugin.App.Components.Studio.StudioPluginGui)
local Tooltip = require(Plugin.App.Components.Tooltip) local Tooltip = require(Plugin.App.Components.Tooltip)
local PatchVisualizer = require(Plugin.App.Components.PatchVisualizer) local PatchVisualizer = require(Plugin.App.Components.PatchVisualizer)
@ -37,6 +38,7 @@ function ConfirmingPage:init()
showingAcceptConfirm = false, showingAcceptConfirm = false,
unselectedCount = 0, unselectedCount = 0,
selections = initialSelections, selections = initialSelections,
filterText = "",
}) })
-- Callback to update individual selection -- Callback to update individual selection
@ -116,27 +118,48 @@ function ConfirmingPage:render()
return Theme.with(function(theme) return Theme.with(function(theme)
local pageContent = Roact.createFragment({ local pageContent = Roact.createFragment({
Title = e("TextLabel", { Header = e("Frame", {
Text = string.format( Size = UDim2.new(1, 0, 0, 28),
"Sync changes for project '%s':",
self.props.confirmData.serverInfo.projectName or "UNKNOWN"
),
FontFace = theme.Font.Thin,
LineHeight = 1.2,
TextSize = theme.TextSize.Body,
TextColor3 = theme.TextColor,
TextXAlignment = Enum.TextXAlignment.Left,
TextTransparency = self.props.transparency,
Size = UDim2.new(1, 0, 0, theme.TextSize.Large + 2),
BackgroundTransparency = 1, BackgroundTransparency = 1,
LayoutOrder = 1,
}, {
Title = e("TextLabel", {
Text = string.format(
"Sync changes for project '%s':",
self.props.confirmData.serverInfo.projectName or "UNKNOWN"
),
FontFace = theme.Font.Thin,
LineHeight = 1.2,
TextSize = theme.TextSize.Body,
TextColor3 = theme.TextColor,
TextXAlignment = Enum.TextXAlignment.Left,
TextYAlignment = Enum.TextYAlignment.Center,
TextTransparency = self.props.transparency,
Size = UDim2.new(0.5, -5, 1, 0),
Position = UDim2.new(0, 0, 0, 0),
BackgroundTransparency = 1,
}),
FilterInput = e(TextInput, {
text = self.state.filterText,
placeholder = "Filter (* = wildcard)",
enabled = true,
transparency = self.props.transparency,
size = UDim2.new(0.5, -5, 1, 0),
position = UDim2.new(0.5, 5, 0, 0),
onEntered = function(text)
self:setState({ filterText = text })
end,
}),
}), }),
PatchVisualizer = e(PatchVisualizer, { PatchVisualizer = e(PatchVisualizer, {
size = UDim2.new(1, 0, 1, -100), size = UDim2.new(1, 0, 1, -100),
transparency = self.props.transparency, transparency = self.props.transparency,
layoutOrder = 3, layoutOrder = 2,
patchTree = self.props.patchTree, patchTree = self.props.patchTree,
filterText = self.state.filterText,
selections = self.state.selections, selections = self.state.selections,
onSelectionChange = self.onSelectionChange, onSelectionChange = self.onSelectionChange,
onSubtreeSelectionChange = self.onSubtreeSelectionChange, onSubtreeSelectionChange = self.onSubtreeSelectionChange,
@ -159,7 +182,7 @@ function ConfirmingPage:render()
Buttons = e("Frame", { Buttons = e("Frame", {
Size = UDim2.new(1, 0, 0, 34), Size = UDim2.new(1, 0, 0, 34),
LayoutOrder = 4, LayoutOrder = 3,
BackgroundTransparency = 1, BackgroundTransparency = 1,
}, { }, {
-- Only show Abort button when there are changes -- Only show Abort button when there are changes

42
src/cli/cursor.rs Normal file
View file

@ -0,0 +1,42 @@
use std::path::PathBuf;
use std::process::{Command, Stdio};
use anyhow::Context;
use clap::Parser;
use super::resolve_path;
/// Open the project directory in Cursor IDE.
#[derive(Debug, Parser)]
pub struct CursorCommand {
/// Path to open. Defaults to the current directory.
#[clap(default_value = ".")]
pub path: PathBuf,
}
impl CursorCommand {
pub fn run(self) -> anyhow::Result<()> {
let path = resolve_path(&self.path);
// Use cmd /c to run cursor through the shell (cursor is a shell command, not an exe)
#[cfg(windows)]
Command::new("cmd")
.args(["/c", "cursor", &path.to_string_lossy()])
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.context("Failed to launch Cursor IDE. Is 'cursor' in your PATH?")?;
#[cfg(not(windows))]
Command::new("cursor")
.arg(&*path)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.context("Failed to launch Cursor IDE. Is 'cursor' in your PATH?")?;
Ok(())
}
}

View file

@ -1,12 +1,14 @@
//! Defines Rojo's CLI through clap types. //! Defines Rojo's CLI through clap types.
mod build; mod build;
mod cursor;
mod doc; mod doc;
mod fmt_project; mod fmt_project;
mod init; mod init;
mod plugin; mod plugin;
mod serve; mod serve;
mod sourcemap; mod sourcemap;
mod studio;
mod syncback; mod syncback;
mod upload; mod upload;
@ -16,12 +18,14 @@ use clap::Parser;
use thiserror::Error; use thiserror::Error;
pub use self::build::BuildCommand; pub use self::build::BuildCommand;
pub use self::cursor::CursorCommand;
pub use self::doc::DocCommand; pub use self::doc::DocCommand;
pub use self::fmt_project::FmtProjectCommand; pub use self::fmt_project::FmtProjectCommand;
pub use self::init::{InitCommand, InitKind}; pub use self::init::{InitCommand, InitKind};
pub use self::plugin::{PluginCommand, PluginSubcommand}; pub use self::plugin::{PluginCommand, PluginSubcommand};
pub use self::serve::ServeCommand; pub use self::serve::ServeCommand;
pub use self::sourcemap::SourcemapCommand; pub use self::sourcemap::SourcemapCommand;
pub use self::studio::StudioCommand;
pub use self::syncback::SyncbackCommand; pub use self::syncback::SyncbackCommand;
pub use self::upload::UploadCommand; pub use self::upload::UploadCommand;
@ -46,8 +50,10 @@ impl Options {
Subcommand::Upload(subcommand) => subcommand.run(), Subcommand::Upload(subcommand) => subcommand.run(),
Subcommand::Sourcemap(subcommand) => subcommand.run(), Subcommand::Sourcemap(subcommand) => subcommand.run(),
Subcommand::FmtProject(subcommand) => subcommand.run(), Subcommand::FmtProject(subcommand) => subcommand.run(),
Subcommand::Cursor(subcommand) => subcommand.run(),
Subcommand::Doc(subcommand) => subcommand.run(), Subcommand::Doc(subcommand) => subcommand.run(),
Subcommand::Plugin(subcommand) => subcommand.run(), Subcommand::Plugin(subcommand) => subcommand.run(),
Subcommand::Studio(subcommand) => subcommand.run(),
Subcommand::Syncback(subcommand) => subcommand.run(self.global), Subcommand::Syncback(subcommand) => subcommand.run(self.global),
} }
} }
@ -120,8 +126,10 @@ pub enum Subcommand {
Upload(UploadCommand), Upload(UploadCommand),
Sourcemap(SourcemapCommand), Sourcemap(SourcemapCommand),
FmtProject(FmtProjectCommand), FmtProject(FmtProjectCommand),
Cursor(CursorCommand),
Doc(DocCommand), Doc(DocCommand),
Plugin(PluginCommand), Plugin(PluginCommand),
Studio(StudioCommand),
Syncback(SyncbackCommand), Syncback(SyncbackCommand),
} }

79
src/cli/studio.rs Normal file
View file

@ -0,0 +1,79 @@
use std::path::PathBuf;
use std::process::Command;
use anyhow::Context;
use clap::Parser;
use memofs::Vfs;
use serde::Deserialize;
use crate::project::Project;
use super::resolve_path;
#[derive(Deserialize)]
struct UniverseResponse {
#[serde(rename = "universeId")]
universe_id: u64,
}
/// Open a Rojo project in Roblox Studio.
#[derive(Debug, Parser)]
pub struct StudioCommand {
/// Path to the project. Defaults to the current directory.
#[clap(default_value = ".")]
pub project: PathBuf,
}
impl StudioCommand {
pub fn run(self) -> anyhow::Result<()> {
let vfs = Vfs::new_oneshot();
let base_path = resolve_path(&self.project);
let project = Project::load_fuzzy(&vfs, &base_path)?
.context("A project file is required to run 'rojo studio'")?;
let serve_place_ids = project
.serve_place_ids
.as_ref()
.context("No servePlaceIds in project file. Add servePlaceIds to your project file.")?;
let place_id = serve_place_ids
.iter()
.min()
.copied()
.context("servePlaceIds is empty in project file")?;
let universe_id = get_universe_id(place_id)?;
let url = format!(
"roblox-studio:1+launchmode:edit+task:EditPlace+placeId:{}+universeId:{}",
place_id, universe_id
);
// Use cmd /c start to fully detach the process on Windows
#[cfg(windows)]
Command::new("cmd")
.args(["/c", "start", "", &url])
.spawn()
.context("Failed to launch Roblox Studio")?;
#[cfg(not(windows))]
opener::open(&url).context("Failed to open Roblox Studio")?;
Ok(())
}
}
fn get_universe_id(place_id: u64) -> anyhow::Result<u64> {
let url = format!(
"https://apis.roblox.com/universes/v1/places/{}/universe",
place_id
);
let response: UniverseResponse = reqwest::blocking::get(&url)
.context("Failed to fetch universe ID from Roblox API")?
.json()
.context("Failed to parse universe ID response")?;
Ok(response.universe_id)
}

View file

@ -83,6 +83,9 @@ impl SyncbackCommand {
// Determine if we need to download the input file // Determine if we need to download the input file
let resolved_input = resolve_path(&self.input); let resolved_input = resolve_path(&self.input);
let _temp_file: Option<NamedTempFile>; let _temp_file: Option<NamedTempFile>;
// Track if we should delete the input file after successful syncback
// (when using default Project.rbxl and file exists locally)
let delete_input_after_syncback: Option<PathBuf>;
// Logic: // Logic:
// - If --download=PLACEID: always download that specific place // - If --download=PLACEID: always download that specific place
@ -100,11 +103,18 @@ impl SyncbackCommand {
); );
let temp_path = temp.path().to_path_buf(); let temp_path = temp.path().to_path_buf();
_temp_file = Some(temp); _temp_file = Some(temp);
delete_input_after_syncback = None;
temp_path temp_path
} }
None if resolved_input.exists() => { None if resolved_input.exists() => {
// No --download flag, input file exists: use it // No --download flag, input file exists: use it
_temp_file = None; _temp_file = None;
// If using default input path, mark for deletion after success
delete_input_after_syncback = if self.input.as_os_str() == "Project.rbxl" {
Some(resolved_input.clone().into_owned())
} else {
None
};
resolved_input.into_owned() resolved_input.into_owned()
} }
None => { None => {
@ -123,6 +133,7 @@ impl SyncbackCommand {
); );
let temp_path = temp.path().to_path_buf(); let temp_path = temp.path().to_path_buf();
_temp_file = Some(temp); _temp_file = Some(temp);
delete_input_after_syncback = None;
temp_path temp_path
} }
}; };
@ -251,6 +262,18 @@ impl SyncbackCommand {
// Refresh git index if in a git repository // Refresh git index if in a git repository
refresh_git_index(base_path); refresh_git_index(base_path);
// Delete input file if using default Project.rbxl location
if let Some(input_path) = &delete_input_after_syncback {
match std::fs::remove_file(input_path) {
Ok(()) => log::info!("Deleted input file: {}", input_path.display()),
Err(e) => log::warn!(
"Failed to delete input file {}: {}",
input_path.display(),
e
),
}
}
} else { } else {
log::info!( log::info!(
"Would write {} files/folders and remove {} files/folders.", "Would write {} files/folders and remove {} files/folders.",