Compare commits
3 Commits
v0.2.13
...
v0.2.16-ma
| Author | SHA1 | Date | |
|---|---|---|---|
| 3935104cb5 | |||
| b17c759bd6 | |||
| bab1df1c57 |
@@ -36,6 +36,13 @@ jobs:
|
|||||||
username: ${{ gitea.actor }}
|
username: ${{ gitea.actor }}
|
||||||
password: ${{ secrets.REGISTRY_TOKEN }}
|
password: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
|
|
||||||
|
- name: Login to GitHub Container Registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ghcr.io
|
||||||
|
username: shadowdao
|
||||||
|
password: ${{ secrets.GH_PAT }}
|
||||||
|
|
||||||
- name: Build and push container image
|
- name: Build and push container image
|
||||||
uses: docker/build-push-action@v5
|
uses: docker/build-push-action@v5
|
||||||
with:
|
with:
|
||||||
@@ -46,5 +53,7 @@ jobs:
|
|||||||
tags: |
|
tags: |
|
||||||
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
|
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
|
||||||
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ gitea.sha }}
|
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ gitea.sha }}
|
||||||
|
ghcr.io/shadowdao/triple-c-sandbox:latest
|
||||||
|
ghcr.io/shadowdao/triple-c-sandbox:${{ gitea.sha }}
|
||||||
cache-from: type=gha
|
cache-from: type=gha
|
||||||
cache-to: type=gha,mode=max
|
cache-to: type=gha,mode=max
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ Choose an **Image Source**:
|
|||||||
|
|
||||||
| Source | Description | When to Use |
|
| Source | Description | When to Use |
|
||||||
|--------|-------------|-------------|
|
|--------|-------------|-------------|
|
||||||
| **Registry** | Pulls the pre-built image from `repo.anhonesthost.net` | Fastest setup — recommended for most users |
|
| **Registry** | Pulls the pre-built image from `ghcr.io` | Fastest setup — recommended for most users |
|
||||||
| **Local Build** | Builds the image locally from the embedded Dockerfile | If you can't reach the registry, or want a custom build |
|
| **Local Build** | Builds the image locally from the embedded Dockerfile | If you can't reach the registry, or want a custom build |
|
||||||
| **Custom** | Use any Docker image you specify | Advanced — bring your own sandbox image |
|
| **Custom** | Use any Docker image you specify | Advanced — bring your own sandbox image |
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ use std::sync::OnceLock;
|
|||||||
use tokio::sync::Mutex;
|
use tokio::sync::Mutex;
|
||||||
|
|
||||||
const HELP_URL: &str =
|
const HELP_URL: &str =
|
||||||
"https://repo.anhonesthost.net/cybercovellc/triple-c/raw/branch/main/HOW-TO-USE.md";
|
"https://raw.githubusercontent.com/shadowdao/triple-c/main/HOW-TO-USE.md";
|
||||||
|
|
||||||
const EMBEDDED_HELP: &str = include_str!("../../../../HOW-TO-USE.md");
|
const EMBEDDED_HELP: &str = include_str!("../../../../HOW-TO-USE.md");
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,20 @@
|
|||||||
|
use serde::Deserialize;
|
||||||
use tauri::State;
|
use tauri::State;
|
||||||
|
|
||||||
use crate::docker;
|
use crate::docker;
|
||||||
use crate::models::{container_config, GiteaRelease, ImageUpdateInfo, ReleaseAsset, UpdateInfo};
|
use crate::models::{container_config, GitHubRelease, ImageUpdateInfo, ReleaseAsset, UpdateInfo};
|
||||||
use crate::AppState;
|
use crate::AppState;
|
||||||
|
|
||||||
const RELEASES_URL: &str =
|
const RELEASES_URL: &str =
|
||||||
"https://repo.anhonesthost.net/api/v1/repos/cybercovellc/triple-c/releases";
|
"https://api.github.com/repos/shadowdao/triple-c/releases";
|
||||||
|
|
||||||
/// Gitea container-registry tag object (v2 manifest).
|
/// GHCR container-registry API base (OCI distribution spec).
|
||||||
const REGISTRY_API_BASE: &str =
|
const REGISTRY_API_BASE: &str =
|
||||||
"https://repo.anhonesthost.net/v2/cybercovellc/triple-c/triple-c-sandbox";
|
"https://ghcr.io/v2/shadowdao/triple-c-sandbox";
|
||||||
|
|
||||||
|
/// GHCR token endpoint for anonymous pull access.
|
||||||
|
const GHCR_TOKEN_URL: &str =
|
||||||
|
"https://ghcr.io/token?scope=repository:shadowdao/triple-c-sandbox:pull";
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn get_app_version() -> String {
|
pub fn get_app_version() -> String {
|
||||||
@@ -23,9 +28,10 @@ pub async fn check_for_updates() -> Result<Option<UpdateInfo>, String> {
|
|||||||
.build()
|
.build()
|
||||||
.map_err(|e| format!("Failed to create HTTP client: {}", e))?;
|
.map_err(|e| format!("Failed to create HTTP client: {}", e))?;
|
||||||
|
|
||||||
let releases: Vec<GiteaRelease> = client
|
let releases: Vec<GitHubRelease> = client
|
||||||
.get(RELEASES_URL)
|
.get(RELEASES_URL)
|
||||||
.header("Accept", "application/json")
|
.header("Accept", "application/json")
|
||||||
|
.header("User-Agent", "triple-c-updater")
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| format!("Failed to fetch releases: {}", e))?
|
.map_err(|e| format!("Failed to fetch releases: {}", e))?
|
||||||
@@ -36,30 +42,27 @@ pub async fn check_for_updates() -> Result<Option<UpdateInfo>, String> {
|
|||||||
let current_version = env!("CARGO_PKG_VERSION");
|
let current_version = env!("CARGO_PKG_VERSION");
|
||||||
let current_semver = parse_semver(current_version).unwrap_or((0, 0, 0));
|
let current_semver = parse_semver(current_version).unwrap_or((0, 0, 0));
|
||||||
|
|
||||||
// Determine platform suffix for tag filtering
|
// Determine platform-specific asset extensions
|
||||||
let platform_suffix: &str = if cfg!(target_os = "windows") {
|
let platform_extensions: &[&str] = if cfg!(target_os = "windows") {
|
||||||
"-win"
|
&[".msi", ".exe"]
|
||||||
} else if cfg!(target_os = "macos") {
|
} else if cfg!(target_os = "macos") {
|
||||||
"-mac"
|
&[".dmg", ".app.tar.gz"]
|
||||||
} else {
|
} else {
|
||||||
"" // Linux uses bare tags (no suffix)
|
&[".AppImage", ".deb", ".rpm"]
|
||||||
};
|
};
|
||||||
|
|
||||||
// Filter releases by platform tag suffix
|
// Filter releases that have at least one asset matching the current platform
|
||||||
let platform_releases: Vec<&GiteaRelease> = releases
|
let platform_releases: Vec<&GitHubRelease> = releases
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|r| {
|
.filter(|r| {
|
||||||
if platform_suffix.is_empty() {
|
r.assets.iter().any(|a| {
|
||||||
// Linux: bare tag only (no -win, no -mac)
|
platform_extensions.iter().any(|ext| a.name.ends_with(ext))
|
||||||
!r.tag_name.ends_with("-win") && !r.tag_name.ends_with("-mac")
|
})
|
||||||
} else {
|
|
||||||
r.tag_name.ends_with(platform_suffix)
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
// Find the latest release with a higher semver version
|
// Find the latest release with a higher semver version
|
||||||
let mut best: Option<(&GiteaRelease, (u32, u32, u32))> = None;
|
let mut best: Option<(&GitHubRelease, (u32, u32, u32))> = None;
|
||||||
for release in &platform_releases {
|
for release in &platform_releases {
|
||||||
if let Some(ver) = parse_semver_from_tag(&release.tag_name) {
|
if let Some(ver) = parse_semver_from_tag(&release.tag_name) {
|
||||||
if ver > current_semver {
|
if ver > current_semver {
|
||||||
@@ -72,9 +75,13 @@ pub async fn check_for_updates() -> Result<Option<UpdateInfo>, String> {
|
|||||||
|
|
||||||
match best {
|
match best {
|
||||||
Some((release, _)) => {
|
Some((release, _)) => {
|
||||||
|
// Only include assets matching the current platform
|
||||||
let assets = release
|
let assets = release
|
||||||
.assets
|
.assets
|
||||||
.iter()
|
.iter()
|
||||||
|
.filter(|a| {
|
||||||
|
platform_extensions.iter().any(|ext| a.name.ends_with(ext))
|
||||||
|
})
|
||||||
.map(|a| ReleaseAsset {
|
.map(|a| ReleaseAsset {
|
||||||
name: a.name.clone(),
|
name: a.name.clone(),
|
||||||
browser_download_url: a.browser_download_url.clone(),
|
browser_download_url: a.browser_download_url.clone(),
|
||||||
@@ -82,7 +89,6 @@ pub async fn check_for_updates() -> Result<Option<UpdateInfo>, String> {
|
|||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
// Reconstruct version string from tag
|
|
||||||
let version = extract_version_from_tag(&release.tag_name)
|
let version = extract_version_from_tag(&release.tag_name)
|
||||||
.unwrap_or_else(|| release.tag_name.clone());
|
.unwrap_or_else(|| release.tag_name.clone());
|
||||||
|
|
||||||
@@ -113,17 +119,13 @@ fn parse_semver(version: &str) -> Option<(u32, u32, u32)> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parse semver from a tag like "v0.2.5", "v0.2.5-win", "v0.2.5-mac" -> (0, 2, 5)
|
/// Parse semver from a tag like "v0.2.5" -> (0, 2, 5)
|
||||||
fn parse_semver_from_tag(tag: &str) -> Option<(u32, u32, u32)> {
|
fn parse_semver_from_tag(tag: &str) -> Option<(u32, u32, u32)> {
|
||||||
let clean = tag.trim_start_matches('v');
|
let clean = tag.trim_start_matches('v');
|
||||||
// Remove platform suffix
|
|
||||||
let clean = clean.strip_suffix("-win")
|
|
||||||
.or_else(|| clean.strip_suffix("-mac"))
|
|
||||||
.unwrap_or(clean);
|
|
||||||
parse_semver(clean)
|
parse_semver(clean)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Extract a clean version string from a tag like "v0.2.5-win" -> "0.2.5"
|
/// Extract a clean version string from a tag like "v0.2.5" -> "0.2.5"
|
||||||
fn extract_version_from_tag(tag: &str) -> Option<String> {
|
fn extract_version_from_tag(tag: &str) -> Option<String> {
|
||||||
let (major, minor, patch) = parse_semver_from_tag(tag)?;
|
let (major, minor, patch) = parse_semver_from_tag(tag)?;
|
||||||
Some(format!("{}.{}.{}", major, minor, patch))
|
Some(format!("{}.{}.{}", major, minor, patch))
|
||||||
@@ -152,7 +154,7 @@ pub async fn check_image_update(
|
|||||||
// 1. Get local image digest via Docker
|
// 1. Get local image digest via Docker
|
||||||
let local_digest = docker::get_local_image_digest(&image_name).await.ok().flatten();
|
let local_digest = docker::get_local_image_digest(&image_name).await.ok().flatten();
|
||||||
|
|
||||||
// 2. Get remote digest from the Gitea container registry (OCI distribution spec)
|
// 2. Get remote digest from the GHCR container registry (OCI distribution spec)
|
||||||
let remote_digest = fetch_remote_digest("latest").await?;
|
let remote_digest = fetch_remote_digest("latest").await?;
|
||||||
|
|
||||||
// No remote digest available — nothing to compare
|
// No remote digest available — nothing to compare
|
||||||
@@ -176,25 +178,36 @@ pub async fn check_image_update(
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fetch the digest of a tag from the Gitea container registry using the
|
/// Fetch the digest of a tag from GHCR using the OCI / Docker Registry HTTP API v2.
|
||||||
/// OCI / Docker Registry HTTP API v2.
|
|
||||||
///
|
///
|
||||||
/// We issue a HEAD request to /v2/<repo>/manifests/<tag> and read the
|
/// GHCR requires authentication even for public images, so we first obtain an
|
||||||
/// `Docker-Content-Digest` header that the registry returns.
|
/// anonymous token, then issue a HEAD request to /v2/<repo>/manifests/<tag>
|
||||||
|
/// and read the `Docker-Content-Digest` header.
|
||||||
async fn fetch_remote_digest(tag: &str) -> Result<Option<String>, String> {
|
async fn fetch_remote_digest(tag: &str) -> Result<Option<String>, String> {
|
||||||
let url = format!("{}/manifests/{}", REGISTRY_API_BASE, tag);
|
|
||||||
|
|
||||||
let client = reqwest::Client::builder()
|
let client = reqwest::Client::builder()
|
||||||
.timeout(std::time::Duration::from_secs(15))
|
.timeout(std::time::Duration::from_secs(15))
|
||||||
.build()
|
.build()
|
||||||
.map_err(|e| format!("Failed to create HTTP client: {}", e))?;
|
.map_err(|e| format!("Failed to create HTTP client: {}", e))?;
|
||||||
|
|
||||||
|
// 1. Obtain anonymous bearer token from GHCR
|
||||||
|
let token = match fetch_ghcr_token(&client).await {
|
||||||
|
Ok(t) => t,
|
||||||
|
Err(e) => {
|
||||||
|
log::warn!("Failed to obtain GHCR token: {}", e);
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 2. HEAD the manifest with the token
|
||||||
|
let url = format!("{}/manifests/{}", REGISTRY_API_BASE, tag);
|
||||||
|
|
||||||
let response = client
|
let response = client
|
||||||
.head(&url)
|
.head(&url)
|
||||||
.header(
|
.header(
|
||||||
"Accept",
|
"Accept",
|
||||||
"application/vnd.docker.distribution.manifest.v2+json, application/vnd.oci.image.index.v1+json",
|
"application/vnd.docker.distribution.manifest.v2+json, application/vnd.oci.image.index.v1+json",
|
||||||
)
|
)
|
||||||
|
.header("Authorization", format!("Bearer {}", token))
|
||||||
.send()
|
.send()
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
@@ -221,3 +234,23 @@ async fn fetch_remote_digest(tag: &str) -> Result<Option<String>, String> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Fetch an anonymous bearer token from GHCR for pulling public images.
|
||||||
|
async fn fetch_ghcr_token(client: &reqwest::Client) -> Result<String, String> {
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct TokenResponse {
|
||||||
|
token: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
let resp: TokenResponse = client
|
||||||
|
.get(GHCR_TOKEN_URL)
|
||||||
|
.header("User-Agent", "triple-c-updater")
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("GHCR token request failed: {}", e))?
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to parse GHCR token response: {}", e))?;
|
||||||
|
|
||||||
|
Ok(resp.token)
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ pub struct ContainerInfo {
|
|||||||
|
|
||||||
pub const LOCAL_IMAGE_NAME: &str = "triple-c";
|
pub const LOCAL_IMAGE_NAME: &str = "triple-c";
|
||||||
pub const IMAGE_TAG: &str = "latest";
|
pub const IMAGE_TAG: &str = "latest";
|
||||||
pub const REGISTRY_IMAGE: &str = "repo.anhonesthost.net/cybercovellc/triple-c/triple-c-sandbox:latest";
|
pub const REGISTRY_IMAGE: &str = "ghcr.io/shadowdao/triple-c-sandbox:latest";
|
||||||
|
|
||||||
pub fn local_build_image_name() -> String {
|
pub fn local_build_image_name() -> String {
|
||||||
format!("{LOCAL_IMAGE_NAME}:{IMAGE_TAG}")
|
format!("{LOCAL_IMAGE_NAME}:{IMAGE_TAG}")
|
||||||
|
|||||||
@@ -18,19 +18,19 @@ pub struct ReleaseAsset {
|
|||||||
pub size: u64,
|
pub size: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Gitea API release response (internal).
|
/// GitHub API release response (internal).
|
||||||
#[derive(Debug, Clone, Deserialize)]
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
pub struct GiteaRelease {
|
pub struct GitHubRelease {
|
||||||
pub tag_name: String,
|
pub tag_name: String,
|
||||||
pub html_url: String,
|
pub html_url: String,
|
||||||
pub body: String,
|
pub body: String,
|
||||||
pub assets: Vec<GiteaAsset>,
|
pub assets: Vec<GitHubAsset>,
|
||||||
pub published_at: String,
|
pub published_at: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Gitea API asset response (internal).
|
/// GitHub API asset response (internal).
|
||||||
#[derive(Debug, Clone, Deserialize)]
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
pub struct GiteaAsset {
|
pub struct GitHubAsset {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub browser_download_url: String,
|
pub browser_download_url: String,
|
||||||
pub size: u64,
|
pub size: u64,
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useSettings } from "../../hooks/useSettings";
|
|||||||
import type { ImageSource } from "../../lib/types";
|
import type { ImageSource } from "../../lib/types";
|
||||||
import Tooltip from "../ui/Tooltip";
|
import Tooltip from "../ui/Tooltip";
|
||||||
|
|
||||||
const REGISTRY_IMAGE = "repo.anhonesthost.net/cybercovellc/triple-c/triple-c-sandbox:latest";
|
const REGISTRY_IMAGE = "ghcr.io/shadowdao/triple-c-sandbox:latest";
|
||||||
|
|
||||||
const IMAGE_SOURCE_OPTIONS: { value: ImageSource; label: string; description: string }[] = [
|
const IMAGE_SOURCE_OPTIONS: { value: ImageSource; label: string; description: string }[] = [
|
||||||
{ value: "registry", label: "Registry", description: "Pull from container registry" },
|
{ value: "registry", label: "Registry", description: "Pull from container registry" },
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ export default function TerminalView({ sessionId, active }: Props) {
|
|||||||
const [detectedUrl, setDetectedUrl] = useState<string | null>(null);
|
const [detectedUrl, setDetectedUrl] = useState<string | null>(null);
|
||||||
const [imagePasteMsg, setImagePasteMsg] = useState<string | null>(null);
|
const [imagePasteMsg, setImagePasteMsg] = useState<string | null>(null);
|
||||||
const [isAtBottom, setIsAtBottom] = useState(true);
|
const [isAtBottom, setIsAtBottom] = useState(true);
|
||||||
|
const isAtBottomRef = useRef(true);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!containerRef.current) return;
|
if (!containerRef.current) return;
|
||||||
@@ -131,10 +132,19 @@ export default function TerminalView({ sessionId, active }: Props) {
|
|||||||
sendInput(sessionId, data);
|
sendInput(sessionId, data);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Track scroll position to show "Jump to Current" button
|
// Track scroll position to show "Jump to Current" button.
|
||||||
|
// Debounce state updates via rAF to avoid excessive re-renders during rapid output.
|
||||||
|
let scrollStateRafId: number | null = null;
|
||||||
const scrollDisposable = term.onScroll(() => {
|
const scrollDisposable = term.onScroll(() => {
|
||||||
const buf = term.buffer.active;
|
const buf = term.buffer.active;
|
||||||
setIsAtBottom(buf.viewportY >= buf.baseY);
|
const atBottom = buf.viewportY >= buf.baseY;
|
||||||
|
isAtBottomRef.current = atBottom;
|
||||||
|
if (scrollStateRafId === null) {
|
||||||
|
scrollStateRafId = requestAnimationFrame(() => {
|
||||||
|
scrollStateRafId = null;
|
||||||
|
setIsAtBottom(isAtBottomRef.current);
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Track text selection to show copy hint in status bar
|
// Track text selection to show copy hint in status bar
|
||||||
@@ -187,7 +197,15 @@ export default function TerminalView({ sessionId, active }: Props) {
|
|||||||
|
|
||||||
const outputPromise = onOutput(sessionId, (data) => {
|
const outputPromise = onOutput(sessionId, (data) => {
|
||||||
if (aborted) return;
|
if (aborted) return;
|
||||||
term.write(data);
|
const shouldFollow = isAtBottomRef.current;
|
||||||
|
term.write(data, () => {
|
||||||
|
// Keep viewport pinned to bottom when user hasn't scrolled up
|
||||||
|
if (shouldFollow) {
|
||||||
|
term.scrollToBottom();
|
||||||
|
isAtBottomRef.current = true;
|
||||||
|
setIsAtBottom(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
detector.feed(data);
|
detector.feed(data);
|
||||||
|
|
||||||
// Scan for SSO refresh marker in terminal output
|
// Scan for SSO refresh marker in terminal output
|
||||||
@@ -229,8 +247,13 @@ export default function TerminalView({ sessionId, active }: Props) {
|
|||||||
resizeRafId = requestAnimationFrame(() => {
|
resizeRafId = requestAnimationFrame(() => {
|
||||||
resizeRafId = null;
|
resizeRafId = null;
|
||||||
if (!containerRef.current || containerRef.current.offsetWidth === 0) return;
|
if (!containerRef.current || containerRef.current.offsetWidth === 0) return;
|
||||||
|
const wasAtBottom = isAtBottomRef.current;
|
||||||
fitAddon.fit();
|
fitAddon.fit();
|
||||||
resize(sessionId, term.cols, term.rows);
|
resize(sessionId, term.cols, term.rows);
|
||||||
|
// Maintain scroll position after resize reflow
|
||||||
|
if (wasAtBottom) {
|
||||||
|
term.scrollToBottom();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
resizeObserver.observe(containerRef.current);
|
resizeObserver.observe(containerRef.current);
|
||||||
@@ -249,6 +272,7 @@ export default function TerminalView({ sessionId, active }: Props) {
|
|||||||
containerRef.current?.removeEventListener("paste", handlePaste, { capture: true });
|
containerRef.current?.removeEventListener("paste", handlePaste, { capture: true });
|
||||||
outputPromise.then((fn) => fn?.());
|
outputPromise.then((fn) => fn?.());
|
||||||
exitPromise.then((fn) => fn?.());
|
exitPromise.then((fn) => fn?.());
|
||||||
|
if (scrollStateRafId !== null) cancelAnimationFrame(scrollStateRafId);
|
||||||
if (resizeRafId !== null) cancelAnimationFrame(resizeRafId);
|
if (resizeRafId !== null) cancelAnimationFrame(resizeRafId);
|
||||||
resizeObserver.disconnect();
|
resizeObserver.disconnect();
|
||||||
try { webglRef.current?.dispose(); } catch { /* may already be disposed */ }
|
try { webglRef.current?.dispose(); } catch { /* may already be disposed */ }
|
||||||
@@ -314,8 +338,14 @@ export default function TerminalView({ sessionId, active }: Props) {
|
|||||||
}, [detectedUrl]);
|
}, [detectedUrl]);
|
||||||
|
|
||||||
const handleScrollToBottom = useCallback(() => {
|
const handleScrollToBottom = useCallback(() => {
|
||||||
termRef.current?.scrollToBottom();
|
const term = termRef.current;
|
||||||
|
if (term) {
|
||||||
|
// Re-fit first to fix viewport desync (same thing a resize does)
|
||||||
|
fitRef.current?.fit();
|
||||||
|
term.scrollToBottom();
|
||||||
|
isAtBottomRef.current = true;
|
||||||
setIsAtBottom(true);
|
setIsAtBottom(true);
|
||||||
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
Reference in New Issue
Block a user