feat: tabs, state management

This commit is contained in:
Artemy Egorov 2024-07-29 22:14:05 +03:00
parent 9dfd66c14a
commit 107a8245e4
12 changed files with 414 additions and 51 deletions

View file

@ -14,8 +14,21 @@ Browser is WIP.
See also: [Dalet](https://github.com/TxtDot/dalet). See also: [Dalet](https://github.com/TxtDot/dalet).
Format support:
- [ ] Dalet support - [ ] Dalet support
- [x] Text support - [x] Text support
- [ ] Gemtext support - [ ] Gemtext support
- [ ] Proxy support - [ ] Proxy support
- [ ] Local txtdot engines support - [ ] Local txtdot engines support
Browser features:
- [x] input url processing
- [x] tab window hiding
- [x] tabs
- [x] tab switching
- [x] tab closing
- [x] save and restore tabs
- [ ] save and restore favorites
- [ ] cache tab data

View file

@ -2,8 +2,7 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
use std::{ use std::{
fs::{self, File}, fs::{self},
io::Write,
sync::Mutex, sync::Mutex,
}; };
@ -13,30 +12,106 @@ mod types;
mod utils; mod utils;
use tauri::Manager; use tauri::Manager;
use types::{State, VigiError}; use types::{TabType, VigiError, VigiState};
use utils::{read_jsonl_tabs, read_or_create_current_tab_index, read_or_create_jsonl}; use utils::{read_or_create_jsonl, read_or_create_number};
// Learn more about Tauri commands at https://tauri.app/v1/guides/features/command // Learn more about Tauri commands at https://tauri.app/v1/guides/features/command
#[tauri::command] #[tauri::command]
async fn process_input(input: String) -> Result<Vec<Tag>, VigiError> { async fn process_input(
input: String,
state: tauri::State<'_, Mutex<VigiState>>,
) -> Result<Vec<Tag>, VigiError> {
// TODO: Implement mime type, language, protocol or search detection // TODO: Implement mime type, language, protocol or search detection
// TODO: Implement text links parsing // TODO: Implement text links parsing
match reqwest::get(input).await { println!("Processing: {}", input);
match reqwest::get(input.clone()).await {
Ok(res) => match res.text().await { Ok(res) => match res.text().await {
Ok(res) => Ok(vec![Tag::new(0, Body::Text(res), Argument::Null)]), Ok(res) => {
Err(_) => Err(VigiError::ParseError), update_tab(state, TabType::Text, res.clone(), input.clone())?;
Ok(vec![Tag::new(0, Body::Text(res), Argument::Null)])
}
Err(_) => Err(VigiError::Parse),
}, },
Err(_) => Err(VigiError::NetworkError), Err(_) => Err(VigiError::Network),
} }
} }
#[tauri::command]
fn get_state(state: tauri::State<Mutex<VigiState>>) -> VigiState {
(*state.lock().unwrap()).clone()
}
#[tauri::command]
fn select_tab(state: tauri::State<Mutex<VigiState>>, index: usize) -> Result<(), VigiError> {
match state.lock() {
Ok(mut state) => {
state.update_current_tab_index(index)?;
Ok(())
}
Err(_) => Err(VigiError::StateLock),
}
}
#[tauri::command]
fn add_tab(state: tauri::State<Mutex<VigiState>>) -> Result<(), VigiError> {
match state.lock() {
Ok(mut state) => {
state.add_tab()?;
Ok(())
}
Err(_) => Err(VigiError::StateLock),
}
}
#[tauri::command]
fn remove_tab(state: tauri::State<Mutex<VigiState>>, index: usize) -> Result<(), VigiError> {
match state.lock() {
Ok(mut state) => {
state.remove_tab(index)?;
Ok(())
}
Err(_) => Err(VigiError::StateLock),
}
}
fn update_tab(
state: tauri::State<Mutex<VigiState>>,
tab_type: TabType,
tab_title: String,
tab_url: String,
) -> Result<(), VigiError> {
match state.lock() {
Ok(mut state) => {
state.update_tab(tab_type, tab_title, tab_url)?;
Ok(())
}
Err(_) => Err(VigiError::StateLock),
}
}
fn main() {
tauri::Builder::default()
.manage(Mutex::new(VigiState::null()))
.setup(setup_handler)
.invoke_handler(tauri::generate_handler![
process_input,
get_state,
select_tab,
add_tab,
remove_tab
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
fn setup_handler(app: &mut tauri::App) -> Result<(), Box<dyn std::error::Error + 'static>> { fn setup_handler(app: &mut tauri::App) -> Result<(), Box<dyn std::error::Error + 'static>> {
println!("---Setup---"); println!("---Setup---");
let app_handle = app.handle(); let app_handle = app.handle();
let state = app.state::<Mutex<State>>(); let state = app.state::<Mutex<VigiState>>();
let mut state = state.lock().unwrap(); let mut state = state.lock().unwrap();
let config_dir = app_handle let config_dir = app_handle
@ -78,19 +153,13 @@ fn setup_handler(app: &mut tauri::App) -> Result<(), Box<dyn std::error::Error +
state.local_tabs_path = local_data_dir.join("tabs.jsonl"); state.local_tabs_path = local_data_dir.join("tabs.jsonl");
state.tabs = read_or_create_jsonl(&state.local_tabs_path); state.tabs = read_or_create_jsonl(&state.local_tabs_path);
state.tabs_id_counter_path = local_data_dir.join("tabs_id_counter");
state.tabs_id_counter = read_or_create_number(&state.tabs_id_counter_path);
state.current_tab_index_path = local_data_dir.join("current_tab_index"); state.current_tab_index_path = local_data_dir.join("current_tab_index");
state.current_tab_index = read_or_create_current_tab_index(&state.current_tab_index_path); state.current_tab_index = read_or_create_number(&state.current_tab_index_path);
println!("---Setup done---"); println!("---Setup done---");
Ok(()) Ok(())
} }
fn main() {
tauri::Builder::default()
.manage(Mutex::new(State::null()))
.setup(setup_handler)
.invoke_handler(tauri::generate_handler![process_input])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}

View file

@ -1,47 +1,129 @@
use std::path::PathBuf;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::{fs, path::PathBuf};
use crate::utils::write_tabs;
#[derive(Serialize, Deserialize, Debug)] #[derive(Serialize, Deserialize, Debug)]
pub enum VigiError { pub enum VigiError {
NetworkError, Network,
ParseError, Parse,
StateLock,
StateUpdate,
} }
pub struct State { #[derive(Serialize, Deserialize, Debug, Clone)]
pub struct VigiState {
pub tabs_id_counter_path: PathBuf,
pub current_tab_index_path: PathBuf, pub current_tab_index_path: PathBuf,
pub local_tabs_path: PathBuf, pub local_tabs_path: PathBuf,
pub favorites_tabs_path: PathBuf, pub favorites_tabs_path: PathBuf,
pub cache_dir: PathBuf, pub cache_dir: PathBuf,
pub tabs_id_counter: usize,
pub current_tab_index: usize, pub current_tab_index: usize,
pub tabs: Vec<Tab>, pub tabs: Vec<Tab>,
pub favorites_tabs: Vec<Tab>, pub favorites_tabs: Vec<Tab>,
} }
impl State { impl VigiState {
pub fn null() -> Self { pub fn null() -> Self {
Self { Self {
tabs_id_counter_path: PathBuf::new(),
current_tab_index_path: PathBuf::new(), current_tab_index_path: PathBuf::new(),
local_tabs_path: PathBuf::new(), local_tabs_path: PathBuf::new(),
favorites_tabs_path: PathBuf::new(), favorites_tabs_path: PathBuf::new(),
cache_dir: PathBuf::new(), cache_dir: PathBuf::new(),
tabs_id_counter: 0,
current_tab_index: 0, current_tab_index: 0,
tabs: Vec::new(), tabs: Vec::new(),
favorites_tabs: Vec::new(), favorites_tabs: Vec::new(),
} }
} }
pub fn update_current_tab_index(&mut self, new_index: usize) -> Result<(), VigiError> {
self.current_tab_index = new_index;
self.write_current_tab_index()?;
Ok(())
}
fn write_current_tab_index(&mut self) -> Result<(), VigiError> {
fs::write(
&self.current_tab_index_path,
self.current_tab_index.to_string(),
)
.map_err(|_| VigiError::StateUpdate)
}
fn write_id_counter(&mut self) -> Result<(), VigiError> {
fs::write(&self.tabs_id_counter_path, self.tabs_id_counter.to_string())
.map_err(|_| VigiError::StateUpdate)
}
pub fn update_tab(
&mut self,
tab_type: TabType,
tab_title: String,
tab_url: String,
) -> Result<(), VigiError> {
match self.tabs.get_mut(self.current_tab_index) {
Some(tab) => {
*tab = Tab::new(tab_type, tab_title, tab_url, tab.id);
write_tabs(&self.local_tabs_path, &self.tabs)?;
Ok(())
}
None => Err(VigiError::StateUpdate),
}
}
pub fn add_tab(&mut self) -> Result<(), VigiError> {
self.tabs_id_counter += 1;
self.tabs.push(Tab::new(
TabType::HomePage,
"New tab".to_string(),
"".to_string(),
self.tabs_id_counter,
));
self.write_id_counter()?;
write_tabs(&self.local_tabs_path, &self.tabs)?;
Ok(())
}
pub fn remove_tab(&mut self, index: usize) -> Result<(), VigiError> {
if self.tabs.len() == 1 {
self.current_tab_index = 0;
} else {
self.current_tab_index = self.current_tab_index + 1;
}
self.tabs.remove(index);
write_tabs(&self.local_tabs_path, &self.tabs)?;
self.write_current_tab_index()?;
Ok(())
}
} }
#[derive(Serialize, Deserialize, Debug)] #[derive(Serialize, Deserialize, Debug, Clone)]
pub struct Tab { pub struct Tab {
ty: TabType, ty: TabType,
name: String, title: String,
url: String, url: String,
id: usize,
} }
#[derive(Serialize, Deserialize, Debug)] impl Tab {
pub fn new(ty: TabType, title: String, url: String, id: usize) -> Self {
Self { ty, title, url, id }
}
}
#[derive(Serialize, Deserialize, Debug, Clone)]
pub enum TabType { pub enum TabType {
HomePage, HomePage,
Text, Text,

View file

@ -3,7 +3,7 @@ use std::{
path::PathBuf, path::PathBuf,
}; };
use crate::types::Tab; use crate::types::{Tab, VigiError};
pub fn read_jsonl_tabs(path: &PathBuf) -> Vec<Tab> { pub fn read_jsonl_tabs(path: &PathBuf) -> Vec<Tab> {
fs::read_to_string(&path) fs::read_to_string(&path)
@ -24,11 +24,8 @@ pub fn read_or_create_jsonl(path: &PathBuf) -> Vec<Tab> {
} }
} }
pub fn read_or_create_current_tab_index(path: &PathBuf) -> usize { pub fn read_or_create_number(path: &PathBuf) -> usize {
println!( println!(" Getting number from {}", path.to_string_lossy());
" Getting current tab index from {}",
path.to_string_lossy()
);
if path.exists() { if path.exists() {
fs::read_to_string(path) fs::read_to_string(path)
.unwrap() .unwrap()
@ -40,3 +37,14 @@ pub fn read_or_create_current_tab_index(path: &PathBuf) -> usize {
0 0
} }
} }
pub fn write_tabs(path: &PathBuf, tabs: &Vec<Tab>) -> Result<(), VigiError> {
fs::write(
path,
tabs.iter()
.map(|tab| serde_json::to_string(tab).unwrap())
.collect::<Vec<String>>()
.join("\n"),
)
.map_err(|_| VigiError::StateUpdate)
}

View file

@ -43,24 +43,28 @@ body {
@apply ease-out duration-150; @apply ease-out duration-150;
@apply hover:bg-vigi-90; @apply hover:bg-vigi-90;
@apply hover:px-2 hover:mx-1 hover:scale-125 hover:cursor-pointer; @apply hover:px-2 hover:mx-1 hover:scale-125 cursor-pointer;
@apply active:bg-vigi-100; @apply active:bg-vigi-100;
} }
.open-tabs {
@apply flex justify-between mt-2 mx-2;
}
.block { .block {
@apply p-2 rounded-xl bg-vigi-60; @apply p-2 rounded-xl bg-vigi-60;
} }
.sidebar { .sidebar {
@apply basis-1/5 shrink-0; @apply shrink-0 grow-0 flex flex-col w-1/5;
@apply ease-out duration-100; @apply ease-out duration-100;
} }
.sidebar.collapsed { .sidebar.collapsed {
@apply basis-0; @apply basis-0;
@apply p-0 m-0; @apply p-0 m-0 bg-transparent;
} }
.top-bar { .top-bar {
@ -80,13 +84,54 @@ input::placeholder {
@apply text-vigi-40 focus:text-vigi-20; @apply text-vigi-40 focus:text-vigi-20;
} }
.tabs {
@apply flex flex-col gap-1 mt-2 grow overflow-auto;
}
.tab {
@apply p-2 rounded-xl bg-vigi-50;
@apply cursor-pointer;
@apply ease-out duration-100;
@apply hover:bg-vigi-55;
@apply hover:px-4;
@apply flex items-center justify-between gap-2 w-full;
}
.close-button {
@apply p-1 rounded-lg;
@apply ease-out duration-100;
@apply hover:bg-vigi-90 active:bg-vigi-100;
}
/* .tab .close-button {
@apply text-transparent;
}
.tab:hover .close-button {
@apply text-vigi-0;
} */
.tab.active {
@apply bg-vigi-40 text-vigi-0 font-bold;
@apply hover:bg-vigi-45;
}
.tab-title {
@apply truncate;
}
::selection { ::selection {
@apply bg-vigi-60; @apply bg-vigi-60;
} }
/* width */ /* width */
::-webkit-scrollbar { ::-webkit-scrollbar {
width: 20px; width: 15px;
} }
/* Track */ /* Track */
@ -96,12 +141,12 @@ input::placeholder {
/* Handle */ /* Handle */
::-webkit-scrollbar-thumb { ::-webkit-scrollbar-thumb {
@apply rounded-xl bg-vigi-90 bg-clip-content; @apply rounded-xl bg-vigi-70 bg-clip-content;
border: 6px solid transparent; border: 6px solid transparent;
} }
/* Handle on hover */ /* Handle on hover */
::-webkit-scrollbar-thumb:hover { ::-webkit-scrollbar-thumb:hover {
@apply bg-vigi-100; @apply bg-vigi-75;
border: 5px solid transparent; border: 5px solid transparent;
} }

View file

@ -1,16 +1,45 @@
<script> <script lang="ts">
import { slide } from "svelte/transition"; import { slide } from "svelte/transition";
import Block from "./Block.svelte"; import Block from "./Block.svelte";
import WindowControls from "./WindowControls.svelte"; import WindowControls from "./WindowControls.svelte";
import type { StateTab } from "$lib/types";
import { state } from "$lib/stores";
import Tab from "./Tab.svelte";
import { addTab } from "$lib/utils";
import Button from "./Button.svelte";
import Add from "$lib/icons/Add.svelte";
export let sidebarOpen = true; export let collapsed = true;
let tabs: StateTab[] = [];
let currentTabIndex = 0;
state.subscribe(async (state) => {
tabs = state.tabs;
currentTabIndex = state.current_tab_index;
if (tabs.length === 0) {
await addTab();
}
});
</script> </script>
<Block className={`sidebar${sidebarOpen ? "" : " collapsed"}`} draggable> <Block className={`sidebar${collapsed ? "" : " collapsed"}`} draggable>
{#if sidebarOpen} {#if collapsed}
<div transition:slide={{ axis: "x", duration: 100 }}> <WindowControls />
<WindowControls />
<div class="open-tabs">
Open tabs
<Button onClick={addTab}>
<Add />
</Button>
</div>
<div class="tabs">
{#each tabs as tab, i (tab.id)}
<Tab {tab} active={currentTabIndex === i} id={i} />
{/each}
</div> </div>
{/if} {/if}
</Block> </Block>

View file

@ -0,0 +1,46 @@
<script lang="ts">
import type { StateTab } from "$lib/types";
import { removeTab, selectTab } from "$lib/utils";
import { slide } from "svelte/transition";
import Close from "$lib/icons/Close.svelte";
import Button from "./Button.svelte";
export let active = false;
export let tab: StateTab;
export let id: number;
let tabElement: HTMLButtonElement;
let hovered = false;
</script>
<div
class="flex gap-1 items-center"
on:mouseenter={() => (hovered = true)}
on:mouseleave={() => (hovered = false)}
role="tab"
tabindex={id}
>
<button
class="tab"
class:active
transition:slide={{ duration: 100 }}
bind:this={tabElement}
on:click={() => {
selectTab(id);
}}
>
<div class="tab-title">
{tab.title}
</div>
</button>
{#if hovered}
<button
class="close-button"
transition:slide={{ duration: 100, axis: "x" }}
on:click={() => removeTab(id)}><Close /></button
>
{/if}
</div>

17
src/lib/icons/Add.svelte Normal file
View file

@ -0,0 +1,17 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="1em"
height="1em"
viewBox="0 0 24 24"
{...$$props}
>
<path
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1.5"
d="M12 4v16m-8-8h16"
color="currentColor"
/>
</svg>

After

Width:  |  Height:  |  Size: 300 B

View file

@ -1,3 +1,6 @@
import { writable } from "svelte/store"; import { writable, type Writable } from "svelte/store";
import type { VigiState } from "./types";
export const topBarInput = writable(""); export const topBarInput: Writable<string> = writable("");
export const state: Writable<VigiState> = writable();

14
src/lib/types.ts Normal file
View file

@ -0,0 +1,14 @@
export interface VigiState {
current_tab_index: number;
tabs: StateTab[];
favorites_tabs: StateTab[];
}
type TabType = "HomePage" | "Text";
export interface StateTab {
ty: TabType;
title: string;
url: string;
id: number;
}

32
src/lib/utils.ts Normal file
View file

@ -0,0 +1,32 @@
import { invoke } from "@tauri-apps/api";
import { state, topBarInput } from "./stores";
import type { StateTab, VigiState } from "./types";
export function updateVigiState() {
invoke("get_state")
.then((r) => {
let st = r as VigiState;
state.set(st);
topBarInput.set(st.tabs[st.current_tab_index].url);
})
.catch((err) => console.log(err));
}
export async function addTab() {
await invoke("add_tab");
updateVigiState();
}
export async function selectTab(index: number) {
await invoke("select_tab", { index });
updateVigiState();
}
export async function removeTab(index: number) {
await invoke("remove_tab", { index });
updateVigiState();
}

View file

@ -8,14 +8,16 @@
import { invoke } from "@tauri-apps/api/tauri"; import { invoke } from "@tauri-apps/api/tauri";
import { topBarInput } from "$lib/stores"; import { topBarInput } from "$lib/stores";
import { updateVigiState } from "$lib/utils";
let sidebarOpen = true; let sidebarOpen = true;
let inputValue = "";
let isLoading = false; let isLoading = false;
let data: Root = []; let data: Root = [];
updateVigiState();
document.addEventListener("keypress", (e: KeyboardEvent) => { document.addEventListener("keypress", (e: KeyboardEvent) => {
const formElements = ["INPUT", "TEXTAREA", "SELECT", "OPTION"]; const formElements = ["INPUT", "TEXTAREA", "SELECT", "OPTION"];
if (formElements.includes((e.target as Element).tagName)) { if (formElements.includes((e.target as Element).tagName)) {
@ -34,6 +36,9 @@
.catch((err) => { .catch((err) => {
data = [{ id: 0, body: "Error: " + err, argument: null }]; data = [{ id: 0, body: "Error: " + err, argument: null }];
isLoading = false; isLoading = false;
})
.finally(() => {
updateVigiState();
}); });
}); });
</script> </script>
@ -42,7 +47,7 @@
class={`common-window${sidebarOpen ? "" : " collapsed"}`} class={`common-window${sidebarOpen ? "" : " collapsed"}`}
data-tauri-drag-region data-tauri-drag-region
> >
<Sidebar bind:sidebarOpen /> <Sidebar bind:collapsed={sidebarOpen} />
<div class="main-window"> <div class="main-window">
<TopBar bind:sidebarOpen /> <TopBar bind:sidebarOpen />