Bootstrap saved state from ProxiFyre config
This commit is contained in:
@@ -47,6 +47,7 @@ ProxyWarden - standalone Windows desktop client в корне репозитор
|
||||
- Для service/install операций сохранять UAC/admin boundary и человекочитаемые ошибки.
|
||||
- При удалении install folders сохранять safe-path checks; не ослаблять рекурсивное удаление.
|
||||
- В UI держать стиль компактной Windows-утилиты, а не landing/dashboard. Использовать existing `Button`, `Tabs`, `ServiceControlRow`, `StatusPill`, `Field`, `ActionMenu`, `LogDock`.
|
||||
- Всплывающие подсказки при наведении делать быстрыми, кастомными и читаемыми: темная compact-плашка с мягкой рамкой/тенью, появление ~120ms, без нативного browser `title` как основного UI. Для иконок расширять общий `IconButton`/tooltip-паттерн, а не дублировать JSX/CSS локально.
|
||||
- Apply actions должны быть disabled с объяснением, когда нет приложений, ProxiFyre отсутствует, proxy input неверный или local route не готов.
|
||||
|
||||
## Проверка
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
#[cfg(not(test))]
|
||||
use crate::adapters::proxifyre::ProxiFyreAdapter;
|
||||
use crate::adapters::proxifyre::{ProxiFyreAdapter, ProxiFyreConfig, ProxiFyreProxy};
|
||||
#[cfg(not(test))]
|
||||
use crate::adapters::proxy_router::{
|
||||
ProxyRouterAdapter, ProxyRouterError, ProxyRouterErrorKind, ProxyRouterGeneratedConfig,
|
||||
@@ -21,7 +21,7 @@ use crate::models::{
|
||||
SubscriptionCache, SubscriptionServer, Target, TargetInput, TargetKind,
|
||||
};
|
||||
#[cfg(test)]
|
||||
use crate::proxifyre::ProxiFyreAdapter;
|
||||
use crate::proxifyre::{ProxiFyreAdapter, ProxiFyreConfig, ProxiFyreProxy};
|
||||
#[cfg(test)]
|
||||
use crate::proxy_router::{
|
||||
ProxyRouterAdapter, ProxyRouterError, ProxyRouterErrorKind, ProxyRouterGeneratedConfig,
|
||||
@@ -50,6 +50,8 @@ use std::time::{Duration, Instant};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
const PROXIFYRE_INSTALL_DIR: &str = r"C:\Tools\ProxiFyre";
|
||||
const MAIN_PROFILE_ID: &str = "main-profile";
|
||||
const MAIN_TARGET_ID: &str = "main-proxy";
|
||||
const PROXIFYRE_RELEASE_API_URL: &str =
|
||||
"https://api.github.com/repos/wiresock/proxifyre/releases/latest";
|
||||
const NDISAPI_RELEASE_API_URL: &str =
|
||||
@@ -627,7 +629,9 @@ pub fn ping_all_singbox_servers(
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn ping_proxy_target(input: PingProxyTargetInputDto) -> Result<PingServerResponse, CommandError> {
|
||||
pub fn ping_proxy_target(
|
||||
input: PingProxyTargetInputDto,
|
||||
) -> Result<PingServerResponse, CommandError> {
|
||||
ping_proxy_target_endpoint(input)
|
||||
}
|
||||
|
||||
@@ -744,7 +748,9 @@ pub async fn start_singbox_service(
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn stop_singbox_service() -> Result<ComponentStatusDto, CommandError> {
|
||||
tauri::async_runtime::spawn_blocking(|| control_singbox_service(SingBoxServiceAction::Stop, None))
|
||||
tauri::async_runtime::spawn_blocking(|| {
|
||||
control_singbox_service(SingBoxServiceAction::Stop, None)
|
||||
})
|
||||
.await
|
||||
.map_err(background_task_error)?
|
||||
}
|
||||
@@ -869,9 +875,31 @@ pub fn read_activity(storage: &JsonStorage) -> Result<Vec<ActivityEntryDto>, Com
|
||||
}
|
||||
|
||||
pub fn read_saved_state(storage: &JsonStorage) -> Result<SavedStateResponse, CommandError> {
|
||||
let detected_config_path = detect_proxyfier_install().and_then(|detected| detected.config_path);
|
||||
read_saved_state_with_proxifyre_config(storage, detected_config_path.as_deref())
|
||||
}
|
||||
|
||||
pub fn read_saved_state_with_proxifyre_config(
|
||||
storage: &JsonStorage,
|
||||
proxifyre_config_path: Option<&Path>,
|
||||
) -> Result<SavedStateResponse, CommandError> {
|
||||
let mut profiles = storage.read_profiles().map_err(storage_error)?;
|
||||
let mut targets = storage.read_targets().map_err(storage_error)?;
|
||||
|
||||
if should_bootstrap_profiles(&profiles) {
|
||||
if let Some(imported) =
|
||||
proxifyre_config_path.and_then(import_saved_state_from_proxifyre_config)
|
||||
{
|
||||
profiles = imported.profiles;
|
||||
upsert_targets(&mut targets, imported.targets);
|
||||
storage.write_targets(&targets).map_err(storage_error)?;
|
||||
storage.write_profiles(&profiles).map_err(storage_error)?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(SavedStateResponse {
|
||||
profiles: read_profiles(storage)?,
|
||||
targets: read_targets(storage)?,
|
||||
profiles: profiles.iter().map(ProfileDto::from).collect(),
|
||||
targets: targets.iter().map(TargetDto::from).collect(),
|
||||
generated_config_path: storage
|
||||
.paths()
|
||||
.generated_dir
|
||||
@@ -881,6 +909,198 @@ pub fn read_saved_state(storage: &JsonStorage) -> Result<SavedStateResponse, Com
|
||||
})
|
||||
}
|
||||
|
||||
struct ImportedSavedState {
|
||||
profiles: Vec<Profile>,
|
||||
targets: Vec<Target>,
|
||||
}
|
||||
|
||||
fn should_bootstrap_profiles(profiles: &[Profile]) -> bool {
|
||||
!profiles
|
||||
.iter()
|
||||
.any(|profile| profile.enabled && !profile.items.is_empty())
|
||||
}
|
||||
|
||||
fn import_saved_state_from_proxifyre_config(path: &Path) -> Option<ImportedSavedState> {
|
||||
let contents = fs::read_to_string(path).ok()?;
|
||||
let config: ProxiFyreConfig = serde_json::from_str(&contents).ok()?;
|
||||
|
||||
let proxy_entries = config
|
||||
.proxies
|
||||
.iter()
|
||||
.filter_map(import_proxy_entry)
|
||||
.collect::<Vec<_>>();
|
||||
if proxy_entries.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let single_entry = proxy_entries.len() == 1;
|
||||
let mut profiles = Vec::with_capacity(proxy_entries.len());
|
||||
let mut targets = Vec::with_capacity(proxy_entries.len());
|
||||
|
||||
for (index, entry) in proxy_entries.into_iter().enumerate() {
|
||||
let ordinal = index + 1;
|
||||
let target_id = if single_entry {
|
||||
MAIN_TARGET_ID.to_string()
|
||||
} else {
|
||||
format!("proxifyre-import-target-{ordinal}")
|
||||
};
|
||||
let profile_id = if single_entry {
|
||||
MAIN_PROFILE_ID.to_string()
|
||||
} else {
|
||||
format!("proxifyre-import-profile-{ordinal}")
|
||||
};
|
||||
let profile_name = if single_entry {
|
||||
"Приложения через прокси".to_string()
|
||||
} else {
|
||||
format!("Импорт ProxiFyre {ordinal}")
|
||||
};
|
||||
|
||||
targets.push(Target {
|
||||
id: target_id.clone(),
|
||||
name: if single_entry {
|
||||
"Основной прокси".to_string()
|
||||
} else {
|
||||
format!("Прокси ProxiFyre {ordinal}")
|
||||
},
|
||||
kind: TargetKind::External,
|
||||
protocol: ProxyProtocol::Socks5,
|
||||
host: entry.host,
|
||||
port: entry.port,
|
||||
requires_component: None,
|
||||
});
|
||||
profiles.push(Profile {
|
||||
id: profile_id,
|
||||
name: profile_name,
|
||||
enabled: true,
|
||||
target_id,
|
||||
protocols: entry.protocols,
|
||||
items: entry.items,
|
||||
});
|
||||
}
|
||||
|
||||
Some(ImportedSavedState { profiles, targets })
|
||||
}
|
||||
|
||||
struct ImportedProxyEntry {
|
||||
items: Vec<ProfileItem>,
|
||||
protocols: Vec<Protocol>,
|
||||
host: String,
|
||||
port: u16,
|
||||
}
|
||||
|
||||
fn import_proxy_entry(proxy: &ProxiFyreProxy) -> Option<ImportedProxyEntry> {
|
||||
let items = proxy
|
||||
.app_names
|
||||
.iter()
|
||||
.filter_map(|name| imported_profile_item(name))
|
||||
.collect::<Vec<_>>();
|
||||
if items.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let (host, port) = parse_socks5_endpoint(&proxy.socks5_proxy_endpoint)?;
|
||||
|
||||
Some(ImportedProxyEntry {
|
||||
items,
|
||||
protocols: imported_protocols(&proxy.supported_protocols),
|
||||
host,
|
||||
port,
|
||||
})
|
||||
}
|
||||
|
||||
fn imported_profile_item(raw_value: &str) -> Option<ProfileItem> {
|
||||
let value = raw_value.trim().trim_matches('"');
|
||||
if value.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let looks_like_path = value.contains('\\') || value.contains('/');
|
||||
let item_type = if looks_like_path && value.to_ascii_lowercase().ends_with(".exe") {
|
||||
ProfileItemType::Exe
|
||||
} else if looks_like_path {
|
||||
ProfileItemType::Folder
|
||||
} else {
|
||||
ProfileItemType::Process
|
||||
};
|
||||
let value = match item_type {
|
||||
ProfileItemType::Process => {
|
||||
let base = value.rsplit(['\\', '/']).next().unwrap_or(value);
|
||||
if base.to_ascii_lowercase().ends_with(".exe") {
|
||||
base[..base.len() - 4].to_string()
|
||||
} else {
|
||||
base.to_string()
|
||||
}
|
||||
}
|
||||
ProfileItemType::Folder | ProfileItemType::Exe => value.to_string(),
|
||||
};
|
||||
|
||||
if value.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(ProfileItem {
|
||||
recursive: matches!(item_type, ProfileItemType::Folder),
|
||||
item_type,
|
||||
value,
|
||||
})
|
||||
}
|
||||
|
||||
fn imported_protocols(values: &[String]) -> Vec<Protocol> {
|
||||
let mut protocols = Vec::new();
|
||||
for value in values {
|
||||
let protocol = match value.trim().to_ascii_uppercase().as_str() {
|
||||
"TCP" => Protocol::Tcp,
|
||||
"UDP" => Protocol::Udp,
|
||||
_ => continue,
|
||||
};
|
||||
if !protocols.contains(&protocol) {
|
||||
protocols.push(protocol);
|
||||
}
|
||||
}
|
||||
|
||||
if protocols.is_empty() {
|
||||
vec![Protocol::Tcp, Protocol::Udp]
|
||||
} else {
|
||||
protocols
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_socks5_endpoint(endpoint: &str) -> Option<(String, u16)> {
|
||||
let endpoint = endpoint.trim();
|
||||
let endpoint = if endpoint
|
||||
.get(.."socks5://".len())
|
||||
.is_some_and(|prefix| prefix.eq_ignore_ascii_case("socks5://"))
|
||||
{
|
||||
&endpoint["socks5://".len()..]
|
||||
} else {
|
||||
endpoint
|
||||
};
|
||||
if endpoint.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
if let Some(rest) = endpoint.strip_prefix('[') {
|
||||
let (host, rest) = rest.split_once(']')?;
|
||||
let port = rest.strip_prefix(':')?.parse::<u16>().ok()?;
|
||||
let host = host.trim();
|
||||
return (!host.is_empty()).then(|| (host.to_string(), port));
|
||||
}
|
||||
|
||||
let (host, port) = endpoint.rsplit_once(':')?;
|
||||
let host = host.trim();
|
||||
let port = port.trim().parse::<u16>().ok()?;
|
||||
(!host.is_empty()).then(|| (host.to_string(), port))
|
||||
}
|
||||
|
||||
fn upsert_targets(targets: &mut Vec<Target>, imported_targets: Vec<Target>) {
|
||||
for target in imported_targets {
|
||||
match targets.iter().position(|existing| existing.id == target.id) {
|
||||
Some(index) => targets[index] = target,
|
||||
None => targets.push(target),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn resolve_preview(
|
||||
input: ProfileInputDto,
|
||||
) -> Result<ResolveProfilePreviewResponse, CommandError> {
|
||||
@@ -1510,7 +1730,8 @@ fn write_elevated_singbox_service_script(
|
||||
.map(|duration| duration.as_millis())
|
||||
.unwrap_or(0);
|
||||
let script_path = env::temp_dir().join(format!("proxywarden-singbox-service-{nonce}.ps1"));
|
||||
let script = elevated_singbox_service_script(action, service_name, config_source, config_target);
|
||||
let script =
|
||||
elevated_singbox_service_script(action, service_name, config_source, config_target);
|
||||
|
||||
write_powershell_script(&script_path, &script).map_err(|error| {
|
||||
CommandError::new(
|
||||
|
||||
@@ -22,10 +22,10 @@ mod subscription;
|
||||
mod validation;
|
||||
|
||||
use commands::{
|
||||
apply_profiles_with_services, build_status, resolve_component_statuses, resolve_preview,
|
||||
save_profile_to_storage, save_target_to_storage, Clock, CommandError, DetectedProxyApplyHelper,
|
||||
HelperApplyRequest, HelperApplyResult, ProfileInputDto, ProfileItemInputDto, ProxyApplyHelper,
|
||||
TargetInputDto,
|
||||
apply_profiles_with_services, build_status, read_saved_state_with_proxifyre_config,
|
||||
resolve_component_statuses, resolve_preview, save_profile_to_storage, save_target_to_storage,
|
||||
Clock, CommandError, DetectedProxyApplyHelper, HelperApplyRequest, HelperApplyResult,
|
||||
ProfileInputDto, ProfileItemInputDto, ProxyApplyHelper, TargetInputDto,
|
||||
};
|
||||
use component_detection::{
|
||||
DetectedProxyfier, ProxyfierDetectionHost, ProxyfierEngine, RegistryInstallEntry,
|
||||
@@ -95,6 +95,95 @@ fn save_commands_normalize_and_persist_profile_and_target() {
|
||||
cleanup(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saved_state_bootstraps_from_existing_proxifyre_app_config() {
|
||||
let root = test_root("proxifyre-config-import");
|
||||
let storage = JsonStorage::new(root.clone());
|
||||
let install_dir = root.join("ProxiFyre");
|
||||
let config_path = install_dir.join("app-config.json");
|
||||
fs::create_dir_all(&install_dir).expect("create proxifyre dir");
|
||||
fs::write(
|
||||
&config_path,
|
||||
r#"{
|
||||
"logLevel": "Info",
|
||||
"bypassLan": true,
|
||||
"proxies": [
|
||||
{
|
||||
"appNames": ["Discord.exe", "C:\\Games\\Launcher.exe"],
|
||||
"socks5ProxyEndpoint": "127.0.0.1:1090",
|
||||
"supportedProtocols": ["TCP", "UDP"]
|
||||
}
|
||||
]
|
||||
}"#,
|
||||
)
|
||||
.expect("write proxifyre config");
|
||||
|
||||
let state = read_saved_state_with_proxifyre_config(&storage, Some(&config_path))
|
||||
.expect("state should import proxifyre app config");
|
||||
|
||||
assert_eq!(state.profiles.len(), 1);
|
||||
assert_eq!(state.targets.len(), 1);
|
||||
assert_eq!(state.profiles[0].id, "main-profile");
|
||||
assert_eq!(state.profiles[0].target_id, "main-proxy");
|
||||
assert_eq!(state.profiles[0].items.len(), 2);
|
||||
assert_eq!(
|
||||
state.profiles[0].items[0].item_type,
|
||||
ProfileItemType::Process
|
||||
);
|
||||
assert_eq!(state.profiles[0].items[0].value, "Discord");
|
||||
assert_eq!(state.profiles[0].items[1].item_type, ProfileItemType::Exe);
|
||||
assert_eq!(state.profiles[0].items[1].value, r"C:\Games\Launcher.exe");
|
||||
assert_eq!(state.targets[0].id, "main-proxy");
|
||||
assert_eq!(state.targets[0].host, "127.0.0.1");
|
||||
assert_eq!(state.targets[0].port, 1090);
|
||||
|
||||
let persisted = storage.read_profiles().expect("read persisted profiles");
|
||||
assert_eq!(persisted.len(), 1);
|
||||
assert_eq!(persisted[0].items.len(), 2);
|
||||
|
||||
cleanup(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saved_state_keeps_existing_proxywarden_profiles_over_proxifyre_config() {
|
||||
let root = test_root("proxifyre-config-keeps-state");
|
||||
let storage = JsonStorage::new(root.clone());
|
||||
let install_dir = root.join("ProxiFyre");
|
||||
let config_path = install_dir.join("app-config.json");
|
||||
fs::create_dir_all(&install_dir).expect("create proxifyre dir");
|
||||
fs::write(
|
||||
&config_path,
|
||||
r#"{
|
||||
"logLevel": "Info",
|
||||
"bypassLan": true,
|
||||
"proxies": [
|
||||
{
|
||||
"appNames": ["Telegram.exe"],
|
||||
"socks5ProxyEndpoint": "127.0.0.1:1091",
|
||||
"supportedProtocols": ["TCP"]
|
||||
}
|
||||
]
|
||||
}"#,
|
||||
)
|
||||
.expect("write proxifyre config");
|
||||
storage
|
||||
.write_profiles(&[discord_profile("home-gateway")])
|
||||
.expect("write profiles");
|
||||
storage
|
||||
.write_targets(&[external_socks5_target()])
|
||||
.expect("write targets");
|
||||
|
||||
let state = read_saved_state_with_proxifyre_config(&storage, Some(&config_path))
|
||||
.expect("state should keep proxywarden storage");
|
||||
|
||||
assert_eq!(state.profiles.len(), 1);
|
||||
assert_eq!(state.profiles[0].id, "discord");
|
||||
assert_eq!(state.profiles[0].items[0].value, "Discord");
|
||||
assert_eq!(state.targets[0].id, "home-gateway");
|
||||
|
||||
cleanup(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_preview_returns_structured_apps_without_filesystem_scan() {
|
||||
let preview = resolve_preview(ProfileInputDto {
|
||||
@@ -194,9 +283,14 @@ fn singbox_runner_preserves_installer_args_with_spaces() {
|
||||
],
|
||||
);
|
||||
|
||||
assert!(script.contains("$installerArgs = @('-InstallRoot', 'C:\\Program Files\\ProxyWarden\\sing-box'"));
|
||||
assert!(script.contains("& powershell.exe -NoProfile -ExecutionPolicy Bypass -File $installerPath @installerArgs"));
|
||||
assert!(!script.contains("Start-Process -FilePath 'powershell.exe' -ArgumentList $argumentList"));
|
||||
assert!(script
|
||||
.contains("$installerArgs = @('-InstallRoot', 'C:\\Program Files\\ProxyWarden\\sing-box'"));
|
||||
assert!(script.contains(
|
||||
"& powershell.exe -NoProfile -ExecutionPolicy Bypass -File $installerPath @installerArgs"
|
||||
));
|
||||
assert!(
|
||||
!script.contains("Start-Process -FilePath 'powershell.exe' -ArgumentList $argumentList")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1050,7 +1050,7 @@ export function App() {
|
||||
<span>{items.length}</span>
|
||||
</div>
|
||||
<div className="apps-config-actions">
|
||||
{renderApplyActions('proxifyre', { showConfigPath: false })}
|
||||
{renderApplyActions('proxifyre', { showConfigPath: false, showState: false })}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1158,9 +1158,10 @@ export function App() {
|
||||
|
||||
function renderApplyActions(
|
||||
context: 'proxifyre' | 'proxy',
|
||||
options: { showConfigPath?: boolean } = {},
|
||||
options: { showConfigPath?: boolean; showState?: boolean } = {},
|
||||
) {
|
||||
const showConfigPath = options.showConfigPath ?? true;
|
||||
const showState = options.showState ?? true;
|
||||
const externalProxyError = routeMode === 'external' ? safeProxyError(proxyInput) : null;
|
||||
const readiness = getApplyReadiness({
|
||||
routeMode,
|
||||
@@ -1173,15 +1174,18 @@ export function App() {
|
||||
busy: isApplying || Boolean(serviceAction) || Boolean(singBoxAction),
|
||||
});
|
||||
const showBlocker = !readiness.ready && !isLoading && !isDetectingComponents;
|
||||
const disabledReason = !readiness.ready && readiness.title && readiness.text
|
||||
? `${readiness.title}. ${readiness.text}`
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<>
|
||||
{showBlocker ? (
|
||||
{showState && showBlocker ? (
|
||||
<div className="apply-state blocked" role="status">
|
||||
<strong>{readiness.title}</strong>
|
||||
<span>{readiness.text}</span>
|
||||
</div>
|
||||
) : hasUnappliedChanges ? (
|
||||
) : showState && hasUnappliedChanges ? (
|
||||
<div className="apply-state pending" role="status">
|
||||
<strong>Изменения еще не применены в ProxiFyre</strong>
|
||||
<span>{applyStateText(routeMode, isSingBoxInstalled, Boolean(singbox?.running))}</span>
|
||||
@@ -1197,6 +1201,7 @@ export function App() {
|
||||
disabled={!readiness.ready}
|
||||
loading={isApplying}
|
||||
loadingLabel={applyButtonLabel(context, true, hasUnappliedChanges, singBoxAction)}
|
||||
title={disabledReason}
|
||||
>
|
||||
{applyButtonLabel(context, isApplying, hasUnappliedChanges, singBoxAction)}
|
||||
</Button>
|
||||
|
||||
@@ -188,9 +188,11 @@ button:disabled {
|
||||
}
|
||||
|
||||
.ui-icon-button {
|
||||
position: relative;
|
||||
width: 42px;
|
||||
min-width: 42px;
|
||||
min-height: 38px;
|
||||
overflow: visible;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
@@ -1889,6 +1891,8 @@ button.summary-card:hover {
|
||||
background: #182033;
|
||||
}
|
||||
|
||||
.ui-icon-button[data-tooltip]::before,
|
||||
.ui-icon-button[data-tooltip]::after,
|
||||
.add-tile[data-tooltip]::before,
|
||||
.add-tile[data-tooltip]::after {
|
||||
position: absolute;
|
||||
@@ -1896,10 +1900,13 @@ button.summary-card:hover {
|
||||
z-index: 30;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
transition: opacity 90ms ease, transform 120ms ease;
|
||||
transition:
|
||||
opacity 90ms var(--ease-out),
|
||||
transform var(--motion-fast) var(--ease-out);
|
||||
transition-delay: 55ms;
|
||||
}
|
||||
|
||||
.ui-icon-button[data-tooltip]::before,
|
||||
.add-tile[data-tooltip]::before {
|
||||
bottom: calc(100% + 3px);
|
||||
width: 8px;
|
||||
@@ -1911,6 +1918,7 @@ button.summary-card:hover {
|
||||
transform: translate(-50%, 2px) rotate(45deg);
|
||||
}
|
||||
|
||||
.ui-icon-button[data-tooltip]::after,
|
||||
.add-tile[data-tooltip]::after {
|
||||
bottom: calc(100% + 8px);
|
||||
border: 1px solid #334155;
|
||||
@@ -1927,6 +1935,10 @@ button.summary-card:hover {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ui-icon-button[data-tooltip]:hover::before,
|
||||
.ui-icon-button[data-tooltip]:hover::after,
|
||||
.ui-icon-button[data-tooltip]:focus-visible::before,
|
||||
.ui-icon-button[data-tooltip]:focus-visible::after,
|
||||
.add-tile[data-tooltip]:hover::before,
|
||||
.add-tile[data-tooltip]:hover::after,
|
||||
.add-tile[data-tooltip]:focus-visible::before,
|
||||
@@ -1934,11 +1946,15 @@ button.summary-card:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.ui-icon-button[data-tooltip]:hover::before,
|
||||
.ui-icon-button[data-tooltip]:focus-visible::before,
|
||||
.add-tile[data-tooltip]:hover::before,
|
||||
.add-tile[data-tooltip]:focus-visible::before {
|
||||
transform: translate(-50%, 0) rotate(45deg);
|
||||
}
|
||||
|
||||
.ui-icon-button[data-tooltip]:hover::after,
|
||||
.ui-icon-button[data-tooltip]:focus-visible::after,
|
||||
.add-tile[data-tooltip]:hover::after,
|
||||
.add-tile[data-tooltip]:focus-visible::after {
|
||||
transform: translate(-50%, 0);
|
||||
@@ -2058,8 +2074,7 @@ button.summary-card:hover {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.app-row,
|
||||
.empty-state {
|
||||
.app-row {
|
||||
border: 1px solid #2b3342;
|
||||
border-radius: 4px;
|
||||
background: #131720;
|
||||
@@ -2072,7 +2087,8 @@ button.summary-card:hover {
|
||||
|
||||
.empty-state {
|
||||
color: #8d99ae;
|
||||
min-height: 48px;
|
||||
min-height: 30px;
|
||||
padding: 6px 2px 2px;
|
||||
}
|
||||
|
||||
.list-skeleton {
|
||||
@@ -2551,6 +2567,7 @@ button.summary-card:hover {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.add-toolbar .ui-icon-button,
|
||||
.add-tile {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ export function IconButton({
|
||||
title,
|
||||
...props
|
||||
}: IconButtonProps) {
|
||||
const tooltip = title === '' ? undefined : title ?? label;
|
||||
const classes = [
|
||||
'ui-icon-button',
|
||||
`ui-icon-button--${variant}`,
|
||||
@@ -32,7 +33,7 @@ export function IconButton({
|
||||
type={props.type ?? 'button'}
|
||||
className={classes}
|
||||
aria-label={label}
|
||||
title={title ?? label}
|
||||
data-tooltip={tooltip}
|
||||
disabled={disabled || loading}
|
||||
>
|
||||
{loading ? <span className="ui-button-spinner" aria-hidden="true" /> : icon}
|
||||
|
||||
Reference in New Issue
Block a user