diff --git a/Cargo.lock b/Cargo.lock index c10d8ad..84e1283 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -254,6 +254,7 @@ dependencies = [ "matchit", "memchr", "mime", + "multer", "percent-encoding", "pin-project-lite", "rustversion", @@ -626,6 +627,15 @@ version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a26ae43d7bcc3b814de94796a5e736d4029efb0ee900c12e2d54c993ad1a1e07" +[[package]] +name = "encoding_rs" +version = "0.8.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7268b386296a025e474d5140678f75d6de9493ae55a5d709eeb9dd08149945e1" +dependencies = [ + "cfg-if", +] + [[package]] name = "equivalent" version = "1.0.1" @@ -1247,6 +1257,24 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "multer" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01acbdc23469fd8fe07ab135923371d5f5a422fbf9c522158677c8eb15bc51c2" +dependencies = [ + "bytes", + "encoding_rs", + "futures-util", + "http", + "httparse", + "log", + "memchr", + "mime", + "spin", + "version_check", +] + [[package]] name = "new_debug_unreachable" version = "1.0.4" @@ -1994,6 +2022,12 @@ dependencies = [ "url", ] +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" + [[package]] name = "st-map" version = "0.2.0" diff --git a/Cargo.toml b/Cargo.toml index c8734ce..971a06d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,7 +7,7 @@ edition = "2021" anyhow = { version = "1", features = ["backtrace"] } askama = { version = "0.12", features = ["with-axum"] } askama_axum = "0.3" -axum = "0.6" +axum = { version = "0.6", features = ["multipart"] } bollard = { version = "0.15", features = ["time"] } clap = { version = "4", features = ["env", "derive"] } dotenvy = "0.15" diff --git a/src/git/history.rs b/src/git/history.rs index db3706a..abb1e02 100644 --- a/src/git/history.rs +++ b/src/git/history.rs @@ -1,7 +1,7 @@ use anyhow::Result; -use git2::DiffLine; +use git2::{DiffLine, TreeWalkMode, TreeWalkResult}; use serde::Serialize; -use std::path::Path; +use std::{collections::HashMap, path::Path}; use time::{ format_description::well_known::{Iso8601, Rfc2822}, OffsetDateTime, @@ -23,11 +23,11 @@ pub struct CommitInfo { pub message: String, pub author: String, pub date: String, - pub changes: Vec, + pub changes: HashMap>, } impl ThreadSafeRepository { - pub fn get_history(&self, path: &Path) -> Result> { + pub fn get_history(&self, path: &str) -> Result> { let repository = self.repository()?; let mut revwalk = repository.revwalk()?; @@ -35,41 +35,77 @@ impl ThreadSafeRepository { revwalk.set_sorting(git2::Sort::TIME | git2::Sort::REVERSE)?; let mut history = Vec::new(); - let mut last_blob = None; + let mut last_blobs = HashMap::new(); for oid in revwalk { // Get commit let oid = oid?; let commit = repository.find_commit(oid)?; let tree = commit.tree()?; - // Check if file changed in this commit - let entry = tree.get_path(path)?; - if last_blob == Some(entry.id()) { + let mut changed = vec![]; + + // Check if any file we care about was modified + tree.walk(TreeWalkMode::PreOrder, |val, entry| { + // We're at root, only traverse the path we care about + if val.is_empty() { + if entry.name().unwrap_or_default() == path { + TreeWalkResult::Ok + } else { + TreeWalkResult::Skip + } + } else { + // Track files, skip directories + if entry.kind() == Some(git2::ObjectType::Blob) { + let file_path = entry.name().unwrap_or_default().to_string(); + + // Check if blob exists + match last_blobs.get(&file_path) { + Some(blob_id) => { + if blob_id != &entry.id() { + changed.push((file_path, entry.id())) + } + } + None => changed.push((file_path, entry.id())), + } + } + TreeWalkResult::Skip + } + })?; + + if changed.is_empty() { continue; } - // Get changes - let current = repository.find_blob(entry.id())?; - let old = last_blob.and_then(|id| repository.find_blob(id).ok()); + // Get changes for each file + let mut file_changes = HashMap::new(); + for (file_path, id) in changed { + let current = repository.find_blob(id)?; + let old = last_blobs + .get(&file_path) + .and_then(|id| repository.find_blob(*id).ok()); - let mut changes = vec![]; - repository.diff_blobs( - old.as_ref(), - None, - Some(¤t), - None, - None, - None, - None, - None, - Some(&mut |_, _, line| { - changes.push(line.into()); - true - }), - )?; + let mut changes = vec![]; + repository.diff_blobs( + old.as_ref(), + None, + Some(¤t), + None, + None, + None, + None, + None, + Some(&mut |_, _, line| { + changes.push(line.into()); + true + }), + )?; - // Write new blob id to compare against - last_blob = Some(entry.id()); + // Write new blob id to compare against + last_blobs.insert(file_path.clone(), id); + + // Add changes to file changes list + file_changes.insert(file_path, changes); + } history.push(CommitInfo { oid: commit.id().to_string(), @@ -79,7 +115,7 @@ impl ThreadSafeRepository { .unwrap_or_else(|_| OffsetDateTime::now_utc()) .format(&Iso8601::DEFAULT) .unwrap_or_default(), - changes, + changes: file_changes, }); } @@ -109,17 +145,24 @@ impl From> for LineChange { } impl CommitInfo { - pub fn diff(&self) -> Vec { - let mut ordered = self.changes.clone(); - ordered.sort_by(|a, b| { - let line_a = a.old_line.or_else(|| a.new_line).unwrap_or_default(); - let line_b = b.old_line.or_else(|| b.new_line).unwrap_or_default(); - line_a.cmp(&line_b) - }); - ordered - .iter() - .map(|change| format!("{} {}", change.op, change.content)) - .collect() + pub fn diff(&self) -> HashMap> { + let mut files = HashMap::new(); + for (file, changes) in &self.changes { + let mut ordered = changes.clone(); + ordered.sort_by(|a, b| { + let line_a = a.old_line.or(a.new_line).unwrap_or_default(); + let line_b = b.old_line.or(b.new_line).unwrap_or_default(); + line_a.cmp(&line_b) + }); + files.insert( + file.clone(), + ordered + .iter() + .map(|change| format!("{} {}", change.op, change.content)) + .collect(), + ); + } + files } pub fn date_human(&self) -> String { diff --git a/src/http/accept.rs b/src/http/accept.rs index 948e574..2d510be 100644 --- a/src/http/accept.rs +++ b/src/http/accept.rs @@ -29,17 +29,17 @@ where /// Supported content types pub(super) enum ResponseType { - HTML, - JSON, + Html, + Json, } /// Parses the Accept header and returns content type to return pub(super) fn parse_accept(accept: &HeaderValue) -> ResponseType { let bytes = accept.as_bytes(); if bytes.starts_with(b"application/json") { - return ResponseType::JSON; + return ResponseType::Json; } - ResponseType::HTML + ResponseType::Html } #[cfg(test)] diff --git a/src/http/response.rs b/src/http/response.rs index 6e948a3..ca4de8b 100644 --- a/src/http/response.rs +++ b/src/http/response.rs @@ -45,8 +45,8 @@ pub async fn response_interceptor( let accept = request .headers() .get(&ACCEPT) - .map(|header| parse_accept(header)) - .unwrap_or_else(|| ResponseType::HTML); + .map(parse_accept) + .unwrap_or_else(|| ResponseType::Html); let mut response = next.run(request).await; @@ -57,7 +57,7 @@ pub async fn response_interceptor( }) = response.extensions_mut().remove::() { match accept { - ResponseType::JSON => { + ResponseType::Json => { return (status, Json(json!({"code": code, "message": message}))).into_response(); } _ => { @@ -75,7 +75,7 @@ pub async fn response_interceptor( if let Some(Response { html, json }) = response.extensions_mut().remove::() { match accept { - ResponseType::JSON => { + ResponseType::Json => { return Json(json).into_response(); } _ => { diff --git a/src/main.rs b/src/main.rs index b68d590..e5fb08b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -105,7 +105,7 @@ fn parse_author(git_author: &str) -> Result<(String, String)> { match git_author.split_once('<') { Some((name, email)) => Ok(( name.trim().to_string(), - email.trim_end_matches(">").trim().to_string(), + email.trim_end_matches('>').trim().to_string(), )), None => Err(anyhow!( "invalid git author format (email must be specified)" diff --git a/src/node/container.rs b/src/node/container.rs index 72e62fe..88b61b4 100644 --- a/src/node/container.rs +++ b/src/node/container.rs @@ -78,7 +78,7 @@ impl From for ContainerInfo { state: value .state .and_then(|s| s.status) - .unwrap_or_else(|| bollard::service::ContainerStateStatusEnum::EMPTY) + .unwrap_or(bollard::service::ContainerStateStatusEnum::EMPTY) .to_string(), image: config.image.unwrap_or_default(), image_id: value.image.unwrap_or_default(), @@ -100,32 +100,32 @@ pub async fn get_info(docker: &Docker, name: &str) -> Result { pub async fn start(docker: &Docker, name: &str) -> Result<()> { Ok(docker - .start_container(&name, None::>) + .start_container(name, None::>) .await?) } pub async fn restart(docker: &Docker, name: &str) -> Result<()> { Ok(docker - .restart_container(&name, Some(RestartContainerOptions { t: 30 })) + .restart_container(name, Some(RestartContainerOptions { t: 30 })) .await?) } pub async fn stop(docker: &Docker, name: &str) -> Result<()> { Ok(docker - .stop_container(&name, Some(StopContainerOptions { t: 30 })) + .stop_container(name, Some(StopContainerOptions { t: 30 })) .await?) } pub async fn kill(docker: &Docker, name: &str) -> Result<()> { Ok(docker - .kill_container(&name, None::>) + .kill_container(name, None::>) .await?) } pub async fn remove(docker: &Docker, name: &str) -> Result<()> { Ok(docker .remove_container( - &name, + name, Some(RemoveContainerOptions { v: false, force: true, diff --git a/src/node/nix.rs b/src/node/nix.rs index e294c6a..1539510 100644 --- a/src/node/nix.rs +++ b/src/node/nix.rs @@ -25,7 +25,7 @@ pub fn parse_arion_compose(file: &str) -> Result { .filter_map(|(key, _)| { key.strip_prefix(SERVICES_PREFIX) .and_then(|key| key.split_once('\0')) - .and_then(|k| Some(k.0.to_string())) + .map(|k| k.0.to_string()) }) .collect::>() .into_iter() diff --git a/src/route/stack/edit.rs b/src/route/stack/edit.rs index c9521fd..c82b448 100644 --- a/src/route/stack/edit.rs +++ b/src/route/stack/edit.rs @@ -1,12 +1,8 @@ -use std::collections::HashMap; - use axum::{ - extract::{Path, State}, + extract::{Multipart, Path, State}, http::StatusCode, response::Redirect, - Form, }; -use serde::Deserialize; use crate::{ http::error::{AppError, Result}, @@ -14,24 +10,31 @@ use crate::{ AppState, }; -#[derive(Deserialize)] -pub(super) struct EditStackForm { - files: HashMap, - commit_message: String, -} - pub(super) async fn edit_stack( Path(stack_name): Path, State(state): State, - Form(form): Form, + mut multipart: Multipart, ) -> Result { - let commit_message = if form.commit_message.trim().is_empty() { - format!("Update {}", stack_name) - } else { - form.commit_message - }; + let mut commit_message = format!("Update {}", stack_name); + let mut files = FileList::new(); + while let Ok(Some(field)) = multipart.next_field().await { + let name = field.name().unwrap_or_default().to_string(); + let data = field.text().await.map_err(|e| AppError::Client { + status: StatusCode::BAD_REQUEST, + code: "invalid-data", + message: e.to_string(), + })?; - if form.files.is_empty() { + if name.starts_with("files/") { + // Clean up source (like line endings) in each file + let source = data.replace("\r\n", "\n"); + files.insert(name.strip_prefix("files/").unwrap().to_string(), source); + } else if name == "commit_message" && !data.is_empty() { + commit_message = data; + } + } + + if files.is_empty() { return Err(AppError::Client { status: StatusCode::BAD_REQUEST, code: "invalid-files", @@ -39,14 +42,7 @@ pub(super) async fn edit_stack( }); } - // Cleanup source (like line endings) in each file - let mut files = FileList::new(); - for (name, source) in form.files { - files.insert(name, source.replace("\r\n", "\n")); - } - edit_stack_int(state, &stack_name, &files, &commit_message).await?; - Ok(Redirect::to("./")) } diff --git a/src/route/stack/history.rs b/src/route/stack/history.rs index c8a63c7..a307f84 100644 --- a/src/route/stack/history.rs +++ b/src/route/stack/history.rs @@ -39,8 +39,7 @@ pub(super) async fn list( let compose_file = compose::get(&state.stack_dir, &stack_name).await?; let info = parse_arion_compose(&compose_file)?; - let git_compose_path = compose::path(&stack_name); - let commits = state.repository.get_history(&git_compose_path)?; + let commits = state.repository.get_history(&stack_name)?; let mut newest_first = commits.clone(); newest_first.reverse(); diff --git a/src/stack/list.rs b/src/stack/list.rs index db3697d..a451ff7 100644 --- a/src/stack/list.rs +++ b/src/stack/list.rs @@ -14,7 +14,7 @@ impl StackInfo { } } -fn get_service(containers: &Vec, stack_name: &str, service: &str) -> ServiceInfo { +fn get_service(containers: &[ContainerInfo], stack_name: &str, service: &str) -> ServiceInfo { let container = containers.iter().find(|cont| { let labels = cont.labels.clone().unwrap_or_default(); labels.get("com.docker.compose.project") == Some(&stack_name.to_string()) diff --git a/static/scripts/components/editor/script.ts b/static/scripts/components/editor/script.ts index 4938418..867992b 100644 --- a/static/scripts/components/editor/script.ts +++ b/static/scripts/components/editor/script.ts @@ -4,18 +4,19 @@ import { $el } from "/static/vendor/domutil/domutil.js"; class Editor extends HTMLElement { private editor: AceAjax.Editor; private tabEl: HTMLDivElement; - private files: Record; + private sessions: Record; private root: ShadowRoot; private addTabButton: HTMLDivElement; constructor() { super(); - this.files = {}; + this.sessions = {}; this.root = this.attachShadow({ mode: "open" }); } connectedCallback() { this.addTabButton = $el("div", { className: "tab" }, "+"); + this.addTabButton.addEventListener("click", () => this.#createNewTab()); this.tabEl = $el("div", { className: "tab-container" }, this.addTabButton); this.root.append(this.tabEl); @@ -66,10 +67,10 @@ class Editor extends HTMLElement { */ addFile(name: string, content: string) { // Add to session list - this.files[name] = ace.createEditSession(content); + this.sessions[name] = ace.createEditSession(content); // TODO replace this with auto-detection - this.files[name].setMode("ace/mode/nix"); + this.sessions[name].setMode("ace/mode/nix"); // Create tab and set as active const tab = this.#addTab(name); @@ -77,13 +78,17 @@ class Editor extends HTMLElement { } setCurrent(name: string) { - this.editor.setSession(this.files[name]); + this.editor.setSession(this.sessions[name]); this.tabEl.querySelectorAll(".tab").forEach(el => { if (el.dataset.name === name) el.classList.add("active"); else el.classList.remove("active"); }); } + files(): [String, String][] { + return Object.entries(this.sessions).map(([name, session]) => [name, session.getValue()]); + } + /** * Create a new tab * @param name Tab name @@ -98,6 +103,11 @@ class Editor extends HTMLElement { this.tabEl.insertBefore(tab, this.addTabButton); return tab; } + + + #createNewTab() { + this.addFile(`untitled${Object.keys(this.sessions).filter(s => s.startsWith("untitled")).length + 1}`, ""); + } } customElements.define("file-editor", Editor); \ No newline at end of file diff --git a/templates/stack/get-one.html b/templates/stack/get-one.html index 474d2c9..5a783a2 100644 --- a/templates/stack/get-one.html +++ b/templates/stack/get-one.html @@ -61,13 +61,13 @@ Editor
-
+
{% for (filename, content) in files %} {% endfor %} @@ -114,14 +114,19 @@ {% endblock %} \ No newline at end of file diff --git a/templates/stack/history.html b/templates/stack/history.html index a7f4b34..2e0859e 100644 --- a/templates/stack/history.html +++ b/templates/stack/history.html @@ -12,18 +12,23 @@
{% for (i, commit) in commits.iter().enumerate() %}
-

+

{{commit.oid[..7]}} -

+ + ({{commit.author}}) + +
+ {% for (file, lines) in commit.diff() %} +

{{commit.message}}

- {{commit.message}} + {{file}} - {% for line in commit.diff() %} + {% for line in lines %} {% match line.chars().next() %} {% when Some with ('+') %}
{{line}}
@@ -35,6 +40,7 @@ {% endfor %}
+ {% endfor %}
{% if i == 0 %} @@ -73,7 +79,7 @@ flex-direction: column; gap: 0.5ch; - & p { + & header { font-size: 9pt; margin: 0; @@ -82,6 +88,10 @@ } } + & p { + margin: 0; + } + & code { font-size: 10pt; background-color: var(--bg-raised);