feat(docker): add Services dashboards, layers, processes, view filters and log editor tabs (#2246)

Цей коміт міститься в:
Deng Liu authored and GitHub committed 2026-11-09 15:24:52 +08:00
1 джерело a206ac0ca5
коміт c5124b809d
52 files changed
+3679 -137

No files matched your search

+26
Переглянути файл
@@ -253,6 +253,32 @@ pub fn inspect(ep: &DockerEndpoint, object: DockerObject, id: &str) -> IdeResult
}
}
fn check_id(id: &str) -> IdeResult<&str> {
let id = id.trim();
if id.is_empty() || id.starts_with('-') { Err(IdeError::invalid("invalid Docker identifier")) } else { Ok(id) }
}
/// `docker image history --no-trunc --format '{{json .}}'` of one image.
pub fn image_history(ep: &DockerEndpoint, id: &str) -> IdeResult<Vec<DockerImageLayer>> {
let id = check_id(id)?;
let out = run(ep, &["image", "history", "--no-trunc", "--format", "{{json .}}", id], QUICK)?;
Ok(parse_image_history(&out))
}
/// `docker top <id>` of a running container.
pub fn container_top(ep: &DockerEndpoint, id: &str) -> IdeResult<DockerProcessList> {
let id = check_id(id)?;
let out = run(ep, &["top", id], QUICK)?;
Ok(parse_container_top(&out))
}
/// `docker stats --no-stream --no-trunc --format '{{json .}}' <id>` of a running container.
pub fn container_stats(ep: &DockerEndpoint, id: &str) -> IdeResult<DockerContainerStats> {
let id = check_id(id)?;
let out = run(ep, &["stats", "--no-stream", "--no-trunc", "--format", "{{json .}}", id], QUICK)?;
parse_container_stats(&out).ok_or_else(|| IdeError::not_found("no container stats returned"))
}
/// Streams a container's logs (`tail` last lines, then follows while `follow`)
/// until it ends or `cancel` fires.
pub fn logs(
+181 -19
Переглянути файл
@@ -41,6 +41,15 @@ pub struct DockerContainer {
pub compose_working_dir: Option<String>,
/// `com.docker.compose.project.config_files` label (comma-separated in docker).
pub compose_files: Vec<String>,
/// Mounted volume names or host paths reported by `docker ps` (`Mounts`).
#[serde(default)]
pub mounts: Vec<String>,
/// Connected network names reported by `docker ps` (`Networks`).
#[serde(default)]
pub networks: Vec<String>,
/// User and tool labels assigned to the container.
#[serde(default)]
pub labels: BTreeMap<String, String>,
}
impl DockerContainer {
@@ -80,6 +89,10 @@ pub struct DockerVolume {
pub driver: String,
pub mountpoint: String,
pub compose_project: Option<String>,
#[serde(default)]
pub scope: String,
#[serde(default)]
pub labels: BTreeMap<String, String>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
@@ -90,6 +103,46 @@ pub struct DockerNetwork {
pub driver: String,
pub scope: String,
pub compose_project: Option<String>,
#[serde(default)]
pub created_at: String,
#[serde(default)]
pub ipv6: bool,
#[serde(default)]
pub internal: bool,
#[serde(default)]
pub labels: BTreeMap<String, String>,
}
/// One layer in `docker image history --no-trunc --format '{{json .}}'`.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct DockerImageLayer {
pub id: String,
pub created_by: String,
pub size: String,
pub created_since: String,
pub created_at: String,
pub comment: String,
}
/// Process table returned by `docker top <container>`.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct DockerProcessList {
pub titles: Vec<String>,
pub processes: Vec<Vec<String>>,
}
/// Live resource metrics returned by `docker stats --no-stream --no-trunc --format '{{json .}}'`.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct DockerContainerStats {
pub cpu_perc: String,
pub mem_usage: String,
pub mem_perc: String,
pub net_io: String,
pub block_io: String,
pub pids: String,
}
/// Everything the Services tree shows, fetched in one go.
@@ -186,17 +239,20 @@ struct RawContainer {
ports: String,
created_at: String,
labels: String,
mounts: String,
networks: String,
}
fn split_csv(raw: &str) -> Vec<String> {
raw.split(',').map(str::trim).filter(|s| !s.is_empty()).map(str::to_owned).collect()
}
/// `docker ps -a --no-trunc --format '{{json .}}'`.
pub fn parse_containers(text: &str) -> Vec<DockerContainer> {
let mut out: Vec<DockerContainer> = lines::<RawContainer>(text)
.map(|r| {
let mut labels = parse_labels(&r.labels);
let files = labels
.remove(COMPOSE_FILES)
.map(|f| f.split(',').map(str::trim).filter(|s| !s.is_empty()).map(str::to_owned).collect())
.unwrap_or_default();
let all_labels = parse_labels(&r.labels);
let files = all_labels.get(COMPOSE_FILES).map(|f| split_csv(f)).unwrap_or_default();
// Older docker versions derive `State` from nothing: fall back on the status text.
let state = if r.state.is_empty() {
if r.status.starts_with("Up") { "running".into() } else { "exited".into() }
@@ -212,10 +268,13 @@ pub fn parse_containers(text: &str) -> Vec<DockerContainer> {
status: r.status,
ports: r.ports,
created_at: r.created_at,
compose_project: labels.remove(COMPOSE_PROJECT).filter(|s| !s.is_empty()),
compose_service: labels.remove(COMPOSE_SERVICE).filter(|s| !s.is_empty()),
compose_working_dir: labels.remove(COMPOSE_DIR).filter(|s| !s.is_empty()),
compose_project: all_labels.get(COMPOSE_PROJECT).filter(|s| !s.is_empty()).cloned(),
compose_service: all_labels.get(COMPOSE_SERVICE).filter(|s| !s.is_empty()).cloned(),
compose_working_dir: all_labels.get(COMPOSE_DIR).filter(|s| !s.is_empty()).cloned(),
compose_files: files,
mounts: split_csv(&r.mounts),
networks: split_csv(&r.networks),
labels: all_labels,
}
})
.collect();
@@ -258,17 +317,23 @@ struct RawVolume {
name: String,
driver: String,
mountpoint: String,
scope: String,
labels: String,
}
/// `docker volume ls --format '{{json .}}'`.
pub fn parse_volumes(text: &str) -> Vec<DockerVolume> {
let mut out: Vec<DockerVolume> = lines::<RawVolume>(text)
.map(|r| DockerVolume {
compose_project: parse_labels(&r.labels).remove(COMPOSE_PROJECT).filter(|s| !s.is_empty()),
name: r.name,
driver: r.driver,
mountpoint: r.mountpoint,
.map(|r| {
let labels = parse_labels(&r.labels);
DockerVolume {
compose_project: labels.get(COMPOSE_PROJECT).filter(|s| !s.is_empty()).cloned(),
name: r.name,
driver: r.driver,
mountpoint: r.mountpoint,
scope: r.scope,
labels,
}
})
.collect();
out.sort_by(|a, b| a.name.cmp(&b.name));
@@ -283,24 +348,121 @@ struct RawNetwork {
name: String,
driver: String,
scope: String,
created_at: String,
#[serde(rename = "IPv6")]
ipv6: String,
internal: String,
labels: String,
}
/// `docker network ls --no-trunc --format '{{json .}}'`.
pub fn parse_networks(text: &str) -> Vec<DockerNetwork> {
let mut out: Vec<DockerNetwork> = lines::<RawNetwork>(text)
.map(|r| DockerNetwork {
compose_project: parse_labels(&r.labels).remove(COMPOSE_PROJECT).filter(|s| !s.is_empty()),
id: r.id,
name: r.name,
driver: r.driver,
scope: r.scope,
.map(|r| {
let labels = parse_labels(&r.labels);
DockerNetwork {
compose_project: labels.get(COMPOSE_PROJECT).filter(|s| !s.is_empty()).cloned(),
id: r.id,
name: r.name,
driver: r.driver,
scope: r.scope,
created_at: r.created_at,
ipv6: r.ipv6.eq_ignore_ascii_case("true"),
internal: r.internal.eq_ignore_ascii_case("true"),
labels,
}
})
.collect();
out.sort_by(|a, b| a.name.cmp(&b.name));
out
}
#[derive(Deserialize, Default)]
#[serde(rename_all = "PascalCase", default)]
struct RawImageLayer {
#[serde(rename = "ID")]
id: String,
created_by: String,
size: String,
created_since: String,
created_at: String,
comment: String,
}
/// `docker image history --no-trunc --format '{{json .}}'`.
pub fn parse_image_history(text: &str) -> Vec<DockerImageLayer> {
lines::<RawImageLayer>(text)
.map(|r| DockerImageLayer {
id: r.id,
created_by: r.created_by,
size: r.size,
created_since: r.created_since,
created_at: r.created_at,
comment: r.comment,
})
.collect()
}
/// `docker top <container>` tabular output.
pub fn parse_container_top(text: &str) -> DockerProcessList {
let mut lines = text.lines().map(str::trim).filter(|l| !l.is_empty());
let Some(header) = lines.next() else {
return DockerProcessList::default();
};
let titles: Vec<String> = header.split_whitespace().map(str::to_owned).collect();
if titles.is_empty() {
return DockerProcessList::default();
}
let cols = titles.len();
let processes = lines
.map(|line| {
let mut row = Vec::with_capacity(cols);
let mut rest = line.trim_start();
for _ in 0..cols.saturating_sub(1) {
match rest.split_once(char::is_whitespace) {
Some((field, tail)) => {
row.push(field.to_owned());
rest = tail.trim_start();
}
None => break,
}
}
if !rest.is_empty() || row.len() < cols {
row.push(rest.to_owned());
}
row
})
.collect();
DockerProcessList { titles, processes }
}
#[derive(Deserialize, Default)]
#[serde(rename_all = "PascalCase", default)]
struct RawStats {
#[serde(rename = "CPUPerc")]
cpu_perc: String,
mem_usage: String,
mem_perc: String,
#[serde(rename = "NetIO")]
net_io: String,
#[serde(rename = "BlockIO")]
block_io: String,
#[serde(rename = "PIDs")]
pids: String,
}
/// `docker stats --no-stream --no-trunc --format '{{json .}}'`.
pub fn parse_container_stats(text: &str) -> Option<DockerContainerStats> {
lines::<RawStats>(text).next().map(|r| DockerContainerStats {
cpu_perc: r.cpu_perc,
mem_usage: r.mem_usage,
mem_perc: r.mem_perc,
net_io: r.net_io,
block_io: r.block_io,
pids: r.pids,
})
}
#[derive(Deserialize, Default)]
#[serde(default)]
struct RawVersion {
+44 -1
Переглянути файл
@@ -1,6 +1,9 @@
//! Parsing of recorded `docker … --format '{{json .}}'` output.
use ide_docker::{parse_containers, parse_images, parse_networks, parse_volumes};
use ide_docker::{
parse_container_stats, parse_container_top, parse_containers, parse_image_history, parse_images, parse_networks,
parse_volumes,
};
const PS: &str = include_str!("fixtures/ps.jsonl");
const IMAGES: &str = include_str!("fixtures/images.jsonl");
@@ -22,11 +25,16 @@ fn containers_with_compose_labels() {
assert_eq!(db.compose_working_dir.as_deref(), Some("/work/shop"));
assert_eq!(db.compose_files, ["/work/shop/compose.yaml", "/work/shop/compose.override.yaml"]);
assert_eq!(db.ports, "0.0.0.0:5432->5432/tcp");
assert_eq!(db.mounts, ["shop_pgdata"]);
assert_eq!(db.networks, ["shop_default"]);
assert_eq!(db.labels.get("com.docker.compose.project").map(String::as_str), Some("shop"));
let web = &c[1];
assert_eq!(web.name, "web", "leading slash of legacy names is dropped");
assert!(!web.running());
assert_eq!(web.compose_project, None);
assert!(web.mounts.is_empty());
assert_eq!(web.networks, ["bridge"]);
}
#[test]
@@ -44,9 +52,44 @@ fn volumes_and_networks() {
assert_eq!(v.iter().map(|v| v.name.as_str()).collect::<Vec<_>>(), ["0a1b", "shop_pgdata"]);
assert_eq!(v[1].compose_project.as_deref(), Some("shop"));
assert_eq!(v[1].mountpoint, "/var/lib/docker/volumes/shop_pgdata/_data");
assert_eq!(v[1].scope, "local");
assert_eq!(v[1].labels.get("com.docker.compose.volume").map(String::as_str), Some("pgdata"));
let n = parse_networks(NETWORKS);
assert_eq!(n.iter().map(|n| n.name.as_str()).collect::<Vec<_>>(), ["bridge", "host", "shop_default"]);
assert_eq!(n[2].compose_project.as_deref(), Some("shop"));
assert_eq!(n[1].driver, "host");
assert!(!n[2].ipv6);
assert!(!n[2].internal);
assert_eq!(n[2].labels.get("com.docker.compose.network").map(String::as_str), Some("default"));
}
#[test]
fn image_history_top_and_stats() {
let history = [
r#"{"Comment":"buildkit.dockerfile.v0","CreatedAt":"2026-10-01 09:00:00 +0000 UTC","CreatedBy":"CMD [\"postgres\"]","CreatedSince":"5 weeks ago","ID":"sha256:1234567890ab","Size":"0B"}"#,
r#"{"Comment":"","CreatedAt":"2026-10-01 08:59:00 +0000 UTC","CreatedBy":"/bin/sh -c #(nop) COPY file:abc in /usr/local/bin/ ","CreatedSince":"5 weeks ago","ID":"<missing>","Size":"42.5MB"}"#,
]
.join("\n");
let layers = parse_image_history(&history);
assert_eq!(layers.len(), 2);
assert_eq!(layers[0].created_by, "CMD [\"postgres\"]");
assert_eq!(layers[1].size, "42.5MB");
let top = "UID PID PPID C STIME TTY TIME CMD\nroot 1234 1200 0 09:12 ? 00:00:01 postgres -c config_file=/etc/postgresql.conf\n";
let parsed_top = parse_container_top(top);
assert_eq!(parsed_top.titles, ["UID", "PID", "PPID", "C", "STIME", "TTY", "TIME", "CMD"]);
assert_eq!(parsed_top.processes.len(), 1);
assert_eq!(parsed_top.processes[0][0], "root");
assert_eq!(parsed_top.processes[0][1], "1234");
assert_eq!(parsed_top.processes[0][7], "postgres -c config_file=/etc/postgresql.conf");
let stats_line = r#"{"BlockIO":"1.2MB / 0B","CPUPerc":"1.25%","Container":"shop-db-1","ID":"4f2b8c1d9e0a","MemPerc":"2.50%","MemUsage":"50MiB / 2GiB","Name":"shop-db-1","NetIO":"12kB / 8kB","PIDs":"14"}"#;
let stats = parse_container_stats(stats_line).expect("stats parsed");
assert_eq!(stats.cpu_perc, "1.25%");
assert_eq!(stats.mem_usage, "50MiB / 2GiB");
assert_eq!(stats.mem_perc, "2.50%");
assert_eq!(stats.net_io, "12kB / 8kB");
assert_eq!(stats.block_io, "1.2MB / 0B");
assert_eq!(stats.pids, "14");
}
+23 -2
Переглянути файл
@@ -3,8 +3,8 @@
use crate::state::AppState;
use ide_core::{IdeError, IdeResult, RequestId};
use ide_docker::{
ComposeOp, ContainerOp, DockerEndpoint, DockerEvent, DockerLogLine, DockerObject, DockerRegistry, DockerSettings,
DockerSnapshot, PruneTarget, RegistryAuth,
ComposeOp, ContainerOp, DockerContainerStats, DockerEndpoint, DockerEvent, DockerImageLayer, DockerLogLine,
DockerObject, DockerProcessList, DockerRegistry, DockerSettings, DockerSnapshot, PruneTarget, RegistryAuth,
};
use tauri::State;
use tauri::ipc::Channel;
@@ -56,6 +56,27 @@ pub async fn docker_inspect(endpoint: DockerEndpoint, object: DockerObject, id:
blocking(move || ide_docker::inspect(&endpoint, object, &id)).await
}
/// Layers of an image (`docker image history`).
#[tauri::command]
#[specta::specta]
pub async fn docker_image_history(endpoint: DockerEndpoint, id: String) -> IdeResult<Vec<DockerImageLayer>> {
blocking(move || ide_docker::image_history(&endpoint, &id)).await
}
/// Processes running inside a container (`docker top`).
#[tauri::command]
#[specta::specta]
pub async fn docker_container_top(endpoint: DockerEndpoint, id: String) -> IdeResult<DockerProcessList> {
blocking(move || ide_docker::container_top(&endpoint, &id)).await
}
/// One-shot resource usage snapshot of a running container (`docker stats --no-stream`).
#[tauri::command]
#[specta::specta]
pub async fn docker_container_stats(endpoint: DockerEndpoint, id: String) -> IdeResult<DockerContainerStats> {
blocking(move || ide_docker::container_stats(&endpoint, &id)).await
}
/// Streams a container's log lines until it stops or the request is cancelled.
#[tauri::command]
#[specta::specta]
+3
Переглянути файл
@@ -563,6 +563,9 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
docker::docker_container_op,
docker::docker_remove,
docker::docker_inspect,
docker::docker_image_history,
docker::docker_container_top,
docker::docker_container_stats,
docker::docker_logs,
docker::docker_events,
docker::docker_pull,
+44
Переглянути файл
@@ -1282,6 +1282,12 @@ export const commands = {
dockerRemove: (endpoint: DockerEndpoint, object: DockerObject, ids: string[], force: boolean) => typedError<null, IdeError>(__TAURI_INVOKE("docker_remove", { endpoint, object, ids, force })),
/** Pretty-printed `docker inspect` JSON. */
dockerInspect: (endpoint: DockerEndpoint, object: DockerObject, id: string) => typedError<string, IdeError>(__TAURI_INVOKE("docker_inspect", { endpoint, object, id })),
/** Layers of an image (`docker image history`). */
dockerImageHistory: (endpoint: DockerEndpoint, id: string) => typedError<DockerImageLayer[], IdeError>(__TAURI_INVOKE("docker_image_history", { endpoint, id })),
/** Processes running inside a container (`docker top`). */
dockerContainerTop: (endpoint: DockerEndpoint, id: string) => typedError<DockerProcessList, IdeError>(__TAURI_INVOKE("docker_container_top", { endpoint, id })),
/** One-shot resource usage snapshot of a running container (`docker stats --no-stream`). */
dockerContainerStats: (endpoint: DockerEndpoint, id: string) => typedError<DockerContainerStats, IdeError>(__TAURI_INVOKE("docker_container_stats", { endpoint, id })),
/** Streams a container's log lines until it stops or the request is cancelled. */
dockerLogs: (request: RequestId, endpoint: DockerEndpoint, id: string, tail: number, follow: boolean, timestamps: boolean, onLine: Channel<DockerLogLine>) => typedError<null, IdeError>(__TAURI_INVOKE("docker_logs", { request, endpoint, id, tail, follow, timestamps, onLine })),
/** Follows `docker events` until the daemon goes away or the request is cancelled. */
@@ -3546,6 +3552,22 @@ export type DockerContainer = {
composeWorkingDir: string | null,
/** `com.docker.compose.project.config_files` label (comma-separated in docker). */
composeFiles: string[],
/** Mounted volume names or host paths reported by `docker ps` (`Mounts`). */
mounts?: string[],
/** Connected network names reported by `docker ps` (`Networks`). */
networks?: string[],
/** User and tool labels assigned to the container. */
labels?: { [key in string]: string },
};
/** Live resource metrics returned by `docker stats --no-stream --no-trunc --format '{{json .}}'`. */
export type DockerContainerStats = {
cpuPerc: string,
memUsage: string,
memPerc: string,
netIo: string,
blockIo: string,
pids: string,
};
/** Which daemon a docker command talks to. */
@@ -3580,6 +3602,16 @@ export type DockerImage = {
createdSince: string,
};
/** One layer in `docker image history --no-trunc --format '{{json .}}'`. */
export type DockerImageLayer = {
id: string,
createdBy: string,
size: string,
createdSince: string,
createdAt: string,
comment: string,
};
/** A line of `docker logs` output. */
export type DockerLogLine = {
stderr: boolean,
@@ -3592,10 +3624,20 @@ export type DockerNetwork = {
driver: string,
scope: string,
composeProject: string | null,
createdAt?: string,
ipv6?: boolean,
internal?: boolean,
labels?: { [key in string]: string },
};
export type DockerObject = "container" | "image" | "volume" | "network";
/** Process table returned by `docker top <container>`. */
export type DockerProcessList = {
titles: string[],
processes: string[][],
};
/** A registry to log in to (Docker Hub, GHCR, a private one). The password is stored separately. */
export type DockerRegistry = {
id: string,
@@ -3630,6 +3672,8 @@ export type DockerVolume = {
driver: string,
mountpoint: string,
composeProject: string | null,
scope?: string,
labels?: { [key in string]: string },
};
/** What the UI needs to show an opened document. */
+277
Переглянути файл
@@ -228,3 +228,280 @@
border: 0;
background: none;
}
/* Dashboards, Layers & Processes (Phase 1) */
.dashboard {
flex: 1;
min-height: 0;
overflow: auto;
padding-bottom: 12px;
-webkit-user-select: text;
user-select: text;
}
.dashboardActions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
padding: 8px 12px;
border-bottom: 1px solid var(--border);
}
.sectionBanner {
display: flex;
align-items: center;
justify-content: space-between;
padding: 5px 12px;
font-size: var(--fs-small);
font-weight: 600;
color: var(--fg-secondary);
background: var(--bg-elev);
border-top: 1px solid var(--border);
border-bottom: 1px solid var(--border);
}
.propList {
display: flex;
flex-direction: column;
}
.propRow {
display: grid;
grid-template-columns: 140px 1fr;
gap: 8px;
align-items: baseline;
padding: 5px 12px;
border-bottom: 1px solid var(--border);
}
.propName {
color: var(--fg-secondary);
font-size: var(--fs-small);
}
.propVal {
min-width: 0;
overflow-wrap: anywhere;
color: var(--fg);
}
.inlineRow {
display: inline-flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
}
.mono {
font-family: var(--font-mono);
font-size: var(--fs-code);
}
.codeInline {
display: inline-block;
padding: 1px 6px;
border-radius: var(--radius-small);
background: var(--bg-elev);
font-family: var(--font-mono);
font-size: var(--fs-code);
overflow-wrap: anywhere;
}
.mutedMeta {
color: var(--fg-subtle);
font-size: var(--fs-small);
}
.statusBadge {
display: inline-flex;
align-items: center;
padding: 1px 6px;
border-radius: var(--radius-small);
font-size: var(--fs-small);
font-family: var(--font-mono);
}
.badgeOk {
color: var(--success);
background: color-mix(in srgb, var(--success) 16%, transparent);
}
.badgeWarn {
color: var(--warning);
background: color-mix(in srgb, var(--warning) 16%, transparent);
}
.badgeDanger {
color: var(--danger);
background: color-mix(in srgb, var(--danger) 16%, transparent);
}
.badgeMuted {
color: var(--fg-secondary);
background: var(--bg-elev);
}
.statusDot {
width: 7px;
height: 7px;
border-radius: 50%;
flex: none;
}
.dotOk {
background: var(--success);
}
.dotMuted {
background: var(--fg-subtle);
}
.metricBlock {
display: flex;
flex-direction: column;
gap: 4px;
max-width: 320px;
}
.lineProgress {
height: 5px;
width: 100%;
border-radius: 3px;
background: var(--bg-elev);
overflow: hidden;
}
.lineProgressFill {
height: 100%;
border-radius: 3px;
}
.progressNormal {
background: var(--accent);
}
.progressWarning {
background: var(--warning);
}
.progressDanger {
background: var(--danger);
}
.chipGroup {
display: flex;
flex-wrap: wrap;
gap: 6px;
padding: 8px 12px;
}
.chipGroupInline {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.entityChip {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 2px 8px;
border-radius: var(--radius-small);
border: 1px solid var(--border-strong);
background: var(--bg-elev);
color: var(--fg);
font: inherit;
font-size: var(--fs-small);
cursor: pointer;
}
.entityChip:disabled {
cursor: default;
}
span.entityChip {
cursor: default;
}
button.entityChip:not(:disabled):hover {
background: var(--bg-hover);
border-color: var(--accent);
}
.portChipLink {
color: var(--link);
}
.chipMeta {
color: var(--fg-subtle);
font-size: var(--fs-small);
}
.emptySection {
padding: 8px 12px;
color: var(--fg-subtle);
font-size: var(--fs-small);
display: flex;
align-items: center;
gap: 6px;
}
.kvTable {
display: flex;
flex-direction: column;
}
.kvRow {
display: grid;
grid-template-columns: minmax(140px, 240px) 1fr;
gap: 10px;
padding: 4px 12px;
border-bottom: 1px solid var(--border);
font-family: var(--font-mono);
font-size: var(--fs-code);
}
.kvKey {
color: var(--fg-secondary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.kvVal {
color: var(--fg);
overflow-wrap: anywhere;
}
.dataTable {
display: flex;
flex-direction: column;
}
.dataRow {
display: grid;
grid-template-columns: 72px minmax(120px, 1fr) minmax(140px, 1.3fr) 56px;
gap: 8px;
align-items: center;
padding: 4px 12px;
border-bottom: 1px solid var(--border);
font-size: var(--fs-small);
}
.dataHeader {
color: var(--fg-secondary);
font-weight: 600;
}
.subToolbar {
height: 28px;
flex: none;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 8px 0 12px;
border-bottom: 1px solid var(--border);
}
.subToolbarTitle {
color: var(--fg-secondary);
font-size: var(--fs-small);
font-weight: 600;
}
.tableScroll {
flex: 1;
min-height: 0;
overflow: auto;
-webkit-user-select: text;
user-select: text;
}
.gridTable {
width: 100%;
border-collapse: collapse;
font-size: var(--fs-small);
}
.gridTable th {
position: sticky;
top: 0;
text-align: left;
padding: 5px 10px;
color: var(--fg-secondary);
background: var(--bg-elev);
border-bottom: 1px solid var(--border);
font-weight: 600;
white-space: nowrap;
}
.gridTable td {
padding: 4px 10px;
border-bottom: 1px solid var(--border);
vertical-align: top;
}
.layerCmd {
max-width: 520px;
overflow-wrap: anywhere;
font-family: var(--font-mono);
font-size: var(--fs-code);
}
.layerSizeCell {
min-width: 96px;
white-space: nowrap;
}
+14
Переглянути файл
@@ -40,6 +40,7 @@ import {
useServicesTree,
} from "./tree";
import styles from "./Services.module.css";
import { viewFilterMenu } from "./ops";
import { selectionActions, selectionMenu, selectedServices } from "./selection";
export const SERVICES_VIEW_FOCUS = "servicesViewFocus";
@@ -88,6 +89,7 @@ export function ServicesTreeView() {
const selectedKeys = useServicesTree((s) => s.selectedKeys);
const expanded = useServicesTree((s) => s.expanded);
const addRef = useRef<HTMLButtonElement>(null);
const filterRef = useRef<HTMLButtonElement>(null);
const roots = useMemo(() => orderedRoots(rootsByProvider, providers), [rootsByProvider, providers]);
const index = useMemo(() => indexTree(rootsByProvider), [rootsByProvider]);
@@ -181,6 +183,18 @@ export function ServicesTreeView() {
disabled={!roots.length}
onClick={() => setExpanded(new Set())}
/>
<IconButton
ref={filterRef}
icon="filter"
size="small"
iconSize={16}
title="View Options"
disabled={!roots.length}
onClick={() => {
const r = filterRef.current?.getBoundingClientRect();
showContextMenu({ x: r?.left ?? 140, y: (r?.bottom ?? 100) + 2 }, viewFilterMenu());
}}
/>
{actions.length > 0 && <span className={styles.sep} />}
{actions.map((a) => (
<IconButton
File diff suppressed because it is too large. Load diff
+142 -36
Переглянути файл
@@ -10,6 +10,7 @@ import { Channel } from "@tauri-apps/api/core";
import type {
DockerAvailability,
DockerConnection,
DockerContainer,
DockerEndpoint,
DockerLogLine,
DockerObject,
@@ -28,6 +29,7 @@ import {
import { confirm } from "@/workbench/messages";
import { openConnectionDialog, openRegistryDialog } from "./dialogs";
import {
DEFAULT_DOCKER_FILTERS,
DOCKER_KEY,
childrenOf,
connectionKey,
@@ -40,9 +42,20 @@ import {
registryKey,
rootNodes,
treeKey,
type DockerFilters,
type DockerTarget,
type ServiceNode,
} from "./model";
import {
ComposeProjectDashboard,
ContainerDashboard,
ContainerProcessesView,
ImageDashboard,
ImageLayersView,
NetworkDashboard,
VolumeDashboard,
} from "./dashboard";
import { openContainerLogInEditor } from "./logEditor";
import {
canRemove,
canRestart,
@@ -68,6 +81,7 @@ import {
useServices,
watch,
type ConnectionState,
type DetailTab,
} from "./store";
import { useServicesTree } from "./tree";
import styles from "./Services.module.css";
@@ -101,11 +115,12 @@ export function dockerItems(
connections: readonly DockerConnection[],
registries: readonly DockerRegistry[],
conns: Record<string, ConnectionState>,
filters: DockerFilters = DEFAULT_DOCKER_FILTERS,
): ServiceTreeItem[] {
const items: ServiceTreeItem[] = connections.map((c) => {
const state = conns[c.id] ?? { snapshot: null, loading: false, error: null };
const snap = state.snapshot;
const children = childrenOf(snap);
const children = childrenOf(snap, filters);
const convert = (n: ServiceNode): ServiceTreeItem => {
const kids = children(n);
return {
@@ -121,13 +136,14 @@ export function dockerItems(
return {
key: connectionKey(c.id),
label: c.name,
icon: "file-docker",
icon: snap?.availability.kind === "ready" ? "docker-connected" : "file-docker",
detail: connectionDetail(state),
dim: snap?.availability.kind !== "ready",
children: children(root)?.map(convert) ?? [],
};
});
for (const r of registries) items.push({ key: registryKey(r.id), label: r.name, icon: "package", detail: r.address });
for (const r of registries)
items.push({ key: registryKey(r.id), label: r.name, icon: "docker-registry", detail: r.address });
return items;
}
@@ -205,7 +221,7 @@ function actions(item: ServiceTreeItem): ServiceItemAction[] {
disabled: !container || !isRunning(container),
run: () => execShell(t),
},
{ id: "pull", title: "Pull Image…", icon: "git-pull", run: () => void pullPrompt(t.conn) },
{ id: "pull", title: "Pull Image…", icon: "docker-pull", run: () => void pullPrompt(t.conn) },
];
}
@@ -275,14 +291,23 @@ export function DockerTaskBanner() {
}
/** Follows a container's log while shown. */
function LogView({ conn, id, running }: { conn: string; id: string; running: boolean }) {
function LogView({ conn, container, running }: { conn: string; container: DockerContainer; running: boolean }) {
const tx = useTx();
const id = container.id;
return (
<div className={styles.detailBody}>
<LogPane
sourceKey={`${conn}:${id}:${running}`}
live={running}
toggles={[{ id: "timestamps", label: "Timestamps" }]}
actions={
<IconButton
icon="external-link"
size="small"
title="Open Log in Editor"
onClick={() => openContainerLogInEditor(conn, container)}
/>
}
ariaLabel={tx("Container log")}
start={(onLine, onError, on) => {
const request = nextRequestId();
@@ -300,21 +325,43 @@ function LogView({ conn, id, running }: { conn: string; id: string; running: boo
);
}
function InspectView({ conn, object, id }: { conn: string; object: DockerObject; id: string }) {
const tx = useTx();
const [text, setText] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
function useObjectInspect(conn: string, object: DockerObject | null, id: string | null) {
const [state, setState] = useState<{ key: string; text: string | null; error: string | null }>({
key: "",
text: null,
error: null,
});
const currentKey = object && id ? `${conn}:${object}:${id}` : "";
useEffect(() => {
if (!object || !id) return;
const reqKey = `${conn}:${object}:${id}`;
let alive = true;
call(commands.dockerInspect(endpointOf(conn), object, id))
.then((t) => alive && setText(t))
.catch((e) => alive && setError(errorMessage(e)));
.then((t) => alive && setState({ key: reqKey, text: t, error: null }))
.catch((e) => alive && setState({ key: reqKey, text: null, error: errorMessage(e) }));
return () => {
alive = false;
};
}, [conn, object, id]);
return state.key === currentKey ? { text: state.text, error: state.error } : { text: null, error: null };
}
function InspectView({ text, error }: { text: string | null; error: string | null }) {
const tx = useTx();
return (
<div className={styles.detailBody}>
<div className={styles.subToolbar}>
<span className={styles.subToolbarTitle}>
<T>Inspect JSON</T>
</span>
<IconButton
icon="copy"
size="small"
title="Copy"
disabled={!text}
onClick={() => text && void navigator.clipboard?.writeText(text)}
/>
</div>
<pre
tabIndex={0}
onKeyDown={copySelectionOnKeyDown}
@@ -362,10 +409,26 @@ function DockerDetail({ item }: { item: ServiceTreeItem }) {
const state = useServices((s) => (parsed ? connectionState(parsed.conn, s) : null));
const target = resolveTarget(item.key);
const registry = settings?.registries.find((r) => registryKey(r.id) === item.key);
const node = target?.node ?? null;
const object: DockerObject | null =
node && (node.kind === "container" || node.kind === "image" || node.kind === "volume" || node.kind === "network")
? node.kind
: null;
const objectId =
node?.kind === "container"
? node.container.id
: node?.kind === "image"
? node.image.id
: node?.kind === "volume"
? node.volume.name
: node?.kind === "network"
? node.network.id
: null;
const inspect = useObjectInspect(parsed?.conn ?? "default", object, objectId);
if (registry) return <RegistryDetail registry={registry} />;
if (!target || !parsed || !state) return <EmptyState title="Select service to view details" />;
if (!target || !parsed || !state || !node) return <EmptyState title="Select service to view details" />;
const conn = connectionOf(parsed.conn);
const node = target.node;
const snapshot = state.snapshot;
if (!snapshot)
return (
@@ -379,7 +442,14 @@ function DockerDetail({ item }: { item: ServiceTreeItem }) {
);
if (snapshot.availability.kind !== "ready")
return <Unavailable availability={snapshot.availability} conn={parsed.conn} />;
if (node.kind === "docker" || node.kind === "group" || node.kind === "compose") {
if (node.kind === "compose") {
return (
<div className={styles.detailPane}>
<ComposeProjectDashboard conn={parsed.conn} project={node.project} containers={node.containers} />
</div>
);
}
if (node.kind === "docker" || node.kind === "group") {
const a = snapshot.availability;
return (
<div className={styles.detailPane}>
@@ -388,9 +458,7 @@ function DockerDetail({ item }: { item: ServiceTreeItem }) {
<span className={styles.bannerText}>{snapshot.errors.join("; ")}</span>
</div>
)}
<EmptyState
title={node.kind === "compose" ? txTemplate(`Compose project '${node.project}'`) : (conn?.name ?? "Docker")}
>
<EmptyState title={conn?.name ?? "Docker"}>
<div>
{txTemplate(`Engine ${a.serverVersion} · CLI ${a.clientVersion}`)}
{a.context ? txTemplate(` · context ${a.context}`) : ""}
@@ -403,32 +471,66 @@ function DockerDetail({ item }: { item: ServiceTreeItem }) {
</div>
);
}
const object: DockerObject = node.kind;
const id =
const tabItems: Array<{ id: DetailTab; label: string }> =
node.kind === "container"
? node.container.id
? [
{ id: "dashboard", label: "Dashboard" },
{ id: "log", label: "Log" },
{ id: "processes", label: "Processes" },
{ id: "inspect", label: "Inspect" },
]
: node.kind === "image"
? node.image.id
: node.kind === "volume"
? node.volume.name
: node.network.id;
const showLog = node.kind === "container" && tab === "log";
? [
{ id: "dashboard", label: "Dashboard" },
{ id: "layers", label: "Layers" },
{ id: "inspect", label: "Inspect" },
]
: [
{ id: "dashboard", label: "Dashboard" },
{ id: "inspect", label: "Inspect" },
];
const activeId: DetailTab = tabItems.some((t) => t.id === tab) ? tab : "dashboard";
const id = objectId!;
return (
<div className={styles.detailPane}>
<Tabs
variant="compact"
activeId={showLog ? "log" : "inspect"}
onSelect={(t) => useServices.setState({ detailTab: t as "log" | "inspect" })}
activeId={activeId}
onSelect={(t) => useServices.setState({ detailTab: t as DetailTab })}
aria-label="Details"
items={[
...(node.kind === "container" ? [{ id: "log", label: "Log" }] : []),
{ id: "inspect", label: "Inspect" },
]}
items={tabItems}
/>
{showLog ? (
<LogView key={id} conn={parsed.conn} id={id} running={isRunning(node.container)} />
{activeId === "log" && node.kind === "container" ? (
<LogView key={id} conn={parsed.conn} container={node.container} running={isRunning(node.container)} />
) : activeId === "processes" && node.kind === "container" ? (
<ContainerProcessesView key={id} conn={parsed.conn} id={id} running={isRunning(node.container)} />
) : activeId === "layers" && node.kind === "image" ? (
<ImageLayersView key={id} conn={parsed.conn} id={id} />
) : activeId === "inspect" ? (
<InspectView key={id} text={inspect.text} error={inspect.error} />
) : node.kind === "container" ? (
<ContainerDashboard
key={id}
conn={parsed.conn}
container={node.container}
snapshot={snapshot}
inspectText={inspect.text}
/>
) : node.kind === "image" ? (
<ImageDashboard key={id} conn={parsed.conn} image={node.image} snapshot={snapshot} inspectText={inspect.text} />
) : node.kind === "volume" ? (
<VolumeDashboard key={id} conn={parsed.conn} volume={node.volume} snapshot={snapshot} />
) : (
<InspectView key={id} conn={parsed.conn} object={object} id={id} />
<NetworkDashboard
key={id}
conn={parsed.conn}
network={node.network}
snapshot={snapshot}
inspectText={inspect.text}
/>
)}
</div>
);
@@ -437,7 +539,11 @@ function DockerDetail({ item }: { item: ServiceTreeItem }) {
function useRoots(): ServiceTreeItem[] {
const settings = useServices((s) => s.settings);
const conns = useServices((s) => s.conns);
return useMemo(() => dockerItems(settings?.connections ?? [], settings?.registries ?? [], conns), [settings, conns]);
const filters = useServices((s) => s.filters);
return useMemo(
() => dockerItems(settings?.connections ?? [], settings?.registries ?? [], conns, filters),
[settings, conns, filters],
);
}
export const dockerProvider: ServiceProviderContribution = {
@@ -456,7 +562,7 @@ export const dockerProvider: ServiceProviderContribution = {
detail: DockerDetail,
banner: DockerTaskBanner,
addMenu: () => [
{ label: "Docker Registry…", icon: "package", run: () => openRegistryDialog() },
{ label: "Docker Registry…", icon: "docker-registry", run: () => openRegistryDialog() },
{ label: "Docker Connection…", icon: "file-docker", run: () => openConnectionDialog() },
],
};
+71 -2
Переглянути файл
@@ -12,6 +12,7 @@ import { registerActions } from "@/platform/actions";
import { registerEditorContribution } from "@/editor/api";
import {
SERVICES_WINDOW,
registerEditorKind,
registerOverlay,
registerServiceProvider,
registerServiceView,
@@ -22,6 +23,7 @@ import { dockerGutter } from "./dockerGutter";
import { registerDockerRunTypes } from "./runTypes";
import { ServicesDialogHost } from "./dialogs";
import { dockerProvider, selectedTarget } from "./docker";
import { DOCKER_LOG_EDITOR_KIND, DockerLogEditor, dockerLogEditorTitle, openContainerLogInEditor } from "./logEditor";
import {
canRemove,
canStart,
@@ -37,7 +39,7 @@ import {
} from "./ops";
import { SERVICES_VIEW_FOCUS, ServicesTreeView, showAddServiceMenu } from "./ServicesTree";
import { ServicesHeaderActions, ServicesHost, ServicesTabs } from "./ServicesHost";
import { connections, refresh } from "./store";
import { connections, refresh, setDockerFilters, useServices } from "./store";
import { SERVICES_TREE_VIEW } from "./tree";
import { isRunning } from "./model";
import type { PruneTarget } from "@/platform/ipc";
@@ -68,6 +70,13 @@ export function activate(): () => void {
component: ServicesTreeView,
}),
registerServiceProvider(dockerProvider),
registerEditorKind({
kind: DOCKER_LOG_EDITOR_KIND,
component: DockerLogEditor,
title: dockerLogEditorTitle,
icon: () => "file-log",
keepAlive: true,
}),
registerOverlay({ id: "services.dialogs", component: ServicesDialogHost }),
registerEditorContribution({ id: "services.dockerGutter", order: 56, extensions: dockerGutter }),
registerDockerRunTypes(),
@@ -135,6 +144,17 @@ export function activate(): () => void {
when: PROJECT,
run: () => (canRemove(selectedTarget()) ? remove(selectedTarget()) : undefined),
},
{
id: "docker.showDashboard",
title: "Show Docker Dashboard",
category: "Docker",
icon: "layout",
when: PROJECT,
run: () => {
showDetail("dashboard");
showServiceView(SERVICES_TREE_VIEW, true);
},
},
{
id: "docker.showLog",
title: "Show Container Log",
@@ -145,6 +165,39 @@ export function activate(): () => void {
showServiceView(SERVICES_TREE_VIEW, true);
},
},
{
id: "docker.openLogInEditor",
title: "Open Container Log in Editor",
category: "Docker",
icon: "file-log",
when: PROJECT,
run: () => {
const t = selectedTarget();
if (t?.node.kind === "container") openContainerLogInEditor(t.conn, t.node.container);
},
},
{
id: "docker.showProcesses",
title: "Show Container Processes",
category: "Docker",
icon: "list",
when: PROJECT,
run: () => {
showDetail("processes");
showServiceView(SERVICES_TREE_VIEW, true);
},
},
{
id: "docker.showLayers",
title: "Show Image Layers",
category: "Docker",
icon: "list",
when: PROJECT,
run: () => {
showDetail("layers");
showServiceView(SERVICES_TREE_VIEW, true);
},
},
{
id: "docker.inspect",
title: "Inspect Docker Object",
@@ -156,6 +209,22 @@ export function activate(): () => void {
showServiceView(SERVICES_TREE_VIEW, true);
},
},
{
id: "docker.toggleStoppedContainers",
title: "Toggle Stopped Containers",
category: "Docker",
icon: "filter",
when: PROJECT,
run: () => setDockerFilters({ showStoppedContainers: !useServices.getState().filters.showStoppedContainers }),
},
{
id: "docker.toggleUntaggedImages",
title: "Toggle Untagged Images",
category: "Docker",
icon: "filter",
when: PROJECT,
run: () => setDockerFilters({ showUntaggedImages: !useServices.getState().filters.showUntaggedImages }),
},
{
id: "docker.execShell",
title: "Create Terminal in Container",
@@ -172,7 +241,7 @@ export function activate(): () => void {
id: "docker.pullImage",
title: "Pull Docker Image…",
category: "Docker",
icon: "git-pull",
icon: "docker-pull",
when: PROJECT,
run: () => {
const conn = currentConnection();
+442
Переглянути файл
@@ -0,0 +1,442 @@
/**
* Container log in a regular CodeMirror 6 Editor Tab (`DOCKER_LOG_EDITOR_KIND`),
* with log syntax highlighting, line numbers, Timestamps & Soft-wrap toggles,
* Find (`⌘F`) and font zoom (`⌘=` / `⌘-` / `⌘0`), matching `PluginLogEditor`.
*/
import { useEffect, useId, useMemo, useRef, useState, type KeyboardEvent } from "react";
import { Channel } from "@tauri-apps/api/core";
import { Compartment, EditorState, Prec, type StateEffect } from "@codemirror/state";
import { EditorView, highlightSpecialChars, keymap } from "@codemirror/view";
import { defaultKeymap } from "@codemirror/commands";
import { LanguageSupport, syntaxHighlighting } from "@codemirror/language";
import { caretRowHighlight } from "@/editor/core/cm/caretRow";
import { findExtension, findResultLines, findScope, type FindOptions } from "@/editor/core/cm/find";
import { openideaHighlightStyle } from "@/editor/core/cm/highlight";
import { overviewLane } from "@/editor/core/cm/lanes";
import { logLanguage } from "@/editor/core/cm/log";
import { caretExtension, fontZoom, setFontZoom, softWrapActive, stepFontZoom } from "@/editor/core/cm/prefs";
import { fontExtension, heavyExtension, lineNumbersExtension, prefsOf } from "@/editor/core/cm/setup";
import { openideaTheme } from "@/editor/core/cm/theme";
import { FindPanel, searchAgain, seedFind, type FindTarget } from "@/editor/core/view/FindBar";
import { editorPrefs } from "@/platform/editorSettings";
import { tx } from "@/platform/i18n";
import { call, commands, isCancelled, nextRequestId, type DockerContainer, type DockerLogLine } from "@/platform/ipc";
import { bindingsFor, normalizeBinding, registerKeyClaim, strokeOf } from "@/platform/keymap";
import { useProject } from "@/platform/project";
import { EmptyState, IconButton, Toggle, selectableText } from "@/ui";
import logStyles from "@/ui/LogPane.module.css";
import { editorService, type EditorInput } from "@/workbench/contributions";
import { isRunning } from "./model";
import { connectionState, endpointOf, errorMessage, useServices } from "./store";
export const DOCKER_LOG_EDITOR_KIND = "dockerLog";
export interface DockerLogEditorData {
conn: string;
containerId: string;
containerName: string;
}
export function dockerLogEditorTitle(input: EditorInput): string {
const data = input.data as Partial<DockerLogEditorData> | undefined;
return data?.containerName ? `${data.containerName} (Log)` : input.key;
}
export function openContainerLogInEditor(conn: string, container: DockerContainer): void {
const data: DockerLogEditorData = {
conn,
containerId: container.id,
containerName: container.name,
};
void editorService().openInput({
kind: DOCKER_LOG_EDITOR_KIND,
key: `${DOCKER_LOG_EDITOR_KIND}:${conn}:${container.id}`,
data,
});
}
const ANSI = /\x1b\[[0-9;?]*[A-Za-z]/g; // eslint-disable-line no-control-regex
function boundedLog(text: string): string {
let result = text.slice(-(1 << 20));
if (/^[\uDC00-\uDFFF]/.test(result)) result = result.slice(1);
const lines = result.split("\n");
if (lines.length > 5000) result = lines.slice(-5000).join("\n");
return result;
}
function isActionStroke(stroke: string | null, actionId: string): boolean {
return !!stroke && bindingsFor(actionId).some((b) => normalizeBinding(b) === stroke);
}
const wheelZoom = EditorView.domEventHandlers({
wheel(e, view) {
if (!e.metaKey && !e.ctrlKey) return false;
e.preventDefault();
stepFontZoom(view, e.deltaY < 0 ? 1 : -1);
return true;
},
});
export function DockerLogEditor({ input }: { input: EditorInput }) {
const data = input.data as DockerLogEditorData | undefined;
if (!data || !data.containerId) {
return <EmptyState title={tx("Invalid log stream configuration")} />;
}
return <DockerLogEditorBody input={input} data={data} />;
}
function DockerLogEditorBody({ input, data }: { input: EditorInput; data: DockerLogEditorData }) {
const initialWrap = useMemo(() => {
const s = useProject.getState().settings?.editor;
return softWrapActive(s?.softWrap ?? true, prefsOf(s), "stream.log");
}, []);
const [timestamps, setTimestamps] = useState(false);
const [wrap, setWrap] = useState<boolean>(initialWrap);
const [find, setFind] = useState<{ seq: number } | null>(null);
const [viewGen, setViewGen] = useState(0);
const running = useServices((s) => {
const c = connectionState(data.conn, s).snapshot?.containers.find((x) => x.id === data.containerId);
return c ? isRunning(c) : true;
});
const rootRef = useRef<HTMLDivElement>(null);
const hostRef = useRef<HTMLDivElement>(null);
const viewRef = useRef<EditorView | null>(null);
const wrapSlotRef = useRef(new Compartment());
const stickRef = useRef(true);
const receivedRef = useRef(false);
const findOpenRef = useRef(false);
const findWatchersRef = useRef(new Set<(u: { docChanged: boolean; selectionSet: boolean }) => void>());
const findEscapeRef = useRef<() => boolean>(() => false);
const findId = useId();
const title = `${data.containerName} (Log)`;
const findTarget = useMemo<FindTarget>(
() => ({
id: `dockerLog:${findId}:${input.key}`,
view: () => viewRef.current,
watch(fn) {
findWatchersRef.current.add(fn);
return () => void findWatchersRef.current.delete(fn);
},
isOpen: () => findOpenRef.current,
results(options: FindOptions) {
const view = viewRef.current;
if (!view) return null;
const state = view.state;
const scope = state.field(findScope, false) ?? { from: 0, to: state.doc.length };
return {
title: tx`Occurrences of '${options.search}'`,
query: {
pattern: options.search,
isRegex: options.regexp,
caseSensitive: options.caseSensitive,
wholeWord: options.wholeWord,
context: options.context,
include: [],
exclude: [],
scope: { kind: "files", paths: [title] },
maxMatches: 0,
},
read: () => ({
path: title,
relPath: title,
matches: findResultLines(
viewRef.current?.state ?? state,
options,
state.field(findScope, false)
? ((viewRef.current?.state ?? state).field(findScope, false) ?? scope)
: { from: 0, to: (viewRef.current?.state ?? state).doc.length },
),
}),
};
},
}),
[findId, input.key, title],
);
const openFind = () => {
if (!viewRef.current) return;
findOpenRef.current = true;
seedFind(findTarget, false);
setFind((f) => ({ seq: (f?.seq ?? 0) + 1 }));
};
const closeFind = () => {
findOpenRef.current = false;
setFind(null);
viewRef.current?.focus();
};
findEscapeRef.current = () => {
if (!findOpenRef.current) return false;
closeFind();
return true;
};
useEffect(() => {
const host = hostRef.current;
if (!host) return;
const fontSlot = new Compartment();
const caretSlot = new Compartment();
const lineNumbersSlot = new Compartment();
const heavySlot = new Compartment();
const wrapSlot = wrapSlotRef.current;
const editorSettings = () => useProject.getState().settings?.editor;
const view = new EditorView({
parent: host,
state: EditorState.create({
doc: tx("Waiting for output…"),
extensions: [
fontSlot.of(fontExtension(editorSettings())),
caretSlot.of(caretExtension(editorPrefs())),
lineNumbersSlot.of(lineNumbersExtension(editorSettings())),
wrapSlot.of(wrap ? EditorView.lineWrapping : []),
heavySlot.of(heavyExtension(0, editorSettings())),
fontZoom,
wheelZoom,
caretRowHighlight,
openideaTheme,
highlightSpecialChars(),
syntaxHighlighting(openideaHighlightStyle),
new LanguageSupport(logLanguage),
findExtension(),
overviewLane,
Prec.high(keymap.of([{ key: "Escape", run: () => findEscapeRef.current() }])),
keymap.of(defaultKeymap),
EditorState.readOnly.of(true),
EditorView.editable.of(false),
EditorView.contentAttributes.of({
"aria-label": title,
class: selectableText,
tabindex: "0",
}),
EditorView.updateListener.of((u) => {
if (u.docChanged || u.selectionSet) {
findWatchersRef.current.forEach((fn) => fn(u));
}
}),
],
}),
});
viewRef.current = view;
setViewGen((g) => g + 1);
const onScroll = () => {
const el = view.scrollDOM;
stickRef.current = el.scrollHeight - el.scrollTop - el.clientHeight < 32;
};
view.scrollDOM.addEventListener("scroll", onScroll, { passive: true });
let prevAppearance = editorPrefs().appearance;
let prevEditor = editorSettings();
const offSettings = useProject.subscribe(() => {
const effects: StateEffect<unknown>[] = [];
const nextPrefs = editorPrefs();
const nextEditor = editorSettings();
if (JSON.stringify(nextPrefs.appearance) !== JSON.stringify(prevAppearance)) {
prevAppearance = nextPrefs.appearance;
effects.push(caretSlot.reconfigure(caretExtension(nextPrefs)));
}
if (nextEditor !== prevEditor) {
prevEditor = nextEditor;
effects.push(
fontSlot.reconfigure(fontExtension(nextEditor)),
lineNumbersSlot.reconfigure(lineNumbersExtension(nextEditor)),
heavySlot.reconfigure(heavyExtension(view.state.doc.length, nextEditor)),
);
}
if (effects.length) view.dispatch({ effects });
});
return () => {
offSettings();
view.scrollDOM.removeEventListener("scroll", onScroll);
view.destroy();
viewRef.current = null;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [title]);
useEffect(() => {
const view = viewRef.current;
if (!view) return;
view.dispatch({
effects: wrapSlotRef.current.reconfigure(wrap ? EditorView.lineWrapping : []),
});
}, [wrap]);
useEffect(() => {
return registerKeyClaim((e, stroke) => {
if (!rootRef.current?.contains(e.target as Node)) return false;
const view = viewRef.current;
if (!view) return false;
if (isActionStroke(stroke, "find.inFile") || stroke === "Meta+F" || stroke === "Ctrl+F") {
e.preventDefault();
e.stopPropagation();
openFind();
return true;
}
if (isActionStroke(stroke, "find.next") || isActionStroke(stroke, "find.previous")) {
e.preventDefault();
e.stopPropagation();
if (!searchAgain(findTarget, isActionStroke(stroke, "find.next"))) openFind();
return true;
}
if (
isActionStroke(stroke, "editor.increaseFontSize") ||
stroke === "Meta+=" ||
stroke === "Meta+Shift+=" ||
stroke === "Ctrl+=" ||
stroke === "Ctrl+Shift+=" ||
stroke === "Meta+NumpadAdd" ||
stroke === "Ctrl+NumpadAdd"
) {
e.preventDefault();
e.stopPropagation();
stepFontZoom(view, 1);
return true;
}
if (
isActionStroke(stroke, "editor.decreaseFontSize") ||
stroke === "Meta+-" ||
stroke === "Meta+Shift+-" ||
stroke === "Ctrl+-" ||
stroke === "Ctrl+Shift+-" ||
stroke === "Meta+NumpadSubtract" ||
stroke === "Ctrl+NumpadSubtract"
) {
e.preventDefault();
e.stopPropagation();
stepFontZoom(view, -1);
return true;
}
if (isActionStroke(stroke, "editor.resetFontSize") || stroke === "Meta+0" || stroke === "Ctrl+0") {
e.preventDefault();
e.stopPropagation();
view.dispatch({ effects: setFontZoom.of(0) });
return true;
}
if (stroke === "Escape" && findOpenRef.current) {
e.preventDefault();
e.stopPropagation();
closeFind();
return true;
}
return false;
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [findTarget]);
const onKeyDown = (e: KeyboardEvent) => {
if (e.defaultPrevented) return;
const stroke = strokeOf(e.nativeEvent);
const view = viewRef.current;
if (!view || !stroke) return;
if (isActionStroke(stroke, "find.inFile") || stroke === "Meta+F" || stroke === "Ctrl+F") {
openFind();
} else if (isActionStroke(stroke, "find.next") || isActionStroke(stroke, "find.previous")) {
if (!searchAgain(findTarget, isActionStroke(stroke, "find.next"))) openFind();
} else if (
isActionStroke(stroke, "editor.increaseFontSize") ||
stroke === "Meta+=" ||
stroke === "Meta+Shift+=" ||
stroke === "Ctrl+="
) {
stepFontZoom(view, 1);
} else if (
isActionStroke(stroke, "editor.decreaseFontSize") ||
stroke === "Meta+-" ||
stroke === "Meta+Shift+-" ||
stroke === "Ctrl+-"
) {
stepFontZoom(view, -1);
} else if (isActionStroke(stroke, "editor.resetFontSize") || stroke === "Meta+0" || stroke === "Ctrl+0") {
view.dispatch({ effects: setFontZoom.of(0) });
} else if (e.key === "Escape" && findOpenRef.current) {
closeFind();
} else {
return;
}
e.preventDefault();
e.stopPropagation();
};
useEffect(() => {
const view = viewRef.current;
if (!view) return;
receivedRef.current = false;
view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: tx("Waiting for output…") } });
stickRef.current = true;
let stopped = false;
let buffer: string[] = [];
let frame = 0;
const append = (chunk: string) => {
if (stopped || !viewRef.current) return;
const v = viewRef.current;
const previous = receivedRef.current ? v.state.doc.toString() : "";
receivedRef.current = true;
const joined = previous ? `${previous}\n${chunk}` : chunk;
const result = boundedLog(joined);
const stick = stickRef.current;
v.dispatch({ changes: { from: 0, to: v.state.doc.length, insert: result } });
if (stick && !findOpenRef.current) v.scrollDOM.scrollTop = v.scrollDOM.scrollHeight;
};
const flush = () => {
frame = 0;
if (!buffer.length) return;
const lines = buffer;
buffer = [];
append(lines.join("\n"));
};
const request = nextRequestId();
const channel = new Channel<DockerLogLine>();
channel.onmessage = (line) => {
if (stopped) return;
buffer.push(line.text.replace(ANSI, ""));
frame ||= requestAnimationFrame(flush);
};
call(
commands.dockerLogs(request, endpointOf(data.conn), data.containerId, 1000, running, timestamps, channel),
).catch((e) => {
if (!stopped && !isCancelled(e)) {
append(`Error: ${errorMessage(e)}`);
}
});
return () => {
stopped = true;
if (frame) cancelAnimationFrame(frame);
void commands.cancelRequest(request);
};
}, [data.conn, data.containerId, running, timestamps]);
const clearLogs = () => {
const view = viewRef.current;
if (!view) return;
receivedRef.current = true;
view.dispatch({
changes: { from: 0, to: view.state.doc.length, insert: "" },
});
};
return (
<div ref={rootRef} className={logStyles.pane} onKeyDown={onKeyDown}>
<div className={logStyles.toolbar}>
<Toggle checked={timestamps} onChange={setTimestamps} label="Timestamps" aria-label="Timestamps" />
<Toggle checked={wrap} onChange={setWrap} label="Soft-wrap" aria-label="Soft-wrap" />
<span className={logStyles.flex} />
<IconButton icon="search" size="small" title="Find in Logs" onClick={openFind} />
<IconButton icon="trash" size="small" title="Clear" onClick={clearLogs} />
</div>
{find && <FindPanel target={findTarget} version={viewGen} replace={false} seq={find.seq} onClose={closeFind} />}
<div ref={hostRef} className={logStyles.output} />
</div>
);
}
+160 -50
Переглянути файл
@@ -1,7 +1,17 @@
/** The Services tree (Docker → Containers / Images / Volumes / Networks), built from a snapshot. */
import type { DockerContainer, DockerImage, DockerNetwork, DockerSnapshot, DockerVolume } from "@/platform/ipc";
export type GroupKind = "containers" | "images" | "volumes" | "networks";
export type GroupKind = "devContainers" | "containers" | "images" | "networks" | "volumes";
export interface DockerFilters {
showStoppedContainers: boolean;
showUntaggedImages: boolean;
}
export const DEFAULT_DOCKER_FILTERS: DockerFilters = {
showStoppedContainers: true,
showUntaggedImages: true,
};
export type ServiceNode =
| { kind: "docker"; key: string }
@@ -14,10 +24,11 @@ export type ServiceNode =
export const DOCKER_KEY = "docker";
const GROUPS: Array<[GroupKind, string]> = [
["devContainers", "Dev Containers"],
["containers", "Containers"],
["images", "Images"],
["volumes", "Volumes"],
["networks", "Networks"],
["volumes", "Volumes"],
];
export const shortId = (id: string) => id.replace(/^sha256:/, "").slice(0, 12);
@@ -26,6 +37,18 @@ export function isRunning(c: DockerContainer): boolean {
return c.state === "running" || c.state === "restarting" || c.state === "paused";
}
export function isDevContainer(c: DockerContainer): boolean {
const labels = c.labels;
if (!labels) return false;
return (
"devcontainer.local_folder" in labels || "com.intellij.devcontainer.id" in labels || "vsch.local.folder" in labels
);
}
export function isUntaggedImage(i: DockerImage): boolean {
return (!i.repository || i.repository === "<none>") && (!i.tag || i.tag === "<none>");
}
export function imageRef(i: DockerImage): string {
if (!i.repository || i.repository === "<none>") return shortId(i.id);
return !i.tag || i.tag === "<none>" ? i.repository : `${i.repository}:${i.tag}`;
@@ -35,23 +58,49 @@ export function rootNodes(): ServiceNode[] {
return [{ kind: "docker", key: DOCKER_KEY }];
}
function composeProjects(snap: DockerSnapshot, filters: DockerFilters): ServiceNode[] {
const projects = new Map<string, DockerContainer[]>();
for (const c of snap.containers) {
if (c.composeProject) projects.set(c.composeProject, [...(projects.get(c.composeProject) ?? []), c]);
}
return [...projects.entries()]
.filter(([, containers]) => filters.showStoppedContainers || containers.some(isRunning))
.sort((a, b) => a[0].localeCompare(b[0]))
.map(([project, containers]) => ({
kind: "compose" as const,
key: `compose:${project}`,
project,
containers: [...containers].sort((a, b) =>
(a.composeService ?? a.name).localeCompare(b.composeService ?? b.name),
),
}));
}
/** Children lookup over one snapshot (stable node objects per snapshot). */
export function childrenOf(snap: DockerSnapshot | null): (n: ServiceNode) => ServiceNode[] | undefined {
export function childrenOf(
snap: DockerSnapshot | null,
filters: DockerFilters = DEFAULT_DOCKER_FILTERS,
): (n: ServiceNode) => ServiceNode[] | undefined {
const cache = new Map<string, ServiceNode[] | undefined>();
const compute = (n: ServiceNode): ServiceNode[] | undefined => {
if (!snap || snap.availability.kind !== "ready") return undefined;
switch (n.kind) {
case "docker":
return GROUPS.map(([group]) => ({
kind: "group",
key: `group:${group}`,
group,
count: groupCount(snap, group),
}));
return [
...composeProjects(snap, filters),
...GROUPS.map(([group]) => ({
kind: "group" as const,
key: `group:${group}`,
group,
count: groupCount(snap, group, filters),
})),
];
case "group":
return groupChildren(snap, n.group);
case "compose":
return n.containers.map(containerNode);
return groupChildren(snap, n.group, filters);
case "compose": {
const list = filters.showStoppedContainers ? n.containers : n.containers.filter(isRunning);
return list.map(containerNode);
}
default:
return undefined;
}
@@ -62,14 +111,21 @@ export function childrenOf(snap: DockerSnapshot | null): (n: ServiceNode) => Ser
};
}
function groupCount(snap: DockerSnapshot, g: GroupKind): number {
return g === "containers"
? snap.containers.length
: g === "images"
? snap.images.length
: g === "volumes"
? snap.volumes.length
: snap.networks.length;
function groupCount(snap: DockerSnapshot, g: GroupKind, filters: DockerFilters = DEFAULT_DOCKER_FILTERS): number {
if (g === "devContainers") {
return snap.containers.filter(
(c) => !c.composeProject && isDevContainer(c) && (filters.showStoppedContainers || isRunning(c)),
).length;
}
if (g === "containers") {
return snap.containers.filter(
(c) => !c.composeProject && !isDevContainer(c) && (filters.showStoppedContainers || isRunning(c)),
).length;
}
if (g === "images") {
return filters.showUntaggedImages ? snap.images.length : snap.images.filter((i) => !isUntaggedImage(i)).length;
}
return g === "volumes" ? snap.volumes.length : snap.networks.length;
}
const containerNode = (c: DockerContainer): ServiceNode => ({
@@ -78,30 +134,25 @@ const containerNode = (c: DockerContainer): ServiceNode => ({
container: c,
});
/** Compose projects (by label) first, then standalone containers, as IDEA groups them. */
export function groupChildren(snap: DockerSnapshot, g: GroupKind): ServiceNode[] {
/** Children of one group node (`Dev Containers`, `Containers`, `Images`, `Networks`, `Volumes`). */
export function groupChildren(
snap: DockerSnapshot,
g: GroupKind,
filters: DockerFilters = DEFAULT_DOCKER_FILTERS,
): ServiceNode[] {
switch (g) {
case "containers": {
const projects = new Map<string, DockerContainer[]>();
const loose: DockerContainer[] = [];
for (const c of snap.containers) {
if (c.composeProject) projects.set(c.composeProject, [...(projects.get(c.composeProject) ?? []), c]);
else loose.push(c);
}
const compose: ServiceNode[] = [...projects.entries()]
.sort((a, b) => a[0].localeCompare(b[0]))
.map(([project, containers]) => ({
kind: "compose",
key: `compose:${project}`,
project,
containers: [...containers].sort((a, b) =>
(a.composeService ?? a.name).localeCompare(b.composeService ?? b.name),
),
}));
return [...compose, ...loose.map(containerNode)];
case "devContainers":
return snap.containers
.filter((c) => !c.composeProject && isDevContainer(c) && (filters.showStoppedContainers || isRunning(c)))
.map(containerNode);
case "containers":
return snap.containers
.filter((c) => !c.composeProject && !isDevContainer(c) && (filters.showStoppedContainers || isRunning(c)))
.map(containerNode);
case "images": {
const imgs = filters.showUntaggedImages ? snap.images : snap.images.filter((i) => !isUntaggedImage(i));
return imgs.map((image) => ({ kind: "image", key: `image:${image.id}`, image }));
}
case "images":
return snap.images.map((image) => ({ kind: "image", key: `image:${image.id}`, image }));
case "volumes":
return snap.volumes.map((volume) => ({ kind: "volume", key: `volume:${volume.name}`, volume }));
case "networks":
@@ -109,6 +160,42 @@ export function groupChildren(snap: DockerSnapshot, g: GroupKind): ServiceNode[]
}
}
/** Whether `container.image` refers to `image`. */
export function containerMatchesImage(c: DockerContainer, image: DockerImage): boolean {
const ref = imageRef(image);
if (c.image === ref || c.image === image.id || c.image === shortId(image.id)) return true;
if (image.tag === "latest" && c.image === image.repository) return true;
const cleanId = image.id.replace(/^sha256:/, "");
const cleanTarget = c.image.replace(/^sha256:/, "");
return cleanTarget.length >= 12 && cleanId.startsWith(cleanTarget);
}
/** Containers in `snap` created from `image`. */
export function containersUsingImage(snap: DockerSnapshot, image: DockerImage): DockerContainer[] {
return snap.containers.filter((c) => containerMatchesImage(c, image));
}
/** All `repo:tag` references in `snap` that share `image.id`. */
export function imageTagsForId(snap: DockerSnapshot, image: DockerImage): string[] {
const tags = snap.images.filter((i) => i.id === image.id && !isUntaggedImage(i)).map(imageRef);
return [...new Set(tags)];
}
/** The `DockerImage` in `snap` that `container` runs, if present locally. */
export function findImageForContainer(snap: DockerSnapshot, container: DockerContainer): DockerImage | null {
return snap.images.find((i) => containerMatchesImage(container, i)) ?? null;
}
/** Containers in `snap` mounting `volume`. */
export function containersUsingVolume(snap: DockerSnapshot, volume: DockerVolume): DockerContainer[] {
return snap.containers.filter((c) => c.mounts?.includes(volume.name));
}
/** Containers in `snap` attached to `network`. */
export function containersInNetwork(snap: DockerSnapshot, network: DockerNetwork): DockerContainer[] {
return snap.containers.filter((c) => c.networks?.includes(network.name));
}
export function nodeLabel(n: ServiceNode): string {
switch (n.kind) {
case "docker":
@@ -116,7 +203,7 @@ export function nodeLabel(n: ServiceNode): string {
case "group":
return GROUPS.find(([g]) => g === n.group)![1];
case "compose":
return n.project;
return `Docker-compose: ${n.project}`;
case "container":
return n.container.composeService && n.container.composeProject ? n.container.composeService : n.container.name;
case "image":
@@ -149,22 +236,45 @@ export function nodeDetail(n: ServiceNode): string | undefined {
}
}
function groupIcon(g: GroupKind): string {
switch (g) {
case "devContainers":
return "docker-devcontainer";
case "containers":
return "docker-containers";
case "images":
return "docker-images";
case "networks":
return "docker-networks";
case "volumes":
return "docker-volumes";
}
}
export function nodeIcon(n: ServiceNode): string {
switch (n.kind) {
case "docker":
return "file-docker";
return "docker-connected";
case "group":
return "folder";
return groupIcon(n.group);
case "compose":
return "file-yaml";
return n.containers.some(isRunning) ? "docker-compose-running" : "docker-compose-stopped";
case "container":
return n.container.state === "paused" ? "pause" : isRunning(n.container) ? "run" : "module";
if (isDevContainer(n.container)) return "docker-devcontainer";
if (n.container.composeProject) {
return isRunning(n.container) ? "docker-compose-service-running" : "docker-compose-service-stopped";
}
return n.container.state === "paused"
? "pause"
: isRunning(n.container)
? "docker-container-running"
: "docker-container-stopped";
case "image":
return "package";
return "docker-image";
case "volume":
return "database";
return "docker-volumes";
case "network":
return "hierarchy";
return "docker-networks";
}
}
+51 -5
Переглянути файл
@@ -5,6 +5,7 @@ import { executeAction } from "@/platform/actions";
import type { MenuItem } from "@/ui";
import { confirm, confirmChecked } from "@/workbench/messages";
import { promptText } from "./dialogs";
import { openContainerLogInEditor } from "./logEditor";
import { isRunning, nodeContainers, nodeLabel, shortId, type DockerTarget, type ServiceNode } from "./model";
import {
composeOp,
@@ -14,7 +15,9 @@ import {
pullImage,
refresh,
removeObjects,
setDockerFilters,
useServices,
type DetailTab,
} from "./store";
export function canStart(t: DockerTarget | null): boolean {
@@ -185,10 +188,26 @@ export function pruneMenu(conn: string): MenuItem[] {
const copy = (text: string) => void navigator.clipboard?.writeText(text);
export function showDetail(tab: "log" | "inspect") {
export function showDetail(tab: DetailTab) {
useServices.setState({ detailTab: tab });
}
export function viewFilterMenu(): MenuItem[] {
const { filters } = useServices.getState();
return [
{
label: "Stopped Containers",
checked: filters.showStoppedContainers,
run: () => setDockerFilters({ showStoppedContainers: !useServices.getState().filters.showStoppedContainers }),
},
{
label: "Untagged Images",
checked: filters.showUntaggedImages,
run: () => setDockerFilters({ showUntaggedImages: !useServices.getState().filters.showUntaggedImages }),
},
];
}
/** Context menu of a node of a connection's subtree. */
export function nodeMenu(t: DockerTarget): MenuItem[] {
const { conn, node: n } = t;
@@ -212,7 +231,14 @@ export function nodeMenu(t: DockerTarget): MenuItem[] {
run: () => void containerOp(conn, [n.container.id], "pause"),
},
{ kind: "separator" },
{ label: "Dashboard", icon: "layout", run: () => showDetail("dashboard") },
{ label: "Show Log", icon: "file-log", run: () => showDetail("log") },
{
label: "Open Log in Editor",
icon: "external-link",
run: () => openContainerLogInEditor(conn, n.container),
},
{ label: "Show Processes", icon: "list", disabled: !running, run: () => showDetail("processes") },
{ label: "Inspect", icon: "info", run: () => showDetail("inspect") },
{ label: "Create Terminal", icon: "terminal", disabled: !running, run: () => execShell(t) },
{ kind: "separator" },
@@ -221,12 +247,16 @@ export function nodeMenu(t: DockerTarget): MenuItem[] {
);
}
if (n.kind === "image" || n.kind === "volume" || n.kind === "network") {
items.push({ label: "Dashboard", icon: "layout", run: () => showDetail("dashboard") });
if (n.kind === "image") {
items.push({ label: "Show Layers", icon: "list", run: () => showDetail("layers") });
}
items.push({ label: "Inspect", icon: "info", run: () => showDetail("inspect") });
if (n.kind === "image")
items.push(
{
label: "Pull Again",
icon: "git-pull",
icon: "docker-pull",
disabled: n.image.repository === "<none>",
run: () => void pullImage(conn, nodeLabel(n)),
},
@@ -234,10 +264,25 @@ export function nodeMenu(t: DockerTarget): MenuItem[] {
);
}
if (n.kind === "group" && n.group === "images")
items.push({ label: "Pull Image…", icon: "git-pull", run: () => void pullPrompt(conn) });
items.push({ label: "Pull Image…", icon: "docker-pull", run: () => void pullPrompt(conn) });
if (n.kind === "group") {
if (n.group === "containers" || n.group === "devContainers") {
const { filters } = useServices.getState();
items.push({
label: "Stopped Containers",
checked: filters.showStoppedContainers,
run: () => setDockerFilters({ showStoppedContainers: !useServices.getState().filters.showStoppedContainers }),
});
} else if (n.group === "images") {
const { filters } = useServices.getState();
items.push({
label: "Untagged Images",
checked: filters.showUntaggedImages,
run: () => setDockerFilters({ showUntaggedImages: !useServices.getState().filters.showUntaggedImages }),
});
}
const target: PruneTarget =
n.group === "containers"
n.group === "containers" || n.group === "devContainers"
? "containers"
: n.group === "images"
? "images"
@@ -252,7 +297,8 @@ export function nodeMenu(t: DockerTarget): MenuItem[] {
if (n.kind === "docker")
items.push(
{ label: "Refresh", icon: "refresh", run: () => void refresh(conn) },
{ label: "Pull Image…", icon: "git-pull", run: () => void pullPrompt(conn) },
{ label: "Pull Image…", icon: "docker-pull", run: () => void pullPrompt(conn) },
{ label: "View Options", icon: "filter", submenu: viewFilterMenu() },
{ label: "Prune", submenu: pruneMenu(conn) },
);
if (canRemove(t)) {
+262 -17
Переглянути файл
@@ -12,16 +12,36 @@ import {
} from "./runTypes";
import {
childrenOf,
containersInNetwork,
containersUsingImage,
containersUsingVolume,
findImageForContainer,
findNode,
imageTagsForId,
isUntaggedImage,
nodeDetail,
nodeIcon,
nodeLabel,
parseTreeKey,
rootNodes,
treeKey,
type ServiceNode,
} from "./model";
import {
bytesToUnits,
formatCreatedDetailed,
parseContainerInspect,
parseImageInspect,
parseNetworkInspect,
parsePercent,
parsePortMappings,
parseUsagePair,
splitLayerInstruction,
unitsToBytes,
} from "./dashboard";
import { endpointFrom } from "./dialogs";
import { dockerItems } from "./docker";
import { setDockerFilters, useServices } from "./store";
import { addServiceMenu, expandableKeys, indexTree, setProviderRoots, useServicesTree } from "./tree";
const container = (name: string, state: string, project?: string, service?: string): DockerContainer => ({
@@ -54,18 +74,36 @@ const snapshot: DockerSnapshot = {
};
describe("services tree", () => {
it("groups containers by compose project before standalone ones", () => {
it("groups compose projects at the top, followed by Dev Containers, Containers, Images, Networks and Volumes", () => {
const children = childrenOf(snapshot);
const [docker] = rootNodes();
const groups = children(docker!)!;
expect(groups.map(nodeLabel)).toEqual(["Containers", "Images", "Volumes", "Networks"]);
expect(nodeDetail(groups[0]!)).toBe("3");
const containers = children(groups[0]!)!;
expect(containers.map((n) => n.kind)).toEqual(["compose", "container"]);
const shop = containers[0] as Extract<ServiceNode, { kind: "compose" }>;
expect(groups.map(nodeLabel)).toEqual([
"Docker-compose: shop",
"Dev Containers",
"Containers",
"Images",
"Networks",
"Volumes",
]);
expect(groups.map(nodeIcon)).toEqual([
"docker-compose-running",
"docker-devcontainer",
"docker-containers",
"docker-images",
"docker-networks",
"docker-volumes",
]);
const shop = groups[0] as Extract<ServiceNode, { kind: "compose" }>;
expect(nodeDetail(shop)).toBe("1/2 running");
expect(children(shop)!.map(nodeLabel)).toEqual(["api", "db"]);
expect(nodeLabel(children(groups[1]!)![0]!)).toBe("postgres:16");
expect(children(shop)!.map(nodeIcon)).toEqual(["docker-compose-service-stopped", "docker-compose-service-running"]);
expect(nodeDetail(groups[2]!)).toBe("1");
const containers = children(groups[2]!)!;
expect(containers.map((n) => n.kind)).toEqual(["container"]);
expect(nodeIcon(containers[0]!)).toBe("docker-container-stopped");
expect(nodeLabel(children(groups[3]!)![0]!)).toBe("postgres:16");
expect(nodeIcon(children(groups[3]!)![0]!)).toBe("docker-image");
});
it("finds nodes again in a new snapshot", () => {
@@ -88,20 +126,19 @@ describe("services tree host", () => {
it("puts every connection and registry at the root, with connection-scoped keys", () => {
const items = dockerItems(conns, [{ id: "r1", name: "GHCR", address: "ghcr.io", username: "me" }], state);
expect(items.map((i) => [i.key, i.label, i.detail])).toEqual([
["docker:default", "Docker", undefined],
["docker:c2", "Build box", undefined],
["docker-registry:r1", "GHCR", "ghcr.io"],
expect(items.map((i) => [i.key, i.label, i.detail, i.icon])).toEqual([
["docker:default", "Docker", undefined, "docker-connected"],
["docker:c2", "Build box", undefined, "file-docker"],
["docker-registry:r1", "GHCR", "ghcr.io", "docker-registry"],
]);
expect(items[1]!.dim).toBe(true);
expect(items[1]!.children).toEqual([]);
const containers = items[0]!.children![0]!;
const compose = items[0]!.children![0]!;
expect(compose.key).toBe("docker:default:compose:shop");
const containers = items[0]!.children![2]!;
expect(containers.key).toBe("docker:default:group:containers");
expect(containers.children!.map((c) => c.key)).toEqual([
"docker:default:compose:shop",
"docker:default:container:web-id",
]);
expect(containers.children![1]!.dim).toBe(true);
expect(containers.children!.map((c) => c.key)).toEqual(["docker:default:container:web-id"]);
expect(containers.children![0]!.dim).toBe(true);
const index = indexTree({ docker: items });
expect(index.get("docker:default:container:shop-db-1-id")?.item.label).toBe("db");
expect(expandableKeys(items)).toContain("docker:default:compose:shop");
@@ -272,3 +309,211 @@ describe("docker run targets", () => {
expect(shellQuote('a "b"', true)).toBe('"a ""b"""');
});
});
describe("phase 1: filters, cross-references and dashboard parsers", () => {
const richSnapshot: DockerSnapshot = {
...snapshot,
containers: [
{
...container("web", "exited"),
image: "nginx:1.27",
ports: "0.0.0.0:8080->80/tcp",
mounts: [],
networks: ["bridge"],
},
{
...container("shop-db-1", "running", "shop", "db"),
image: "postgres:16",
ports: "127.0.0.1:5432->5432/tcp",
mounts: ["shop_pgdata"],
networks: ["shop_default"],
},
{
...container("shop-api-1", "exited", "shop", "api"),
image: "postgres:16",
mounts: [],
networks: ["shop_default"],
},
],
images: [
{ id: "sha256:1111111111112222", repository: "postgres", tag: "16", size: "431MB", createdSince: "7 weeks ago" },
{
id: "sha256:1111111111112222",
repository: "postgres",
tag: "latest",
size: "431MB",
createdSince: "7 weeks ago",
},
{
id: "sha256:aaaaaaaaaaaa0000",
repository: "<none>",
tag: "<none>",
size: "120MB",
createdSince: "4 weeks ago",
},
],
volumes: [
{
name: "shop_pgdata",
driver: "local",
mountpoint: "/var/lib/docker/volumes/shop_pgdata/_data",
composeProject: "shop",
},
],
networks: [{ id: "net-1", name: "shop_default", driver: "bridge", scope: "local", composeProject: "shop" }],
};
it("filters stopped containers and untagged images when toggled off", () => {
const filtered = childrenOf(richSnapshot, { showStoppedContainers: false, showUntaggedImages: false });
const [docker] = rootNodes();
const groups = filtered(docker!)!;
expect(groups.map(nodeLabel)).toEqual([
"Docker-compose: shop",
"Dev Containers",
"Containers",
"Images",
"Networks",
"Volumes",
]);
const shopChildren = filtered(groups[0]!)!;
expect(shopChildren.map(nodeLabel)).toEqual(["db"]);
expect(nodeDetail(groups[2]!)).toBeUndefined();
expect(nodeDetail(groups[3]!)).toBe("2");
const imageNodes = filtered(groups[3]!)!;
expect(imageNodes.map(nodeLabel)).toEqual(["postgres:16", "postgres:latest"]);
expect(isUntaggedImage(richSnapshot.images[2]!)).toBe(true);
expect(isUntaggedImage(richSnapshot.images[0]!)).toBe(false);
setDockerFilters({ showStoppedContainers: false });
expect(useServices.getState().filters.showStoppedContainers).toBe(false);
setDockerFilters({ showStoppedContainers: true, showUntaggedImages: true });
});
it("cross-references images, volumes and networks with containers", () => {
const pg = richSnapshot.images[0]!;
expect(containersUsingImage(richSnapshot, pg).map((c) => c.name)).toEqual(["shop-db-1", "shop-api-1"]);
expect(imageTagsForId(richSnapshot, pg)).toEqual(["postgres:16", "postgres:latest"]);
expect(findImageForContainer(richSnapshot, richSnapshot.containers[1]!)?.id).toBe(pg.id);
expect(containersUsingVolume(richSnapshot, richSnapshot.volumes[0]!).map((c) => c.name)).toEqual(["shop-db-1"]);
expect(containersInNetwork(richSnapshot, richSnapshot.networks[0]!).map((c) => c.name)).toEqual([
"shop-db-1",
"shop-api-1",
]);
});
it("parses units, usage pairs, timestamps, ports, layers and inspect payloads", () => {
expect(unitsToBytes("50MiB")).toBe(50 * 1024 * 1024);
expect(unitsToBytes("431MB")).toBe(431 * 1000 * 1000);
expect(bytesToUnits(50 * 1024 * 1024)).toBe("50.0 MiB");
expect(parsePercent("12.5%")).toBe(12.5);
const pair = parseUsagePair("512MiB / 2GiB");
expect(pair.percent).toBeCloseTo(25, 1);
const formatted = formatCreatedDetailed("2026-10-01T15:00:00Z", Date.parse("2026-10-03T17:00:00Z"), "UTC");
expect(formatted).toContain("2d 2h ago");
expect(formatted).toContain("2026-10-01 15:00:00");
const localFormatted = formatCreatedDetailed("2026-10-01T15:00:00Z", Date.parse("2026-10-03T17:00:00Z"));
expect(localFormatted).toContain("2d 2h ago");
const ports = parsePortMappings("0.0.0.0:8080->80/tcp, 5432/tcp");
expect(ports).toEqual([
{
hostIp: "0.0.0.0",
hostPort: "8080",
containerPort: "80",
protocol: "tcp",
url: "http://localhost:8080",
label: "0.0.0.0:8080 -> 80/tcp",
},
{
hostIp: "",
hostPort: null,
containerPort: "5432",
protocol: "tcp",
url: null,
label: "5432/tcp",
},
]);
expect(splitLayerInstruction('/bin/sh -c #(nop) CMD ["postgres"]')).toEqual({
instruction: "CMD",
args: '["postgres"]',
});
expect(splitLayerInstruction("RUN /bin/sh -c apt-get update # buildkit")).toEqual({
instruction: "RUN",
args: "/bin/sh -c apt-get update",
});
const cInspect = parseContainerInspect(
JSON.stringify([
{
Created: "2026-10-01T15:00:00Z",
Image: "sha256:1111",
State: { Pid: 4321, ExitCode: 0, StartedAt: "2026-10-01T15:00:01Z", Health: { Status: "healthy" } },
Config: {
User: "postgres",
WorkingDir: "/var/lib/postgresql",
Entrypoint: ["docker-entrypoint.sh"],
Cmd: ["postgres"],
Env: ["POSTGRES_DB=shop", "PGDATA=/var/lib/postgresql/data"],
Labels: { "com.docker.compose.project": "shop" },
},
HostConfig: { RestartPolicy: { Name: "unless-stopped", MaximumRetryCount: 0 } },
Mounts: [
{
Type: "volume",
Name: "shop_pgdata",
Source: "/var/lib/docker/volumes/shop_pgdata/_data",
Destination: "/var/lib/postgresql/data",
Mode: "z",
RW: true,
},
],
NetworkSettings: {
Ports: { "5432/tcp": [{ HostIp: "127.0.0.1", HostPort: "5432" }] },
Networks: { shop_default: { IPAddress: "172.18.0.2", Gateway: "172.18.0.1" } },
},
},
]),
)!;
expect(cInspect.pid).toBe(4321);
expect(cInspect.healthStatus).toBe("healthy");
expect(cInspect.restartPolicy).toBe("unless-stopped");
expect(cInspect.env).toEqual([
["POSTGRES_DB", "shop"],
["PGDATA", "/var/lib/postgresql/data"],
]);
expect(cInspect.mounts[0]!.name).toBe("shop_pgdata");
expect(cInspect.ports[0]!.url).toBe("http://127.0.0.1:5432");
expect(cInspect.networks[0]!.ipAddress).toBe("172.18.0.2");
const iInspect = parseImageInspect(
JSON.stringify({
Created: "2026-09-01T08:00:00Z",
Size: 451936256,
Architecture: "arm64",
Os: "linux",
RepoTags: ["postgres:16"],
Config: {
ExposedPorts: { "5432/tcp": {} },
Entrypoint: ["docker-entrypoint.sh"],
Cmd: ["postgres"],
Env: ["PATH=/usr/local/bin"],
},
}),
)!;
expect(iInspect.os).toBe("linux");
expect(iInspect.architecture).toBe("arm64");
expect(iInspect.exposedPorts).toEqual(["5432/tcp"]);
const nInspect = parseNetworkInspect(
JSON.stringify({
Created: "2026-10-01T09:00:00Z",
IPAM: { Config: [{ Subnet: "172.18.0.0/16", Gateway: "172.18.0.1" }] },
Containers: { c1: { Name: "shop-db-1", IPv4Address: "172.18.0.2/16" } },
}),
)!;
expect(nInspect.ipamConfigs[0]!.subnet).toBe("172.18.0.0/16");
expect(nInspect.containers[0]!.name).toBe("shop-db-1");
});
});
+41 -2
Переглянути файл
@@ -22,8 +22,31 @@ import type {
import { tx } from "@/platform/i18n";
import { call, commands, isCancelled, isTauri, nextRequestId } from "@/platform/ipc";
import { notifications } from "@/workbench/contributions";
import { DEFAULT_DOCKER_FILTERS, type DockerFilters } from "./model";
export type DetailTab = "log" | "inspect";
export type DetailTab = "dashboard" | "log" | "processes" | "layers" | "inspect";
const FILTERS_STORAGE_KEY = "openidea.docker.filters";
function loadStoredFilters(): DockerFilters {
try {
const raw = typeof localStorage !== "undefined" ? localStorage.getItem(FILTERS_STORAGE_KEY) : null;
if (!raw) return DEFAULT_DOCKER_FILTERS;
const parsed = JSON.parse(raw) as Partial<DockerFilters>;
return {
showStoppedContainers:
typeof parsed.showStoppedContainers === "boolean"
? parsed.showStoppedContainers
: DEFAULT_DOCKER_FILTERS.showStoppedContainers,
showUntaggedImages:
typeof parsed.showUntaggedImages === "boolean"
? parsed.showUntaggedImages
: DEFAULT_DOCKER_FILTERS.showUntaggedImages,
};
} catch {
return DEFAULT_DOCKER_FILTERS;
}
}
/** `DockerSettings` with its defaults filled in. */
export interface Settings {
@@ -43,6 +66,7 @@ interface DockerState {
/** By connection id. */
conns: Record<string, ConnectionState>;
detailTab: DetailTab;
filters: DockerFilters;
/** Long operation in progress (pull, prune, compose…): its title and last output line. */
task: { title: string; line: string; request: RequestId } | null;
}
@@ -50,10 +74,25 @@ interface DockerState {
export const useServices = create<DockerState>(() => ({
settings: null,
conns: {},
detailTab: "log",
detailTab: "dashboard",
filters: loadStoredFilters(),
task: null,
}));
export function setDockerFilters(patch: Partial<DockerFilters>): void {
useServices.setState((s) => {
const filters = { ...s.filters, ...patch };
try {
if (typeof localStorage !== "undefined") {
localStorage.setItem(FILTERS_STORAGE_KEY, JSON.stringify(filters));
}
} catch {
// ignore storage quota errors
}
return { filters };
});
}
export const errorMessage = (e: unknown) => (e instanceof Error ? e.message : String(e));
const IDLE: ConnectionState = { snapshot: null, loading: false, error: null };
+55 -1
Переглянути файл
@@ -624,5 +624,59 @@
"Debugging a plugin target requires a container root": "Debugging a plugin target requires a container root",
"This debugger does not support plugin run targets": "This debugger does not support plugin run targets",
"invalid debug launch arguments": "invalid debug launch arguments",
"unknown debug adapter": "unknown debug adapter"
"unknown debug adapter": "unknown debug adapter",
"Container Overview": "Container Overview",
"Container ID": "Container ID",
"Created": "Created",
"Started": "Started",
"Restart policy": "Restart policy",
"Compose": "Compose",
"Resource Usage": "Resource Usage",
"CPU": "CPU",
"Memory": "Memory",
"Network I/O": "Network I/O",
"Block I/O": "Block I/O",
"PIDs": "PIDs",
"Metrics unavailable": "Metrics unavailable",
"Port Bindings": "Port Bindings",
"No port bindings": "No port bindings",
"Volume & Bind Mounts": "Volume & Bind Mounts",
"Destination": "Destination",
"Mode": "Mode",
"No mounts": "No mounts",
"Networks": "Networks",
"No environment variables": "No environment variables",
"Container is not running": "Container is not running",
"Start the container to view its running processes.": "Start the container to view its running processes.",
"No processes reported": "No processes reported",
"Show Layers": "Show Layers",
"Image Overview": "Image Overview",
"Image ID": "Image ID",
"Platform": "Platform",
"Exposed ports": "Exposed ports",
"Containers Using Image": "Containers Using Image",
"No containers are using this image": "No containers are using this image",
"No image layers": "No image layers",
"Instruction": "Instruction",
"Volume Overview": "Volume Overview",
"Mountpoint": "Mountpoint",
"Containers Using Volume": "Containers Using Volume",
"No containers are using this volume": "No containers are using this volume",
"Network Overview": "Network Overview",
"Network ID": "Network ID",
"Subnet / Gateway": "Subnet / Gateway",
"Connected Containers": "Connected Containers",
"No containers are connected to this network": "No containers are connected to this network",
"Inspect JSON": "Inspect JSON",
"Dashboard": "Dashboard",
"Layers": "Layers",
"Show Docker Dashboard": "Show Docker Dashboard",
"Show Container Processes": "Show Container Processes",
"Show Image Layers": "Show Image Layers",
"Toggle Stopped Containers": "Toggle Stopped Containers",
"Toggle Untagged Images": "Toggle Untagged Images",
"Untagged Images": "Untagged Images",
"Show Processes": "Show Processes",
"Open Log in Editor": "Open Log in Editor",
"Open Container Log in Editor": "Open Container Log in Editor"
}
+55 -1
Переглянути файл
@@ -6965,5 +6965,59 @@
"Debugging a plugin target requires a container root": "调试插件目标需要容器根目录",
"This debugger does not support plugin run targets": "此调试器不支持插件运行目标",
"invalid debug launch arguments": "调试启动参数无效",
"unknown debug adapter": "未知调试适配器"
"unknown debug adapter": "未知调试适配器",
"Container Overview": "容器概览",
"Container ID": "容器 ID",
"Created": "创建时间",
"Started": "启动时间",
"Restart policy": "重启策略",
"Compose": "Compose 项目",
"Resource Usage": "资源使用情况",
"CPU": "处理器 (CPU)",
"Memory": "内存",
"Network I/O": "网络 I/O",
"Block I/O": "磁盘 I/O",
"PIDs": "进程数 (PIDs)",
"Metrics unavailable": "指标不可用",
"Port Bindings": "端口绑定",
"No port bindings": "无端口绑定",
"Volume & Bind Mounts": "卷与绑定挂载",
"Destination": "目标路径",
"Mode": "模式",
"No mounts": "无挂载",
"Networks": "网络",
"No environment variables": "无环境变量",
"Container is not running": "容器未运行",
"Start the container to view its running processes.": "启动容器以查看其运行中的进程。",
"No processes reported": "未报告任何进程",
"Show Layers": "显示镜像层",
"Image Overview": "镜像概览",
"Image ID": "镜像 ID",
"Platform": "平台",
"Exposed ports": "暴露端口",
"Containers Using Image": "使用此镜像的容器",
"No containers are using this image": "没有容器正在使用此镜像",
"No image layers": "无镜像层",
"Instruction": "指令",
"Volume Overview": "数据卷概览",
"Mountpoint": "挂载点",
"Containers Using Volume": "使用此数据卷的容器",
"No containers are using this volume": "没有容器正在使用此数据卷",
"Network Overview": "网络概览",
"Network ID": "网络 ID",
"Subnet / Gateway": "子网 / 网关",
"Connected Containers": "已连接的容器",
"No containers are connected to this network": "没有容器连接到此网络",
"Inspect JSON": "检查 JSON",
"Dashboard": "仪表盘",
"Layers": "镜像层",
"Show Docker Dashboard": "显示 Docker 仪表盘",
"Show Container Processes": "显示容器进程",
"Show Image Layers": "显示镜像层",
"Toggle Stopped Containers": "切换显示已停止的容器",
"Toggle Untagged Images": "切换显示未标记的镜像",
"Untagged Images": "未标记的镜像",
"Show Processes": "显示进程",
"Open Log in Editor": "在编辑器中打开日志",
"Open Container Log in Editor": "在编辑器中打开容器日志"
}
+4 -1
Переглянути файл
@@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from "react";
import { useEffect, useRef, useState, type ReactNode } from "react";
import clsx from "clsx";
import { useTx } from "@/platform/i18n";
import { IconButton } from "./Button";
@@ -29,6 +29,8 @@ export interface LogPaneProps {
): () => void;
sourceKey: string;
toggles?: LogToggle[];
/** Extra toolbar buttons placed before Clear. */
actions?: ReactNode;
/** "Waiting for output…" rather than "No output" while empty. */
live: boolean;
ariaLabel: string;
@@ -118,6 +120,7 @@ function LogStream(
))}
<Toggle checked={wrap} onChange={setWrap} label="Soft-wrap" />
<span className={styles.flex} />
{props.actions}
<IconButton icon="trash" size="small" title="Clear" onClick={() => setLines([])} />
</div>
<div
+8
Переглянути файл
@@ -0,0 +1,8 @@
<!-- Copyright 2000-2024 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. -->
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M4 2H12C13.1046 2 14 2.89543 14 4V12C14 13.1046 13.1046 14 12 14H4C2.89543 14 2 13.1046 2 12V4C2 2.89543 2.89543 2 4 2ZM4 3C3.44772 3 3 3.44772 3 4V12C3 12.5523 3.44772 13 4 13H12C12.5523 13 13 12.5523 13 12V4C13 3.44772 12.5523 3 12 3H4Z" fill="#6C707E"/>
<rect x="4.5" y="8.5" width="3" height="3" rx="0.5" fill="#6C707E"/>
<rect x="8.5" y="8.5" width="3" height="3" rx="0.5" fill="#6C707E"/>
<rect x="8.5" y="4.5" width="3" height="3" rx="0.5" fill="#6C707E"/>
<rect x="4.5" y="4.5" width="3" height="3" rx="0.5" fill="#6C707E"/>
</svg>

Після

Ширина:  |  Висота:  |  Розмір: 812 B

+8
Переглянути файл
@@ -0,0 +1,8 @@
<!-- Copyright 2000-2024 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. -->
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M4 2H12C13.1046 2 14 2.89543 14 4V12C14 13.1046 13.1046 14 12 14H4C2.89543 14 2 13.1046 2 12V4C2 2.89543 2.89543 2 4 2ZM4 3C3.44772 3 3 3.44772 3 4V12C3 12.5523 3.44772 13 4 13H12C12.5523 13 13 12.5523 13 12V4C13 3.44772 12.5523 3 12 3H4Z" fill="#CED0D6"/>
<rect x="4.5" y="8.5" width="3" height="3" rx="0.5" fill="#CED0D6"/>
<rect x="8.5" y="8.5" width="3" height="3" rx="0.5" fill="#CED0D6"/>
<rect x="8.5" y="4.5" width="3" height="3" rx="0.5" fill="#CED0D6"/>
<rect x="4.5" y="4.5" width="3" height="3" rx="0.5" fill="#CED0D6"/>
</svg>

Після

Ширина:  |  Висота:  |  Розмір: 812 B

+6
Переглянути файл
@@ -0,0 +1,6 @@
<!-- Copyright 2000-2024 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. -->
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="2.50732" y="9" width="4.5" height="4.5" rx="0.5" fill="#EBECF0" stroke="#6C707E"/>
<rect x="9.5" y="9" width="4.5" height="4.5" rx="0.5" fill="#EBECF0" stroke="#6C707E"/>
<rect x="2.5" y="2.5" width="4.5" height="4.5" rx="0.5" fill="#EBECF0" stroke="#6C707E"/>
</svg>

Після

Ширина:  |  Висота:  |  Розмір: 500 B

+6
Переглянути файл
@@ -0,0 +1,6 @@
<!-- Copyright 2000-2024 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. -->
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="2.50732" y="9" width="4.5" height="4.5" rx="0.5" fill="#43454A" stroke="#CED0D6"/>
<rect x="9.5" y="9" width="4.5" height="4.5" rx="0.5" fill="#43454A" stroke="#CED0D6"/>
<rect x="2.5" y="2.5" width="4.5" height="4.5" rx="0.5" fill="#43454A" stroke="#CED0D6"/>
</svg>

Після

Ширина:  |  Висота:  |  Розмір: 500 B

+8
Переглянути файл
@@ -0,0 +1,8 @@
<!-- Copyright 2000-2024 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. -->
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M4 2H12C13.1046 2 14 2.89543 14 4V12C14 13.1046 13.1046 14 12 14H4C2.89543 14 2 13.1046 2 12V4C2 2.89543 2.89543 2 4 2ZM4 3C3.44772 3 3 3.44772 3 4V12C3 12.5523 3.44772 13 4 13H12C12.5523 13 13 12.5523 13 12V4C13 3.44772 12.5523 3 12 3H4Z" fill="#55A76A"/>
<rect x="4.5" y="8.5" width="3" height="3" rx="0.5" fill="#55A76A"/>
<rect x="8.5" y="8.5" width="3" height="3" rx="0.5" fill="#55A76A"/>
<rect x="4.5" y="4.5" width="3" height="3" rx="0.5" fill="#55A76A"/>
<circle cx="12.5" cy="3.5" r="2.5" fill="#55A76A" stroke="#FFFFFF" stroke-width="1"/>
</svg>

Після

Ширина:  |  Висота:  |  Розмір: 829 B

+8
Переглянути файл
@@ -0,0 +1,8 @@
<!-- Copyright 2000-2024 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. -->
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M4 2H12C13.1046 2 14 2.89543 14 4V12C14 13.1046 13.1046 14 12 14H4C2.89543 14 2 13.1046 2 12V4C2 2.89543 2.89543 2 4 2ZM4 3C3.44772 3 3 3.44772 3 4V12C3 12.5523 3.44772 13 4 13H12C12.5523 13 13 12.5523 13 12V4C13 3.44772 12.5523 3 12 3H4Z" fill="#5FAD65"/>
<rect x="4.5" y="8.5" width="3" height="3" rx="0.5" fill="#5FAD65"/>
<rect x="8.5" y="8.5" width="3" height="3" rx="0.5" fill="#5FAD65"/>
<rect x="4.5" y="4.5" width="3" height="3" rx="0.5" fill="#5FAD65"/>
<circle cx="12.5" cy="3.5" r="2.5" fill="#5FAD65" stroke="#1E1F22" stroke-width="1"/>
</svg>

Після

Ширина:  |  Висота:  |  Розмір: 829 B

+4
Переглянути файл
@@ -0,0 +1,4 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8 7.49977L12.5 5.5M8 7.49977L3.5 5.5M8 7.49977V12.4998" stroke="#3574F0" stroke-linecap="round"/>
<path d="M14.5 11.5199V4.5L7.99998 1.5L1.5 4.5V11.5199L8 14.5L14.5 11.5199Z" stroke="#3574F0" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Після

Ширина:  |  Висота:  |  Розмір: 354 B

+4
Переглянути файл
@@ -0,0 +1,4 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8 7.49977L12.5 5.5M8 7.49977L3.5 5.5M8 7.49977V12.4998" stroke="#548AF7" stroke-linecap="round"/>
<path d="M14.5 11.5199V4.5L7.99998 1.5L1.5 4.5V11.5199L8 14.5L14.5 11.5199Z" stroke="#548AF7" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Після

Ширина:  |  Висота:  |  Розмір: 354 B

+5
Переглянути файл
@@ -0,0 +1,5 @@
<!-- Copyright 2000-2024 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. -->
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M5 2H8C8.55228 2 9 2.44772 9 3V6C9 6.55228 8.55228 7 8 7H5C4.44772 7 4 6.55228 4 6V3C4 2.44772 4.44772 2 5 2Z" fill="#A8ADBD"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M14.0877 6.73537C14.85 6.73537 15.2114 7.00829 15.2502 7.03904L15.391 7.15031L15.3326 7.3212C15.2394 7.55692 15.0959 7.76945 14.9121 7.94392C14.6354 8.21746 14.1338 8.5439 13.3181 8.5439H13.1865C13.1195 8.71333 13.0477 8.91217 12.9698 9.12794C12.6637 9.97522 12.2613 11.0893 11.6675 11.7614C11.2219 12.2658 10.6977 12.6948 10.1153 13.0321C9.40854 13.4323 8.63977 13.7116 7.84084 13.8583C7.26612 13.9652 6.68264 14.0181 6.09807 14.0163C4.80406 14.0163 4.24822 13.8704 3.34558 13.4401C2.44294 13.0098 1.90016 12.3003 1.41263 11.196C1.1422 10.5834 1.04487 9.67345 1.01182 8.98449C0.986026 8.44692 1.42639 8.02076 1.96457 8.02076H10.9655C11.0804 8.01646 11.6484 7.96789 12.0025 7.77057C11.7093 7.30705 11.6263 6.76488 11.7628 6.18763C11.7827 6.10437 11.8065 6.02221 11.834 5.94141C11.9063 5.72897 12.0046 5.5259 12.1267 5.33684L12.2613 5.14258L12.4661 5.26063C12.5043 5.28322 13.3397 5.77629 13.4871 6.78699C13.6856 6.75351 13.8864 6.73624 14.0877 6.73537ZM6 11C6 11.5523 5.55228 12 5 12C4.44772 12 4 11.5523 4 11C4 10.4477 4.44772 10 5 10C5.55228 10 6 10.4477 6 11Z" fill="#A8ADBD"/>
</svg>

Після

Ширина:  |  Висота:  |  Розмір: 1.5 KiB

+5
Переглянути файл
@@ -0,0 +1,5 @@
<!-- Copyright 2000-2024 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. -->
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M5 2H8C8.55228 2 9 2.44772 9 3V6C9 6.55228 8.55228 7 8 7H5C4.44772 7 4 6.55228 4 6V3C4 2.44772 4.44772 2 5 2Z" fill="#6F737A"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M14.0877 6.73537C14.85 6.73537 15.2114 7.00829 15.2502 7.03904L15.391 7.15031L15.3326 7.3212C15.2394 7.55692 15.0959 7.76945 14.9121 7.94392C14.6354 8.21746 14.1338 8.5439 13.3181 8.5439H13.1865C13.1195 8.71333 13.0477 8.91217 12.9698 9.12794C12.6637 9.97522 12.2613 11.0893 11.6675 11.7614C11.2219 12.2658 10.6977 12.6948 10.1153 13.0321C9.40854 13.4323 8.63977 13.7116 7.84084 13.8583C7.26612 13.9652 6.68264 14.0181 6.09807 14.0163C4.80406 14.0163 4.24822 13.8704 3.34558 13.4401C2.44294 13.0098 1.90016 12.3003 1.41263 11.196C1.1422 10.5834 1.04487 9.67345 1.01182 8.98449C0.986026 8.44692 1.42639 8.02076 1.96457 8.02076H10.9655C11.0804 8.01646 11.6484 7.96789 12.0025 7.77057C11.7093 7.30705 11.6263 6.76488 11.7628 6.18763C11.7827 6.10437 11.8065 6.02221 11.834 5.94141C11.9063 5.72897 12.0046 5.5259 12.1267 5.33684L12.2613 5.14258L12.4661 5.26063C12.5043 5.28322 13.3397 5.77629 13.4871 6.78699C13.6856 6.75351 13.8864 6.73624 14.0877 6.73537ZM6 11C6 11.5523 5.55228 12 5 12C4.44772 12 4 11.5523 4 11C4 10.4477 4.44772 10 5 10C5.55228 10 6 10.4477 6 11Z" fill="#6F737A"/>
</svg>

Після

Ширина:  |  Висота:  |  Розмір: 1.5 KiB

+5
Переглянути файл
@@ -0,0 +1,5 @@
<!-- Copyright 2000-2024 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. -->
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M5 2H8C8.55228 2 9 2.44772 9 3V6C9 6.55228 8.55228 7 8 7H5C4.44772 7 4 6.55228 4 6V3C4 2.44772 4.44772 2 5 2Z" fill="#55A76A"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M14.0877 6.73537C14.85 6.73537 15.2114 7.00829 15.2502 7.03904L15.391 7.15031L15.3326 7.3212C15.2394 7.55692 15.0959 7.76945 14.9121 7.94392C14.6354 8.21746 14.1338 8.5439 13.3181 8.5439H13.1865C13.1195 8.71333 13.0477 8.91217 12.9698 9.12794C12.6637 9.97522 12.2613 11.0893 11.6675 11.7614C11.2219 12.2658 10.6977 12.6948 10.1153 13.0321C9.40854 13.4323 8.63977 13.7116 7.84084 13.8583C7.26612 13.9652 6.68264 14.0181 6.09807 14.0163C4.80406 14.0163 4.24822 13.8704 3.34558 13.4401C2.44294 13.0098 1.90016 12.3003 1.41263 11.196C1.1422 10.5834 1.04487 9.67345 1.01182 8.98449C0.986026 8.44692 1.42639 8.02076 1.96457 8.02076H10.9655C11.0804 8.01646 11.6484 7.96789 12.0025 7.77057C11.7093 7.30705 11.6263 6.76488 11.7628 6.18763C11.7827 6.10437 11.8065 6.02221 11.834 5.94141C11.9063 5.72897 12.0046 5.5259 12.1267 5.33684L12.2613 5.14258L12.4661 5.26063C12.5043 5.28322 13.3397 5.77629 13.4871 6.78699C13.6856 6.75351 13.8864 6.73624 14.0877 6.73537ZM6 11C6 11.5523 5.55228 12 5 12C4.44772 12 4 11.5523 4 11C4 10.4477 4.44772 10 5 10C5.55228 10 6 10.4477 6 11Z" fill="#55A76A"/>
</svg>

Після

Ширина:  |  Висота:  |  Розмір: 1.5 KiB

+5
Переглянути файл
@@ -0,0 +1,5 @@
<!-- Copyright 2000-2024 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. -->
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M5 2H8C8.55228 2 9 2.44772 9 3V6C9 6.55228 8.55228 7 8 7H5C4.44772 7 4 6.55228 4 6V3C4 2.44772 4.44772 2 5 2Z" fill="#57965C"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M14.0877 6.73537C14.85 6.73537 15.2114 7.00829 15.2502 7.03904L15.391 7.15031L15.3326 7.3212C15.2394 7.55692 15.0959 7.76945 14.9121 7.94392C14.6354 8.21746 14.1338 8.5439 13.3181 8.5439H13.1865C13.1195 8.71333 13.0477 8.91217 12.9698 9.12794C12.6637 9.97522 12.2613 11.0893 11.6675 11.7614C11.2219 12.2658 10.6977 12.6948 10.1153 13.0321C9.40854 13.4323 8.63977 13.7116 7.84084 13.8583C7.26612 13.9652 6.68264 14.0181 6.09807 14.0163C4.80406 14.0163 4.24822 13.8704 3.34558 13.4401C2.44294 13.0098 1.90016 12.3003 1.41263 11.196C1.1422 10.5834 1.04487 9.67345 1.01182 8.98449C0.986026 8.44692 1.42639 8.02076 1.96457 8.02076H10.9655C11.0804 8.01646 11.6484 7.96789 12.0025 7.77057C11.7093 7.30705 11.6263 6.76488 11.7628 6.18763C11.7827 6.10437 11.8065 6.02221 11.834 5.94141C11.9063 5.72897 12.0046 5.5259 12.1267 5.33684L12.2613 5.14258L12.4661 5.26063C12.5043 5.28322 13.3397 5.77629 13.4871 6.78699C13.6856 6.75351 13.8864 6.73624 14.0877 6.73537ZM6 11C6 11.5523 5.55228 12 5 12C4.44772 12 4 11.5523 4 11C4 10.4477 4.44772 10 5 10C5.55228 10 6 10.4477 6 11Z" fill="#57965C"/>
</svg>

Після

Ширина:  |  Висота:  |  Розмір: 1.5 KiB

+6
Переглянути файл
@@ -0,0 +1,6 @@
<!-- Copyright 2000-2024 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. -->
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M5 2H8C8.55228 2 9 2.44772 9 3V6C9 6.55228 8.55228 7 8 7H5C4.44772 7 4 6.55228 4 6V3C4 2.44772 4.44772 2 5 2Z" fill="#3574F0"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M14.0877 6.73537C14.85 6.73537 15.2114 7.00829 15.2502 7.03904L15.391 7.15031L15.3326 7.3212C15.2394 7.55692 15.0959 7.76945 14.9121 7.94392C14.6354 8.21746 14.1338 8.5439 13.3181 8.5439H13.1865C13.1195 8.71333 13.0477 8.91217 12.9698 9.12794C12.6637 9.97522 12.2613 11.0893 11.6675 11.7614C11.2219 12.2658 10.6977 12.6948 10.1153 13.0321C9.40854 13.4323 8.63977 13.7116 7.84084 13.8583C7.26612 13.9652 6.68264 14.0181 6.09807 14.0163C4.80406 14.0163 4.24822 13.8704 3.34558 13.4401C2.44294 13.0098 1.90016 12.3003 1.41263 11.196C1.1422 10.5834 1.04487 9.67345 1.01182 8.98449C0.986026 8.44692 1.42639 8.02076 1.96457 8.02076H10.9655C11.0804 8.01646 11.6484 7.96789 12.0025 7.77057C11.7093 7.30705 11.6263 6.76488 11.7628 6.18763C11.7827 6.10437 11.8065 6.02221 11.834 5.94141C11.9063 5.72897 12.0046 5.5259 12.1267 5.33684L12.2613 5.14258L12.4661 5.26063C12.5043 5.28322 13.3397 5.77629 13.4871 6.78699C13.6856 6.75351 13.8864 6.73624 14.0877 6.73537ZM6 11C6 11.5523 5.55228 12 5 12C4.44772 12 4 11.5523 4 11C4 10.4477 4.44772 10 5 10C5.55228 10 6 10.4477 6 11Z" fill="#3574F0"/>
<circle cx="9.5" cy="3" r="2.5" fill="#55A76A" stroke="#FFFFFF" stroke-width="1"/>
</svg>

Після

Ширина:  |  Висота:  |  Розмір: 1.5 KiB

+6
Переглянути файл
@@ -0,0 +1,6 @@
<!-- Copyright 2000-2024 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. -->
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M5 2H8C8.55228 2 9 2.44772 9 3V6C9 6.55228 8.55228 7 8 7H5C4.44772 7 4 6.55228 4 6V3C4 2.44772 4.44772 2 5 2Z" fill="#548AF7"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M14.0877 6.73537C14.85 6.73537 15.2114 7.00829 15.2502 7.03904L15.391 7.15031L15.3326 7.3212C15.2394 7.55692 15.0959 7.76945 14.9121 7.94392C14.6354 8.21746 14.1338 8.5439 13.3181 8.5439H13.1865C13.1195 8.71333 13.0477 8.91217 12.9698 9.12794C12.6637 9.97522 12.2613 11.0893 11.6675 11.7614C11.2219 12.2658 10.6977 12.6948 10.1153 13.0321C9.40854 13.4323 8.63977 13.7116 7.84084 13.8583C7.26612 13.9652 6.68264 14.0181 6.09807 14.0163C4.80406 14.0163 4.24822 13.8704 3.34558 13.4401C2.44294 13.0098 1.90016 12.3003 1.41263 11.196C1.1422 10.5834 1.04487 9.67345 1.01182 8.98449C0.986026 8.44692 1.42639 8.02076 1.96457 8.02076H10.9655C11.0804 8.01646 11.6484 7.96789 12.0025 7.77057C11.7093 7.30705 11.6263 6.76488 11.7628 6.18763C11.7827 6.10437 11.8065 6.02221 11.834 5.94141C11.9063 5.72897 12.0046 5.5259 12.1267 5.33684L12.2613 5.14258L12.4661 5.26063C12.5043 5.28322 13.3397 5.77629 13.4871 6.78699C13.6856 6.75351 13.8864 6.73624 14.0877 6.73537ZM6 11C6 11.5523 5.55228 12 5 12C4.44772 12 4 11.5523 4 11C4 10.4477 4.44772 10 5 10C5.55228 10 6 10.4477 6 11Z" fill="#548AF7"/>
<circle cx="9.5" cy="3" r="2.5" fill="#5FAD65" stroke="#1E1F22" stroke-width="1"/>
</svg>

Після

Ширина:  |  Висота:  |  Розмір: 1.5 KiB

+8
Переглянути файл
@@ -0,0 +1,8 @@
<!-- Copyright 2000-2024 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. -->
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M4.16796 13.2205C5.01982 13.6629 6.31219 14 8 14C9.68781 14 10.9802 13.6629 11.832 13.2205C12.2586 12.999 12.561 12.7582 12.7512 12.5336C12.9429 12.307 13 12.123 13 12H14C14 12.4293 13.8071 12.8339 13.5145 13.1796C13.2202 13.5273 12.8039 13.8425 12.293 14.1079C11.2698 14.6393 9.81219 15 8 15C6.18781 15 4.73018 14.6393 3.70704 14.1079C3.19606 13.8425 2.77979 13.5273 2.48553 13.1796C2.19295 12.8339 2 12.4293 2 12H3C3 12.123 3.05705 12.307 3.24884 12.5336C3.43896 12.7582 3.74144 12.999 4.16796 13.2205Z" fill="#6C707E"/>
<path d="M11.7071 0.878953C11.3166 0.488429 10.6834 0.488429 10.2929 0.878953L8.87868 2.29317C8.48815 2.68369 8.48815 3.31686 8.87868 3.70738L10.2929 5.12159C10.6834 5.51212 11.3166 5.51212 11.7071 5.12159L13.1213 3.70738C13.5118 3.31686 13.5118 2.68369 13.1213 2.29317L11.7071 0.878953Z" fill="#4682FA"/>
<path d="M4.0004 1.00039C3.44811 1.00039 3.0004 1.44811 3.0004 2.00039V4.00039C3.0004 4.55268 3.44811 5.00039 4.0004 5.00039H6.0004C6.55268 5.00039 7.0004 4.55268 7.0004 4.00039V2.00039C7.0004 1.44811 6.55268 1.00039 6.0004 1.00039H4.0004Z" fill="#4682FA"/>
<path d="M4 6.99979C3.44772 6.99979 3 7.44751 3 7.99979V9.99979C3 10.5521 3.44772 10.9998 4 10.9998H6C6.55228 10.9998 7 10.5521 7 9.99979V7.99979C7 7.44751 6.55228 6.99979 6 6.99979H4Z" fill="#4682FA"/>
<path d="M9.00027 8.00027C9.00027 7.44799 9.44799 7.00027 10.0003 7.00027H12.0003C12.5526 7.00027 13.0003 7.44799 13.0003 8.00027V10.0003C13.0003 10.5526 12.5526 11.0003 12.0003 11.0003H10.0003C9.44799 11.0003 9.00027 10.5526 9.00027 10.0003V8.00027Z" fill="#4682FA"/>
</svg>

Після

Ширина:  |  Висота:  |  Розмір: 1.8 KiB

+8
Переглянути файл
@@ -0,0 +1,8 @@
<!-- Copyright 2000-2024 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. -->
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M4.16796 13.2205C5.01982 13.6629 6.31219 14 8 14C9.68781 14 10.9802 13.6629 11.832 13.2205C12.2586 12.999 12.561 12.7582 12.7512 12.5336C12.9429 12.307 13 12.123 13 12H14C14 12.4293 13.8071 12.8339 13.5145 13.1796C13.2202 13.5273 12.8039 13.8425 12.293 14.1079C11.2698 14.6393 9.81219 15 8 15C6.18781 15 4.73018 14.6393 3.70704 14.1079C3.19606 13.8425 2.77979 13.5273 2.48553 13.1796C2.19295 12.8339 2 12.4293 2 12H3C3 12.123 3.05705 12.307 3.24884 12.5336C3.43896 12.7582 3.74144 12.999 4.16796 13.2205Z" fill="#CED0D6"/>
<path d="M11.7071 0.878953C11.3166 0.488429 10.6834 0.488429 10.2929 0.878953L8.87868 2.29317C8.48815 2.68369 8.48815 3.31686 8.87868 3.70738L10.2929 5.12159C10.6834 5.51212 11.3166 5.51212 11.7071 5.12159L13.1213 3.70738C13.5118 3.31686 13.5118 2.68369 13.1213 2.29317L11.7071 0.878953Z" fill="#548AF7"/>
<path d="M4.0004 1.00039C3.44811 1.00039 3.0004 1.44811 3.0004 2.00039V4.00039C3.0004 4.55268 3.44811 5.00039 4.0004 5.00039H6.0004C6.55268 5.00039 7.0004 4.55268 7.0004 4.00039V2.00039C7.0004 1.44811 6.55268 1.00039 6.0004 1.00039H4.0004Z" fill="#548AF7"/>
<path d="M4 6.99979C3.44772 6.99979 3 7.44751 3 7.99979V9.99979C3 10.5521 3.44772 10.9998 4 10.9998H6C6.55228 10.9998 7 10.5521 7 9.99979V7.99979C7 7.44751 6.55228 6.99979 6 6.99979H4Z" fill="#548AF7"/>
<path d="M9.00027 8.00027C9.00027 7.44799 9.44799 7.00027 10.0003 7.00027H12.0003C12.5526 7.00027 13.0003 7.44799 13.0003 8.00027V10.0003C13.0003 10.5526 12.5526 11.0003 12.0003 11.0003H10.0003C9.44799 11.0003 9.00027 10.5526 9.00027 10.0003V8.00027Z" fill="#548AF7"/>
</svg>

Після

Ширина:  |  Висота:  |  Розмір: 1.8 KiB

+6
Переглянути файл
@@ -0,0 +1,6 @@
<!-- Copyright 2000-2024 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. -->
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="2" y="9" width="5" height="5" rx="2.5" fill="#EBECF0" stroke="#6C707E"/>
<rect x="9" y="9" width="5" height="5" rx="2.5" fill="#EBECF0" stroke="#6C707E"/>
<rect x="2" y="2" width="5" height="5" rx="2.5" fill="#EBECF0" stroke="#6C707E"/>
</svg>

Після

Ширина:  |  Висота:  |  Розмір: 476 B

+6
Переглянути файл
@@ -0,0 +1,6 @@
<!-- Copyright 2000-2024 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. -->
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="2" y="9" width="5" height="5" rx="2.5" fill="#43454A" stroke="#CED0D6"/>
<rect x="9" y="9" width="5" height="5" rx="2.5" fill="#43454A" stroke="#CED0D6"/>
<rect x="2" y="2" width="5" height="5" rx="2.5" fill="#43454A" stroke="#CED0D6"/>
</svg>

Після

Ширина:  |  Висота:  |  Розмір: 476 B

+8
Переглянути файл
@@ -0,0 +1,8 @@
<!-- Copyright 2000-2024 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. -->
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M7 9L9 6" stroke="#6C707E"/>
<path d="M7.5 11L10.5 12" stroke="#6C707E"/>
<rect x="2.5" y="8.5" width="5" height="5" rx="1" fill="#EBECF0" stroke="#6C707E"/>
<rect x="10.5347" y="10.502" width="2.99854" height="2.99805" rx="1" fill="#EBECF0" stroke="#6C707E"/>
<rect x="8.53467" y="2.49414" width="4" height="4.00488" rx="1" fill="#EBECF0" stroke="#6C707E"/>
</svg>

Після

Ширина:  |  Висота:  |  Розмір: 598 B

+8
Переглянути файл
@@ -0,0 +1,8 @@
<!-- Copyright 2000-2024 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. -->
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M7 9L9 6" stroke="#CED0D6"/>
<path d="M7.5 11L10.5 12" stroke="#CED0D6"/>
<rect x="2.5" y="8.5" width="5" height="5" rx="0.5" fill="#43454A" stroke="#CED0D6"/>
<rect x="10.5347" y="10.502" width="2.99854" height="2.99805" rx="0.5" fill="#43454A" stroke="#CED0D6"/>
<rect x="8.53467" y="2.49414" width="4" height="4.00488" rx="0.5" fill="#43454A" stroke="#CED0D6"/>
</svg>

Після

Ширина:  |  Висота:  |  Розмір: 604 B

+5
Переглянути файл
@@ -0,0 +1,5 @@
<!-- Copyright 2000-2024 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. -->
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M7.85338 8.85354C8.04863 8.65826 8.04861 8.34168 7.85334 8.14643C7.65807 7.95118 7.34149 7.95119 7.14623 8.14646L3 12.2931V9.5C3 9.22386 2.77614 9 2.5 9C2.22386 9 2 9.22386 2 9.5V13.5C2 13.7761 2.22386 14 2.5 14H6.5C6.77614 14 7 13.7761 7 13.5C7 13.2239 6.77614 13 6.5 13H3.70732L7.85338 8.85354Z" fill="#3574F0"/>
<rect x="9.5" y="2.5" width="4" height="4" rx="0.5" fill="#EBECF0" stroke="#6C707E"/>
</svg>

Після

Ширина:  |  Висота:  |  Розмір: 680 B

+5
Переглянути файл
@@ -0,0 +1,5 @@
<!-- Copyright 2000-2024 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. -->
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M7.85338 8.85354C8.04863 8.65826 8.04861 8.34168 7.85334 8.14643C7.65807 7.95118 7.34149 7.95119 7.14623 8.14646L3 12.2931V9.5C3 9.22386 2.77614 9 2.5 9C2.22386 9 2 9.22386 2 9.5V13.5C2 13.7761 2.22386 14 2.5 14H6.5C6.77614 14 7 13.7761 7 13.5C7 13.2239 6.77614 13 6.5 13H3.70732L7.85338 8.85354Z" fill="#548AF7"/>
<rect x="9.5" y="2.5" width="4" height="4" rx="0.5" fill="#43454A" stroke="#CED0D6"/>
</svg>

Після

Ширина:  |  Висота:  |  Розмір: 680 B

+5
Переглянути файл
@@ -0,0 +1,5 @@
<!-- Copyright 2000-2024 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. -->
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M8.14662 7.14646C7.95137 7.34174 7.95139 7.65832 8.14666 7.85357C8.34193 8.04882 8.65851 8.04881 8.85377 7.85354L13 3.70689L13 6.5C13 6.77614 13.2239 7 13.5 7C13.7761 7 14 6.77614 14 6.5L14 2.5C14 2.22386 13.7761 2 13.5 2L9.5 2C9.22386 2 9 2.22386 9 2.5C9 2.77614 9.22386 3 9.5 3L12.2927 3L8.14662 7.14646Z" fill="#369650"/>
<rect x="6.5" y="13.5" width="4" height="4" rx="0.5" transform="rotate(-180 6.5 13.5)" fill="#EBECF0" stroke="#6C707E"/>
</svg>

Після

Ширина:  |  Висота:  |  Розмір: 725 B

+5
Переглянути файл
@@ -0,0 +1,5 @@
<!-- Copyright 2000-2024 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. -->
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M8.14662 7.14646C7.95137 7.34174 7.95139 7.65832 8.14666 7.85357C8.34193 8.04882 8.65851 8.04881 8.85377 7.85354L13 3.70689L13 6.5C13 6.77614 13.2239 7 13.5 7C13.7761 7 14 6.77614 14 6.5L14 2.5C14 2.22386 13.7761 2 13.5 2L9.5 2C9.22386 2 9 2.22386 9 2.5C9 2.77614 9.22386 3 9.5 3L12.2927 3L8.14662 7.14646Z" fill="#5FAD65"/>
<rect x="6.5" y="13.5" width="4" height="4" rx="0.5" transform="rotate(-180 6.5 13.5)" fill="#43454A" stroke="#CED0D6"/>
</svg>

Після

Ширина:  |  Висота:  |  Розмір: 725 B

+4
Переглянути файл
@@ -0,0 +1,4 @@
<!-- Copyright 2000-2024 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. -->
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="2.5" y="2.5" width="11" height="11" rx="5.5" fill="#EBECF0" stroke="#6C707E"/>
</svg>

Після

Ширина:  |  Висота:  |  Розмір: 318 B

+4
Переглянути файл
@@ -0,0 +1,4 @@
<!-- Copyright 2000-2024 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. -->
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="2.5" y="2.5" width="11" height="11" rx="5.5" fill="#43454A" stroke="#CED0D6"/>
</svg>

Після

Ширина:  |  Висота:  |  Розмір: 318 B

+3
Переглянути файл
@@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4.00015 2.5H12C12.8284 2.5 13.5 3.17157 13.5 4V12C13.5 12.8284 12.8284 13.5 12 13.5H4.00003C3.17159 13.5 2.50002 12.8284 2.50003 12L2.50015 3.99998C2.50016 3.17156 3.17173 2.5 4.00015 2.5Z" fill="#55A76A"/>
</svg>

Після

Ширина:  |  Висота:  |  Розмір: 320 B

+3
Переглянути файл
@@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4.00015 2.5H12C12.8284 2.5 13.5 3.17157 13.5 4V12C13.5 12.8284 12.8284 13.5 12 13.5H4.00003C3.17159 13.5 2.50002 12.8284 2.50003 12L2.50015 3.99998C2.50016 3.17156 3.17173 2.5 4.00015 2.5Z" fill="#5FAD65"/>
</svg>

Після

Ширина:  |  Висота:  |  Розмір: 320 B

+3
Переглянути файл
@@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4.00015 2.5H12C12.8284 2.5 13.5 3.17157 13.5 4V12C13.5 12.8284 12.8284 13.5 12 13.5H4.00003C3.17159 13.5 2.50002 12.8284 2.50003 12L2.50015 3.99998C2.50016 3.17156 3.17173 2.5 4.00015 2.5Z" fill="#8C8F95"/>
</svg>

Після

Ширина:  |  Висота:  |  Розмір: 320 B

+3
Переглянути файл
@@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4.00015 2.5H12C12.8284 2.5 13.5 3.17157 13.5 4V12C13.5 12.8284 12.8284 13.5 12 13.5H4.00003C3.17159 13.5 2.50002 12.8284 2.50003 12L2.50015 3.99998C2.50016 3.17156 3.17173 2.5 4.00015 2.5Z" fill="#6F737A"/>
</svg>

Після

Ширина:  |  Висота:  |  Розмір: 320 B

+4
Переглянути файл
@@ -0,0 +1,4 @@
<!-- Copyright 2000-2024 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. -->
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M2 3.39246H2.01664C2.07865 3.00405 2.30765 2.67483 2.59694 2.41334C2.9371 2.10586 3.40129 1.85189 3.93236 1.64885C4.99792 1.24147 6.43619 1 8 1C9.56381 1 11.0021 1.24147 12.0676 1.64885C12.5987 1.85189 13.0629 2.10586 13.4031 2.41334C13.6924 2.67483 13.9213 3.00405 13.9834 3.39246H14V12.5184H13.9974C13.9695 12.9621 13.7232 13.3344 13.4031 13.6237C13.0629 13.9312 12.5987 14.1852 12.0676 14.3882C11.0021 14.7956 9.56381 15.0371 8 15.0371C6.43619 15.0371 4.99792 14.7956 3.93236 14.3882C3.40129 14.1852 2.9371 13.9312 2.59694 13.6237C2.2768 13.3344 2.03051 12.9621 2.00263 12.5184H2V3.39246ZM3 3.60275C3 3.50293 3.05102 3.35087 3.2675 3.15519C3.48396 2.95953 3.82523 2.7604 4.28947 2.58291C5.21452 2.22925 6.52624 2 8 2C9.47376 2 10.7855 2.22925 11.7105 2.58291C12.1748 2.7604 12.516 2.95953 12.7325 3.15519C12.949 3.35087 13 3.50293 13 3.60275C13 3.70256 12.949 3.85463 12.7325 4.05031C12.516 4.24597 12.1748 4.4451 11.7105 4.62258C10.7855 4.97625 9.47376 5.2055 8 5.2055C6.52624 5.2055 5.21452 4.97625 4.28947 4.62258C3.82523 4.4451 3.48396 4.24597 3.2675 4.05031C3.05102 3.85463 3 3.70256 3 3.60275ZM3 6.54663V5.09723C3.27236 5.27221 3.58833 5.42512 3.93236 5.55665C4.99792 5.96403 6.43619 6.2055 8 6.2055C9.56381 6.2055 11.0021 5.96403 12.0676 5.55665C12.4117 5.42512 12.7276 5.27221 13 5.09723V6.54663C13 6.64645 12.949 6.79851 12.7325 6.99419C12.516 7.18985 12.1748 7.38898 11.7105 7.56647C10.7855 7.92013 9.47376 8.14938 8 8.14938C6.52624 8.14938 5.21452 7.92013 4.28947 7.56647C3.82523 7.38898 3.48396 7.18985 3.2675 6.99419C3.05102 6.79851 3 6.64645 3 6.54663ZM3 8.04112V9.49048C3 9.59029 3.05102 9.74236 3.2675 9.93804C3.48396 10.1337 3.82523 10.3328 4.28947 10.5103C5.21452 10.864 6.52624 11.0932 8 11.0932C9.47376 11.0932 10.7855 10.864 11.7105 10.5103C12.1748 10.3328 12.516 10.1337 12.7325 9.93804C12.949 9.74236 13 9.59029 13 9.49048V8.04112C12.7276 8.21609 12.4117 8.369 12.0676 8.50053C11.0021 8.90791 9.56381 9.14938 8 9.14938C6.43619 9.14938 4.99792 8.90791 3.93236 8.50053C3.58833 8.369 3.27236 8.21609 3 8.04112ZM3 10.985V12.4343C3 12.5341 3.05102 12.6862 3.2675 12.8819C3.48396 13.0775 3.82523 13.2767 4.28947 13.4542C5.21452 13.8078 6.52624 14.0371 8 14.0371C9.47376 14.0371 10.7855 13.8078 11.7105 13.4542C12.1748 13.2767 12.516 13.0775 12.7325 12.8819C12.949 12.6862 13 12.5341 13 12.4343V10.985C12.7276 11.1599 12.4117 11.3128 12.0676 11.4444C11.0021 11.8518 9.56381 12.0932 8 12.0932C6.43619 12.0932 4.99792 11.8518 3.93236 11.4444C3.58833 11.3128 3.27236 11.1599 3 10.985Z" fill="#6C707E"/>
</svg>

Після

Ширина:  |  Висота:  |  Розмір: 2.7 KiB

+4
Переглянути файл
@@ -0,0 +1,4 @@
<!-- Copyright 2000-2024 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. -->
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M2 3.39246H2.01664C2.07865 3.00405 2.30765 2.67483 2.59694 2.41334C2.9371 2.10586 3.40129 1.85189 3.93236 1.64885C4.99792 1.24147 6.43619 1 8 1C9.56381 1 11.0021 1.24147 12.0676 1.64885C12.5987 1.85189 13.0629 2.10586 13.4031 2.41334C13.6924 2.67483 13.9213 3.00405 13.9834 3.39246H14V12.5184H13.9974C13.9695 12.9621 13.7232 13.3344 13.4031 13.6237C13.0629 13.9312 12.5987 14.1852 12.0676 14.3882C11.0021 14.7956 9.56381 15.0371 8 15.0371C6.43619 15.0371 4.99792 14.7956 3.93236 14.3882C3.40129 14.1852 2.9371 13.9312 2.59694 13.6237C2.2768 13.3344 2.03051 12.9621 2.00263 12.5184H2V3.39246ZM3 3.60275C3 3.50293 3.05102 3.35087 3.2675 3.15519C3.48396 2.95953 3.82523 2.7604 4.28947 2.58291C5.21452 2.22925 6.52624 2 8 2C9.47376 2 10.7855 2.22925 11.7105 2.58291C12.1748 2.7604 12.516 2.95953 12.7325 3.15519C12.949 3.35087 13 3.50293 13 3.60275C13 3.70256 12.949 3.85463 12.7325 4.05031C12.516 4.24597 12.1748 4.4451 11.7105 4.62258C10.7855 4.97625 9.47376 5.2055 8 5.2055C6.52624 5.2055 5.21452 4.97625 4.28947 4.62258C3.82523 4.4451 3.48396 4.24597 3.2675 4.05031C3.05102 3.85463 3 3.70256 3 3.60275ZM3 6.54663V5.09723C3.27236 5.27221 3.58833 5.42512 3.93236 5.55665C4.99792 5.96403 6.43619 6.2055 8 6.2055C9.56381 6.2055 11.0021 5.96403 12.0676 5.55665C12.4117 5.42512 12.7276 5.27221 13 5.09723V6.54663C13 6.64645 12.949 6.79851 12.7325 6.99419C12.516 7.18985 12.1748 7.38898 11.7105 7.56647C10.7855 7.92013 9.47376 8.14938 8 8.14938C6.52624 8.14938 5.21452 7.92013 4.28947 7.56647C3.82523 7.38898 3.48396 7.18985 3.2675 6.99419C3.05102 6.79851 3 6.64645 3 6.54663ZM3 8.04112V9.49048C3 9.59029 3.05102 9.74236 3.2675 9.93804C3.48396 10.1337 3.82523 10.3328 4.28947 10.5103C5.21452 10.864 6.52624 11.0932 8 11.0932C9.47376 11.0932 10.7855 10.864 11.7105 10.5103C12.1748 10.3328 12.516 10.1337 12.7325 9.93804C12.949 9.74236 13 9.59029 13 9.49048V8.04112C12.7276 8.21609 12.4117 8.369 12.0676 8.50053C11.0021 8.90791 9.56381 9.14938 8 9.14938C6.43619 9.14938 4.99792 8.90791 3.93236 8.50053C3.58833 8.369 3.27236 8.21609 3 8.04112ZM3 10.985V12.4343C3 12.5341 3.05102 12.6862 3.2675 12.8819C3.48396 13.0775 3.82523 13.2767 4.28947 13.4542C5.21452 13.8078 6.52624 14.0371 8 14.0371C9.47376 14.0371 10.7855 13.8078 11.7105 13.4542C12.1748 13.2767 12.516 13.0775 12.7325 12.8819C12.949 12.6862 13 12.5341 13 12.4343V10.985C12.7276 11.1599 12.4117 11.3128 12.0676 11.4444C11.0021 11.8518 9.56381 12.0932 8 12.0932C6.43619 12.0932 4.99792 11.8518 3.93236 11.4444C3.58833 11.3128 3.27236 11.1599 3 10.985Z" fill="#CED0D6"/>
</svg>

Після

Ширина:  |  Висота:  |  Розмір: 2.7 KiB

+16
Переглянути файл
@@ -151,6 +151,22 @@ const ICONS = {
"file-latex": "openidea/fileTypes/latex",
"file-rst": "fileTypes/rst",
"file-docker": "fileTypes/docker",
"docker-connected": "docker/dockerConnected",
"docker-compose-running": "docker/containersRunning",
"docker-compose-stopped": "docker/composeProjectStopped",
"docker-compose-service-running": "docker/dockerComposeServiceUp",
"docker-compose-service-stopped": "docker/dockerComposeServiceStopped",
"docker-devcontainer": "docker/devContainers",
"docker-containers": "docker/containersList",
"docker-images": "docker/imagesList",
"docker-networks": "docker/network",
"docker-volumes": "docker/volume",
"docker-container-running": "docker/singleRunningContainer",
"docker-container-stopped": "docker/singleStoppedContainer",
"docker-image": "docker/singleImage",
"docker-registry": "docker/dockerRegistry",
"docker-pull": "docker/pullImage",
"docker-push": "docker/pushImage",
"file-makefile": "fileTypes/makefile",
"file-cmake": "openidea/fileTypes/cmake",
"file-bazel": "fileTypes/bazel",