From 5124bc341b59eb3f671ca3ea98ae08675a40620d Mon Sep 17 00:00:00 2001 From: Marlon Date: Thu, 9 Mar 2023 12:19:46 +0100 Subject: [PATCH] Build a single binary (#24) --- .github/workflows/build.yml | 195 +++++++++++++++++----------------- .github/workflows/check.yml | 77 ++++---------- .github/workflows/release.yml | 187 ++++++++++++++++++++++++++++++++ Dockerfile | 5 +- backend/Cargo.lock | 68 +++++++++++- backend/Cargo.toml | 3 +- backend/build.rs | 8 ++ backend/src/main.rs | 9 +- backend/src/web_server.rs | 49 +++++++-- frontend/Cargo.lock | 2 +- frontend/Cargo.toml | 2 +- frontend/src/view.rs | 4 +- 12 files changed, 436 insertions(+), 173 deletions(-) create mode 100644 .github/workflows/release.yml create mode 100644 backend/build.rs diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 7c49ca2..f8ebe7a 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,109 +1,112 @@ -name: "Build and release" +name: Build on: push: branches: [ "main" ] -env: - CARGO_TERM_COLOR: always - jobs: - get-tag: - name: "Get tag from package version" + frontend: + name: Build frontend assets runs-on: ubuntu-22.04 - outputs: - pkg-version: ${{ steps.pkg-version.outputs.PKG_VERSION }} + steps: - - name: Checkout - uses: actions/checkout@v3 + - uses: actions/checkout@v3 + - run: rustup toolchain install stable --profile minimal + - run: rustup target add wasm32-unknown-unknown + - uses: jetli/trunk-action@v0.4.0 with: - token: ${{ secrets.GITHUB_TOKEN }} - - - name: Get tag - id: pkg-version - shell: bash - run: | - echo PKG_VERSION=$(awk -F ' = ' '$1 ~ /version/ { gsub(/["]/, "", $2); printf("%s",$2) }' backend/Cargo.toml) >> $GITHUB_OUTPUT + version: 'v0.16.0' - - name: Set tag - shell: bash - run: | - git tag v${{ steps.pkg-version.outputs.PKG_VERSION }} && git push --tags + - uses: actions/cache@v3 + with: + path: | + ~/.cargo/bin/ + ~/.cargo/registry/index/ + ~/.cargo/registry/cache/ + ~/.cargo/git/db/ + frontend/target/ + key: frontend-${{ hashFiles('frontend/Cargo.toml') }} + restore-keys: frontend- + + - name: Build frontend + run: trunk build + working-directory: frontend + + - uses: actions/upload-artifact@v3 + with: + name: frontend-build-${{ github.sha }} + path: frontend/dist build: - name: "Build and release" - needs: "get-tag" - runs-on: ubuntu-22.04 + name: Binaries for ${{ matrix.name }} + needs: frontend + runs-on: ${{ matrix.os }} + + strategy: + matrix: + name: + - linux-x86-64-gnu + - linux-armv7-gnu + - linux-arm64-gnu + - linux-x86-64-musl + - linux-arm64-musl + include: + - name: linux-x86-64-gnu + os: ubuntu-22.04 + target: x86_64-unknown-linux-gnu + platform: ~ + cross: false + + - name: linux-armv7-gnu + os: ubuntu-22.04 + target: armv7-unknown-linux-gnueabihf + platform: ~ + cross: true + + - name: linux-arm64-gnu + os: ubuntu-22.04 + target: aarch64-unknown-linux-gnu + platform: ~ + cross: true + + - name: linux-x86-64-musl + os: ubuntu-22.04 + target: x86_64-unknown-linux-musl + platform: amd64 + cross: true + + - name: linux-arm64-musl + os: ubuntu-22.04 + target: aarch64-unknown-linux-musl + platform: arm64 + cross: true + steps: - - uses: actions/checkout@v3 - - - name: Cache build assets - uses: actions/cache@v3 - with: - path: | - ~/.cargo/bin/ - ~/.cargo/registry/index/ - ~/.cargo/registry/cache/ - ~/.cargo/git/db/ - backend/target/ - frontend/target/ - key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} - - - name: Configure Rust toolchain - uses: actions-rs/toolchain@v1 - with: - toolchain: stable - target: wasm32-unknown-unknown - - - name: Set up QEMU - uses: docker/setup-qemu-action@v2 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v2 - - - name: Login to DockerHub - uses: docker/login-action@v2 - with: - username: ${{ secrets.DOCKER_HUB_USERNAME }} - password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }} - - - name: Download trunk - uses: jetli/trunk-action@v0.1.0 - with: - version: 'latest' - - - name: Build target amd64 - uses: actions-rs/cargo@v1 - with: - use-cross: true - command: build - args: --release --target x86_64-unknown-linux-musl --manifest-path backend/Cargo.toml - - - name: Copy binary - run: | - mkdir bin - cp target/x86_64-unknown-linux-musl/release/mailcrab-backend bin/amd64 - working-directory: ./backend - - - name: Cargo clean - run: cargo clean - working-directory: ./backend - - - name: Build target arm64 - uses: actions-rs/cargo@v1 - with: - use-cross: true - command: build - args: --release --target aarch64-unknown-linux-musl --manifest-path backend/Cargo.toml - - - name: Copy binary - run: | - cp target/aarch64-unknown-linux-musl/release/mailcrab-backend bin/arm64 - working-directory: ./backend - - - name: Build frontend - run: trunk build - working-directory: ./frontend - - - name: Build docker image - run: docker buildx build --push --platform=linux/amd64,linux/arm64 . -t marlonb/mailcrab:latest -t marlonb/mailcrab:${{ needs.get-tag.outputs.pkg-version }} + - uses: actions/checkout@v3 + - run: rustup toolchain install stable --profile minimal + + - uses: actions/download-artifact@v3 + with: + name: frontend-build-${{ github.sha }} + path: frontend/dist + + - uses: actions/cache@v3 + with: + path: | + ~/.cargo/bin/ + ~/.cargo/registry/index/ + ~/.cargo/registry/cache/ + ~/.cargo/git/db/ + backend/target/ + key: backend-${{ matrix.name }}-${{ hashFiles('backend/Cargo.toml') }} + restore-keys: backend-${{ matrix.name }}- + + - run: cargo install cross --git https://github.com/cross-rs/cross || true + + - name: Build + if: ${{ !matrix.cross }} + run: cargo build --release --locked --target ${{ matrix.target }} --manifest-path backend/Cargo.toml + + - name: Cross build + if: ${{ matrix.cross }} + run: cross build --release --locked --target ${{ matrix.target }} --manifest-path backend/Cargo.toml diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 82fadf7..e2b48eb 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -4,62 +4,27 @@ on: pull_request: branches: [ "main" ] -env: - CARGO_TERM_COLOR: always - jobs: - build: + check: runs-on: ubuntu-22.04 steps: - - uses: actions/checkout@v3 - - - name: Cache build assets - uses: actions/cache@v3 - with: - path: | - ~/.cargo/bin/ - ~/.cargo/registry/index/ - ~/.cargo/registry/cache/ - ~/.cargo/git/db/ - backend/target/ - frontend/target/ - key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} - - - name: Configure Rust toolchain - uses: actions-rs/toolchain@v1 - with: - toolchain: stable - target: wasm32-unknown-unknown - - - name: Set up QEMU - uses: docker/setup-qemu-action@v2 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v2 - - - name: Download trunk - uses: jetli/trunk-action@v0.1.0 - with: - version: 'latest' - - - name: Build target amd64 - uses: actions-rs/cargo@v1 - with: - use-cross: true - command: build - args: --release --target x86_64-unknown-linux-musl --manifest-path backend/Cargo.toml - - - name: Cargo clean - run: cargo clean - working-directory: ./backend - - - name: Build target arm64 - uses: actions-rs/cargo@v1 - with: - use-cross: true - command: build - args: --release --target aarch64-unknown-linux-musl --manifest-path backend/Cargo.toml - - - name: Build frontend - run: trunk build - working-directory: ./frontend + - uses: actions/checkout@v3 + - run: rustup toolchain install stable --profile minimal + - run: rustup component add clippy + - uses: actions/cache@v3 + with: + path: | + ~/.cargo/bin/ + ~/.cargo/registry/index/ + ~/.cargo/registry/cache/ + ~/.cargo/git/db/ + frontend/target/ + backend/target/ + key: check-${{ hashFiles('**/Cargo.toml') }} + restore-keys: check- + - run: cargo check --manifest-path backend/Cargo.toml + - run: cargo check --manifest-path frontend/Cargo.toml + - run: cargo clippy --manifest-path backend/Cargo.toml + - run: cargo clippy --manifest-path frontend/Cargo.toml + - run: cargo fmt --check --manifest-path backend/Cargo.toml + - run: cargo fmt --check --manifest-path frontend/Cargo.toml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..fdafc02 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,187 @@ +name: Release + +on: + push: + tags: + - v*.*.* + +jobs: + prepare: + name: Prepare release + runs-on: ubuntu-22.04 + + steps: + - name: Release + uses: softprops/action-gh-release@v1 + with: + body: | + Docker releases: https://hub.docker.com/repository/docker/marlonb/mailcrab/tags + Run docker image using: `docker run --rm -p 1080:1080 -p 1025:1025 marlonb/mailcrab:${{ github.ref_name }}` + draft: true + append_body: true + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + frontend: + name: Build frontend assets + runs-on: ubuntu-22.04 + + steps: + - uses: actions/checkout@v3 + - run: rustup toolchain install stable --profile minimal + - run: rustup target add wasm32-unknown-unknown + - uses: jetli/trunk-action@v0.4.0 + with: + version: 'v0.16.0' + + - uses: actions/cache/restore@v3 + with: + path: | + ~/.cargo/bin/ + ~/.cargo/registry/index/ + ~/.cargo/registry/cache/ + ~/.cargo/git/db/ + frontend/target/ + key: frontend-${{ hashFiles('frontend/Cargo.toml') }} + restore-keys: frontend- + + - name: Build frontend + run: trunk build + working-directory: frontend + + - uses: actions/upload-artifact@v3 + with: + name: frontend-build-${{ github.ref_name }} + path: frontend/dist + + build: + name: Binaries for ${{ matrix.name }} + needs: frontend + runs-on: ${{ matrix.os }} + + strategy: + matrix: + name: + - linux-x86-64-gnu + - linux-armv7-gnu + - linux-arm64-gnu + - linux-x86-64-musl + - linux-arm64-musl + include: + - name: linux-x86-64-gnu + os: ubuntu-22.04 + target: x86_64-unknown-linux-gnu + platform: ~ + cross: false + + - name: linux-armv7-gnu + os: ubuntu-22.04 + target: armv7-unknown-linux-gnueabihf + platform: ~ + cross: true + + - name: linux-arm64-gnu + os: ubuntu-22.04 + target: aarch64-unknown-linux-gnu + platform: ~ + cross: true + + - name: linux-x86-64-musl + os: ubuntu-22.04 + target: x86_64-unknown-linux-musl + platform: amd64 + cross: true + + - name: linux-arm64-musl + os: ubuntu-22.04 + target: aarch64-unknown-linux-musl + platform: arm64 + cross: true + + steps: + - uses: actions/checkout@v3 + - run: rustup toolchain install stable --profile minimal + + - uses: actions/download-artifact@v3 + with: + name: frontend-build-${{ github.ref_name }} + path: frontend/dist + + - uses: actions/cache/restore@v3 + with: + path: | + ~/.cargo/bin/ + ~/.cargo/registry/index/ + ~/.cargo/registry/cache/ + ~/.cargo/git/db/ + backend/target/ + key: backend-${{ matrix.name }}-${{ hashFiles('backend/Cargo.toml') }} + restore-keys: backend-${{ matrix.name }}- + + - run: cargo install cross --git https://github.com/cross-rs/cross || true + + - name: Build + if: ${{ !matrix.cross }} + run: cargo build --release --locked --target ${{ matrix.target }} --manifest-path backend/Cargo.toml + + - name: Cross build + if: ${{ matrix.cross }} + run: cross build --release --locked --target ${{ matrix.target }} --manifest-path backend/Cargo.toml + + - name: Copy binaries + if: ${{ matrix.platform == null }} + shell: bash + run: | + mkdir -p bin + src="backend/target/${{ matrix.target }}/release/mailcrab-backend" + dst="bin/mailcrab-${{ matrix.name }}-${{ github.ref_name }}" + cp "$src" "$dst" + sha256sum -b "$dst" > "$dst.sha256" + + - uses: softprops/action-gh-release@v1 + if: ${{ matrix.platform == null }} + with: + draft: true + files: bin/mailcrab-* + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Copy binary + if: ${{ matrix.platform != null }} + run: mkdir -p bin && cp backend/target/${{ matrix.target }}/release/mailcrab-backend bin/${{ matrix.platform }} + + - uses: actions/upload-artifact@v3 + if: ${{ matrix.platform != null }} + with: + name: ${{ matrix.platform }}-build-${{ github.ref_name }} + path: bin/${{ matrix.platform }} + + release: + name: Release + needs: build + runs-on: ubuntu-22.04 + + steps: + - uses: actions/checkout@v3 + + - name: Setup docker buildx + uses: docker/setup-buildx-action@v2 + + - name: Login to dockerhub + uses: docker/login-action@v2 + with: + username: ${{ secrets.DOCKER_HUB_USERNAME }} + password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }} + + - uses: actions/download-artifact@v3 + with: + name: amd64-build-${{ github.ref_name }} + path: bin + + - uses: actions/download-artifact@v3 + with: + name: arm64-build-${{ github.ref_name }} + path: bin + + - name: Build docker image + run: docker buildx build --push --platform=linux/amd64,linux/arm64 . -t marlonb/mailcrab:latest -t marlonb/mailcrab:${{ github.ref_name }} diff --git a/Dockerfile b/Dockerfile index a642a12..b58013d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,6 @@ FROM alpine:3.16 ARG TARGETARCH WORKDIR /app -COPY ./frontend/dist /app/dist -COPY "./backend/bin/$TARGETARCH" /app/mailcrab +COPY --chmod=755 "./bin/$TARGETARCH" /app/mailcrab CMD ["/app/mailcrab"] -EXPOSE 1080 1025 \ No newline at end of file +EXPOSE 1080 1025 diff --git a/backend/Cargo.lock b/backend/Cargo.lock index b28b5a7..2dc06f1 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -753,7 +753,7 @@ dependencies = [ [[package]] name = "mailcrab-backend" -version = "0.7.0" +version = "0.8.0" dependencies = [ "axum", "base64 0.13.1", @@ -765,6 +765,7 @@ dependencies = [ "mailin", "mailin-embedded", "rand", + "rust-embed", "serde", "serde_json", "tokio", @@ -1162,6 +1163,40 @@ dependencies = [ "winapi", ] +[[package]] +name = "rust-embed" +version = "6.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb133b9a38b5543fad3807fb2028ea47c5f2b566f4f5e28a11902f1a358348b6" +dependencies = [ + "rust-embed-impl", + "rust-embed-utils", + "walkdir", +] + +[[package]] +name = "rust-embed-impl" +version = "6.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d4e0f0ced47ded9a68374ac145edd65a6c1fa13a96447b873660b2a568a0fd7" +dependencies = [ + "proc-macro2", + "quote", + "rust-embed-utils", + "syn", + "walkdir", +] + +[[package]] +name = "rust-embed-utils" +version = "7.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "512b0ab6853f7e14e3c8754acb43d6f748bb9ced66aa5915a6553ac8213f7731" +dependencies = [ + "sha2", + "walkdir", +] + [[package]] name = "rustix" version = "0.36.9" @@ -1209,6 +1244,15 @@ version = "1.0.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f91339c0467de62360649f8d3e185ca8de4224ff281f66000de5eb2a77a79041" +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + [[package]] name = "schannel" version = "0.1.21" @@ -1332,6 +1376,17 @@ dependencies = [ "digest", ] +[[package]] +name = "sha2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82e6b795fe2e3b1e845bafcb27aa35405c4d47cdfc92af5fc8d3002f76cebdc0" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "sharded-slab" version = "0.1.4" @@ -1797,6 +1852,17 @@ version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f" +[[package]] +name = "walkdir" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "808cf2735cd4b6866113f648b791c6adc5714537bc222d9347bb203386ffda56" +dependencies = [ + "same-file", + "winapi", + "winapi-util", +] + [[package]] name = "want" version = "0.3.0" diff --git a/backend/Cargo.toml b/backend/Cargo.toml index fd6bbc6..9c5d357 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mailcrab-backend" -version = "0.7.0" +version = "0.8.0" edition = "2021" publish = false @@ -12,6 +12,7 @@ humansize = "2.0" mail-parser = "0.8" mailin = "0.6" mailin-embedded = { version = "0.7", features = ["rtls"] } +rust-embed = "6.6" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" tokio = { version = "1", features = ["full"] } diff --git a/backend/build.rs b/backend/build.rs new file mode 100644 index 0000000..a31d8c6 --- /dev/null +++ b/backend/build.rs @@ -0,0 +1,8 @@ +use std::{fs, path::Path}; + +fn main() { + let path = "../frontend/dist"; + if !Path::new(&path).exists() { + fs::create_dir_all(path).expect("Could not create a frontend/dist folder"); + } +} diff --git a/backend/src/main.rs b/backend/src/main.rs index c463d8e..bc3026a 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -1,3 +1,4 @@ +use rust_embed::{EmbeddedFile, RustEmbed}; use std::{ collections::HashMap, env, process, @@ -14,6 +15,7 @@ use crate::web_server::http_server; mod mail_server; mod types; mod web_server; + pub struct AppState { rx: Receiver, storage: RwLock>, @@ -21,6 +23,10 @@ pub struct AppState { index: Option, } +#[derive(RustEmbed)] +#[folder = "../frontend/dist"] +pub struct Asset; + /// get a port number from the environment or return default value fn get_env_port(name: &'static str, default: u16) -> u16 { env::var(name) @@ -30,7 +36,8 @@ fn get_env_port(name: &'static str, default: u16) -> u16 { } fn load_index() -> Option { - let index: String = std::fs::read_to_string("dist/index.html").ok()?; + let index: EmbeddedFile = Asset::get("index.html")?; + let index = String::from_utf8(index.data.to_vec()).ok()?; // remove slash from start of asset includes, so they are loaded by relative path Some( diff --git a/backend/src/web_server.rs b/backend/src/web_server.rs index 84f5384..26f1e45 100644 --- a/backend/src/web_server.rs +++ b/backend/src/web_server.rs @@ -1,24 +1,22 @@ use axum::{ + body, extract::{ ws::{self, WebSocket}, Path, WebSocketUpgrade, }, - http::StatusCode, - response::{Html, IntoResponse}, + http::{header, StatusCode, Uri}, + response::{Html, IntoResponse, Response}, routing::get, Extension, Json, Router, }; -use std::{net::SocketAddr, sync::Arc}; -use tower_http::{ - services::ServeDir, - trace::{DefaultMakeSpan, TraceLayer}, -}; +use std::{ffi::OsStr, net::SocketAddr, sync::Arc}; +use tower_http::trace::{DefaultMakeSpan, TraceLayer}; use tracing::{event, Level}; use uuid::Uuid; use crate::{ types::{Action, MailMessage, MailMessageMetadata}, - AppState, + AppState, Asset, }; /// send mail message metadata to websocket clients when broadcaster by the SMTP server @@ -142,19 +140,48 @@ async fn message_body_handler( } } +async fn not_found() -> Response { + Response::builder() + .status(StatusCode::NOT_FOUND) + .body(body::boxed(body::Full::from("404"))) + .unwrap() +} + async fn index(Extension(state): Extension>) -> impl IntoResponse { Html(state.index.as_ref().expect("index.html not found").clone()) } -pub async fn http_server(app_state: Arc, port: u16) { - let serve_dir = ServeDir::new("dist"); +async fn static_handler(uri: Uri) -> impl IntoResponse { + let path = uri.path().trim_start_matches('/'); + let mime = std::path::Path::new(path) + .extension() + .and_then(OsStr::to_str) + .and_then(|ext| match ext.to_lowercase().as_str() { + "js" => Some("text/javascript"), + "css" => Some("text/css"), + "svg" => Some("image/svg+xml"), + "png" => Some("image/png"), + "wasm" => Some("application/wasm"), + "woff2" => Some("font/woff2"), + _ => None, + }); + + match (Asset::get(path), mime) { + (Some(content), Some(mime)) => Response::builder() + .header(header::CONTENT_TYPE, mime) + .body(body::boxed(body::Full::from(content.data))) + .unwrap(), + _ => not_found().await, + } +} +pub async fn http_server(app_state: Arc, port: u16) { let mut router = Router::new() .route("/ws", get(ws_handler)) .route("/api/messages", get(messages_handler)) .route("/api/message/:id", get(message_handler)) .route("/api/message/:id/body", get(message_body_handler)) - .nest_service("/static", serve_dir); + .nest_service("/static", get(static_handler)); if app_state.index.is_some() { router = router.route("/", get(index)); diff --git a/frontend/Cargo.lock b/frontend/Cargo.lock index 80769fc..d6a516e 100644 --- a/frontend/Cargo.lock +++ b/frontend/Cargo.lock @@ -518,7 +518,7 @@ dependencies = [ [[package]] name = "mailcrab-frontend" -version = "0.7.0" +version = "0.8.0" dependencies = [ "base64", "futures", diff --git a/frontend/Cargo.toml b/frontend/Cargo.toml index 1e9b721..3f90349 100644 --- a/frontend/Cargo.toml +++ b/frontend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mailcrab-frontend" -version = "0.7.0" +version = "0.8.0" edition = "2021" publish = false diff --git a/frontend/src/view.rs b/frontend/src/view.rs index 5b1f8db..77162f9 100644 --- a/frontend/src/view.rs +++ b/frontend/src/view.rs @@ -44,8 +44,8 @@ pub fn view(props: &ViewMessageProps) -> Html { let mut tabs = vec![("Raw", Tab::Raw), ("Headers", Tab::Headers)]; if !message.text.is_empty() && !message.html.is_empty() { - tabs.push(("Text", Tab::Text)); - tabs.push(("Html", Tab::Formatted)); + tabs.push(("Plain", Tab::Text)); + tabs.push(("Formatted", Tab::Formatted)); } else { tabs.push(("Formatted", Tab::Formatted)); }