diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1a23b5d3e..a4f8d5e28 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -183,6 +183,7 @@ jobs: PLAYGROUND_UI_ROOT: server/build/ PLAYGROUND_CORS_ENABLED: true PLAYGROUND_GITHUB_TOKEN: "${{ secrets.PLAYGROUND_GITHUB_TOKEN }}" + PLAYGROUND_MULTIFILE_THRESHOLD: 1 run: |- killall -q ui || true chmod +x ./server/ui && ./server/ui & diff --git a/ci/workflows.yml b/ci/workflows.yml index 22ac8088e..9cfc84617 100644 --- a/ci/workflows.yml +++ b/ci/workflows.yml @@ -256,6 +256,7 @@ workflows: PLAYGROUND_UI_ROOT: server/build/ PLAYGROUND_CORS_ENABLED: true PLAYGROUND_GITHUB_TOKEN: ${{ secrets.PLAYGROUND_GITHUB_TOKEN }} + PLAYGROUND_MULTIFILE_THRESHOLD: 1 run: |- killall -q ui || true chmod +x ./server/ui && ./server/ui & diff --git a/compiler/base/orchestrator/src/coordinator.rs b/compiler/base/orchestrator/src/coordinator.rs index 3825f60cb..e58c22285 100644 --- a/compiler/base/orchestrator/src/coordinator.rs +++ b/compiler/base/orchestrator/src/coordinator.rs @@ -331,13 +331,6 @@ impl CrateType { } } - pub(crate) fn other_path(self) -> &'static str { - match self { - CrateType::Binary => Self::LIB_RS, - CrateType::Library(_) => Self::MAIN_RS, - } - } - pub(crate) fn to_cargo_toml_key(self) -> &'static str { use {CrateType::*, LibraryType::*}; @@ -379,16 +372,112 @@ pub struct ExecuteRequest { pub crate_type: CrateType, pub tests: bool, pub backtrace: bool, - pub code: String, + pub code: Code, +} + +#[derive(Debug, Clone)] +pub enum Code { + Single(String), + Multiple(Vec), +} + +#[derive(Debug, Clone)] +pub struct CodeFile { + pub name: String, + pub content: String, +} + +impl From<&str> for Code { + fn from(value: &str) -> Self { + Self::Single(value.to_owned()) + } +} + +impl From for Code { + fn from(value: String) -> Self { + Self::Single(value) + } +} + +impl<'a> FromIterator<(&'a str, &'a str)> for Code { + fn from_iter(iter: T) -> Self + where + T: IntoIterator, + { + let files = iter + .into_iter() + .map(|(name, content)| CodeFile { + name: name.into(), + content: content.into(), + }) + .collect(); + Self::Multiple(files) + } +} + +impl Code { + #[cfg(test)] + const fn new() -> Self { + Self::Single(String::new()) + } + + fn files(&self, crate_type: CrateType) -> Box + '_> { + match self { + Code::Single(code) => Box::new([(crate_type.primary_path(), &**code)].into_iter()), + Code::Multiple(files) => Box::new(files.iter().map(|cf| (&*cf.name, &*cf.content))), + } + } + + fn delete_requests( + &self, + crate_type: CrateType, + ) -> impl Iterator + use<'_> { + let mut candidates = BTreeSet::from(["build.rs", CrateType::LIB_RS, CrateType::MAIN_RS]); + + for (name, _) in self.files(crate_type) { + candidates.remove(name); + } + + candidates + .into_iter() + .map(|path| DeleteFileRequest { path: path.into() }) + } + + fn write_requests( + &self, + crate_type: CrateType, + ) -> impl Iterator + use<'_> { + self.files(crate_type) + .map(|(path, content)| WriteFileRequest { + path: path.to_owned(), + content: content.to_owned().into_bytes(), + }) + } + + #[cfg(test)] + fn assume_single(&self) -> &str { + match self { + Code::Single(c) => c, + Code::Multiple(_) => panic!("Code::Multiple found when expecting Single"), + } + } + + #[cfg(test)] + fn assume_multiple(&self) -> &[CodeFile] { + match self { + Code::Single(_) => panic!("Code::Single found when expecting Multiple"), + Code::Multiple(f) => f, + } + } } impl LowerRequest for ExecuteRequest { fn delete_files(&self) -> impl Iterator { - [delete_previous_primary_file_request(self.crate_type)].into_iter() + self.code.delete_requests(self.crate_type) } fn write_files(&self) -> impl Iterator { - [write_primary_file_request(self.crate_type, &self.code)].into_iter() + self.code.write_requests(self.crate_type) } fn execute_cargo_request(&self) -> ExecuteCommandRequest { @@ -465,7 +554,7 @@ pub struct CompileRequest { // TODO: Remove `tests` and `backtrace` -- don't make sense for compiling. pub tests: bool, pub backtrace: bool, - pub code: String, + pub code: Code, } impl CompileRequest { @@ -494,11 +583,11 @@ impl CompileRequest { impl LowerRequest for CompileRequest { fn delete_files(&self) -> impl Iterator { - [delete_previous_primary_file_request(self.crate_type)].into_iter() + self.code.delete_requests(self.crate_type) } fn write_files(&self) -> impl Iterator { - [write_primary_file_request(self.crate_type, &self.code)].into_iter() + self.code.write_requests(self.crate_type) } fn execute_cargo_request(&self) -> ExecuteCommandRequest { @@ -513,6 +602,11 @@ impl LowerRequest for CompileRequest { args.push("--release"); } + match self.crate_type { + CrateType::Binary => args.extend(["--bin", "playground"]), + CrateType::Library(_) => args.push("--lib"), + } + match self.target { Assembly(flavor, _, _) => { args.extend(&["--", "--emit", "asm=compilation"]); @@ -577,24 +671,24 @@ pub struct FormatRequest { pub channel: Channel, pub crate_type: CrateType, pub edition: Edition, - pub code: String, + pub code: Code, } impl FormatRequest { - fn read_output_request(&self) -> ReadFileRequest { - ReadFileRequest { - path: self.crate_type.primary_path().to_owned(), - } + fn read_output_requests(&self) -> impl Iterator + use<'_> { + self.code + .files(self.crate_type) + .map(|(path, _)| ReadFileRequest { path: path.into() }) } } impl LowerRequest for FormatRequest { fn delete_files(&self) -> impl Iterator { - [delete_previous_primary_file_request(self.crate_type)].into_iter() + self.code.delete_requests(self.crate_type) } fn write_files(&self) -> impl Iterator { - [write_primary_file_request(self.crate_type, &self.code)].into_iter() + self.code.write_requests(self.crate_type) } fn execute_cargo_request(&self) -> ExecuteCommandRequest { @@ -622,7 +716,7 @@ impl CargoTomlModifier for FormatRequest { pub struct FormatResponse { pub success: bool, pub exit_detail: String, - pub code: String, + pub code: Code, } #[derive(Debug, Clone)] @@ -630,16 +724,16 @@ pub struct ClippyRequest { pub channel: Channel, pub crate_type: CrateType, pub edition: Edition, - pub code: String, + pub code: Code, } impl LowerRequest for ClippyRequest { fn delete_files(&self) -> impl Iterator { - [delete_previous_primary_file_request(self.crate_type)].into_iter() + self.code.delete_requests(self.crate_type) } fn write_files(&self) -> impl Iterator { - [write_primary_file_request(self.crate_type, &self.code)].into_iter() + self.code.write_requests(self.crate_type) } fn execute_cargo_request(&self) -> ExecuteCommandRequest { @@ -676,16 +770,16 @@ pub struct MiriRequest { pub edition: Edition, pub tests: bool, pub aliasing_model: AliasingModel, - pub code: String, + pub code: Code, } impl LowerRequest for MiriRequest { fn delete_files(&self) -> impl Iterator { - [delete_previous_primary_file_request(self.crate_type)].into_iter() + self.code.delete_requests(self.crate_type) } fn write_files(&self) -> impl Iterator { - [write_primary_file_request(self.crate_type, &self.code)].into_iter() + self.code.write_requests(self.crate_type) } fn execute_cargo_request(&self) -> ExecuteCommandRequest { @@ -741,24 +835,31 @@ pub struct MacroExpansionRequest { pub channel: Channel, pub crate_type: CrateType, pub edition: Edition, - pub code: String, + pub code: Code, } impl LowerRequest for MacroExpansionRequest { fn delete_files(&self) -> impl Iterator { - [delete_previous_primary_file_request(self.crate_type)].into_iter() + self.code.delete_requests(self.crate_type) } fn write_files(&self) -> impl Iterator { - [write_primary_file_request(self.crate_type, &self.code)].into_iter() + self.code.write_requests(self.crate_type) } fn execute_cargo_request(&self) -> ExecuteCommandRequest { + let mut args = vec!["rustc"]; + + match self.crate_type { + CrateType::Binary => args.extend(["--bin", "playground"]), + CrateType::Library(_) => args.push("--lib"), + } + + args.extend(["--", "-Zunpretty=expanded"]); + ExecuteCommandRequest { cmd: "cargo".to_owned(), - args: ["rustc", "--", "-Zunpretty=expanded"] - .map(str::to_owned) - .to_vec(), + args: args.into_iter().map(str::to_owned).collect(), envs: Default::default(), cwd: None, } @@ -835,19 +936,6 @@ impl ops::Deref for WithOutput { } } -fn write_primary_file_request(crate_type: CrateType, code: &str) -> WriteFileRequest { - WriteFileRequest { - path: crate_type.primary_path().to_owned(), - content: code.into(), - } -} - -fn delete_previous_primary_file_request(crate_type: CrateType) -> DeleteFileRequest { - DeleteFileRequest { - path: crate_type.other_path().to_owned(), - } -} - #[derive(Debug)] enum DemultiplexCommand { Listen(JobId, mpsc::Sender), @@ -1576,12 +1664,27 @@ impl Container { .context(CargoTaskPanickedSnafu)? .context(CargoFailedSnafu)?; - let read_output = request.read_output_request(); - let file = commander - .one(read_output) - .await - .context(CouldNotReadCodeSnafu)?; - let code = String::from_utf8(file.0).context(CodeNotUtf8Snafu)?; + let mut files = request + .read_output_requests() + .map(|read_output| async { + let name = read_output.path.clone(); + let file = commander + .one(read_output) + .await + .context(CouldNotReadCodeSnafu)?; + let content = String::from_utf8(file.0).context(CodeNotUtf8Snafu)?; + + Ok::<_, FormatError>(CodeFile { name, content }) + }) + .collect::>() + .try_collect::>() + .await?; + + let code = if matches!(request.code, Code::Single(..)) { + Code::Single(files.pop().map(|cf| cf.content).unwrap_or_default()) + } else { + Code::Multiple(files) + }; Ok(FormatResponse { success, @@ -3099,7 +3202,7 @@ mod tests { crate_type: CrateType::Binary, tests: false, backtrace: false, - code: String::new(), + code: Code::new(), }; fn new_execute_request() -> ExecuteRequest { @@ -3309,6 +3412,30 @@ mod tests { Ok(()) } + #[tokio::test] + #[snafu::report] + async fn execute_multiple_files() -> Result<()> { + let coordinator = new_coordinator(); + + let request = ExecuteRequest { + code: kvs! { + "src/main.rs" => r#"mod the; fn main() { println!("{}", the::inner::light()); }"#, + "src/the.rs" => r#"pub mod inner;"#, + "src/the/inner/mod.rs" => r#"pub fn light() -> bool { true }"#, + } + .collect(), + ..new_execute_request() + }; + let response = coordinator.execute(request).await.unwrap(); + + assert!(response.success, "stderr: {}", response.stderr); + assert_contains!(response.stdout, "true"); + + coordinator.shutdown().await?; + + Ok(()) + } + #[tokio::test] #[snafu::report] async fn execute_stdin() -> Result<()> { @@ -3548,7 +3675,7 @@ mod tests { edition: Edition::Rust2021, tests: false, backtrace: false, - code: String::new(), + code: Code::new(), }; #[tokio::test] @@ -3632,6 +3759,42 @@ mod tests { Ok(()) } + #[tokio::test] + #[snafu::report] + async fn compile_multiple_files() -> Result<()> { + let coordinator = new_coordinator(); + + let req = CompileRequest { + code: kvs! { + "src/main.rs" => "fn main() { playground::add(41, 1); }", + "src/lib.rs" => ADD_CODE, + } + .collect(), + crate_type: CrateType::Library(LibraryType::Rlib), + ..ARBITRARY_ASSEMBLY_REQUEST + }; + + let response = coordinator.compile(req).with_timeout().await.unwrap(); + + assert!(response.success, "stderr: {}", response.stderr); + + let asm = docker_target_arch! { + x86_64: "eax, [rsi + rdi]", + aarch64: "w0, w1, w0", + }; + + assert_contains!(response.code, asm); + assert_not_contains!( + response.code, + "playground::main", + "Only compiling the library" + ); + + coordinator.shutdown().await?; + + Ok(()) + } + const ADD_CODE: &str = r#"#[inline(never)] pub fn add(a: u8, b: u8) -> u8 { a + b }"#; const ARBITRARY_ASSEMBLY_REQUEST: CompileRequest = CompileRequest { @@ -3646,7 +3809,7 @@ mod tests { edition: Edition::Rust2018, tests: false, backtrace: false, - code: String::new(), + code: Code::new(), }; const DEFAULT_ASSEMBLY_FLAVOR: AssemblyFlavor = AssemblyFlavor::Intel; @@ -3792,7 +3955,7 @@ mod tests { edition: Edition::Rust2021, tests: false, backtrace: false, - code: String::new(), + code: Code::new(), }; #[tokio::test] @@ -3875,7 +4038,7 @@ mod tests { channel: Channel::Stable, crate_type: CrateType::Binary, edition: Edition::Rust2015, - code: String::new(), + code: Code::new(), }; const ARBITRARY_FORMAT_INPUT: &str = "fn main(){1+1;}"; @@ -3899,7 +4062,7 @@ mod tests { let response = coordinator.format(req).with_timeout().await.unwrap(); assert!(response.success, "stderr: {}", response.stderr); - let lines = response.code.lines().collect::>(); + let lines = response.code.assume_single().lines().collect::>(); assert_eq!(ARBITRARY_FORMAT_OUTPUT, lines); coordinator.shutdown().await?; @@ -3922,7 +4085,7 @@ mod tests { let response = coordinator.format(req).with_timeout().await.unwrap(); assert!(response.success, "stderr: {}", response.stderr); - let lines = response.code.lines().collect::>(); + let lines = response.code.assume_single().lines().collect::>(); assert_eq!(ARBITRARY_FORMAT_OUTPUT, lines); coordinator.shutdown().await?; @@ -3958,11 +4121,52 @@ mod tests { Ok(()) } + #[tokio::test] + #[snafu::report] + async fn format_multiple_files() -> Result<()> { + let coordinator = new_coordinator(); + + let req = FormatRequest { + code: kvs! { + "src/main.rs" => "fn main ( ){playground :: amaze()}", + "src/lib.rs" => "fn amaze ( ){}", + } + .collect(), + ..ARBITRARY_FORMAT_REQUEST + }; + + let response = coordinator.format(req).with_timeout().await.unwrap(); + + assert!(response.success, "stderr: {}", response.stderr); + + let lines_for = |name| { + response + .code + .assume_multiple() + .iter() + .find(|cf| cf.name == name) + .unwrap() + .content + .lines() + .collect::>() + }; + + assert_eq!( + lines_for("src/main.rs"), + ["fn main() {", " playground::amaze()", "}"] + ); + assert_eq!(lines_for("src/lib.rs"), ["fn amaze() {}"]); + + coordinator.shutdown().await?; + + Ok(()) + } + const ARBITRARY_CLIPPY_REQUEST: ClippyRequest = ClippyRequest { channel: Channel::Stable, crate_type: CrateType::Library(LibraryType::Rlib), edition: Edition::Rust2021, - code: String::new(), + code: Code::new(), }; #[tokio::test] @@ -3992,6 +4196,31 @@ mod tests { Ok(()) } + #[tokio::test] + #[snafu::report] + async fn clippy_multiple_files() -> Result<()> { + let coordinator = new_coordinator(); + + let req = ClippyRequest { + code: kvs! { + "src/main.rs" => "fn main() { let _ = 1 == 1; }", + "src/lib.rs" => "pub fn oopsie_clippy() { let _ = 'a'..'z'; }", + } + .collect(), + ..ARBITRARY_CLIPPY_REQUEST + }; + + let response = coordinator.clippy(req).with_timeout().await.unwrap(); + + assert!(!response.success, "stderr: {}", response.stderr); + assert_contains!(response.stderr, "deny(clippy::eq_op)"); + assert_contains!(response.stderr, "warn(clippy::almost_complete_range)"); + + coordinator.shutdown().await?; + + Ok(()) + } + #[tokio::test] #[snafu::report] async fn clippy_edition() -> Result<()> { @@ -4038,7 +4267,7 @@ mod tests { edition: Edition::Rust2021, tests: false, aliasing_model: AliasingModel::Stacked, - code: String::new(), + code: Code::new(), }; #[tokio::test] @@ -4101,11 +4330,43 @@ mod tests { Ok(()) } + #[tokio::test] + #[snafu::report] + async fn miri_multiple_files() -> Result<()> { + let coordinator = new_coordinator_docker(); + + let req = MiriRequest { + code: kvs! { + "src/main.rs" => "fn main() { playground::bad_stuff(); }", + + "src/lib.rs" => r#" + pub fn bad_stuff() { + unsafe { core::mem::MaybeUninit::::uninit().assume_init() }; + } + "# + } + .collect(), + ..ARBITRARY_MIRI_REQUEST + }; + + let response = coordinator.miri(req).with_timeout().await.unwrap(); + + assert!(!response.success, "stderr: {}", response.stderr); + + assert_contains!(response.stderr, "Undefined Behavior"); + assert_contains!(response.stderr, "encountered uninitialized memory"); + assert_contains!(response.stderr, "expected an integer"); + + coordinator.shutdown().await?; + + Ok(()) + } + const ARBITRARY_MACRO_EXPANSION_REQUEST: MacroExpansionRequest = MacroExpansionRequest { channel: Channel::Nightly, crate_type: CrateType::Library(LibraryType::Cdylib), edition: Edition::Rust2018, - code: String::new(), + code: Code::new(), }; #[tokio::test] @@ -4133,6 +4394,42 @@ mod tests { assert!(response.success, "stderr: {}", response.stderr); assert_contains!(response.stdout, "impl ::core::fmt::Debug for Dummy"); assert_contains!(response.stdout, "Formatter::write_str"); + assert_contains!(response.stdout, "::std::io::_print"); + + coordinator.shutdown().await?; + + Ok(()) + } + + #[tokio::test] + #[snafu::report] + async fn macro_expansion_multiple_files() -> Result<()> { + let coordinator = new_coordinator(); + + let req = MacroExpansionRequest { + code: kvs! { + "src/main.rs" => r#"fn main() { println!("{}", playground::Dummy); }"#, + "src/lib.rs" => r#"#[derive(Debug)] pub struct Dummy;"#, + } + .collect(), + crate_type: CrateType::Library(LibraryType::Rlib), + ..ARBITRARY_MACRO_EXPANSION_REQUEST + }; + + let response = coordinator + .macro_expansion(req) + .with_timeout() + .await + .unwrap(); + + assert!(response.success, "stderr: {}", response.stderr); + assert_contains!(response.stdout, "impl ::core::fmt::Debug for Dummy"); + assert_contains!(response.stdout, "Formatter::write_str"); + assert_not_contains!( + response.stdout, + "::std::io::_print", + "This is only in main.rs" + ); coordinator.shutdown().await?; @@ -4254,7 +4551,7 @@ mod tests { crate_type: CrateType::Binary, tests: false, backtrace: false, - code: Default::default(), + code: Code::new(), } } diff --git a/tests/spec/features/filetree_spec.rb b/tests/spec/features/filetree_spec.rb new file mode 100644 index 000000000..bc36f62b2 --- /dev/null +++ b/tests/spec/features/filetree_spec.rb @@ -0,0 +1,94 @@ +require 'spec_helper' +require 'support/playground_actions' + +RSpec.feature "Interacting with the file tree", type: :feature, js: true do + include PlaygroundActions + + before do + visit "/" + in_config_menu { choose('multiple') } + end + + scenario "creating a new file" do + within :filetree do + create_file('src/awesome.rs') + + expect(page).to have_button('src/awesome.rs') + end + end + + scenario "renaming a file" do + within :filetree do + create_file('src/junk.rs') + + find_button('src/junk.rs').double_click + fill_in 'pathname', with: 'src/details/new_name.rs' + click_on 'Rename file' + + expect(page).to have_button('src/details/new_name.rs') + expect(page).to_not have_button('src/junk.rs') + end + end + + scenario "removing a file" do + within :filetree do + create_file('src/junk.rs') + + click_on 'src/junk.rs' + click_on 'Remove selected file' + click_on 'Remove File' + + expect(page).to_not have_button('src/junk.rs') + end + end + + scenario "renaming a directory" do + within :filetree do + create_file('src/a/x.rs') + create_file('src/a/y/z.rs') + create_file('src/b/k.rs') + + find_button('src/a').double_click + fill_in 'pathname', with: 'src/b' + click_on 'Rename directory' + + expect(page).to have_button('src/b/x.rs') + expect(page).to have_button('src/b/y/z.rs') + expect(page).to have_button('src/b/k.rs') + + expect(page).to_not have_button('src/a/x.rs') + expect(page).to_not have_button('src/a/y/z.rs') + end + end + + scenario "removing a directory" do + within :filetree do + create_file('src/a/x.rs') + create_file('src/a/y/z.rs') + create_file('src/b/k.rs') + + click_on 'src/a' + click_on 'Remove selected directory' + click_on 'Remove Directory' + + expect(page).to_not have_button('src/a/x.rs') + expect(page).to_not have_button('src/a/y/z.rs') + expect(page).to have_button('src/b/k.rs') + end + end + + scenario "triggering a validation error" do + within :filetree do + create_file('..') + + expect(page).to_not have_button('..') + expect(page).to have_text('May not create path components starting with a dot') + end + end + + def create_file(name) + click_on 'Create file' + fill_in 'pathname', with: name + click_on 'Create new file' + end +end diff --git a/tests/spec/features/multi_file_editor_spec.rb b/tests/spec/features/multi_file_editor_spec.rb new file mode 100644 index 000000000..4474099db --- /dev/null +++ b/tests/spec/features/multi_file_editor_spec.rb @@ -0,0 +1,91 @@ +require 'spec_helper' +require 'support/editor' +require 'support/file_tree' +require 'support/matchers/editor' +require 'support/playground_actions' + +RSpec.feature "Multi-file editor interaction", type: :feature, js: true do + include PlaygroundActions + + before do + visit '/' + end + + scenario "editing and running multiple files" do + in_config_menu { choose('multiple') } + + filetree.select 'src/main.rs' + editor.set(<<~CODE) + mod utils; + + fn main() { + println!("Hello to the whole {}", utils::greeting()); + } + CODE + + filetree.create_file 'src/utils.rs' + editor.set(<<~CODE) + pub fn greeting() -> &'static str { + "world" + } + CODE + + # Verify both files retain their content + filetree.select 'src/main.rs' + expect(editor).to have_line 'mod utils;' + expect(editor).to_not have_line 'pub fn greeting()' + + filetree.select 'src/utils.rs' + expect(editor).to have_line 'pub fn greeting()' + expect(editor).to_not have_line 'mod utils;' + + # And that the files are transferred to the server + click_on "Run" + + within(:output, :stdout) do + expect(page).to have_content 'Hello to the whole world' + end + end + + scenario "toggling from single to multi-file view preserves contents" do + editor.set(<<~CODE) + fn main() { + println!("From a single file"); + } + CODE + + in_config_menu { choose('multiple') } + + # The original code had a `main` function, so should be in main.rs + filetree.select 'src/main.rs' + + expect(editor).to have_line 'println!("From a single file")' + end + + scenario "toggling from multi-file to single view preserves contents" do + in_config_menu { choose('multiple') } + + filetree.select 'src/main.rs' + editor.set('// Main file content') + + filetree.create_file 'src/helper.rs' + editor.set('// Helper module content') + + # Toggle back to single-file mode + in_config_menu { choose('single') } + + # The content should be preserved + expect(editor).to have_line '// src/main.rs' + expect(editor).to have_line '// Main file content' + expect(editor).to have_line '// src/helper.rs' + expect(editor).to have_line '// Helper module content' + end + + def editor + Editor.new(page) + end + + def filetree + FileTree.new(page) + end +end diff --git a/tests/spec/features/sharing_with_others_spec.rb b/tests/spec/features/sharing_with_others_spec.rb index 80fe0d7f5..d480fe723 100644 --- a/tests/spec/features/sharing_with_others_spec.rb +++ b/tests/spec/features/sharing_with_others_spec.rb @@ -1,5 +1,6 @@ require 'spec_helper' require 'support/editor' +require 'support/file_tree' require 'support/matchers/be_at_url' require 'support/playground_actions' @@ -11,7 +12,9 @@ # This test does more than one thing so we can avoid sending too # many requests to GitHub scenario "saving to a Gist" do - editor.set(code) + editor.set(<<~CODE) + // This code was saved by an automated test for the Rust Playground + CODE in_channel_menu { click_on("Nightly") } in_mode_menu { click_on("Release") } @@ -47,13 +50,51 @@ expect(urlo_link).to include('automated+test') end + # This test does more than one thing so we can avoid sending too + # many requests to GitHub + scenario "saving a multi-file Gist and restoring it" do + in_config_menu { choose('multiple') } + + filetree.select 'src/main.rs' + editor.set(<<~CODE) + mod utils; + + fn main() { + println!("{}", utils::greet()); + } + CODE + + filetree.create_file 'src/utils.rs' + editor.set(<<~CODE) + pub fn greet() -> &'static str { + "Hello from multi-file!" + } + CODE + + # Save to gist + within(:header) { click_on 'Share' } + + perma_link = find_link("Permalink to the playground")[:href] + + # Navigate away so we can tell that we go back to the same page + visit 'about:blank' + + visit perma_link + + filetree.select 'src/main.rs' + expect(editor).to have_line 'mod utils;' + expect(editor).to have_line 'println!("{}", utils::greet());' + + filetree.select 'src/utils.rs' + expect(editor).to have_line 'pub fn greet()' + expect(editor).to have_line '"Hello from multi-file!"' + end + def editor Editor.new(page) end - def code - <<~EOF - // This code was saved by an automated test for the Rust Playground - EOF + def filetree + FileTree.new(page) end end diff --git a/tests/spec/spec_helper.rb b/tests/spec/spec_helper.rb index 431f8033e..8ca00b498 100644 --- a/tests/spec/spec_helper.rb +++ b/tests/spec/spec_helper.rb @@ -98,6 +98,10 @@ end end +Capybara.add_selector(:filetree) do + css { '[data-test-id = "filetree"]' } +end + RSpec.configure do |config| config.after(:example, :js) do page.execute_script <<~JS diff --git a/tests/spec/support/file_tree.rb b/tests/spec/support/file_tree.rb new file mode 100644 index 000000000..06c8f81fb --- /dev/null +++ b/tests/spec/support/file_tree.rb @@ -0,0 +1,20 @@ +class FileTree + attr_reader :page + def initialize(page) + @page = page + end + + def create_file(name) + page.within(:filetree) do + page.click_on 'Create file' + page.fill_in 'pathname', with: name + page.click_on 'Create new file' + end + end + + def select(name) + page.within(:filetree) do + page.click_on name + end + end +end diff --git a/ui/Cargo.lock b/ui/Cargo.lock index d7b5ef7c4..33f8c653c 100644 --- a/ui/Cargo.lock +++ b/ui/Cargo.lock @@ -2329,6 +2329,7 @@ version = "1.1.3+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "53c96ecdfa941c8fc4fcaed14f99ada8ebed502eef533015095a07e3301d4c3c" dependencies = [ + "indexmap", "serde_core", "serde_spanned", "toml_datetime", @@ -2537,6 +2538,7 @@ dependencies = [ "futures", "octocrab", "orchestrator", + "percent-encoding", "prometheus", "regex", "rusqlite", @@ -2548,6 +2550,7 @@ dependencies = [ "tempfile", "tokio", "tokio-util", + "toml", "tower-http 0.7.0", "tracing", "tracing-subscriber", diff --git a/ui/Cargo.toml b/ui/Cargo.toml index ec0958ce9..68d5797cf 100644 --- a/ui/Cargo.toml +++ b/ui/Cargo.toml @@ -16,6 +16,7 @@ dotenv = "0.15.0" futures = "0.3.21" octocrab = "0.54" orchestrator = { path = "../compiler/base/orchestrator" } +percent-encoding = { version = "2.3.2", default-features = false } prometheus = { version = "0.14.0", default-features = false } regex = "1.0.0" rusqlite = { version = "0.40.0", default-features = false, features = ["bundled"] } @@ -27,6 +28,7 @@ strum = { version = "0.28.0", features = ["derive"] } tempfile = "3" tokio = { version = "1.9", features = ["macros", "time", "process", "rt-multi-thread"] } tokio-util = { version = "0.7.9", default-features = false, features = ["time"] } +toml = { version = "1.1.2", default-features = false, features = ["display", "parse", "serde", "std"] } tower-http = { version = "0.7", features = ["cors", "fs", "request-id", "set-header", "trace"] } tracing = { version = "0.1.37", features = ["attributes"] } tracing-subscriber = { version = "0.3.16", features = ["env-filter"] } diff --git a/ui/frontend/ConfigMenu.tsx b/ui/frontend/ConfigMenu.tsx index aebd57a25..dc34a862b 100644 --- a/ui/frontend/ConfigMenu.tsx +++ b/ui/frontend/ConfigMenu.tsx @@ -12,16 +12,48 @@ import { AssemblyFlavor, DemangleAssembly, Editor, + FileView, Orientation, PairCharacters, ProcessAssembly, Theme, } from './types'; +import * as selectors from './selectors'; +import MenuAside from './MenuAside'; +import { preserveContentAndChangeFileView } from './actions'; const MONACO_THEMES = [ 'vs', 'vs-dark', 'vscode-dark-plus', ]; +const MultifileAside = () => All files will be prefixed with their name and concatenated if Single is selected.; + +const MultifileConfig: React.FC = () => { + 'use memo'; + + const allowMultipleFiles = useAppSelector(selectors.allowMultipleFiles); + const fileView = useAppSelector((state) => state.configuration.fileView); + + const dispatch = useAppDispatch(); + + if (!allowMultipleFiles) { + return null; + } + + const aside = (fileView === FileView.Multiple) ? : undefined; + + return ( + dispatch(preserveContentAndChangeFileView(v))} /> + ); +} + const ConfigMenu: React.FC = () => { 'use memo'; @@ -41,6 +73,7 @@ const ConfigMenu: React.FC = () => { return ( <> + { + switch (err.kind) { + case 'dir-duplicate': + return ( + <> + The file {err.value} already exists + + ); + case 'dir-leading-slash': + return 'May not start with a leading slash'; + case 'dir-empty-component': + return 'May not have directories with no name'; + case 'file-cargo-toml': + return ( + <> + May not create a Cargo.toml at the top level + + ); + case 'file-playground-toml': + return ( + <> + May not create a Playground.toml at the top level + + ); + case 'file-dir-conflict': + return 'May not create a file and directory of the same name'; + case 'file-dotfile': + return 'May not create path components starting with a dot'; + case 'file-duplicate': + return ( + <> + The file {err.value} already exists + + ); + case 'file-empty-name': + return 'May not have files with no name'; + default: { + const _exhaustive: never = err; + } + } +}; + +interface NameInputLabels { + accept: string; + cancel: string; +} + +interface NameInputProps { + Icon?: React.ComponentType; + labels: NameInputLabels; + defaultValue: string; + originalValue?: string; + validate: (n: string) => E | null; + describeError: (e: E) => React.ReactNode; + onAccept: (n: string) => void; + onCancel: () => void; +} + +const NameInput = ({ + Icon, + labels, + defaultValue, + originalValue, + validate, + describeError, + onAccept: onCallerAccept, + onCancel, +}: NameInputProps) => { + 'use memo'; + + const [errorKind, setErrorKind] = useState(null); + const inputRef = useRef(null); + + const keydown = (evt: React.KeyboardEvent) => { + if (evt.key === 'Escape') { + onCancel(); + } + }; + + const submit = (evt: React.SubmitEvent) => { + evt.preventDefault(); + setErrorKind(null); + + const newName = inputRef.current?.value; + if (!newName || originalValue === newName) { + onCancel(); + return; + } + + const errorKind = validate(newName); + if (errorKind) { + setErrorKind(errorKind); + return; + } + + onCancel(); + onCallerAccept(newName); + }; + + const setSelection = (node: HTMLInputElement) => { + inputRef.current = node; + const [s, e] = defaultSelection(defaultValue); + node.setSelectionRange(s, e); + return () => { + inputRef.current = null; + }; + }; + + return ( +
+ {Icon && } + + + + {errorKind && {describeError(errorKind)}} + + ); +}; + +const NAME_INPUT_LABELS = { + create: { accept: 'Create new file', cancel: 'Cancel file creation' }, + rename: { accept: 'Rename file', cancel: 'Cancel file rename' }, +}; + +interface SpecificInputProps { + Icon?: React.ComponentType; + defaultValue: string; + originalValue?: string; + onAccept: (n: string) => void; + onCancel: () => void; +} + +const FilenameInput: React.FC = ({ + Icon, + kind, + defaultValue, + originalValue, + onAccept, + onCancel, +}) => { + 'use memo'; + + const filenames = useAppSelector(files.filenamesSelector); + const validate = (name: string) => validateFilename(filenames, name); + const labels = NAME_INPUT_LABELS[kind]; + + return ( + + ); +}; + +const DIRNAME_INPUT_LABELS = { + accept: 'Rename directory', + cancel: 'Cancel directory rename', +}; + +const DirnameInput: React.FC = ({ + Icon, + defaultValue, + originalValue, + onAccept, + onCancel, +}) => { + 'use memo'; + + const filenames = useAppSelector(files.filenamesSelector); + const validate = (name: string) => + validateDirRename(filenames, { from: originalValue, to: name }); + + return ( + + ); +}; + +interface PathButtonProps { + path: filesRules.Path; + onClick: () => void; + children: React.ReactNode; +} + +const PathButton: React.FC = ({ path, onClick, children }) => { + 'use memo'; + + const activeFile = useAppSelector(files.activeFilePathSelector); + + const focus = useAtomValue(focusAtom); + const startRename = useSetAtom(startRenameAtom); + + const isFocus = focus?.absolute === path.absolute ? true : undefined; + const isActive = activeFile?.absolute === path.absolute ? true : undefined; + + return ( + + ); +}; + +const FilePathIcon = () => ; + +interface FilePathProps { + path: filesRules.FilePath; +} + +const FilePath: React.FC = ({ path }) => { + 'use memo'; + + const dispatch = useAppDispatch(); + const setFocus = useSetAtom(focusAtom); + const rename = useAtomValue(renameAtom); + const cancelRename = useSetAtom(cancelRenameAtom); + + if (rename?.absolute === path.absolute) { + return ( +
+ dispatch(filesActions.renameFile({ from: path.absolute, to }))} + onCancel={cancelRename} + /> +
+ ); + } else { + return ( + { + setFocus(path); + dispatch(filesActions.activateFile(path.absolute)); + }} + > + {path.name} + + ); + } +}; + +const DirPathIcon = () => ; + +interface DirPathProps { + path: filesRules.DirPath; +} + +const DirPath: React.FC = ({ path }) => { + 'use memo'; + + const dispatch = useAppDispatch(); + const setFocus = useSetAtom(focusAtom); + const rename = useAtomValue(renameAtom); + const cancelRename = useSetAtom(cancelRenameAtom); + + if (rename?.absolute === path.absolute) { + return ( +
+ dispatch(filesActions.renameDirectory({ from: path.absolute, to }))} + onCancel={cancelRename} + /> +
+ ); + } else { + return ( + setFocus(path)}> + {path.name}/ + + ); + } +}; + +interface PathsProps { + paths: filesRules.Path[]; +} + +const Paths: React.FC = ({ paths }) => { + 'use memo'; + + if (paths.length === 0) { + return

No files have been created yet.

; + } + + const kids = paths.map((p) => { + const child = p.kind === 'file' ? : ; + + const style = { '--depth': p.parentNames.length } as React.CSSProperties; + + return ( +
  • + {child} +
  • + ); + }); + + return
      {kids}
    ; +}; + +interface RemoveDialogLabels { + message: string; + remove: string; + cancel: string; +} + +interface RemoveDialogProps { + labels: RemoveDialogLabels; + onRemove: () => void; + ref: React.RefObject; +} + +const RemoveDialog: React.FC = ({ labels, onRemove, ref }) => { + 'use memo'; + + // This can be replaced with the Invoker API [1] once it's been + // stable long enough. (invoker-api) + // + // https://developer.mozilla.org/en-US/docs/Web/API/Invoker_Commands_API + const accept = () => { + onRemove(); + ref.current?.close(); + }; + + // See (invoker-api) + const cancel = () => { + ref.current?.close(); + }; + + return ( + +
    +

    {labels.message}

    +

    + Tip: shift-click the remove icon to skip this confirmation dialog. +

    + + +
    +
    + ); +}; + +const newFileSuggestion = (selectedPath: filesRules.Path | null) => { + if (!selectedPath) { + return 'src/main.rs'; + } + + const pathParts = [...selectedPath.parentNames]; + + if (selectedPath.kind === 'file') { + const [_prefix, suffix] = filesRules.filenameSplitAtDot(selectedPath.name); + const newFile = 'file' + suffix; + pathParts.push(newFile); + } else { + pathParts.push(selectedPath.name, 'file.rs'); + } + + return pathParts.join('/'); +}; + +const REMOVE_LABELS = { + file: { + message: 'Would you like to remove this file? The contents will be lost.', + remove: 'Remove File', + cancel: 'Keep File', + }, + dir: { + message: + 'Would you like to remove this directory? All children files and directories will be removed and their contents will be lost.', + remove: 'Remove Directory', + cancel: 'Keep Directory', + }, +}; + +const focusAtom = atom(null); + +const isAddingAtom = atom(false); +const startAddingAtom = atom(false, (_get, set) => { + set(isAddingAtom, true); +}); +const cancelAddingAtom = atom(false, (_get, set) => { + set(isAddingAtom, false); +}); + +const renameAtom = atom(null); +const startRenameAtom = atom(null, (get, set) => { + set(renameAtom, get(focusAtom)); +}); +const cancelRenameAtom = atom(null, (_get, set) => { + set(renameAtom, null); +}); + +const FileTree: React.FC = () => { + 'use memo'; + + const dispatch = useAppDispatch(); + + const paths = useAppSelector(files.filetreeSelector); + const activeFile = useAppSelector(files.activeFilePathSelector); + const [focus, setFocus] = useAtom(focusAtom); + const isAdding = useAtomValue(isAddingAtom); + + const dialog = useRef(null); + + const startAdd = useSetAtom(startAddingAtom); + const acceptAdd = (newName: string) => dispatch(filesActions.createFile(newName)); + const cancelAdd = useSetAtom(cancelAddingAtom); + + const startRename = useSetAtom(startRenameAtom); + + const remove = () => { + if (!focus) { + return; + } + if (focus.kind === 'file') { + dispatch(filesActions.deleteFile(focus.absolute)); + } else { + dispatch(filesActions.deleteDirectory(focus.absolute)); + } + }; + const startRemove = (evt: React.MouseEvent) => { + if (evt.shiftKey) { + remove(); + } else { + dialog.current?.showModal(); + } + }; + + // If someone changes the active file from outside, such as when a + // file is deleted, update the focus to follow. + const focusOnActive = useEffectEvent(() => { + if (focus?.absolute !== activeFile?.absolute) { + setFocus(activeFile ?? null); + } + }); + useEffect(() => focusOnActive(), [activeFile?.absolute]); + + const suggestedFilePath = newFileSuggestion(focus); + const focusedLabel = focus?.kind === 'file' ? 'file' : 'directory'; + + return ( +
    +
    + Files + + + +
    + + {isAdding ? ( + + ) : null} + {focus && } +
    + ); +}; + +export default FileTree; diff --git a/ui/frontend/Icon.tsx b/ui/frontend/Icon.tsx index 02f647fc2..272a3797c 100644 --- a/ui/frontend/Icon.tsx +++ b/ui/frontend/Icon.tsx @@ -5,6 +5,16 @@ import * as styles from './Icon.module.css'; // These icons came from Material Design originally // https://material.io/tools/icons/?icon=assignment&style=outline +type IconProps = { className?: string }; + +function addClassName(className?: string) { + if (className) { + return `${styles.icon} ${className}`; + } else { + return styles.icon; + } +} + // M.D. 'play_arrow' export const BuildIcon = () => ( @@ -68,3 +78,54 @@ export const Close = () => ( ); + +// M.D. 'insert_drive_file' +export const File: React.FC = ({ className }) => ( + + + +); + +// M.D. 'folder' +export const Directory: React.FC = ({ className }) => ( + + + +); + +// M.D. 'note_add' +export const FileAdd = () => ( + + + +); + +// M.D. 'delete_forever' +export const DeleteForever = () => ( + + + + +); + +// M.D. 'edit' +export const Edit = () => ( + + + + +); + +// M.D. 'check_circle' +export const CheckCircle = () => ( + + + +); + +// M.D. 'cancel' +export const Cancel = () => ( + + + +); diff --git a/ui/frontend/Playground.module.css b/ui/frontend/Playground.module.css index 18ed9f503..d6fd09e47 100644 --- a/ui/frontend/Playground.module.css +++ b/ui/frontend/Playground.module.css @@ -10,6 +10,7 @@ --minimum: 100px; --gutter-dimension: 12px; + --final-files-width: minmax(var(--minimum), var(--files-width, 12em)); --final-editor-dimension: minmax(var(--minimum), 1fr); --final-output-width: minmax(var(--minimum), var(--output-width, 1fr)); @@ -24,11 +25,22 @@ --final-output-height: minmax(var(--minimum), var(--output-height-cap, 1fr)); display: grid; +} + +.playground[data-file-view='single'] { grid-template-areas: 'eee'; grid-template-columns: var(--final-editor-dimension); } -.playground[data-orientation='horizontal'][data-output-mode='full'] { +.playground[data-file-view='multiple'] { + grid-template-areas: 'fff feg eee'; + grid-template-columns: + var(--final-files-width) + var(--gutter-dimension) + var(--final-editor-dimension); +} + +.playground[data-file-view='single'][data-orientation='horizontal'][data-output-mode='full'] { grid-template-areas: 'eee' 'eog' @@ -39,7 +51,18 @@ var(--final-output-height); } -.playground[data-orientation='horizontal'][data-output-mode='slim'] { +.playground[data-file-view='multiple'][data-orientation='horizontal'][data-output-mode='full'] { + grid-template-areas: + 'fff feg eee' + 'eog eog eog' + 'ooo ooo ooo'; + grid-template-rows: + var(--final-editor-dimension) + var(--gutter-dimension) + var(--final-output-height); +} + +.playground[data-file-view='single'][data-orientation='horizontal'][data-output-mode='slim'] { grid-template-areas: 'eee' 'ooo'; @@ -48,7 +71,16 @@ auto; } -.playground[data-orientation='vertical'][data-output-mode='full'] { +.playground[data-file-view='multiple'][data-orientation='horizontal'][data-output-mode='slim'] { + grid-template-areas: + 'fff feg eee' + 'ooo ooo ooo'; + grid-template-rows: + var(--final-editor-dimension) + auto; +} + +.playground[data-file-view='single'][data-orientation='vertical'][data-output-mode='full'] { grid-template-areas: 'eee eog ooo'; grid-template-columns: var(--final-editor-dimension) @@ -56,13 +88,40 @@ var(--final-output-width); } -.playground[data-orientation='vertical'][data-output-mode='slim'] { +.playground[data-file-view='multiple'][data-orientation='vertical'][data-output-mode='full'] { + grid-template-areas: 'fff feg eee eog ooo'; + grid-template-columns: + var(--final-files-width) + var(--gutter-dimension) + var(--final-editor-dimension) + var(--gutter-dimension) + var(--final-output-width); +} + +.playground[data-file-view='single'][data-orientation='vertical'][data-output-mode='slim'] { grid-template-areas: 'eee ooo'; grid-template-columns: var(--final-editor-dimension) auto; } +.playground[data-file-view='multiple'][data-orientation='vertical'][data-output-mode='slim'] { + grid-template-areas: 'fff feg eee ooo'; + grid-template-columns: + var(--final-files-width) + var(--gutter-dimension) + var(--final-editor-dimension) + auto; +} + +.files { + grid-area: fff; +} + +.filesEditorGutter { + grid-area: feg; +} + .editor { grid-area: eee; border: 4px solid var(--border-color); diff --git a/ui/frontend/Playground.tsx b/ui/frontend/Playground.tsx index 0f3c47c57..28d7910a7 100644 --- a/ui/frontend/Playground.tsx +++ b/ui/frontend/Playground.tsx @@ -1,12 +1,13 @@ -import React, { Activity, useRef, useState } from 'react'; +import React, { Activity, StrictMode, useRef, useState } from 'react'; +import FileTree from './FileTree'; import Header from './Header'; import Notifications from './Notifications'; import Output from './Output'; import Editor from './editor/Editor'; import { useAppSelector } from './hooks'; import * as selectors from './selectors'; -import { Orientation } from './types'; +import { FileView, Orientation } from './types'; import * as styles from './Playground.module.css'; @@ -136,15 +137,26 @@ const makeStyleFromPixels = (cssProps: [string, number | undefined][]): React.CS const ResizableArea: React.FC = () => { 'use memo'; + // TODO: name `fileView`??? + const fileView = useAppSelector((state) => state.configuration.fileView); const somethingToShow = useAppSelector(selectors.getSomethingToShow); const isFocused = useAppSelector(selectors.isOutputFocused); const orientation = useAppSelector(selectors.orientation); const container = useRef(null); + const [filesWidth, setFilesWidth] = useState(undefined); const [outputWidth, setOutputWidth] = useState(undefined); const [outputHeight, setOutputHeight] = useState(undefined); + const filesEditorGutterMove = (distances: Distances) => { + setFilesWidth(distances.toLeft); + }; + + const filesEditorGutterReset = () => { + setFilesWidth(undefined); + }; + const editorOutputGutterMove = (distances: Distances) => { if (orientation === Orientation.Horizontal) { setOutputHeight(distances.toBottom); @@ -174,6 +186,7 @@ const ResizableArea: React.FC = () => { const hideEditorOutputGutter = outputMode !== 'full'; const style = makeStyleFromPixels([ + ['--files-width', filesWidth], ['--output-height', outputHeight], ['--output-width', outputWidth], ]); @@ -182,10 +195,27 @@ const ResizableArea: React.FC = () => {
    + +
    + + + +
    + + +
    +
    diff --git a/ui/frontend/actions.ts b/ui/frontend/actions.ts index 9676c07e5..9b2599a4f 100644 --- a/ui/frontend/actions.ts +++ b/ui/frontend/actions.ts @@ -6,9 +6,11 @@ import { changeBacktrace, changeChannel, changeEdition, + changeFileView, changeMode, changePrimaryAction, } from './reducers/configuration'; +import * as files from './reducers/files'; import { performCompileToAssemblyOnly } from './reducers/output/assembly'; import { performCommonExecute } from './reducers/output/execute'; import { performGistLoad } from './reducers/output/gist'; @@ -17,11 +19,18 @@ import { performCompileToLlvmIrOnly } from './reducers/output/llvmIr'; import { performCompileToMirOnly } from './reducers/output/mir'; import { performCompileToWasmOnly } from './reducers/output/wasm'; import { navigateToHelp, navigateToIndex } from './reducers/page'; -import { getCrateType, runAsTest, wasmLikelyToWork } from './selectors'; +import { + getCrateType, + hasMainFunction, + linearCodeSelector, + runAsTest, + wasmLikelyToWork, +} from './selectors'; import { Backtrace, Channel, Edition, + FileView, Mode, PrimaryAction, PrimaryActionAuto, @@ -178,3 +187,18 @@ export function showExample(code: string): ThunkAction { dispatch(editCode(code)); }; } + +export function preserveContentAndChangeFileView(fv: FileView): ThunkAction { + return function (dispatch, getState) { + const state = getState(); + dispatch(changeFileView(fv)); + if (fv === FileView.Single) { + const code = linearCodeSelector(state); + dispatch(editCode(code)); + } else { + const content = state.code; + const name = hasMainFunction(content) ? 'src/main.rs' : 'src/lib.rs'; + dispatch(files.initializeWith({ name, content })); + } + }; +} diff --git a/ui/frontend/compileActions.ts b/ui/frontend/compileActions.ts index f103966c2..92f455662 100644 --- a/ui/frontend/compileActions.ts +++ b/ui/frontend/compileActions.ts @@ -4,13 +4,14 @@ import * as z from 'zod'; import { ThunkAction } from './actions'; import { jsonPost, routes } from './api'; import { compileRequestPayloadSelector } from './selectors'; +import { Code } from './types'; interface CompileRequestBody { channel: string; mode: string; crateType: string; tests: boolean; // Used? - code: string; + code: Code; edition: string; backtrace: boolean; // Used? target: string; diff --git a/ui/frontend/configureStore.ts b/ui/frontend/configureStore.ts index d1a63f03d..4aede07ea 100644 --- a/ui/frontend/configureStore.ts +++ b/ui/frontend/configureStore.ts @@ -1,6 +1,6 @@ import { configureStore as reduxConfigureStore } from '@reduxjs/toolkit'; import { produce } from 'immer'; -import { merge } from 'lodash-es'; +import { mergeWith } from 'lodash-es'; import initializeLocalStorage from './local_storage'; import { observer } from './observer'; @@ -37,12 +37,24 @@ export default function configureStore(window: Window) { const sessionStorage = initializeSessionStorage(); const preloadedState = produce(initialAppState, (initialAppState) => - merge( + mergeWith( initialAppState, initialGlobalState, initialThemes, localStorage.initialState, sessionStorage.initialState, + (_objValue, srcValue, key, _object, _source, _stack) => { + if (key === 'code') { + // We want user-provided code to replace the initial code, + // not merge with it. + // + // This feels pretty brittle; it'd be nice if we could check + // the full path to the key we are merging. + return srcValue; + } + + return undefined; + }, ), ); diff --git a/ui/frontend/domain/filesRules.spec.ts b/ui/frontend/domain/filesRules.spec.ts new file mode 100644 index 000000000..c43819748 --- /dev/null +++ b/ui/frontend/domain/filesRules.spec.ts @@ -0,0 +1,94 @@ +import * as codeRules from './filesRules'; + +describe('manipulating filenames', () => { + test('building a file tree', () => { + const r = codeRules.filenamesToTree(['a.rs', 'a/b/c/d/e.rs']); + expect(r).toEqual([ + codeRules.makeFilePath('a.rs'), + codeRules.makeDirPath('a'), + codeRules.makeDirPath('a/b'), + codeRules.makeDirPath('a/b/c'), + codeRules.makeDirPath('a/b/c/d'), + codeRules.makeFilePath('a/b/c/d/e.rs'), + ]); + }); + + test('default selection when editing filenames', () => { + const getSelection = (n: string) => codeRules.defaultSelection(n); + + expect(getSelection('alpha')).toEqual([0, 5]); + expect(getSelection('alpha.rs')).toEqual([0, 5]); + expect(getSelection('alpha.rs.bak')).toEqual([0, 5]); + expect(getSelection('foo/bar.rs')).toEqual([4, 7]); + expect(getSelection('foo/bar/baz.rs')).toEqual([8, 11]); + }); + + test('detects duplicate filenames', () => { + const files = ['src/main.rs']; + + const result = codeRules.validateFilename(files, 'src/main.rs'); + expect(result).toEqual({ kind: 'file-duplicate', value: 'src/main.rs' }); + }); + + test('rejects paths with empty components (double slash)', () => { + const files: string[] = []; + + const result = codeRules.validateFilename(files, 'foo//bar.rs'); + expect(result).toEqual({ kind: 'dir-empty-component' }); + }); + + test('rejects paths with leading slash', () => { + const files: string[] = []; + + const result = codeRules.validateFilename(files, '/absolute/path.rs'); + expect(result).toEqual({ kind: 'dir-leading-slash' }); + }); + + test('rejects dot files', () => { + const files: string[] = []; + + const expectFail = (name: string) => { + const result = codeRules.validateFilename(files, name); + expect(result).toEqual({ kind: 'file-dotfile' }); + }; + + expectFail('.'); + expectFail('..'); + expectFail('.cargo'); + }); + + test('rejects a root `Cargo.toml`', () => { + const files: string[] = []; + + const result = codeRules.validateFilename(files, 'Cargo.toml'); + expect(result).toEqual({ kind: 'file-cargo-toml' }); + }); + + test('creating a file when it exists as a directory', () => { + const files: string[] = ['src/main.rs']; + + const result = codeRules.validateFilename(files, 'src'); + expect(result).toEqual({ kind: 'file-dir-conflict' }); + }); + + test('creating a directory when it exists as a file', () => { + const files: string[] = ['src']; + + const result = codeRules.validateFilename(files, 'src/main.rs'); + expect(result).toEqual({ kind: 'file-dir-conflict' }); + }); + + test('renaming a directory to an invalid filename', () => { + const files = ['a/main.rs']; + + const r = codeRules.validateDirRename(files, { from: 'a', to: '.foo' }); + expect(r).toEqual({ kind: 'file-dotfile' }); + }); + + test('renaming a directory that would clobber files', () => { + const files = ['a/main.rs', 'b/main.rs']; + + const r = codeRules.validateDirRename(files, { from: 'a', to: 'b' }); + expect(r).toEqual({ kind: 'dir-duplicate', value: 'b/main.rs' }); + }); +}); diff --git a/ui/frontend/domain/filesRules.ts b/ui/frontend/domain/filesRules.ts new file mode 100644 index 000000000..2c32002f8 --- /dev/null +++ b/ui/frontend/domain/filesRules.ts @@ -0,0 +1,249 @@ +interface Success { + readonly ok: true; + readonly value: T; +} + +interface Failure { + readonly ok: false; + readonly error: E; +} + +export type Result = Success | Failure; + +export const arrayIsPrefix = (needle: string[], haystack: string[]): boolean => { + if (haystack.length < needle.length) { + return false; + } + + for (let i = 0; i < needle.length; i++) { + const n = needle[i]; + const h = haystack[i]; + + if (n !== h) { + return false; + } + } + + return true; +}; + +interface CommonPath { + readonly absolute: string; + readonly parts: string[]; + readonly parentNames: string[]; + readonly name: string; +} + +const makeCommonPathFromParts = (parts: string[]): CommonPath => ({ + parts, + get absolute(): string { + return this.parts.join('/'); + }, + get parentNames(): string[] { + return this.parts.slice(0, -1); + }, + get name(): string { + return this.parts[this.parts.length - 1]; + }, +}); + +const makeCommonPath = (absolute: string): CommonPath => { + return makeCommonPathFromParts(absolute.split('/')); +}; + +export type FilePath = CommonPath & { readonly kind: 'file' }; +export type DirPath = CommonPath & { readonly kind: 'dir' }; +export type Path = FilePath | DirPath; + +export const makeFilePath = (absolute: string): FilePath => { + const path = makeCommonPath(absolute); + return { kind: 'file', ...path }; +}; + +export const makeDirPath = (absolute: string): DirPath => { + const path = makeCommonPath(absolute); + return { kind: 'dir', ...path }; +}; + +function* parentsFromRoot(path: Path): Generator { + for (let i = 0; i < path.parts.length - 1; i++) { + const parts = path.parts.slice(0, i + 1); + yield { kind: 'dir', ...makeCommonPathFromParts(parts) }; + } +} + +export const filenamesToTree = (names: string[]): Path[] => { + const myNames = [...names]; + myNames.sort((a, b) => a.localeCompare(b)); + + const files: Path[] = []; + const addedDirs = new Set(); + + for (const absoluteName of myNames) { + const filePath = makeFilePath(absoluteName); + + for (const parentDir of parentsFromRoot(filePath)) { + if (!addedDirs.has(parentDir.absolute)) { + addedDirs.add(parentDir.absolute); + files.push(parentDir); + } + } + + files.push(filePath); + } + + return files; +}; + +type FilePathError = + | { kind: 'file-cargo-toml' } + | { kind: 'file-playground-toml' } + | { kind: 'file-dotfile' } + | { kind: 'file-empty-name' } + | { kind: 'dir-leading-slash' } + | { kind: 'dir-empty-component' }; + +const makeValidatedFilePath = (absolute: string): Result => { + const path = makeFilePath(absolute); + + if (path.name === '') { + return { ok: false, error: { kind: 'file-empty-name' } }; + } + + if (path.parts[0] === '') { + return { ok: false, error: { kind: 'dir-leading-slash' } }; + } + + for (const parentName of path.parentNames) { + if (parentName === '') { + return { ok: false, error: { kind: 'dir-empty-component' } }; + } + } + + if (path.parts.some((n) => n.startsWith('.'))) { + return { ok: false, error: { kind: 'file-dotfile' } }; + } + + const hasTopLevelFile = (name: string) => path.parentNames.length === 0 && path.name === name; + + if (hasTopLevelFile('Cargo.toml')) { + return { ok: false, error: { kind: 'file-cargo-toml' } }; + } + + if (hasTopLevelFile('Playground.toml')) { + return { ok: false, error: { kind: 'file-playground-toml' } }; + } + + return { ok: true, value: path }; +}; + +export type FilePathInError = + { kind: 'file-duplicate'; value: string } | { kind: 'file-dir-conflict' } | FilePathError; + +const makeValidatedFilePathIn = ( + files: string[], + absolute: string, +): Result => { + const r = makeValidatedFilePath(absolute); + if (!r.ok) { + return r; + } + const path = r.value; + + for (const file of files) { + const fp = makeFilePath(file); + + if (fp.absolute === path.absolute) { + return { ok: false, error: { kind: 'file-duplicate', value: file } }; + } + + if (arrayIsPrefix(path.parts, fp.parts) || arrayIsPrefix(fp.parts, path.parts)) { + return { ok: false, error: { kind: 'file-dir-conflict' } }; + } + } + + return { ok: true, value: path }; +}; + +export const validateFilename = (files: string[], absolute: string): FilePathInError | null => { + const p = makeValidatedFilePathIn(files, absolute); + return p.ok ? null : p.error; +}; + +export interface Rename { + from: string; + to: string; +} + +// If the path begins with the `from` directory, it will be replaced +// with the `to` directory. Otherwise, the path is returned unchanged. +const makePerformDirRename = ({ from, to }: Rename) => { + const fromPath = makeCommonPath(from); + const toPath = makeCommonPath(to); + + return (s: string): string => { + const path = makeCommonPath(s); + + if (arrayIsPrefix(fromPath.parts, path.parentNames)) { + const newParts = [...toPath.parts, ...path.parts.slice(fromPath.parts.length)]; + const newPath = makeCommonPathFromParts(newParts); + return newPath.absolute; + } else { + return s; + } + }; +}; + +export type DirInError = { kind: 'dir-duplicate'; value: string } | FilePathError; + +export const makeValidatedDirRenamer = ( + files: string[], + rename: Rename, +): Result<(s: string) => string, DirInError> => { + const renamer = makePerformDirRename(rename); + + const set = new Set(); + + for (const file of files) { + const newName = renamer(file); + if (set.has(newName)) { + return { ok: false, error: { kind: 'dir-duplicate', value: newName } }; + } + set.add(newName); + + const r = makeValidatedFilePath(newName); + if (!r.ok) { + return r; + } + } + + return { ok: true, value: renamer }; +}; + +export const validateDirRename = (files: string[], rename: Rename): DirInError | null => { + const r = makeValidatedDirRenamer(files, rename); + return r.ok ? null : r.error; +}; + +export const defaultSelection = (name: string): [number, number] => { + const path = makeCommonPath(name); + + const parentLength = path.parentNames.reduce((sum, n) => sum + n.length, 0); + const slashesLength = path.parentNames.length * '/'.length; + const startPos = parentLength + slashesLength; + + const endOffset = filenameDotIndex(path.name); + const endPos = startPos + endOffset; + + return [startPos, endPos]; +}; + +const filenameDotIndex = (name: string): number => { + const dotOffset = name.indexOf('.'); + return dotOffset === -1 ? name.length : dotOffset; +}; + +export const filenameSplitAtDot = (name: string): [string, string] => { + const index = filenameDotIndex(name); + return [name.substring(0, index), name.substring(index)]; +}; diff --git a/ui/frontend/editor/AceEditorCore.tsx b/ui/frontend/editor/AceEditorCore.tsx index 6db042f6d..dea50100b 100644 --- a/ui/frontend/editor/AceEditorCore.tsx +++ b/ui/frontend/editor/AceEditorCore.tsx @@ -6,7 +6,7 @@ import 'ace-builds/src-noconflict/ext-searchbox'; import 'ace-builds/src-noconflict/mode-rust'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { CommonEditorProps, Crate, PairCharacters, Position } from '../types'; +import { CommonEditorProps, Crate, FileId, PairCharacters, Position } from '../types'; import { useLatest } from './useLatest'; import * as styles from './Editor.module.css'; @@ -100,15 +100,30 @@ function useEditorProp(editor: Ace.Editor | null, prop: T, whenPresent: (edit const AceEditor: React.FC = props => { const [editor, setEditor] = useState(null); + const sessionCache = useRef>(new Map()); + const latestActiveFileIdRef = useLatest(props.activeFileId); const latestCodeRef = useLatest(props.code); const latestThemeRef = useLatest(props.theme); + const createCachedSession = useCallback(() => { + const activeFileId = latestActiveFileIdRef.current; + const cache = sessionCache.current; + + if (!cache.has(activeFileId)) { + const session = ace.createEditSession(latestCodeRef.current); + session.setMode('ace/mode/rust'); + cache.set(activeFileId, session); + } + return cache.get(activeFileId); + }, [latestActiveFileIdRef, latestCodeRef]); + const child = useCallback((node: HTMLDivElement | null) => { if (!node) { return; } + const session = createCachedSession(); + const editor = ace.edit(node, { - mode: 'ace/mode/rust', - value: latestCodeRef.current, + session, theme: themeToAceId(latestThemeRef.current), }); setEditor(editor); @@ -133,7 +148,33 @@ const AceEditor: React.FC = props => { setEditor(null); node.textContent = ''; }; - }, [latestCodeRef, latestThemeRef]); + }, [createCachedSession, latestThemeRef]); + + useEditorProp(editor, props.activeFileId, useCallback((editor, _activeFileId) => { + const session = createCachedSession(); + editor.setSession(session); + }, [createCachedSession])); + + // Destroy cached sessions that have been removed + useEffect(() => { + for (const [cachedFileId, session] of sessionCache.current) { + if (!props.fileIds.includes(cachedFileId)) { + session.destroy(); + sessionCache.current.delete(cachedFileId); + } + } + }, [props.fileIds]) + + // Destroy all cached sessions when we unmount + useEffect(() => { + const cache = sessionCache; + return () => { + for (const session of cache.current.values()) { + session.destroy(); + } + cache.current.clear(); + } + }, []); useEditorProp(editor, props.execute, useCallback((editor, execute) => { // TODO: Remove command? diff --git a/ui/frontend/editor/Editor.module.css b/ui/frontend/editor/Editor.module.css index 67969aa41..63734eb51 100644 --- a/ui/frontend/editor/Editor.module.css +++ b/ui/frontend/editor/Editor.module.css @@ -3,6 +3,17 @@ position: relative; } +.nothing { + composes: -autoSize from '../shared.module.css'; + display: grid; + place-items: center; + font-size: 120%; +} + +.nothingAction { + composes: -buttonAsLink from '../shared.module.css'; +} + .-advanced { composes: -bodyMonospace -autoSize from '../shared.module.css'; position: absolute; diff --git a/ui/frontend/editor/Editor.tsx b/ui/frontend/editor/Editor.tsx index ee13d2904..af34ab17d 100644 --- a/ui/frontend/editor/Editor.tsx +++ b/ui/frontend/editor/Editor.tsx @@ -3,7 +3,10 @@ import React from 'react'; import * as actions from '../actions'; import { useAppDispatch, useAppSelector } from '../hooks'; import { editCode } from '../reducers/code'; -import { codeSelector, positionSelector, selectionSelector } from '../selectors'; +import * as config from '../reducers/configuration'; +import * as files from '../reducers/files'; +import { activeCodeSelector, positionSelector, selectionSelector } from '../selectors'; +import { activeFileIdSelector, fileIdsSelector } from '../selectors'; import { Editor as EditorType } from '../types'; import AceEditor from './AceEditor'; import MonacoEditor from './MonacoEditor'; @@ -11,6 +14,39 @@ import SimpleEditor from './SimpleEditor'; import * as styles from './Editor.module.css'; +interface NothingActionProps { + onClick: () => void; + children: React.ReactNode; +} + +const NothingAction: React.FC = ({ onClick, children }) => ( + +); + +const NothingToEdit: React.FC = () => { + 'use memo'; + + const dispatch = useAppDispatch(); + + return ( +
    +

    + No files exist. Create a{' '} + dispatch(files.createFile('src/main.rs'))}> + new file + {' '} + or{' '} + dispatch(config.changeFileView('single'))}> + switch back to single file mode + + . +

    +
    + ); +}; + const editorMap = { [EditorType.Simple]: SimpleEditor, [EditorType.Ace]: AceEditor, @@ -20,14 +56,20 @@ const editorMap = { const Editor: React.FC = () => { 'use memo'; - const code = useAppSelector(codeSelector); + const code = useAppSelector(activeCodeSelector); const editor = useAppSelector((state) => state.configuration.editor); const position = useAppSelector(positionSelector); const selection = useAppSelector(selectionSelector); const crates = useAppSelector((state) => state.crates); + const activeFileId = useAppSelector(activeFileIdSelector); + const fileIds = useAppSelector(fileIdsSelector); const dispatch = useAppDispatch(); + if (code === undefined || activeFileId === null) { + return ; + } + const SelectedEditor = editorMap[editor]; return ( @@ -37,6 +79,8 @@ const Editor: React.FC = () => { position={position} selection={selection} crates={crates} + activeFileId={activeFileId} + fileIds={fileIds} onEditCode={(c) => dispatch(editCode(c))} execute={() => dispatch(actions.performPrimaryAction())} /> diff --git a/ui/frontend/editor/MonacoEditorCore.tsx b/ui/frontend/editor/MonacoEditorCore.tsx index 3e884e9ad..868d2bf59 100644 --- a/ui/frontend/editor/MonacoEditorCore.tsx +++ b/ui/frontend/editor/MonacoEditorCore.tsx @@ -3,7 +3,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useAppSelector } from '../hooks'; import { offerCrateAutocompleteOnUse } from '../selectors'; -import { CommonEditorProps } from '../types'; +import { CommonEditorProps, FileId } from '../types'; import { themeVsDarkPlus } from './rust_monaco_def'; import { useLatest } from './useLatest'; @@ -40,12 +40,22 @@ function useEditorProp( }, [editor, prop, whenPresent]); } +interface EditorState { + model: monaco.editor.ITextModel; + viewState: monaco.editor.ICodeEditorViewState | null; + position: monaco.Position | null; + selections: monaco.Selection[] | null; + readonly dispose: () => void; +} + const MonacoEditorCore: React.FC = (props) => { const [editor, setEditor] = useState(null); + const stateCache = useRef>(new Map()); const theme = useAppSelector((s) => s.configuration.monaco.theme); const completionProvider = useRef(null); const autocompleteOnUse = useAppSelector(offerCrateAutocompleteOnUse); + const latestActiveFileIdRef = useLatest(props.activeFileId); const latestCodeRef = useLatest(props.code); const latestThemeRef = useLatest(theme); @@ -54,6 +64,25 @@ const MonacoEditorCore: React.FC = (props) => { monaco.editor.defineTheme('vscode-dark-plus', themeVsDarkPlus); }, []); + const createCachedState = useCallback(() => { + const activeFileId = latestActiveFileIdRef.current; + const cache = stateCache.current; + + if (!cache.has(activeFileId)) { + cache.set(activeFileId, { + model: monaco.editor.createModel(latestCodeRef.current, 'rust'), + viewState: null, + selections: null, + position: null, + dispose() { + this.model.dispose(); + }, + }); + } + + return cache.get(activeFileId)!; + }, [latestActiveFileIdRef, latestCodeRef]); + // Construct the editor const child = useCallback( (node: HTMLDivElement | null) => { @@ -63,9 +92,10 @@ const MonacoEditorCore: React.FC = (props) => { const nodeStyle = window.getComputedStyle(node); + const { model } = createCachedState(); + const editor = monaco.editor.create(node, { - language: 'rust', - value: latestCodeRef.current, + model, theme: latestThemeRef.current, fontSize: parseInt(nodeStyle.fontSize, 10), fontFamily: nodeStyle.fontFamily, @@ -79,9 +109,60 @@ const MonacoEditorCore: React.FC = (props) => { editor.focus(); }, - [latestCodeRef, latestThemeRef], + [createCachedState, latestThemeRef], + ); + + useEditorProp( + editor, + props.activeFileId, + useCallback( + (editor, _model, _activeFileId) => { + const oldModel = editor.getModel(); + if (oldModel) { + const oldState = stateCache.current.values().find((s) => s.model.id === oldModel.id); + if (oldState) { + oldState.viewState = editor.saveViewState(); + oldState.position = editor.getPosition(); + oldState.selections = editor.getSelections(); + } + } + + const { model, viewState, selections, position } = createCachedState(); + + editor.setModel(model); + editor.restoreViewState(viewState); + if (position) { + editor.setPosition(position); + } + if (selections) { + editor.setSelections(selections); + } + }, + [createCachedState], + ), ); + // Dispose cached states that have been removed + useEffect(() => { + for (const [cachedFileId, session] of stateCache.current) { + if (!props.fileIds.includes(cachedFileId)) { + session.dispose(); + stateCache.current.delete(cachedFileId); + } + } + }, [props.fileIds]); + + // Dispose all cached states when we unmount + useEffect(() => { + const cache = stateCache; + return () => { + for (const state of cache.current.values()) { + state.dispose(); + } + cache.current.clear(); + }; + }, []); + useEditorProp( editor, props.onEditCode, diff --git a/ui/frontend/highlighting.ts b/ui/frontend/highlighting.ts index ddbe2aab2..a4d121884 100644 --- a/ui/frontend/highlighting.ts +++ b/ui/frontend/highlighting.ts @@ -4,6 +4,8 @@ import { Channel, makePosition, Position } from './types'; interface ConfigureRustErrorsArgs { enableFeatureGate: (feature: string) => void; getChannel: () => Channel; + getMultifile: () => boolean; + activateFile: (f: string) => void; gotoPosition: (p: Position) => void; selectText: (start: Position, end: Position) => void; addImport: (code: string) => void; @@ -13,6 +15,8 @@ interface ConfigureRustErrorsArgs { export function configureRustErrors({ enableFeatureGate, getChannel, + getMultifile, + activateFile, gotoPosition, selectText, addImport, @@ -37,7 +41,7 @@ export function configureRustErrors({ 'see-issue': /see .*rust-lang\/rust\/issues\/\d+>/, }, }, - 'error-location': /-->\s+(\/playground\/)?src\/.*\n/, + 'error-location': /-->\s+.*\.rs:.*\n/, 'import-suggestion-outer': { pattern: /\+\s+use\s+([^;]+);/, inside: { @@ -51,9 +55,12 @@ export function configureRustErrors({ }, }, 'backtrace': { - pattern: /at \.\/src\/.*\n/, + pattern: /(panicked |\s+)at [^:]+\.rs.*\n/, inside: { - 'backtrace-location': /src\/main.rs:(\d+)/, + 'backtrace-location': { + pattern: /(at )[^:]+:\d+:\d+/, + lookbehind: true, + }, }, }, 'backtrace-enable': /Run with `RUST_BACKTRACE=1` environment variable to display a backtrace/i, @@ -83,32 +90,25 @@ export function configureRustErrors({ } } if (env.type === 'error-location') { - let line; - let col; - const errorMatchFull = /(\d+):(\d+)/.exec(env.content); - if (errorMatchFull) { - line = errorMatchFull[1]; - col = errorMatchFull[2]; - } else { - const errorMatchShort = /:(\d+)/.exec(env.content); - if (errorMatchShort) { - line = errorMatchShort[1]; - col = '1'; - } - } - env.tag = 'a'; - env.attributes.href = '#'; - if (line && col) { + const errorMatch = /\s([^:]*):(\d+)(?::(\d+))?/.exec(env.content); + if (errorMatch) { + const [_, file, line, col = '1'] = errorMatch; + + env.tag = 'a'; + env.attributes.href = '#'; + env.attributes['data-file'] = file; env.attributes['data-line'] = line; env.attributes['data-col'] = col; } } - if (env.type === 'import-suggestion') { + // I don't know how to tell which file(s) to place the import into. + if (!getMultifile() && env.type === 'import-suggestion') { env.tag = 'a'; env.attributes.href = '#'; env.attributes['data-suggestion'] = env.content; } - if (env.type === 'feature-gate') { + // I don't know how to tell which file(s) to place the feature flag into. + if (!getMultifile() && env.type === 'feature-gate') { const featureMatch = /feature\((.*?)\)/.exec(env.content); if (featureMatch) { const [_, featureGate] = featureMatch; @@ -123,13 +123,19 @@ export function configureRustErrors({ env.attributes['data-backtrace-enable'] = 'true'; } if (env.type === 'backtrace-location') { - const errorMatch = /:(\d+)/.exec(env.content); + const errorMatch = /([^:]+):(\d+):(\d+)/.exec(env.content); if (errorMatch) { - const [_, line] = errorMatch; - env.tag = 'a'; - env.attributes.href = '#'; - env.attributes['data-line'] = line; - env.attributes['data-col'] = '1'; + const [_, file, line, col] = errorMatch; + + if (!file.includes('.rustup') && !file.includes('/rustc/')) { + const normalizedFile = file.replace(/^\.\//, '') + + env.tag = 'a'; + env.attributes.href = '#'; + env.attributes['data-file'] = normalizedFile; + env.attributes['data-line'] = line; + env.attributes['data-col'] = col; + } } } if (env.type === 'mir-source') { @@ -150,9 +156,12 @@ export function configureRustErrors({ const links = env.element.querySelectorAll('.error-location, .backtrace-location'); Array.from(links).forEach(link => { if (link instanceof HTMLAnchorElement) { - const { line, col } = link.dataset; + const { file, line, col } = link.dataset; link.onclick = e => { e.preventDefault(); + if (file) { + activateFile(file); + } if (line && col) { gotoPosition(makePosition(line, col)); } diff --git a/ui/frontend/index.tsx b/ui/frontend/index.tsx index 0c50a5e55..b21b86f09 100644 --- a/ui/frontend/index.tsx +++ b/ui/frontend/index.tsx @@ -28,6 +28,7 @@ import { selectText } from './reducers/selection'; import { useAppSelector } from './hooks'; import { themeSelector } from './selectors'; import { Theme } from './types'; +import { activateFile } from './reducers/files'; const store = configureStore(window); @@ -68,11 +69,13 @@ maxWidthMediaQuery.addEventListener('change', whenBrowserWidthChanged); configureRustErrors({ enableFeatureGate: featureGate => store.dispatch(enableFeatureGate(featureGate)), + activateFile: (f) => store.dispatch(activateFile(f)), gotoPosition: (p) => store.dispatch(gotoPosition(p)), selectText: (start, end) => store.dispatch(selectText(start, end)), addImport: (code) => store.dispatch(addImport(code)), reExecuteWithBacktrace: () => store.dispatch(reExecuteWithBacktrace()), getChannel: () => store.getState().configuration.channel, + getMultifile: () => store.getState().configuration.fileView === 'multiple', }); store.dispatch(performCratesLoad()); diff --git a/ui/frontend/local_storage.ts b/ui/frontend/local_storage.ts index 9805d4073..81638dc3f 100644 --- a/ui/frontend/local_storage.ts +++ b/ui/frontend/local_storage.ts @@ -4,13 +4,14 @@ import * as z from 'zod'; import { State } from './reducers'; -import { codeSelector } from './selectors'; -import { PartialState, initializeStorage, removeVersion } from './storage'; +import { codeOrFilesSelector } from './selectors'; +import { Code, PartialState, expandCode, initializeStorage, removeVersion } from './storage'; import { AssemblyFlavorSchema, DemangleAssemblySchema, Editor, EditorSchema, + FileViewSchema, OrientationSchema, PairCharactersSchema, ProcessAssemblySchema, @@ -32,6 +33,7 @@ const V2Configuration = z .partial(), configuration: z .object({ + fileView: FileViewSchema, editor: EditorSchema, ace: z .object({ @@ -52,7 +54,7 @@ const V2Configuration = z processAssembly: ProcessAssemblySchema, }) .partial(), - code: z.string(), + code: Code, notifications: z.looseObject({}), }) .partial(); @@ -84,7 +86,6 @@ type CurrentConfiguration = V2Configuration; const SomeConfiguration = V1Configuration.or(V2Configuration); export function serialize(state: State): string { - const code = codeSelector(state); const conf: CurrentConfiguration = { version: CURRENT_VERSION, client: { @@ -93,6 +94,7 @@ export function serialize(state: State): string { visitedAt: state.client.visitedAt, }, configuration: { + fileView: state.configuration.fileView, editor: state.configuration.editor, ace: { keybinding: state.configuration.ace.keybinding, @@ -108,7 +110,7 @@ export function serialize(state: State): string { demangleAssembly: state.configuration.demangleAssembly, processAssembly: state.configuration.processAssembly, }, - code, + code: codeOrFilesSelector(state), notifications: { ...state.notifications }, }; return JSON.stringify(conf); @@ -163,7 +165,7 @@ export function deserialize(savedState: string): PartialState { return undefined; } - return removeVersion(result); + return expandCode(removeVersion(result)); } catch { return undefined; } diff --git a/ui/frontend/package.json b/ui/frontend/package.json index 2ab02382c..eb8f50060 100644 --- a/ui/frontend/package.json +++ b/ui/frontend/package.json @@ -11,6 +11,7 @@ "core-js": "^3.1.3", "history": "^5.3.0", "immer": "^11.1.3", + "jotai": "^2.20.0", "lodash-es": "^4.17.21", "monaco-editor": "^0.55.1", "prismjs": "^1.6.0", diff --git a/ui/frontend/pnpm-lock.yaml b/ui/frontend/pnpm-lock.yaml index 3a4f7f855..56e5e20a4 100644 --- a/ui/frontend/pnpm-lock.yaml +++ b/ui/frontend/pnpm-lock.yaml @@ -29,6 +29,9 @@ importers: immer: specifier: ^11.1.3 version: 11.1.8 + jotai: + specifier: ^2.20.0 + version: 2.20.0(@babel/core@7.29.7)(@babel/template@7.29.7)(@types/react@19.2.17)(react@19.2.7) lodash-es: specifier: ^4.17.21 version: 4.18.1 @@ -2931,6 +2934,24 @@ packages: resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} hasBin: true + jotai@2.20.0: + resolution: {integrity: sha512-b5GAqgmXmXzB4WPaTH26ppk9Sl7AA9WSQX7yfdM+gJ1rFROiWcVbi97gFuN/yVCojOcbcvop2sfLL+fjxW0JVg==} + engines: {node: '>=12.20.0'} + peerDependencies: + '@babel/core': '>=7.0.0' + '@babel/template': '>=7.0.0' + '@types/react': '>=17.0.0' + react: '>=17.0.0' + peerDependenciesMeta: + '@babel/core': + optional: true + '@babel/template': + optional: true + '@types/react': + optional: true + react: + optional: true + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -7576,6 +7597,13 @@ snapshots: jiti@2.7.0: {} + jotai@2.20.0(@babel/core@7.29.7)(@babel/template@7.29.7)(@types/react@19.2.17)(react@19.2.7): + optionalDependencies: + '@babel/core': 7.29.7 + '@babel/template': 7.29.7 + '@types/react': 19.2.17 + react: 19.2.7 + js-tokens@4.0.0: {} js-yaml@3.15.0: diff --git a/ui/frontend/reducers/code.ts b/ui/frontend/reducers/code.ts index 4c05678e8..21f579087 100644 --- a/ui/frontend/reducers/code.ts +++ b/ui/frontend/reducers/code.ts @@ -3,11 +3,11 @@ import { PayloadAction, createSlice } from '@reduxjs/toolkit'; import { performFormat } from './output/format'; import { performGistLoad } from './output/gist'; -const HELLO_WORLD: string = `fn main() { +export const HELLO_WORLD: string = `fn main() { println!("Hello, world!"); }`; -const doAddCrateType = (code: string, crate_type: string): string => +export const doAddCrateType = (code: string, crate_type: string): string => `#![crate_type = "${crate_type}"]\n${code}`; const slice = createSlice({ @@ -28,8 +28,20 @@ const slice = createSlice({ extraReducers: (builder) => { builder .addCase(performGistLoad.pending, () => '') - .addCase(performGistLoad.fulfilled, (_state, action) => action.payload.code) - .addCase(performFormat.fulfilled, (_state, action) => action.payload.code); + .addCase(performGistLoad.fulfilled, (state, action) => { + if (typeof action.payload.code === 'string') { + return action.payload.code; + } else { + return state; + } + }) + .addCase(performFormat.fulfilled, (state, action) => { + if (typeof action.payload.code === 'string') { + return action.payload.code; + } else { + return state; + } + }); }, }); diff --git a/ui/frontend/reducers/configuration.ts b/ui/frontend/reducers/configuration.ts index 23fb9b4cf..86715c31b 100644 --- a/ui/frontend/reducers/configuration.ts +++ b/ui/frontend/reducers/configuration.ts @@ -8,6 +8,7 @@ import { DemangleAssembly, Edition, Editor, + FileView, Mode, Orientation, PairCharacters, @@ -18,6 +19,7 @@ import { } from '../types'; interface State { + fileView: FileView; editor: Editor; ace: { keybinding: string; @@ -41,6 +43,7 @@ interface State { } const initialState: State = { + fileView: FileView.Single, editor: Editor.Ace, ace: { keybinding: 'ace', @@ -99,6 +102,10 @@ const slice = createSlice({ state.editor = action.payload; }, + changeFileView: (state, action: PayloadAction) => { + state.fileView = action.payload; + }, + changeKeybinding: (state, action: PayloadAction) => { state.ace.keybinding = action.payload; }, @@ -158,6 +165,7 @@ export const { changeDemangleAssembly, changeEdition, changeEditor, + changeFileView, changeKeybinding, changeMode, changeMonacoTheme, diff --git a/ui/frontend/reducers/featureFlags.ts b/ui/frontend/reducers/featureFlags.ts index 43bf308be..657acb4ab 100644 --- a/ui/frontend/reducers/featureFlags.ts +++ b/ui/frontend/reducers/featureFlags.ts @@ -6,6 +6,7 @@ import { createWebsocketResponse } from '../websocketActions'; interface State { forced: boolean; showGemThreshold: number; + multifileThreshold: number; } const ENABLED = 1.0; @@ -14,12 +15,14 @@ const DISABLED = -1.0; const initialState: State = { forced: false, showGemThreshold: DISABLED, + multifileThreshold: DISABLED, }; const { action: wsFeatureFlags, schema: wsFeatureFlagsSchema } = createWebsocketResponse( 'featureFlags', z.object({ showGemThreshold: z.number().nullish(), + multifileThreshold: z.number().nullish(), }), ); @@ -30,10 +33,12 @@ const slice = createSlice({ forceEnableAll: (state) => { state.forced = true; state.showGemThreshold = ENABLED; + state.multifileThreshold = ENABLED; }, forceDisableAll: (state) => { state.forced = true; state.showGemThreshold = DISABLED; + state.multifileThreshold = DISABLED; }, }, extraReducers: (builder) => { @@ -42,11 +47,15 @@ const slice = createSlice({ return; } - const { showGemThreshold } = action.payload; + const { showGemThreshold, multifileThreshold } = action.payload; if (showGemThreshold) { state.showGemThreshold = showGemThreshold; } + + if (multifileThreshold) { + state.multifileThreshold = multifileThreshold; + } }); }, }); diff --git a/ui/frontend/reducers/files.spec.ts b/ui/frontend/reducers/files.spec.ts new file mode 100644 index 000000000..9b9f38fea --- /dev/null +++ b/ui/frontend/reducers/files.spec.ts @@ -0,0 +1,65 @@ +import { UnknownAction } from '@reduxjs/toolkit'; + +import * as files from './files'; + +const { default: reducer, ...actions } = files; +type State = ReturnType; + +const reduceAll = (state: State, actions: UnknownAction[]): State => actions.reduce(reducer, state); + +const expectFileOfName = (name: string) => + expect.arrayContaining([expect.objectContaining({ name })]); + +describe('modifying the source code', () => { + let state: State; + + beforeEach(() => { + state = reducer(undefined, { type: '@INIT' }); + }); + + test('renaming a directory', () => { + state = reduceAll(state, [ + actions.createFile('testing/test.rs'), + actions.renameDirectory({ from: 'testing', to: 'testing/nested' }), + ]); + + expect(state.files).not.toEqual(expectFileOfName('testing/test.rs')); + expect(state.files).toEqual(expectFileOfName('testing/nested/test.rs')); + }); + + test('renaming a directory with a common prefix', () => { + state = reduceAll(state, [ + actions.createFile('test/test.rs'), + actions.createFile('test1/test.rs'), + actions.renameDirectory({ from: 'test', to: 'test2' }), + ]); + + expect(state.files).not.toEqual(expectFileOfName('test/test.rs')); + expect(state.files).toEqual(expectFileOfName('test1/test.rs')); + expect(state.files).toEqual(expectFileOfName('test2/test.rs')); + }); + + test('renaming a directory that would collide is a no-op', () => { + state = reduceAll(state, [ + actions.createFile('a/main.rs'), + actions.createFile('b/main.rs'), + actions.renameDirectory({ from: 'a', to: 'b' }), + ]); + + expect(state.files).toEqual(expectFileOfName('a/main.rs')); + expect(state.files).toEqual(expectFileOfName('b/main.rs')); + }); + + test('deleting a directory removes all children', () => { + state = reduceAll(state, [ + actions.createFile('a.rs'), + actions.createFile('src/b.rs'), + actions.createFile('src/c/d.rs'), + actions.deleteDirectory('src'), + ]); + + expect(state.files).toEqual(expectFileOfName('a.rs')); + expect(state.files).not.toEqual(expectFileOfName('src/b.rs')); + expect(state.files).not.toEqual(expectFileOfName('src/b/c.rs')); + }); +}); diff --git a/ui/frontend/reducers/files.ts b/ui/frontend/reducers/files.ts new file mode 100644 index 000000000..48bc1db71 --- /dev/null +++ b/ui/frontend/reducers/files.ts @@ -0,0 +1,236 @@ +import { PayloadAction, WritableDraft, createSlice } from '@reduxjs/toolkit'; + +import { + Rename, + arrayIsPrefix, + makeFilePath, + makeValidatedDirRenamer, + validateFilename, +} from '../domain/filesRules'; +import { FileId } from '../types'; +import { HELLO_WORLD, addCrateType, addMainFunction, doAddCrateType, editCode } from './code'; +import { performFormat } from './output/format'; +import { performGistLoad } from './output/gist'; + +const filenames = (state: State): string[] => state.files.map((f) => f.name); + +const isFilenameValid = (state: State, candidateName: string): boolean => + validateFilename(filenames(state), candidateName) === null; + +const validateDirRename = (state: State, rename: Rename) => + makeValidatedDirRenamer(filenames(state), rename); + +const selectActiveFile = (files: File[]) => { + let main; + let lib; + for (const f of files) { + if (f.name === 'src/main.rs') { + main = f; + } else if (f.name === 'src/lib.rs') { + lib = f; + } + + if (main && lib) { + break; + } + } + + if (main) { + return main.id; + } else if (lib) { + return lib.id; + } else { + return files.at(0)?.id ?? null; + } +}; + +// Ensure that the active file is still a file we have. +const fixupAfterDeletion = (state: WritableDraft) => { + const activeStillValid = state.files.some((f) => f.id === state.active); + if (!activeStillValid) { + state.active = selectActiveFile(state.files); + } +}; + +const buildFile = (state: WritableDraft, name: string, content = '') => ({ + id: state.fileId++, + name, + content, +}); + +interface File { + readonly id: FileId; + name: string; + content: string; +} + +interface State { + fileId: FileId; + active: FileId | null; + files: File[]; +} + +const initialState: State = { + fileId: 0, + active: null, + files: [], +}; + +const slice = createSlice({ + name: 'files', + initialState, + reducers: { + initializeWith: (state, action: PayloadAction<{ name: string; content: string }>) => { + const { name, content } = action.payload; + + if (!isFilenameValid(state, name)) { + return; + } + + const newFile = buildFile(state, name, content); + state.files = [newFile]; + state.active = newFile.id; + }, + + createFile: (state, action: PayloadAction) => { + const candidateName = action.payload; + + if (!isFilenameValid(state, candidateName)) { + return; + } + + const newFile = buildFile(state, candidateName); + state.files.push(newFile); + state.active = newFile.id; + }, + + renameFile: (state, action: PayloadAction) => { + const { from, to } = action.payload; + + const fromFile = state.files.find((f) => f.name === from); + + // Does the source exist? + if (!fromFile) { + return; + } + + // No-op + if (from === to) { + return; + } + + if (!isFilenameValid(state, to)) { + return; + } + + fromFile.name = to; + }, + + deleteFile: (state, action: PayloadAction) => { + state.files = state.files.filter((f) => f.name !== action.payload); + fixupAfterDeletion(state); + }, + + // It's not an error to rename a directory that doesn't exist and + // the UI doesn't even show the option to rename something that + // doesn't exist. + renameDirectory: (state, action: PayloadAction) => { + const rename = action.payload; + + // No-op + if (rename.from === rename.to) { + return; + } + + const r = validateDirRename(state, rename); + if (!r.ok) { + return; + } + const performDirRename = r.value; + + for (const file of state.files) { + file.name = performDirRename(file.name); + } + }, + + deleteDirectory: (state, action: PayloadAction) => { + const dirParts = action.payload.split('/'); + + state.files = state.files.filter((f) => { + const path = makeFilePath(f.name); + return !arrayIsPrefix(dirParts, path.parentNames); + }); + + fixupAfterDeletion(state); + }, + + activateFile: (state, action: PayloadAction) => { + const file = state.files.find((f) => f.name === action.payload); + if (file) { + state.active = file.id; + } + }, + }, + extraReducers: (builder) => { + builder + .addCase(editCode, (state, action) => { + const file = state.files.find((f) => f.id === state.active); + if (file) { + file.content = action.payload; + } + }) + .addCase(addMainFunction, (state) => { + const existing = state.files.find((f) => f.name === 'src/main.rs'); + + let file; + if (existing) { + file = existing; + } else { + file = buildFile(state, 'src/main.rs', HELLO_WORLD); + state.files.push(file); + } + + state.active = file.id; + }) + .addCase(addCrateType, (state, action) => { + const file = state.files.find((f) => f.name === 'src/lib.rs'); + if (file) { + file.content = doAddCrateType(file.content, action.payload); + } + }) + .addCase(performGistLoad.pending, (state) => { + state.active = null; + state.files = []; + }) + .addCase(performGistLoad.fulfilled, (state, action) => { + if (typeof action.payload.code !== 'string') { + state.files = action.payload.code.map((f) => buildFile(state, f.name, f.content)); + state.active = selectActiveFile(state.files); + } + }) + .addCase(performFormat.fulfilled, (state, action) => { + if (typeof action.payload.code !== 'string') { + const formattedFiles = action.payload.code; + for (const f of state.files) { + const formattedFile = formattedFiles.find((ff) => ff.name === f.name); + if (formattedFile) { + f.content = formattedFile.content; + } + } + // we expect that active remains correct, + } + }); + }, +}); + +export const { + activateFile, + createFile, + deleteDirectory, + deleteFile, + renameDirectory, + renameFile, + initializeWith, +} = slice.actions; + +export default slice.reducer; diff --git a/ui/frontend/reducers/index.ts b/ui/frontend/reducers/index.ts index 9f9ae3442..b08c8f057 100644 --- a/ui/frontend/reducers/index.ts +++ b/ui/frontend/reducers/index.ts @@ -6,6 +6,7 @@ import code from './code'; import configuration from './configuration'; import crates from './crates'; import featureFlags from './featureFlags'; +import files from './files'; import globalConfiguration from './globalConfiguration'; import notifications from './notifications'; import output from './output'; @@ -22,6 +23,7 @@ const playgroundApp = combineReducers({ configuration, crates, featureFlags, + files, globalConfiguration, notifications, output, diff --git a/ui/frontend/reducers/output/clippy.ts b/ui/frontend/reducers/output/clippy.ts index 5382c5932..01ea42584 100644 --- a/ui/frontend/reducers/output/clippy.ts +++ b/ui/frontend/reducers/output/clippy.ts @@ -4,6 +4,7 @@ import * as z from 'zod'; import { jsonPost, routes } from '../../api'; import { State as RootState } from '../../reducers'; import { clippyRequestSelector } from '../../selectors'; +import { Code } from '../../types'; const sliceName = 'output/clippy'; @@ -22,7 +23,7 @@ interface ClippyRequestBody { channel: string; crateType: string; edition: string; - code: string; + code: Code; } const ClippyResponseBody = z.object({ diff --git a/ui/frontend/reducers/output/execute.ts b/ui/frontend/reducers/output/execute.ts index 9183a1497..68661a3fc 100644 --- a/ui/frontend/reducers/output/execute.ts +++ b/ui/frontend/reducers/output/execute.ts @@ -8,7 +8,7 @@ import { executeRequestPayloadSelector, executeViaWebsocketSelector, } from '../../selectors'; -import { Channel, Edition, Mode } from '../../types'; +import { Channel, Code, Edition, Mode } from '../../types'; import { WsPayloadAction, createWebsocketResponse, @@ -38,7 +38,7 @@ type wsExecuteRequestPayload = { edition: Edition; crateType: string; tests: boolean; - code: string; + code: Code; backtrace: boolean; }; @@ -80,7 +80,7 @@ export interface ExecuteRequestBody { mode: string; crateType: string; tests: boolean; - code: string; + code: Code; edition: string; backtrace: boolean; } diff --git a/ui/frontend/reducers/output/format.ts b/ui/frontend/reducers/output/format.ts index efdedea07..dbf146ce8 100644 --- a/ui/frontend/reducers/output/format.ts +++ b/ui/frontend/reducers/output/format.ts @@ -4,6 +4,7 @@ import * as z from 'zod'; import { jsonPost, routes } from '../../api'; import { State as RootState } from '../../reducers'; import { formatRequestSelector } from '../../selectors'; +import { Code } from '../../types'; const sliceName = 'output/format'; @@ -20,12 +21,12 @@ interface State { interface FormatRequestBody { channel: string; edition: string; - code: string; + code: Code; } const FormatResponseBody = z.object({ success: z.boolean(), - code: z.string(), + code: Code, stdout: z.string(), stderr: z.string(), }); diff --git a/ui/frontend/reducers/output/gist.ts b/ui/frontend/reducers/output/gist.ts index faebd02fb..45830c629 100644 --- a/ui/frontend/reducers/output/gist.ts +++ b/ui/frontend/reducers/output/gist.ts @@ -9,8 +9,8 @@ import * as z from 'zod'; import { jsonGet, jsonPost, routes } from '../../api'; import { State as RootState } from '../../reducers'; -import { baseUrlSelector, codeSelector } from '../../selectors'; -import { Channel, Edition, Mode } from '../../types'; +import { baseUrlSelector, codeOrFilesSelector } from '../../selectors'; +import { Channel, Code, Edition, Mode } from '../../types'; const sliceName = 'output/gist'; @@ -22,7 +22,7 @@ interface State { requestsInProgress: number; id?: string; url?: string; - code?: string; + code?: Code; stdout?: string; stderr?: string; channel?: Channel; @@ -34,7 +34,7 @@ interface State { interface SuccessProps { id: string; url: string; - code: string; + code: Code; stdout: string; stderr: string; channel: Channel; @@ -50,7 +50,7 @@ type PerformGistLoadProps = Pick< const GistResponseBody = z.object({ id: z.string(), url: z.string(), - code: z.string(), + code: Code, }); type GistResponseBody = z.infer; @@ -73,7 +73,7 @@ export const performGistSave = createAsyncThunk { const state = getState(); - const code = codeSelector(state); + const code = codeOrFilesSelector(state); const { configuration: { channel, mode, edition }, output: { diff --git a/ui/frontend/reducers/output/macroExpansion.ts b/ui/frontend/reducers/output/macroExpansion.ts index 850e965c6..7ea2e38bd 100644 --- a/ui/frontend/reducers/output/macroExpansion.ts +++ b/ui/frontend/reducers/output/macroExpansion.ts @@ -4,6 +4,7 @@ import * as z from 'zod'; import { jsonPost, routes } from '../../api'; import { State as RootState } from '../../reducers'; import { macroExpansionRequestSelector } from '../../selectors'; +import { Code } from '../../types'; const sliceName = 'output/macroExpansion'; @@ -19,7 +20,7 @@ interface State { } interface MacroExpansionRequestBody { - code: string; + code: Code; edition: string; } diff --git a/ui/frontend/reducers/output/miri.ts b/ui/frontend/reducers/output/miri.ts index 491a33d78..ebd2210c6 100644 --- a/ui/frontend/reducers/output/miri.ts +++ b/ui/frontend/reducers/output/miri.ts @@ -4,7 +4,7 @@ import * as z from 'zod'; import { jsonPost, routes } from '../../api'; import { State as RootState } from '../../reducers'; import { miriRequestSelector } from '../../selectors'; -import { AliasingModel } from '../../types'; +import { AliasingModel, Code } from '../../types'; const sliceName = 'output/miri'; @@ -20,7 +20,7 @@ interface State { } interface MiriRequestBody { - code: string; + code: Code; edition: string; tests: boolean; aliasingModel: AliasingModel; diff --git a/ui/frontend/selectors/files.ts b/ui/frontend/selectors/files.ts new file mode 100644 index 000000000..707e160c8 --- /dev/null +++ b/ui/frontend/selectors/files.ts @@ -0,0 +1,21 @@ +import { createSelector } from '@reduxjs/toolkit'; + +import { filenamesToTree, makeFilePath } from '../domain/filesRules'; +import { State } from '../reducers'; + +const activeFileIdSelector = (state: State) => state.files.active; + +const filesSelector = (state: State) => state.files.files; + +export const activeFileSelector = createSelector(filesSelector, activeFileIdSelector, (files, id) => + files.find((f) => f.id === id), +); + +export const activeFilePathSelector = createSelector( + activeFileSelector, + (f) => f && makeFilePath(f.name), +); + +export const filenamesSelector = createSelector(filesSelector, (c) => c.map((f) => f.name)); + +export const filetreeSelector = createSelector(filenamesSelector, filenamesToTree); diff --git a/ui/frontend/selectors/gist.spec.ts b/ui/frontend/selectors/gist.spec.ts new file mode 100644 index 000000000..9c129ac24 --- /dev/null +++ b/ui/frontend/selectors/gist.spec.ts @@ -0,0 +1,118 @@ +import { UnknownAction } from '@reduxjs/toolkit'; + +import reducer from '../reducers'; +import { editCode } from '../reducers/code'; +import { changeFileView } from '../reducers/configuration'; +import { featureFlagsForceEnableAll } from '../reducers/featureFlags'; +import { activateFile, createFile, deleteFile, renameFile } from '../reducers/files'; +import { performGistLoad } from '../reducers/output/gist'; +import { Channel, Code, Edition, Mode } from '../types'; +import { textChangedSinceShareSelector } from './gist'; + +type State = ReturnType; + +const gistLoad = (code: Code) => { + const arg = { + id: 'id', + url: 'url', + code, + stdout: 'stdout', + stderr: 'stderr', + channel: Channel.Stable, + mode: Mode.Debug, + edition: Edition.Rust2018, + }; + return performGistLoad.fulfilled(arg, 'request-id', arg); +}; + +const reduceAll = (actions: UnknownAction[]): State => actions.reduce(reducer, undefined)!; + +describe('checking if the code has changed since it was shared', () => { + describe('string code, string gist', () => { + test('unchanged', () => { + const state = reduceAll([changeFileView('single'), gistLoad('code'), editCode('code')]); + + expect(textChangedSinceShareSelector(state)).toBe(false); + }); + + test('changed', () => { + const state = reduceAll([ + changeFileView('single'), + gistLoad('gist code'), + editCode('editor code'), + ]); + + expect(textChangedSinceShareSelector(state)).toBe(true); + }); + }); + + describe('array code, array gist', () => { + test('unchanged', () => { + const state = reduceAll([ + featureFlagsForceEnableAll(), + changeFileView('multiple'), + gistLoad([ + { name: 'file1', content: 'file1content' }, + { name: 'file2', content: 'file2content' }, + ]), + ]); + + expect(textChangedSinceShareSelector(state)).toBe(false); + }); + + test('changed content', () => { + const state = reduceAll([ + featureFlagsForceEnableAll(), + changeFileView('multiple'), + gistLoad([ + { name: 'file1', content: 'file1content' }, + { name: 'file2', content: 'file2content gist' }, + ]), + activateFile('file2'), + editCode('file2content editor'), + ]); + + expect(textChangedSinceShareSelector(state)).toBe(true); + }); + + test('changed filename', () => { + const state = reduceAll([ + featureFlagsForceEnableAll(), + changeFileView('multiple'), + gistLoad([ + { name: 'file1', content: 'file1content' }, + { name: 'file2', content: 'file2content' }, + ]), + renameFile({ from: 'file2', to: 'file3' }), + ]); + + expect(textChangedSinceShareSelector(state)).toBe(true); + }); + + test('added file', () => { + const state = reduceAll([ + featureFlagsForceEnableAll(), + changeFileView('multiple'), + gistLoad([{ name: 'file1', content: 'file1content' }]), + createFile('file2'), + editCode('file2content'), + ]); + + expect(textChangedSinceShareSelector(state)).toBe(true); + }); + + test('removed file', () => { + const state = reduceAll([ + featureFlagsForceEnableAll(), + changeFileView('multiple'), + gistLoad([ + { name: 'file1', content: 'file1content' }, + { name: 'file2', content: 'file2content' }, + ]), + deleteFile('file2'), + ]); + + expect(textChangedSinceShareSelector(state)).toBe(true); + }); + }); +}); diff --git a/ui/frontend/selectors/gist.ts b/ui/frontend/selectors/gist.ts index 73d7f892d..35cd04950 100644 --- a/ui/frontend/selectors/gist.ts +++ b/ui/frontend/selectors/gist.ts @@ -1,11 +1,19 @@ import { createSelector } from '@reduxjs/toolkit'; import { source } from 'common-tags'; -import { baseUrlSelector, codeSelector } from '.'; +import { baseUrlSelector, codeOrFilesSelector, linearizeCode } from '.'; import { State } from '../reducers'; const gistCodeSelector = (state: State) => state.output.gist.code; +const linearCodeSelector = createSelector(gistCodeSelector, (code) => { + if (code) { + return linearizeCode(code); + } else { + return code; + } +}); + // Selects url.query of build configs. const urlQuerySelector = createSelector( (state: State) => state.output.gist.channel, @@ -47,9 +55,37 @@ export const permalinkSelector = createSelector( ); export const textChangedSinceShareSelector = createSelector( - codeSelector, + codeOrFilesSelector, gistCodeSelector, - (code, gistCode) => code !== gistCode, + (code, gistCode) => { + if (typeof code === 'string' && typeof gistCode === 'string') { + return code !== gistCode; + } else if (Array.isArray(code) && Array.isArray(gistCode)) { + // Does the count of files match? + if (code.length !== gistCode.length) { + return true; + } + + const codeMap = new Map(code.map((c) => [c.name, c.content])); + const gistCodeMap = new Map(gistCode.map((gc) => [gc.name, gc.content])); + + const codeFiles = new Set(codeMap.keys()); + const gistCodeFiles = new Set(gistCodeMap.keys()); + + // Do all the filenames match? + if (codeFiles.symmetricDifference(gistCodeFiles).size !== 0) { + return true; + } + + // Do all of the contents match? + return codeMap.entries().some(([name, content]) => { + const gistContent = gistCodeMap.get(name); + return gistContent !== content; + }); + } else { + return true; + } + }, ); const codeBlock = (code: string, language = '') => '```' + language + `\n${code}\n` + '```'; @@ -61,20 +97,32 @@ const maybeOutput = (code: string | undefined, whenPresent: (_: string) => void) }; const snippetSelector = createSelector( - gistCodeSelector, + codeOrFilesSelector, (state: State) => state.output.gist.stdout, (state: State) => state.output.gist.stderr, permalinkSelector, (code, stdout, stderr, permalink) => { let snippet = ''; - maybeOutput(code, (code) => { - snippet += source` + if (typeof code === 'string') { + maybeOutput(code, (code) => { + snippet += source` ${codeBlock(code, 'rust')} ([Playground](${permalink})) `; - }); + }); + } else { + for (const { name, content } of code) { + snippet += source` + **${name}** + ${codeBlock(content, 'rust')} + `; + } + snippet += source` + ([Playground](${permalink})) + `; + } maybeOutput(stdout, (stdout) => { snippet += '\n\n'; @@ -107,7 +155,7 @@ export const urloUrlSelector = createSelector(snippetSelector, (snippet) => { export const codeUrlSelector = createSelector( baseUrlSelector, urlQuerySelector, - gistCodeSelector, + linearCodeSelector, (baseUrl, originalQuery, code) => { const u = new URL(baseUrl); const query = new URLSearchParams(originalQuery); diff --git a/ui/frontend/selectors/index.ts b/ui/frontend/selectors/index.ts index e21a9a3d4..deed66205 100644 --- a/ui/frontend/selectors/index.ts +++ b/ui/frontend/selectors/index.ts @@ -11,6 +11,8 @@ import { PrimaryActionAuto, PrimaryActionCore, Version, + FileView, + Code, } from '../types'; const MS_PER_S = 1000; @@ -21,13 +23,103 @@ const clientFeatureFlagThreshold = (state: State) => state.client.featureFlagThr const createFeatureFlagSelector = (ff: (state: State) => number) => createSelector(clientFeatureFlagThreshold, ff, (c, ff) => c <= ff); -export const codeSelector = (state: State) => state.code; +const allowMultipleFilesThreshold = createSelector(featureFlags, ff => ff.multifileThreshold); +export const allowMultipleFiles = createFeatureFlagSelector(allowMultipleFilesThreshold); + +const codeSelector = (state: State) => state.code; +const filesSelector = (state: State) => state.files.files; +const filesActiveSelector = (state: State) => state.files.active; + +const multifileEnabledSelector = createSelector( + allowMultipleFiles, + (state: State) => state.configuration.fileView, + (allowed, selected) => allowed && selected === FileView.Multiple, +); + +export const codeOrFilesSelector = createSelector( + multifileEnabledSelector, + codeSelector, + filesSelector, + (multifileEnabled, single, multiple) => { + if (multifileEnabled) { + return multiple; + } else { + return single; + } + } +); + +export const linearizeCode = (code: Code) => { + if (typeof code === 'string') { + return code; + } else { + const f = [...code]; + f.sort((a, b) => a.name.localeCompare(b.name)); + return f.reduce((acc, f) => acc + `// ${f.name}` + '\n\n' + f.content + '\n', ''); + } +}; + +export const linearCodeSelector = createSelector(codeOrFilesSelector, linearizeCode); + +export const allCodeContentsSelector = createSelector( + multifileEnabledSelector, + codeSelector, + filesSelector, + (multifileEnabled, single, multiple) => { + if (multifileEnabled) { + return multiple.map((f) => f.content); + } else { + return [single]; + } + } +); + +export const activeCodeSelector = createSelector( + multifileEnabledSelector, + codeSelector, + filesActiveSelector, + filesSelector, + (multifileEnabled, code, activeFile, files) => { + if (multifileEnabled) { + return files.find((f) => f.id === activeFile)?.content; + } else { + return code; + } + } +); + +const SINGLE_FILE_ID = -1; + +export const activeFileIdSelector = createSelector( + multifileEnabledSelector, + filesActiveSelector, + (multifileEnabled, active) => { + if (multifileEnabled) { + return active; + } else { + return SINGLE_FILE_ID; + } + } +); + +export const fileIdsSelector = createSelector( + multifileEnabledSelector, + filesSelector, + (multifileEnabled, files) => { + if (multifileEnabled) { + return files.map((f) => f.id) + } else { + return [SINGLE_FILE_ID] + } + } +) + export const positionSelector = (state: State) => state.position; export const selectionSelector = (state: State) => state.selection; const HAS_TESTS_RE = /^\s*#\s*\[\s*test\s*([^"]*)]/m; const hasTests = (code: string) => !!code.match(HAS_TESTS_RE); -const hasTestsSelector = createSelector(codeSelector, hasTests); +const hasTestsSelector = createSelector(allCodeContentsSelector, (f) => f.some(hasTests)); // https://stackoverflow.com/a/34755045/155423 const HAS_MAIN_FUNCTION_RE = new RegExp( @@ -39,11 +131,35 @@ const HAS_MAIN_FUNCTION_RE = new RegExp( 'm' ); export const hasMainFunction = (code: string) => !!code.match(HAS_MAIN_FUNCTION_RE); -const hasMainFunctionSelector = createSelector(codeSelector, hasMainFunction); +const hasMainFunctionSelector = createSelector( + multifileEnabledSelector, + codeSelector, + filesSelector, + (multifileEnabled, code, files) => { + if (multifileEnabled) { + return files.some((f) => f.name === 'src/main.rs'); + } else { + return hasMainFunction(code); + } + } +); const CRATE_TYPE_RE = /^\s*#!\s*\[\s*crate_type\s*=\s*"([^"]*)"\s*]/m; const crateType = (code: string) => (code.match(CRATE_TYPE_RE) ?? []).at(1); -const crateTypeSelector = createSelector(codeSelector, crateType); +const crateTypeSelector = createSelector( + multifileEnabledSelector, + codeSelector, + filesSelector, + (multifileEnabled, code, files) => { + if (multifileEnabled) { + const file = files.find((f) => f.name === 'src/lib.rs'); + if (!file) { return undefined; } + return crateType(file.content); + } else { + return crateType(code); + } + } +); const autoPrimaryActionSelector = createSelector( crateTypeSelector, @@ -338,14 +454,14 @@ export const clippyRequestSelector = createSelector( channelSelector, getCrateType, editionSelector, - codeSelector, + codeOrFilesSelector, (channel, crateType, edition, code) => ({ channel, crateType, edition, code }), ); export const formatRequestSelector = createSelector( channelSelector, editionSelector, - codeSelector, + codeOrFilesSelector, (channel, edition, code) => ({ channel, edition, code }), ); @@ -353,13 +469,13 @@ export const miriRequestSelector = createSelector( editionSelector, runAsTest, aliasingModelSelector, - codeSelector, + codeOrFilesSelector, (edition, tests, aliasingModel, code, ) => ({ edition, tests, aliasingModel, code }), ); export const macroExpansionRequestSelector = createSelector( editionSelector, - codeSelector, + codeOrFilesSelector, (edition, code) => ({ edition, code }) ); @@ -425,7 +541,7 @@ export const websocketStatusSelector = createSelector( ); export const executeRequestPayloadSelector = createSelector( - codeSelector, + codeOrFilesSelector, channelSelector, (state: State) => state.configuration, getBacktraceSet, @@ -442,7 +558,7 @@ export const executeRequestPayloadSelector = createSelector( ); export const compileRequestPayloadSelector = createSelector( - codeSelector, + codeOrFilesSelector, channelSelector, (state: State) => state.configuration, getCrateType, diff --git a/ui/frontend/session_storage.ts b/ui/frontend/session_storage.ts index 1aa66654c..e7c99b4e4 100644 --- a/ui/frontend/session_storage.ts +++ b/ui/frontend/session_storage.ts @@ -4,8 +4,8 @@ import * as z from 'zod'; import { State } from './reducers'; -import { codeSelector } from './selectors'; -import { PartialState, initializeStorage, removeVersion } from './storage'; +import { codeOrFilesSelector } from './selectors'; +import { Code, PartialState, expandCode, initializeStorage, removeVersion } from './storage'; import { PrimaryActionSchema } from './types'; const CURRENT_VERSION = 1; @@ -18,7 +18,7 @@ const V1Schema = z primaryAction: PrimaryActionSchema, }) .partial(), - code: z.string(), + code: Code, }) .partial(); type V1Schema = z.infer; @@ -29,7 +29,7 @@ export function serialize(state: State): string { configuration: { primaryAction: state.configuration.primaryAction, }, - code: codeSelector(state), + code: codeOrFilesSelector(state), }; return JSON.stringify(V1Schema.parse(value)); @@ -44,7 +44,7 @@ export function deserialize(savedState: string): PartialState { // live state. If that's no longer true, an additional renaming step // needs to be added. - return removeVersion(validatedState); + return expandCode(removeVersion(validatedState)); } catch { return undefined; } diff --git a/ui/frontend/storage.ts b/ui/frontend/storage.ts index b51dca951..e1a901e8d 100644 --- a/ui/frontend/storage.ts +++ b/ui/frontend/storage.ts @@ -1,4 +1,5 @@ import { DeepPartial } from 'ts-essentials'; +import * as z from 'zod'; import { State } from './reducers'; @@ -25,6 +26,35 @@ export function removeVersion(data: T): Omit; + +type FileState = State['files']; +type ExpandedCode = { code?: string; files?: FileState }; + +export function expandCode(data: T): Omit & ExpandedCode { + const { code, ...rest } = data; + + if (code === undefined) { + return rest; + } + + if (typeof code === 'string') { + return { code, ...rest }; + } else { + let fileId = 0; + const files = code.map(({ name, content }) => ({ + id: fileId++, + name, + content, + })); + + return { files: { fileId, files, active: 0 }, ...rest }; + } +} + export class InMemoryStorage { private data: { [s: string]: string } = {}; diff --git a/ui/frontend/types.ts b/ui/frontend/types.ts index 8997ce074..69903fb16 100644 --- a/ui/frontend/types.ts +++ b/ui/frontend/types.ts @@ -44,6 +44,14 @@ export const ChannelVersion = z.object({ export type ChannelVersion = z.infer; +export const CodeFile = z.object({ name: z.string(), content: z.string() }); +export type CodeFile = z.infer; + +export const Code = z.string().or(z.array(CodeFile)); +export type Code = z.infer; + +export type FileId = number; + export interface CommonEditorProps { code: string; execute: () => void; @@ -51,8 +59,17 @@ export interface CommonEditorProps { position: Position; selection: Selection; crates: Crate[]; + activeFileId: FileId; + fileIds: FileId[]; } +export const FileView = { + Single: 'single', + Multiple: 'multiple', +} as const; +export type FileView = ValuesOf; +export const FileViewSchema = z.enum(Object.values(FileView)); + export const Editor = { Simple: 'simple', Ace: 'ace', diff --git a/ui/src/gist.rs b/ui/src/gist.rs index 8e2268677..b9c887562 100644 --- a/ui/src/gist.rs +++ b/ui/src/gist.rs @@ -1,30 +1,113 @@ use octocrab::Octocrab; +use percent_encoding::AsciiSet; +use serde::{Deserialize, Serialize}; +use snafu::prelude::*; +const META_FILENAME: &str = "Playground.toml"; const FILENAME: &str = "playground.rs"; const DESCRIPTION: &str = "Code shared from the Rust Playground"; +// GitHub doesn't allow slashes in filenames, so we encode them. +const DISALLOWED_FILENAME_CHARS: AsciiSet = AsciiSet::EMPTY.add(b'/'); + +#[derive(Debug, Serialize, Deserialize)] +#[serde(tag = "version")] +enum SerializedMeta { + #[serde(rename = "1")] + V1, +} + +impl From for SerializedMeta { + fn from(_: V1) -> Self { + SerializedMeta::V1 + } +} + +enum Meta { + /// Represents gists created before we added the `Playground.toml` + /// file, or gists that were created outside of the Playground. + V0, + + /// Started supporting multiple files and files in a hierarchy. + V1(V1), +} + +impl Meta { + fn decode_filename(&self, name: String) -> String { + match self { + Meta::V0 => name, + Meta::V1(m) => m.decode_filename(name), + } + } +} + +struct V1; + +impl V1 { + fn encode_filename(&self, name: String) -> String { + percent_encoding::utf8_percent_encode(&name, &DISALLOWED_FILENAME_CHARS).to_string() + } + + fn decode_filename(&self, name: String) -> String { + percent_encoding::percent_decode_str(&name) + .decode_utf8_lossy() + .into_owned() + } +} + +type CurrentMeta = V1; +fn current_meta() -> CurrentMeta { + V1 +} + pub struct Gist { pub id: String, pub url: String, - pub code: String, + pub code: Code, +} + +pub enum Code { + Single(String), + Multiple(Vec), +} + +pub struct CodeFile { + pub name: String, + pub content: String, +} + +fn remove_meta(gist: &mut octocrab::models::gists::Gist) -> Meta { + let Some(meta) = gist.files.remove(META_FILENAME) else { + return Meta::V0; + }; + + let Some(content) = &meta.content else { + return Meta::V0; + }; + + match toml::from_str::(content) { + Ok(SerializedMeta::V1) => Meta::V1(V1), + Err(_) => Meta::V0, + } } impl From for Gist { - fn from(other: octocrab::models::gists::Gist) -> Self { + fn from(mut other: octocrab::models::gists::Gist) -> Self { + let meta = remove_meta(&mut other); + let mut files: Vec<_> = other .files .into_iter() - .map(|(name, file)| (name, file.content.unwrap_or_default())) + .map(|(name, file)| { + let name = meta.decode_filename(name); + let content = file.content.unwrap_or_default(); + CodeFile { name, content } + }) .collect(); - files.sort_by(|(name1, _), (name2, _)| name1.cmp(name2)); - let code = match files.len() { - 0 | 1 => files.into_iter().map(|(_, content)| content).collect(), - _ => files - .into_iter() - .map(|(name, content)| format!("// {name}\n{content}\n\n")) - .collect(), + 0 | 1 => Code::Single(files.pop().map(|cf| cf.content).unwrap_or_default()), + _ => Code::Multiple(files), }; Gist { @@ -35,16 +118,40 @@ impl From for Gist { } } -pub async fn create_future(token: String, code: String) -> octocrab::Result { - github(token)? +pub async fn create_future(token: String, code: Code) -> Result { + use create_error::*; + + let meta = current_meta(); + let github = github(token)?; + + let handler = github .gists() .create() .description(DESCRIPTION) - .public(false) - .file(FILENAME, code) - .send() - .await - .map(Into::into) + .public(false); + + let handler = match code { + Code::Single(code) => handler.file(meta.encode_filename(FILENAME.to_owned()), code), + Code::Multiple(files) => files.into_iter().fold(handler, |handler, codefile| { + handler.file(meta.encode_filename(codefile.name), codefile.content) + }), + }; + + let meta_content = + toml::to_string_pretty(&SerializedMeta::from(meta)).context(SerializeSnafu)?; + let handler = handler.file(META_FILENAME, meta_content); + + handler.send().await.map(Into::into).map_err(Into::into) +} + +#[derive(Debug, Snafu)] +#[snafu(module)] +pub enum CreateError { + #[snafu(transparent)] + Octocrab { source: octocrab::Error }, + + #[snafu(display("Unable to serialize playground meta information"))] + Serialize { source: toml::ser::Error }, } pub async fn load_future(token: String, id: &str) -> octocrab::Result { @@ -58,3 +165,26 @@ fn github(token: String) -> octocrab::Result { .personal_token(token) .build() } + +#[cfg(test)] +mod test { + use super::*; + use std::assert_matches; + use std::error::Error; + + #[test] + fn serialize_current_meta() -> Result<(), Box> { + let meta = current_meta(); + let meta = SerializedMeta::from(meta); + let serialized = toml::to_string_pretty(&meta)?; + assert_eq!(r#"version = "1""#, serialized.trim()); + Ok(()) + } + + #[test] + fn deserialize_meta_v1() -> Result<(), Box> { + let meta: SerializedMeta = toml::from_str(r#"version = "1""#)?; + assert_matches!(meta, SerializedMeta::V1); + Ok(()) + } +} diff --git a/ui/src/main.rs b/ui/src/main.rs index 88eaf16d5..13b717121 100644 --- a/ui/src/main.rs +++ b/ui/src/main.rs @@ -48,7 +48,9 @@ fn main() { } #[derive(Copy, Clone)] -pub(crate) struct FeatureFlags {} +pub(crate) struct FeatureFlags { + multifile_threshold: Option, +} struct Config { address: String, @@ -112,7 +114,13 @@ impl Config { let cors_enabled = env::var_os("PLAYGROUND_CORS_ENABLED").is_some(); - let feature_flags = FeatureFlags {}; + let multifile_threshold = env::var("PLAYGROUND_MULTIFILE_THRESHOLD") + .ok() + .and_then(|v| v.parse().ok()); + + let feature_flags = FeatureFlags { + multifile_threshold, + }; let request_db_path = env::var_os("PLAYGROUND_REQUEST_DATABASE").map(Into::into); diff --git a/ui/src/public_http_api.rs b/ui/src/public_http_api.rs index 4ab17b126..a6c712a23 100644 --- a/ui/src/public_http_api.rs +++ b/ui/src/public_http_api.rs @@ -24,7 +24,7 @@ pub(crate) struct CompileRequest { pub(crate) tests: bool, #[serde(default)] pub(crate) backtrace: bool, - pub(crate) code: String, + pub(crate) code: Code, } #[derive(Debug, Clone, Serialize)] @@ -48,7 +48,7 @@ pub(crate) struct ExecuteRequest { pub(crate) tests: bool, #[serde(default)] pub(crate) backtrace: bool, - pub(crate) code: String, + pub(crate) code: Code, } #[derive(Debug, Clone, Serialize)] @@ -66,7 +66,7 @@ pub(crate) struct FormatRequest { pub(crate) channel: Option, #[serde(default)] pub(crate) edition: String, - pub(crate) code: String, + pub(crate) code: Code, } #[derive(Debug, Clone, Serialize)] @@ -74,7 +74,7 @@ pub(crate) struct FormatResponse { pub(crate) success: bool, #[serde(rename = "exitDetail")] pub(crate) exit_detail: String, - pub(crate) code: String, + pub(crate) code: Code, pub(crate) stdout: String, pub(crate) stderr: String, } @@ -87,7 +87,7 @@ pub(crate) struct ClippyRequest { pub(crate) crate_type: String, #[serde(default)] pub(crate) edition: String, - pub(crate) code: String, + pub(crate) code: Code, } #[derive(Debug, Clone, Serialize)] @@ -100,7 +100,7 @@ pub(crate) struct ClippyResponse { #[derive(Debug, Clone, Serialize, Deserialize)] pub(crate) struct MiriRequest { - pub(crate) code: String, + pub(crate) code: Code, #[serde(default)] pub(crate) edition: String, #[serde(default)] @@ -119,7 +119,7 @@ pub(crate) struct MiriResponse { #[derive(Debug, Clone, Serialize, Deserialize)] pub(crate) struct MacroExpansionRequest { - pub(crate) code: String, + pub(crate) code: Code, #[serde(default)] pub(crate) edition: String, } @@ -169,14 +169,14 @@ pub(crate) struct MetaVersionResponse { #[derive(Debug, Clone, Deserialize)] pub(crate) struct MetaGistCreateRequest { - pub(crate) code: String, + pub(crate) code: Code, } #[derive(Debug, Clone, Serialize)] pub(crate) struct MetaGistResponse { pub(crate) id: String, pub(crate) url: String, - pub(crate) code: String, + pub(crate) code: Code, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -199,3 +199,16 @@ pub(crate) struct EvaluateResponse { fn default_crate_type() -> String { "bin".into() } + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(untagged)] +pub(crate) enum Code { + Single(String), + Multiple(Vec), +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub(crate) struct CodeFile { + pub(crate) name: String, + pub(crate) content: String, +} diff --git a/ui/src/server_axum.rs b/ui/src/server_axum.rs index 5affff66f..67cae14f5 100644 --- a/ui/src/server_axum.rs +++ b/ui/src/server_axum.rs @@ -549,7 +549,7 @@ async fn meta_gist_create( Json(req): Json, ) -> Result> { let token = must_get(&token)?; - gist::create_future(token, req.code) + gist::create_future(token, req.code.into()) .await .map(Into::into) .map(Json) @@ -816,7 +816,7 @@ where #[derive(Debug, Snafu)] enum Error { #[snafu(display("Gist creation failed"))] - GistCreation { source: octocrab::Error }, + GistCreation { source: gist::CreateError }, #[snafu(display("Gist loading failed"))] GistLoading { source: octocrab::Error }, @@ -981,6 +981,38 @@ pub(crate) mod api_orchestrator_integration_impls { } } + impl From for Code { + fn from(value: api::Code) -> Self { + match value { + api::Code::Single(c) => Code::Single(c), + api::Code::Multiple(f) => Code::Multiple(f.into_iter().map(Into::into).collect()), + } + } + } + + impl From for api::Code { + fn from(value: Code) -> Self { + match value { + Code::Single(c) => api::Code::Single(c), + Code::Multiple(f) => api::Code::Multiple(f.into_iter().map(Into::into).collect()), + } + } + } + + impl From for CodeFile { + fn from(value: api::CodeFile) -> Self { + let api::CodeFile { name, content } = value; + Self { name, content } + } + } + + impl From for api::CodeFile { + fn from(value: CodeFile) -> Self { + let CodeFile { name, content } = value; + Self { name, content } + } + } + impl TryFrom for ExecuteRequest { type Error = ParseEvaluateRequestError; @@ -1012,7 +1044,7 @@ pub(crate) mod api_orchestrator_integration_impls { crate_type: CrateType::Binary, tests, backtrace: false, - code, + code: code.into(), }) } } @@ -1089,7 +1121,7 @@ pub(crate) mod api_orchestrator_integration_impls { edition: parse_edition(&edition)?, tests, backtrace, - code, + code: code.into(), }) } } @@ -1156,7 +1188,7 @@ pub(crate) mod api_orchestrator_integration_impls { edition: parse_edition(&edition)?, tests, backtrace, - code, + code: code.into(), }) } } @@ -1216,7 +1248,7 @@ pub(crate) mod api_orchestrator_integration_impls { channel, crate_type: CrateType::Binary, // TODO: use what user has submitted edition: parse_edition(&edition)?, - code, + code: code.into(), }) } } @@ -1246,7 +1278,7 @@ pub(crate) mod api_orchestrator_integration_impls { Self { success, exit_detail, - code, + code: code.into(), stdout, stderr, } @@ -1273,7 +1305,7 @@ pub(crate) mod api_orchestrator_integration_impls { channel, crate_type: parse_crate_type(&crate_type)?, edition: parse_edition(&edition)?, - code, + code: code.into(), }) } } @@ -1333,7 +1365,7 @@ pub(crate) mod api_orchestrator_integration_impls { edition: parse_edition(&edition)?, tests, aliasing_model, - code, + code: code.into(), }) } } @@ -1377,7 +1409,7 @@ pub(crate) mod api_orchestrator_integration_impls { channel: Channel::Nightly, // TODO: use what user has submitted crate_type: CrateType::Binary, // TODO: use what user has submitted edition: parse_edition(&edition)?, - code, + code: code.into(), }) } } @@ -1584,8 +1616,44 @@ pub(crate) mod api_orchestrator_integration_impls { api::MetaGistResponse { id: me.id, url: me.url, - code: me.code, + code: me.code.into(), + } + } + } + + impl From for gist::Code { + fn from(value: api::Code) -> Self { + match value { + api::Code::Single(code) => gist::Code::Single(code), + api::Code::Multiple(files) => { + gist::Code::Multiple(files.into_iter().map(Into::into).collect()) + } } } } + + impl From for api::Code { + fn from(value: gist::Code) -> Self { + match value { + gist::Code::Single(code) => api::Code::Single(code), + gist::Code::Multiple(files) => { + api::Code::Multiple(files.into_iter().map(Into::into).collect()) + } + } + } + } + + impl From for gist::CodeFile { + fn from(value: api::CodeFile) -> Self { + let api::CodeFile { name, content } = value; + Self { name, content } + } + } + + impl From for api::CodeFile { + fn from(value: gist::CodeFile) -> Self { + let gist::CodeFile { name, content } = value; + Self { name, content } + } + } } diff --git a/ui/src/server_axum/websocket.rs b/ui/src/server_axum/websocket.rs index 78c677b72..067273bff 100644 --- a/ui/src/server_axum/websocket.rs +++ b/ui/src/server_axum/websocket.rs @@ -82,7 +82,7 @@ struct ExecuteRequest { edition: String, crate_type: String, tests: bool, - code: String, + code: Code, backtrace: bool, } @@ -107,7 +107,7 @@ impl TryFrom for coordinator::ExecuteRequest { crate_type: parse_crate_type(&crate_type)?, tests, backtrace, - code, + code: code.into(), }) } } @@ -127,6 +127,37 @@ pub(crate) enum ExecuteRequestParseError { Edition { source: ParseEditionError }, } +#[derive(serde::Deserialize)] +#[serde(untagged)] +enum Code { + Single(String), + Multiple(Vec), +} + +#[derive(serde::Deserialize)] +struct CodeFile { + name: String, + content: String, +} + +impl From for coordinator::Code { + fn from(value: Code) -> Self { + match value { + Code::Single(c) => coordinator::Code::Single(c), + Code::Multiple(f) => { + coordinator::Code::Multiple(f.into_iter().map(Into::into).collect()) + } + } + } +} + +impl From for coordinator::CodeFile { + fn from(value: CodeFile) -> Self { + let CodeFile { name, content } = value; + Self { name, content } + } +} + #[derive(Debug, serde::Serialize)] #[serde(tag = "type")] enum MessageResponse { @@ -163,11 +194,15 @@ struct WSError { #[derive(Debug, serde::Serialize)] #[serde(rename_all = "camelCase")] -pub(crate) struct FeatureFlags {} +pub(crate) struct FeatureFlags { + multifile_threshold: Option, +} impl From for FeatureFlags { - fn from(_value: crate::FeatureFlags) -> Self { - Self {} + fn from(value: crate::FeatureFlags) -> Self { + Self { + multifile_threshold: value.multifile_threshold, + } } }