From c092bb975c030cf79d042d1a6b7d75e5d8de1178 Mon Sep 17 00:00:00 2001 From: Joe Date: Mon, 2 Feb 2026 15:34:08 -0600 Subject: [PATCH] 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. --- .../App/Components/PatchVisualizer/init.lua | 122 +++++++++++++++--- plugin/src/App/StatusPages/Confirming.lua | 51 ++++++-- src/cli/cursor.rs | 42 ++++++ src/cli/mod.rs | 8 ++ src/cli/studio.rs | 79 ++++++++++++ src/cli/syncback.rs | 23 ++++ 6 files changed, 295 insertions(+), 30 deletions(-) create mode 100644 src/cli/cursor.rs create mode 100644 src/cli/studio.rs diff --git a/plugin/src/App/Components/PatchVisualizer/init.lua b/plugin/src/App/Components/PatchVisualizer/init.lua index ba247234..775e2083 100644 --- a/plugin/src/App/Components/PatchVisualizer/init.lua +++ b/plugin/src/App/Components/PatchVisualizer/init.lua @@ -15,6 +15,38 @@ local e = Roact.createElement 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") function PatchVisualizer:init() @@ -36,6 +68,10 @@ function PatchVisualizer:shouldUpdate(nextProps) return true end + if self.props.filterText ~= nextProps.filterText then + return true + end + local currentPatch, nextPatch = self.props.patch, nextProps.patch if currentPatch ~= nil or nextPatch ~= nil then return not PatchSet.isEqual(currentPatch, nextPatch) @@ -58,23 +94,73 @@ function PatchVisualizer:render() end end - -- Recusively draw tree + -- Recursively draw tree local scrollElements, elementHeights, elementIndex = {}, {}, 0 + local filterText = self.props.filterText or "" 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 function drawNode(node, depth) + + local function drawNode(node, depth, nodeIndex) 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 - if parentNode then - for _id, sibling in parentNode.children do - if type(sibling) == "table" and sibling.name and sibling.name > node.name then - isFinalChild = false - break - end + for i = nodeIndex + 1, #visibleNodes do + local nextEntry = visibleNodes[i] + if nextEntry.depth < depth then + -- Went back up the tree, we're done checking + break + end + if nextEntry.depth == depth and nextEntry.node.parentId == node.parentId then + -- Found a sibling at the same depth + isFinalChild = false + break end end @@ -112,14 +198,16 @@ function PatchVisualizer:render() 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 - for i = depth + 1, #depthsComplete do - depthsComplete[i] = nil + for j = depth + 1, #depthsComplete do + depthsComplete[j] = nil end - drawNode(node, depth) - end) + drawNode(entry.node, depth, i) + end end return Theme.with(function(theme) @@ -132,7 +220,9 @@ function PatchVisualizer:render() }, { CleanMerge = e("TextLabel", { 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, TextSize = theme.TextSize.Medium, TextColor3 = theme.TextColor, diff --git a/plugin/src/App/StatusPages/Confirming.lua b/plugin/src/App/StatusPages/Confirming.lua index b657f82c..13764fc5 100644 --- a/plugin/src/App/StatusPages/Confirming.lua +++ b/plugin/src/App/StatusPages/Confirming.lua @@ -7,6 +7,7 @@ local Roact = require(Packages.Roact) local Theme = require(Plugin.App.Theme) local PatchTree = require(Plugin.PatchTree) local TextButton = require(Plugin.App.Components.TextButton) +local TextInput = require(Plugin.App.Components.TextInput) local StudioPluginGui = require(Plugin.App.Components.Studio.StudioPluginGui) local Tooltip = require(Plugin.App.Components.Tooltip) local PatchVisualizer = require(Plugin.App.Components.PatchVisualizer) @@ -37,6 +38,7 @@ function ConfirmingPage:init() showingAcceptConfirm = false, unselectedCount = 0, selections = initialSelections, + filterText = "", }) -- Callback to update individual selection @@ -116,27 +118,48 @@ function ConfirmingPage:render() return Theme.with(function(theme) local pageContent = Roact.createFragment({ - 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, - TextTransparency = self.props.transparency, - Size = UDim2.new(1, 0, 0, theme.TextSize.Large + 2), + Header = e("Frame", { + Size = UDim2.new(1, 0, 0, 28), 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, { size = UDim2.new(1, 0, 1, -100), transparency = self.props.transparency, - layoutOrder = 3, + layoutOrder = 2, patchTree = self.props.patchTree, + filterText = self.state.filterText, selections = self.state.selections, onSelectionChange = self.onSelectionChange, onSubtreeSelectionChange = self.onSubtreeSelectionChange, @@ -159,7 +182,7 @@ function ConfirmingPage:render() Buttons = e("Frame", { Size = UDim2.new(1, 0, 0, 34), - LayoutOrder = 4, + LayoutOrder = 3, BackgroundTransparency = 1, }, { -- Only show Abort button when there are changes diff --git a/src/cli/cursor.rs b/src/cli/cursor.rs new file mode 100644 index 00000000..d874fdeb --- /dev/null +++ b/src/cli/cursor.rs @@ -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(()) + } +} diff --git a/src/cli/mod.rs b/src/cli/mod.rs index ecd023e9..927c9382 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -1,12 +1,14 @@ //! Defines Rojo's CLI through clap types. mod build; +mod cursor; mod doc; mod fmt_project; mod init; mod plugin; mod serve; mod sourcemap; +mod studio; mod syncback; mod upload; @@ -16,12 +18,14 @@ use clap::Parser; use thiserror::Error; pub use self::build::BuildCommand; +pub use self::cursor::CursorCommand; pub use self::doc::DocCommand; pub use self::fmt_project::FmtProjectCommand; pub use self::init::{InitCommand, InitKind}; pub use self::plugin::{PluginCommand, PluginSubcommand}; pub use self::serve::ServeCommand; pub use self::sourcemap::SourcemapCommand; +pub use self::studio::StudioCommand; pub use self::syncback::SyncbackCommand; pub use self::upload::UploadCommand; @@ -46,8 +50,10 @@ impl Options { Subcommand::Upload(subcommand) => subcommand.run(), Subcommand::Sourcemap(subcommand) => subcommand.run(), Subcommand::FmtProject(subcommand) => subcommand.run(), + Subcommand::Cursor(subcommand) => subcommand.run(), Subcommand::Doc(subcommand) => subcommand.run(), Subcommand::Plugin(subcommand) => subcommand.run(), + Subcommand::Studio(subcommand) => subcommand.run(), Subcommand::Syncback(subcommand) => subcommand.run(self.global), } } @@ -120,8 +126,10 @@ pub enum Subcommand { Upload(UploadCommand), Sourcemap(SourcemapCommand), FmtProject(FmtProjectCommand), + Cursor(CursorCommand), Doc(DocCommand), Plugin(PluginCommand), + Studio(StudioCommand), Syncback(SyncbackCommand), } diff --git a/src/cli/studio.rs b/src/cli/studio.rs new file mode 100644 index 00000000..fc959473 --- /dev/null +++ b/src/cli/studio.rs @@ -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 { + 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) +} diff --git a/src/cli/syncback.rs b/src/cli/syncback.rs index a4219e58..53d916a3 100644 --- a/src/cli/syncback.rs +++ b/src/cli/syncback.rs @@ -83,6 +83,9 @@ impl SyncbackCommand { // Determine if we need to download the input file let resolved_input = resolve_path(&self.input); let _temp_file: Option; + // 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; // Logic: // - If --download=PLACEID: always download that specific place @@ -100,11 +103,18 @@ impl SyncbackCommand { ); let temp_path = temp.path().to_path_buf(); _temp_file = Some(temp); + delete_input_after_syncback = None; temp_path } None if resolved_input.exists() => { // No --download flag, input file exists: use it _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() } None => { @@ -123,6 +133,7 @@ impl SyncbackCommand { ); let temp_path = temp.path().to_path_buf(); _temp_file = Some(temp); + delete_input_after_syncback = None; temp_path } }; @@ -251,6 +262,18 @@ impl SyncbackCommand { // Refresh git index if in a git repository 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 { log::info!( "Would write {} files/folders and remove {} files/folders.",