mirror of
https://github.com/zed-industries/zed.git
synced 2024-12-25 01:34:02 +00:00
Get workspace module in and compiling
This commit is contained in:
parent
171dd0c243
commit
9bab29c72f
12 changed files with 1696 additions and 39 deletions
103
Cargo.lock
generated
103
Cargo.lock
generated
|
@ -673,6 +673,12 @@ dependencies = [
|
|||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fuchsia-cprng"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a06f77d526c1a601b7c4cdd98f54b5eaabffc14d5f2f0296febdc7f357c6d3ba"
|
||||
|
||||
[[package]]
|
||||
name = "futures-core"
|
||||
version = "0.3.12"
|
||||
|
@ -776,7 +782,7 @@ dependencies = [
|
|||
"parking_lot",
|
||||
"pathfinder_color",
|
||||
"pathfinder_geometry",
|
||||
"rand",
|
||||
"rand 0.8.3",
|
||||
"smallvec",
|
||||
"smol",
|
||||
"tree-sitter",
|
||||
|
@ -824,6 +830,12 @@ dependencies = [
|
|||
"cfg-if 1.0.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "0.4.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dd25036021b0de88a0aff6b850051563c6516d0bf53f8638938edbb9de732736"
|
||||
|
||||
[[package]]
|
||||
name = "lazy_static"
|
||||
version = "1.4.0"
|
||||
|
@ -1113,6 +1125,19 @@ dependencies = [
|
|||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.4.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "552840b97013b1a26992c11eac34bdd778e464601a4c2054b5f0bff7c6761293"
|
||||
dependencies = [
|
||||
"fuchsia-cprng",
|
||||
"libc",
|
||||
"rand_core 0.3.1",
|
||||
"rdrand",
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.8.3"
|
||||
|
@ -1121,7 +1146,7 @@ checksum = "0ef9e7e66b4468674bfcb0c81af8b7fa0bb154fa9f28eb840da5c447baeb8d7e"
|
|||
dependencies = [
|
||||
"libc",
|
||||
"rand_chacha",
|
||||
"rand_core",
|
||||
"rand_core 0.6.2",
|
||||
"rand_hc",
|
||||
]
|
||||
|
||||
|
@ -1132,9 +1157,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "e12735cf05c9e10bf21534da50a147b924d555dc7a547c42e6bb2d5b6017ae0d"
|
||||
dependencies = [
|
||||
"ppv-lite86",
|
||||
"rand_core",
|
||||
"rand_core 0.6.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7a6fdeb83b075e8266dcc8762c22776f6877a63111121f5f8c7411e5be7eed4b"
|
||||
dependencies = [
|
||||
"rand_core 0.4.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9c33a3c44ca05fa6f1807d8e6743f3824e8509beca625669633be0acbdf509dc"
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.6.2"
|
||||
|
@ -1150,7 +1190,16 @@ version = "0.3.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3190ef7066a446f2e7f42e239d161e905420ccab01eb967c9eb27d21b2322a73"
|
||||
dependencies = [
|
||||
"rand_core",
|
||||
"rand_core 0.6.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rdrand"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "678054eb77286b51581ba43620cc911abf02758c91f93f479767aed0f90458b2"
|
||||
dependencies = [
|
||||
"rand_core 0.3.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
@ -1207,6 +1256,15 @@ version = "0.6.22"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b5eb417147ba9860a96cfe72a0b93bf88fee1744b5636ec99ab20c1aa9376581"
|
||||
|
||||
[[package]]
|
||||
name = "remove_dir_all"
|
||||
version = "0.5.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3acd125665422973a33ac9d3dd2df85edad0f4ae9b00dafb1a05e43a9f5ef8e7"
|
||||
dependencies = [
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rust-argon2"
|
||||
version = "0.8.3"
|
||||
|
@ -1234,6 +1292,12 @@ dependencies = [
|
|||
"semver",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ryu"
|
||||
version = "1.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "71d301d4193d031abdd79ff7e3dd721168a9572ef3fe51a1517aba235bd8f86e"
|
||||
|
||||
[[package]]
|
||||
name = "same-file"
|
||||
version = "1.0.6"
|
||||
|
@ -1270,6 +1334,23 @@ version = "0.7.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "388a1df253eca08550bef6c72392cfe7c30914bf41df5269b68cbd6ff8f570a3"
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.124"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bd761ff957cb2a45fbb9ab3da6512de9de55872866160b23c25f1a841e99d29f"
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.64"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "799e97dc9fdae36a5c8b8f2cae9ce2ee9fdce2058c57a93e6099d919fd982f79"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"ryu",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "servo-fontconfig"
|
||||
version = "0.5.1"
|
||||
|
@ -1379,6 +1460,16 @@ dependencies = [
|
|||
"unicode-xid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tempdir"
|
||||
version = "0.3.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "15f2b5fb00ccdf689e0149d1b1b3c03fead81c2b37735d812fa8bddbbf41b6d8"
|
||||
dependencies = [
|
||||
"rand 0.4.6",
|
||||
"remove_dir_all",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "termcolor"
|
||||
version = "1.1.2"
|
||||
|
@ -1566,9 +1657,11 @@ dependencies = [
|
|||
"log",
|
||||
"num_cpus",
|
||||
"parking_lot",
|
||||
"rand",
|
||||
"rand 0.8.3",
|
||||
"serde_json",
|
||||
"simplelog",
|
||||
"smallvec",
|
||||
"smol",
|
||||
"tempdir",
|
||||
"unindent",
|
||||
]
|
||||
|
|
|
@ -230,7 +230,6 @@ impl App {
|
|||
read(state.view(handle), state.ctx())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn finish_pending_tasks(&self) -> impl Future<Output = ()> {
|
||||
self.0.borrow().finish_pending_tasks()
|
||||
}
|
||||
|
@ -1036,7 +1035,6 @@ impl MutableAppContext {
|
|||
.detach()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn finish_pending_tasks(&self) -> impl Future<Output = ()> {
|
||||
let mut pending_tasks = self.task_callbacks.keys().cloned().collect::<HashSet<_>>();
|
||||
let task_done = self.task_done.1.clone();
|
||||
|
|
|
@ -31,4 +31,6 @@ smallvec = "1.6.1"
|
|||
smol = "1.2.5"
|
||||
|
||||
[dev-dependencies]
|
||||
serde_json = "1.0.64"
|
||||
tempdir = "0.3.7"
|
||||
unindent = "0.1.7"
|
||||
|
|
|
@ -2,7 +2,7 @@ use super::{
|
|||
buffer, movement, Anchor, Bias, Buffer, BufferElement, DisplayMap, DisplayPoint, Point,
|
||||
ToOffset, ToPoint,
|
||||
};
|
||||
use crate::{settings::Settings, watch};
|
||||
use crate::{settings::Settings, watch, workspace};
|
||||
use anyhow::Result;
|
||||
use easy_parallel::Parallel;
|
||||
use gpui::{
|
||||
|
@ -1161,38 +1161,50 @@ impl View for BufferView {
|
|||
}
|
||||
}
|
||||
|
||||
// impl workspace::ItemView for BufferView {
|
||||
// fn is_activate_event(event: &Self::Event) -> bool {
|
||||
// match event {
|
||||
// Event::Activate => true,
|
||||
// _ => false,
|
||||
// }
|
||||
// }
|
||||
impl workspace::Item for Buffer {
|
||||
type View = BufferView;
|
||||
|
||||
// fn title(&self, app: &AppContext) -> std::string::String {
|
||||
// if let Some(path) = self.buffer.as_ref(app).path(app) {
|
||||
// path.file_name()
|
||||
// .expect("buffer's path is always to a file")
|
||||
// .to_string_lossy()
|
||||
// .into()
|
||||
// } else {
|
||||
// "untitled".into()
|
||||
// }
|
||||
// }
|
||||
fn build_view(
|
||||
buffer: ModelHandle<Self>,
|
||||
settings: watch::Receiver<Settings>,
|
||||
ctx: &mut ViewContext<Self::View>,
|
||||
) -> Self::View {
|
||||
BufferView::for_buffer(buffer, settings, ctx)
|
||||
}
|
||||
}
|
||||
|
||||
// fn entry_id(&self, app: &AppContext) -> Option<(usize, usize)> {
|
||||
// self.buffer.as_ref(app).entry_id()
|
||||
// }
|
||||
impl workspace::ItemView for BufferView {
|
||||
fn is_activate_event(event: &Self::Event) -> bool {
|
||||
match event {
|
||||
Event::Activate => true,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
// fn clone_on_split(&self, ctx: &mut ViewContext<Self>) -> Option<Self>
|
||||
// where
|
||||
// Self: Sized,
|
||||
// {
|
||||
// let clone = BufferView::for_buffer(self.buffer.clone(), self.settings.clone(), ctx);
|
||||
// *clone.scroll_position.lock() = *self.scroll_position.lock();
|
||||
// Some(clone)
|
||||
// }
|
||||
// }
|
||||
fn title(&self, app: &AppContext) -> std::string::String {
|
||||
if let Some(path) = self.buffer.as_ref(app).path(app) {
|
||||
path.file_name()
|
||||
.expect("buffer's path is always to a file")
|
||||
.to_string_lossy()
|
||||
.into()
|
||||
} else {
|
||||
"untitled".into()
|
||||
}
|
||||
}
|
||||
|
||||
fn entry_id(&self, app: &AppContext) -> Option<(usize, usize)> {
|
||||
self.buffer.as_ref(app).entry_id()
|
||||
}
|
||||
|
||||
fn clone_on_split(&self, ctx: &mut ViewContext<Self>) -> Option<Self>
|
||||
where
|
||||
Self: Sized,
|
||||
{
|
||||
let clone = BufferView::for_buffer(self.buffer.clone(), self.settings.clone(), ctx);
|
||||
*clone.scroll_position.lock() = *self.scroll_position.lock();
|
||||
Some(clone)
|
||||
}
|
||||
}
|
||||
|
||||
impl Selection {
|
||||
fn head(&self) -> &Anchor {
|
||||
|
|
|
@ -8,4 +8,5 @@ mod time;
|
|||
mod timer;
|
||||
mod util;
|
||||
mod watch;
|
||||
mod workspace;
|
||||
mod worktree;
|
||||
|
|
|
@ -1,5 +1,9 @@
|
|||
use rand::Rng;
|
||||
use std::collections::BTreeMap;
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
use tempdir::TempDir;
|
||||
|
||||
use crate::time::ReplicaId;
|
||||
|
||||
|
@ -97,3 +101,38 @@ pub fn sample_text(rows: usize, cols: usize) -> String {
|
|||
}
|
||||
text
|
||||
}
|
||||
|
||||
pub fn temp_tree(tree: serde_json::Value) -> TempDir {
|
||||
let dir = TempDir::new("").unwrap();
|
||||
write_tree(dir.path(), tree);
|
||||
dir
|
||||
}
|
||||
|
||||
fn write_tree(path: &Path, tree: serde_json::Value) {
|
||||
use serde_json::Value;
|
||||
use std::fs;
|
||||
|
||||
if let Value::Object(map) = tree {
|
||||
for (name, contents) in map {
|
||||
let mut path = PathBuf::from(path);
|
||||
path.push(name);
|
||||
match contents {
|
||||
Value::Object(_) => {
|
||||
fs::create_dir(&path).unwrap();
|
||||
write_tree(&path, contents);
|
||||
}
|
||||
Value::Null => {
|
||||
fs::create_dir(&path).unwrap();
|
||||
}
|
||||
Value::String(contents) => {
|
||||
fs::write(&path, contents).unwrap();
|
||||
}
|
||||
_ => {
|
||||
panic!("JSON object must contain only objects, strings, or null");
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
panic!("You must pass a JSON object to this helper")
|
||||
}
|
||||
}
|
||||
|
|
119
zed/src/workspace/mod.rs
Normal file
119
zed/src/workspace/mod.rs
Normal file
|
@ -0,0 +1,119 @@
|
|||
pub mod pane;
|
||||
pub mod pane_group;
|
||||
pub mod workspace;
|
||||
pub mod workspace_view;
|
||||
|
||||
pub use pane::*;
|
||||
pub use pane_group::*;
|
||||
pub use workspace::*;
|
||||
pub use workspace_view::*;
|
||||
|
||||
use crate::{settings::Settings, watch};
|
||||
use gpui::{App, MutableAppContext};
|
||||
use std::path::PathBuf;
|
||||
|
||||
pub fn init(app: &mut App) {
|
||||
app.add_global_action("workspace:open_paths", open_paths);
|
||||
pane::init(app);
|
||||
}
|
||||
|
||||
pub struct OpenParams {
|
||||
pub paths: Vec<PathBuf>,
|
||||
pub settings: watch::Receiver<Settings>,
|
||||
}
|
||||
|
||||
fn open_paths(params: &OpenParams, app: &mut MutableAppContext) {
|
||||
log::info!("open paths {:?}", params.paths);
|
||||
|
||||
// Open paths in existing workspace if possible
|
||||
for window_id in app.window_ids().collect::<Vec<_>>() {
|
||||
if let Some(handle) = app.root_view::<WorkspaceView>(window_id) {
|
||||
if handle.update(app, |view, ctx| {
|
||||
if view.contains_paths(¶ms.paths, ctx.app()) {
|
||||
view.open_paths(¶ms.paths, ctx.app_mut());
|
||||
log::info!("open paths on existing workspace");
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log::info!("open new workspace");
|
||||
|
||||
// Add a new workspace if necessary
|
||||
let workspace = app.add_model(|ctx| Workspace::new(params.paths.clone(), ctx));
|
||||
app.add_window(|ctx| WorkspaceView::new(workspace, params.settings.clone(), ctx));
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{settings, test::*};
|
||||
use gpui::{App, FontCache};
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn test_open_paths_action() {
|
||||
App::test(|mut app| async move {
|
||||
let settings = settings::channel(&FontCache::new()).unwrap().1;
|
||||
|
||||
init(&mut app);
|
||||
|
||||
let dir = temp_tree(json!({
|
||||
"a": {
|
||||
"aa": null,
|
||||
"ab": null,
|
||||
},
|
||||
"b": {
|
||||
"ba": null,
|
||||
"bb": null,
|
||||
},
|
||||
"c": {
|
||||
"ca": null,
|
||||
"cb": null,
|
||||
},
|
||||
}));
|
||||
|
||||
app.dispatch_global_action(
|
||||
"workspace:open_paths",
|
||||
OpenParams {
|
||||
paths: vec![
|
||||
dir.path().join("a").to_path_buf(),
|
||||
dir.path().join("b").to_path_buf(),
|
||||
],
|
||||
settings: settings.clone(),
|
||||
},
|
||||
);
|
||||
assert_eq!(app.window_ids().len(), 1);
|
||||
|
||||
app.dispatch_global_action(
|
||||
"workspace:open_paths",
|
||||
OpenParams {
|
||||
paths: vec![dir.path().join("a").to_path_buf()],
|
||||
settings: settings.clone(),
|
||||
},
|
||||
);
|
||||
assert_eq!(app.window_ids().len(), 1);
|
||||
let workspace_view_1 = app.root_view::<WorkspaceView>(app.window_ids()[0]).unwrap();
|
||||
workspace_view_1.read(&app, |view, app| {
|
||||
assert_eq!(view.workspace.as_ref(app).worktrees().len(), 2);
|
||||
});
|
||||
|
||||
app.dispatch_global_action(
|
||||
"workspace:open_paths",
|
||||
OpenParams {
|
||||
paths: vec![
|
||||
dir.path().join("b").to_path_buf(),
|
||||
dir.path().join("c").to_path_buf(),
|
||||
],
|
||||
settings: settings.clone(),
|
||||
},
|
||||
);
|
||||
assert_eq!(app.window_ids().len(), 2);
|
||||
});
|
||||
}
|
||||
}
|
285
zed/src/workspace/pane.rs
Normal file
285
zed/src/workspace/pane.rs
Normal file
|
@ -0,0 +1,285 @@
|
|||
use super::{ItemViewHandle, SplitDirection};
|
||||
use crate::{settings::Settings, watch};
|
||||
use gpui::{
|
||||
color::ColorU, elements::*, keymap::Binding, App, AppContext, ChildView, Entity, View,
|
||||
ViewContext,
|
||||
};
|
||||
use std::cmp;
|
||||
|
||||
pub fn init(app: &mut App) {
|
||||
app.add_action(
|
||||
"pane:activate_item",
|
||||
|pane: &mut Pane, index: &usize, ctx| {
|
||||
pane.activate_item(*index, ctx);
|
||||
},
|
||||
);
|
||||
app.add_action("pane:activate_prev_item", |pane: &mut Pane, _: &(), ctx| {
|
||||
pane.activate_prev_item(ctx);
|
||||
});
|
||||
app.add_action("pane:activate_next_item", |pane: &mut Pane, _: &(), ctx| {
|
||||
pane.activate_next_item(ctx);
|
||||
});
|
||||
app.add_action("pane:close_active_item", |pane: &mut Pane, _: &(), ctx| {
|
||||
pane.close_active_item(ctx);
|
||||
});
|
||||
app.add_action("pane:split_up", |pane: &mut Pane, _: &(), ctx| {
|
||||
pane.split(SplitDirection::Up, ctx);
|
||||
});
|
||||
app.add_action("pane:split_down", |pane: &mut Pane, _: &(), ctx| {
|
||||
pane.split(SplitDirection::Down, ctx);
|
||||
});
|
||||
app.add_action("pane:split_left", |pane: &mut Pane, _: &(), ctx| {
|
||||
pane.split(SplitDirection::Left, ctx);
|
||||
});
|
||||
app.add_action("pane:split_right", |pane: &mut Pane, _: &(), ctx| {
|
||||
pane.split(SplitDirection::Right, ctx);
|
||||
});
|
||||
|
||||
app.add_bindings(vec![
|
||||
Binding::new("shift-cmd-{", "pane:activate_prev_item", Some("Pane")),
|
||||
Binding::new("shift-cmd-}", "pane:activate_next_item", Some("Pane")),
|
||||
Binding::new("cmd-w", "pane:close_active_item", Some("Pane")),
|
||||
Binding::new("cmd-k up", "pane:split_up", Some("Pane")),
|
||||
Binding::new("cmd-k down", "pane:split_down", Some("Pane")),
|
||||
Binding::new("cmd-k left", "pane:split_left", Some("Pane")),
|
||||
Binding::new("cmd-k right", "pane:split_right", Some("Pane")),
|
||||
]);
|
||||
}
|
||||
|
||||
pub enum Event {
|
||||
Activate,
|
||||
Remove,
|
||||
Split(SplitDirection),
|
||||
}
|
||||
|
||||
#[derive(Debug, Eq, PartialEq)]
|
||||
pub struct State {
|
||||
pub tabs: Vec<TabState>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Eq, PartialEq)]
|
||||
pub struct TabState {
|
||||
pub title: String,
|
||||
pub active: bool,
|
||||
}
|
||||
|
||||
pub struct Pane {
|
||||
items: Vec<Box<dyn ItemViewHandle>>,
|
||||
active_item: usize,
|
||||
settings: watch::Receiver<Settings>,
|
||||
}
|
||||
|
||||
impl Pane {
|
||||
pub fn new(settings: watch::Receiver<Settings>) -> Self {
|
||||
Self {
|
||||
items: Vec::new(),
|
||||
active_item: 0,
|
||||
settings,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn activate(&self, ctx: &mut ViewContext<Self>) {
|
||||
ctx.emit(Event::Activate);
|
||||
}
|
||||
|
||||
pub fn add_item(
|
||||
&mut self,
|
||||
item: Box<dyn ItemViewHandle>,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) -> usize {
|
||||
let item_idx = cmp::min(self.active_item + 1, self.items.len());
|
||||
self.items.insert(item_idx, item);
|
||||
ctx.notify();
|
||||
item_idx
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn items(&self) -> &[Box<dyn ItemViewHandle>] {
|
||||
&self.items
|
||||
}
|
||||
|
||||
pub fn active_item(&self) -> Option<Box<dyn ItemViewHandle>> {
|
||||
self.items.get(self.active_item).cloned()
|
||||
}
|
||||
|
||||
pub fn activate_entry(
|
||||
&mut self,
|
||||
entry_id: (usize, usize),
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) -> bool {
|
||||
if let Some(index) = self
|
||||
.items
|
||||
.iter()
|
||||
.position(|item| item.entry_id(ctx.app()).map_or(false, |id| id == entry_id))
|
||||
{
|
||||
self.activate_item(index, ctx);
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
pub fn item_index(&self, item: &dyn ItemViewHandle) -> Option<usize> {
|
||||
self.items.iter().position(|i| i.id() == item.id())
|
||||
}
|
||||
|
||||
pub fn activate_item(&mut self, index: usize, ctx: &mut ViewContext<Self>) {
|
||||
if index < self.items.len() {
|
||||
self.active_item = index;
|
||||
self.focus_active_item(ctx);
|
||||
ctx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn activate_prev_item(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
if self.active_item > 0 {
|
||||
self.active_item -= 1;
|
||||
} else {
|
||||
self.active_item = self.items.len() - 1;
|
||||
}
|
||||
self.focus_active_item(ctx);
|
||||
ctx.notify();
|
||||
}
|
||||
|
||||
pub fn activate_next_item(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
if self.active_item + 1 < self.items.len() {
|
||||
self.active_item += 1;
|
||||
} else {
|
||||
self.active_item = 0;
|
||||
}
|
||||
self.focus_active_item(ctx);
|
||||
ctx.notify();
|
||||
}
|
||||
|
||||
pub fn close_active_item(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
if !self.items.is_empty() {
|
||||
self.items.remove(self.active_item);
|
||||
if self.active_item >= self.items.len() {
|
||||
self.active_item = self.items.len().saturating_sub(1);
|
||||
}
|
||||
ctx.notify();
|
||||
}
|
||||
if self.items.is_empty() {
|
||||
ctx.emit(Event::Remove);
|
||||
}
|
||||
}
|
||||
|
||||
fn focus_active_item(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
if let Some(active_item) = self.active_item() {
|
||||
ctx.focus(active_item.to_any());
|
||||
}
|
||||
}
|
||||
|
||||
pub fn split(&mut self, direction: SplitDirection, ctx: &mut ViewContext<Self>) {
|
||||
ctx.emit(Event::Split(direction));
|
||||
}
|
||||
|
||||
fn render_tabs<'a>(&self, app: &AppContext) -> Box<dyn Element> {
|
||||
let settings = smol::block_on(self.settings.read());
|
||||
let border_color = ColorU::new(0xdb, 0xdb, 0xdc, 0xff);
|
||||
|
||||
let mut row = Flex::row();
|
||||
let last_item_ix = self.items.len() - 1;
|
||||
for (ix, item) in self.items.iter().enumerate() {
|
||||
let title = item.title(app);
|
||||
|
||||
let mut border = Border::new(1.0, border_color);
|
||||
border.left = ix > 0;
|
||||
border.right = ix == last_item_ix;
|
||||
border.bottom = ix != self.active_item;
|
||||
|
||||
let mut container = Container::new(
|
||||
Align::new(
|
||||
Label::new(title, settings.ui_font_family, settings.ui_font_size).boxed(),
|
||||
)
|
||||
.boxed(),
|
||||
)
|
||||
.with_uniform_padding(6.0)
|
||||
.with_border(border);
|
||||
|
||||
if ix == self.active_item {
|
||||
container = container
|
||||
.with_background_color(ColorU::white())
|
||||
.with_overdraw_bottom(1.5);
|
||||
} else {
|
||||
container = container.with_background_color(ColorU::new(0xea, 0xea, 0xeb, 0xff));
|
||||
}
|
||||
|
||||
row.add_child(
|
||||
Expanded::new(
|
||||
1.0,
|
||||
ConstrainedBox::new(
|
||||
EventHandler::new(container.boxed())
|
||||
.on_mouse_down(move |ctx, _| {
|
||||
ctx.dispatch_action("pane:activate_item", ix);
|
||||
true
|
||||
})
|
||||
.boxed(),
|
||||
)
|
||||
.with_max_width(264.0)
|
||||
.boxed(),
|
||||
)
|
||||
.boxed(),
|
||||
);
|
||||
}
|
||||
|
||||
row.add_child(
|
||||
Expanded::new(
|
||||
1.0,
|
||||
Container::new(
|
||||
LineBox::new(
|
||||
settings.ui_font_family,
|
||||
settings.ui_font_size,
|
||||
Empty::new().boxed(),
|
||||
)
|
||||
.boxed(),
|
||||
)
|
||||
.with_uniform_padding(6.0)
|
||||
.with_border(Border::bottom(1.0, border_color))
|
||||
.boxed(),
|
||||
)
|
||||
.boxed(),
|
||||
);
|
||||
|
||||
row.boxed()
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for Pane {
|
||||
type Event = Event;
|
||||
}
|
||||
|
||||
impl View for Pane {
|
||||
fn ui_name() -> &'static str {
|
||||
"Pane"
|
||||
}
|
||||
|
||||
fn render<'a>(&self, app: &AppContext) -> Box<dyn Element> {
|
||||
if let Some(active_item) = self.active_item() {
|
||||
Flex::column()
|
||||
.with_child(self.render_tabs(app))
|
||||
.with_child(Expanded::new(1.0, ChildView::new(active_item.id()).boxed()).boxed())
|
||||
.boxed()
|
||||
} else {
|
||||
Empty::new().boxed()
|
||||
}
|
||||
}
|
||||
|
||||
fn on_focus(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
self.focus_active_item(ctx);
|
||||
}
|
||||
|
||||
// fn state(&self, app: &AppContext) -> Self::State {
|
||||
// State {
|
||||
// tabs: self
|
||||
// .items
|
||||
// .iter()
|
||||
// .enumerate()
|
||||
// .map(|(idx, item)| TabState {
|
||||
// title: item.title(app),
|
||||
// active: idx == self.active_item,
|
||||
// })
|
||||
// .collect(),
|
||||
// }
|
||||
// }
|
||||
}
|
393
zed/src/workspace/pane_group.rs
Normal file
393
zed/src/workspace/pane_group.rs
Normal file
|
@ -0,0 +1,393 @@
|
|||
use anyhow::{anyhow, Result};
|
||||
use gpui::{
|
||||
color::{rgbu, ColorU},
|
||||
elements::*,
|
||||
Axis, ChildView,
|
||||
};
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct PaneGroup {
|
||||
root: Member,
|
||||
}
|
||||
|
||||
impl PaneGroup {
|
||||
pub fn new(pane_id: usize) -> Self {
|
||||
Self {
|
||||
root: Member::Pane(pane_id),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn split(
|
||||
&mut self,
|
||||
old_pane_id: usize,
|
||||
new_pane_id: usize,
|
||||
direction: SplitDirection,
|
||||
) -> Result<()> {
|
||||
match &mut self.root {
|
||||
Member::Pane(pane_id) => {
|
||||
if *pane_id == old_pane_id {
|
||||
self.root = Member::new_axis(old_pane_id, new_pane_id, direction);
|
||||
Ok(())
|
||||
} else {
|
||||
Err(anyhow!("Pane not found"))
|
||||
}
|
||||
}
|
||||
Member::Axis(axis) => axis.split(old_pane_id, new_pane_id, direction),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn remove(&mut self, pane_id: usize) -> Result<bool> {
|
||||
match &mut self.root {
|
||||
Member::Pane(_) => Ok(false),
|
||||
Member::Axis(axis) => {
|
||||
if let Some(last_pane) = axis.remove(pane_id)? {
|
||||
self.root = last_pane;
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn render<'a>(&self) -> Box<dyn Element> {
|
||||
self.root.render()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
enum Member {
|
||||
Axis(PaneAxis),
|
||||
Pane(usize),
|
||||
}
|
||||
|
||||
impl Member {
|
||||
fn new_axis(old_pane_id: usize, new_pane_id: usize, direction: SplitDirection) -> Self {
|
||||
use Axis::*;
|
||||
use SplitDirection::*;
|
||||
|
||||
let axis = match direction {
|
||||
Up | Down => Vertical,
|
||||
Left | Right => Horizontal,
|
||||
};
|
||||
|
||||
let members = match direction {
|
||||
Up | Left => vec![Member::Pane(new_pane_id), Member::Pane(old_pane_id)],
|
||||
Down | Right => vec![Member::Pane(old_pane_id), Member::Pane(new_pane_id)],
|
||||
};
|
||||
|
||||
Member::Axis(PaneAxis { axis, members })
|
||||
}
|
||||
|
||||
pub fn render<'a>(&self) -> Box<dyn Element> {
|
||||
match self {
|
||||
Member::Pane(view_id) => ChildView::new(*view_id).boxed(),
|
||||
Member::Axis(axis) => axis.render(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
struct PaneAxis {
|
||||
axis: Axis,
|
||||
members: Vec<Member>,
|
||||
}
|
||||
|
||||
impl PaneAxis {
|
||||
fn split(
|
||||
&mut self,
|
||||
old_pane_id: usize,
|
||||
new_pane_id: usize,
|
||||
direction: SplitDirection,
|
||||
) -> Result<()> {
|
||||
use SplitDirection::*;
|
||||
|
||||
for (idx, member) in self.members.iter_mut().enumerate() {
|
||||
match member {
|
||||
Member::Axis(axis) => {
|
||||
if axis.split(old_pane_id, new_pane_id, direction).is_ok() {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
Member::Pane(pane_id) => {
|
||||
if *pane_id == old_pane_id {
|
||||
if direction.matches_axis(self.axis) {
|
||||
match direction {
|
||||
Up | Left => {
|
||||
self.members.insert(idx, Member::Pane(new_pane_id));
|
||||
}
|
||||
Down | Right => {
|
||||
self.members.insert(idx + 1, Member::Pane(new_pane_id));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
*member = Member::new_axis(old_pane_id, new_pane_id, direction);
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(anyhow!("Pane not found"))
|
||||
}
|
||||
|
||||
fn remove(&mut self, pane_id_to_remove: usize) -> Result<Option<Member>> {
|
||||
let mut found_pane = false;
|
||||
let mut remove_member = None;
|
||||
for (idx, member) in self.members.iter_mut().enumerate() {
|
||||
match member {
|
||||
Member::Axis(axis) => {
|
||||
if let Ok(last_pane) = axis.remove(pane_id_to_remove) {
|
||||
if let Some(last_pane) = last_pane {
|
||||
*member = last_pane;
|
||||
}
|
||||
found_pane = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
Member::Pane(pane_id) => {
|
||||
if *pane_id == pane_id_to_remove {
|
||||
found_pane = true;
|
||||
remove_member = Some(idx);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if found_pane {
|
||||
if let Some(idx) = remove_member {
|
||||
self.members.remove(idx);
|
||||
}
|
||||
|
||||
if self.members.len() == 1 {
|
||||
Ok(self.members.pop())
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
} else {
|
||||
Err(anyhow!("Pane not found"))
|
||||
}
|
||||
}
|
||||
|
||||
fn render<'a>(&self) -> Box<dyn Element> {
|
||||
let last_member_ix = self.members.len() - 1;
|
||||
Flex::new(self.axis)
|
||||
.with_children(self.members.iter().enumerate().map(|(ix, member)| {
|
||||
let mut member = member.render();
|
||||
if ix < last_member_ix {
|
||||
let mut border = Border::new(border_width(), border_color());
|
||||
match self.axis {
|
||||
Axis::Vertical => border.bottom = true,
|
||||
Axis::Horizontal => border.right = true,
|
||||
}
|
||||
member = Container::new(member).with_border(border).boxed();
|
||||
}
|
||||
|
||||
Expanded::new(1.0, member).boxed()
|
||||
}))
|
||||
.boxed()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub enum SplitDirection {
|
||||
Up,
|
||||
Down,
|
||||
Left,
|
||||
Right,
|
||||
}
|
||||
|
||||
impl SplitDirection {
|
||||
fn matches_axis(self, orientation: Axis) -> bool {
|
||||
use Axis::*;
|
||||
use SplitDirection::*;
|
||||
|
||||
match self {
|
||||
Up | Down => match orientation {
|
||||
Vertical => true,
|
||||
Horizontal => false,
|
||||
},
|
||||
Left | Right => match orientation {
|
||||
Vertical => false,
|
||||
Horizontal => true,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
// use super::*;
|
||||
// use serde_json::json;
|
||||
|
||||
// #[test]
|
||||
// fn test_split_and_remove() -> Result<()> {
|
||||
// let mut group = PaneGroup::new(1);
|
||||
// assert_eq!(
|
||||
// serde_json::to_value(&group)?,
|
||||
// json!({
|
||||
// "type": "pane",
|
||||
// "paneId": 1,
|
||||
// })
|
||||
// );
|
||||
|
||||
// group.split(1, 2, SplitDirection::Right)?;
|
||||
// assert_eq!(
|
||||
// serde_json::to_value(&group)?,
|
||||
// json!({
|
||||
// "type": "axis",
|
||||
// "orientation": "horizontal",
|
||||
// "members": [
|
||||
// {"type": "pane", "paneId": 1},
|
||||
// {"type": "pane", "paneId": 2},
|
||||
// ]
|
||||
// })
|
||||
// );
|
||||
|
||||
// group.split(2, 3, SplitDirection::Up)?;
|
||||
// assert_eq!(
|
||||
// serde_json::to_value(&group)?,
|
||||
// json!({
|
||||
// "type": "axis",
|
||||
// "orientation": "horizontal",
|
||||
// "members": [
|
||||
// {"type": "pane", "paneId": 1},
|
||||
// {
|
||||
// "type": "axis",
|
||||
// "orientation": "vertical",
|
||||
// "members": [
|
||||
// {"type": "pane", "paneId": 3},
|
||||
// {"type": "pane", "paneId": 2},
|
||||
// ]
|
||||
// },
|
||||
// ]
|
||||
// })
|
||||
// );
|
||||
|
||||
// group.split(1, 4, SplitDirection::Right)?;
|
||||
// assert_eq!(
|
||||
// serde_json::to_value(&group)?,
|
||||
// json!({
|
||||
// "type": "axis",
|
||||
// "orientation": "horizontal",
|
||||
// "members": [
|
||||
// {"type": "pane", "paneId": 1},
|
||||
// {"type": "pane", "paneId": 4},
|
||||
// {
|
||||
// "type": "axis",
|
||||
// "orientation": "vertical",
|
||||
// "members": [
|
||||
// {"type": "pane", "paneId": 3},
|
||||
// {"type": "pane", "paneId": 2},
|
||||
// ]
|
||||
// },
|
||||
// ]
|
||||
// })
|
||||
// );
|
||||
|
||||
// group.split(2, 5, SplitDirection::Up)?;
|
||||
// assert_eq!(
|
||||
// serde_json::to_value(&group)?,
|
||||
// json!({
|
||||
// "type": "axis",
|
||||
// "orientation": "horizontal",
|
||||
// "members": [
|
||||
// {"type": "pane", "paneId": 1},
|
||||
// {"type": "pane", "paneId": 4},
|
||||
// {
|
||||
// "type": "axis",
|
||||
// "orientation": "vertical",
|
||||
// "members": [
|
||||
// {"type": "pane", "paneId": 3},
|
||||
// {"type": "pane", "paneId": 5},
|
||||
// {"type": "pane", "paneId": 2},
|
||||
// ]
|
||||
// },
|
||||
// ]
|
||||
// })
|
||||
// );
|
||||
|
||||
// assert_eq!(true, group.remove(5)?);
|
||||
// assert_eq!(
|
||||
// serde_json::to_value(&group)?,
|
||||
// json!({
|
||||
// "type": "axis",
|
||||
// "orientation": "horizontal",
|
||||
// "members": [
|
||||
// {"type": "pane", "paneId": 1},
|
||||
// {"type": "pane", "paneId": 4},
|
||||
// {
|
||||
// "type": "axis",
|
||||
// "orientation": "vertical",
|
||||
// "members": [
|
||||
// {"type": "pane", "paneId": 3},
|
||||
// {"type": "pane", "paneId": 2},
|
||||
// ]
|
||||
// },
|
||||
// ]
|
||||
// })
|
||||
// );
|
||||
|
||||
// assert_eq!(true, group.remove(4)?);
|
||||
// assert_eq!(
|
||||
// serde_json::to_value(&group)?,
|
||||
// json!({
|
||||
// "type": "axis",
|
||||
// "orientation": "horizontal",
|
||||
// "members": [
|
||||
// {"type": "pane", "paneId": 1},
|
||||
// {
|
||||
// "type": "axis",
|
||||
// "orientation": "vertical",
|
||||
// "members": [
|
||||
// {"type": "pane", "paneId": 3},
|
||||
// {"type": "pane", "paneId": 2},
|
||||
// ]
|
||||
// },
|
||||
// ]
|
||||
// })
|
||||
// );
|
||||
|
||||
// assert_eq!(true, group.remove(3)?);
|
||||
// assert_eq!(
|
||||
// serde_json::to_value(&group)?,
|
||||
// json!({
|
||||
// "type": "axis",
|
||||
// "orientation": "horizontal",
|
||||
// "members": [
|
||||
// {"type": "pane", "paneId": 1},
|
||||
// {"type": "pane", "paneId": 2},
|
||||
// ]
|
||||
// })
|
||||
// );
|
||||
|
||||
// assert_eq!(true, group.remove(2)?);
|
||||
// assert_eq!(
|
||||
// serde_json::to_value(&group)?,
|
||||
// json!({
|
||||
// "type": "pane",
|
||||
// "paneId": 1,
|
||||
// })
|
||||
// );
|
||||
|
||||
// assert_eq!(false, group.remove(1)?);
|
||||
// assert_eq!(
|
||||
// serde_json::to_value(&group)?,
|
||||
// json!({
|
||||
// "type": "pane",
|
||||
// "paneId": 1,
|
||||
// })
|
||||
// );
|
||||
|
||||
// Ok(())
|
||||
// }
|
||||
}
|
||||
|
||||
#[inline(always)]
|
||||
fn border_width() -> f32 {
|
||||
2.0
|
||||
}
|
||||
|
||||
#[inline(always)]
|
||||
fn border_color() -> ColorU {
|
||||
rgbu(0xdb, 0xdb, 0xdc)
|
||||
}
|
271
zed/src/workspace/workspace.rs
Normal file
271
zed/src/workspace/workspace.rs
Normal file
|
@ -0,0 +1,271 @@
|
|||
use super::{ItemView, ItemViewHandle};
|
||||
use crate::{
|
||||
editor::Buffer,
|
||||
settings::Settings,
|
||||
time::ReplicaId,
|
||||
watch,
|
||||
worktree::{Worktree, WorktreeHandle as _},
|
||||
};
|
||||
use anyhow::anyhow;
|
||||
use gpui::{
|
||||
App, AppContext, Entity, Handle, ModelContext, ModelHandle, MutableAppContext, ViewContext,
|
||||
};
|
||||
use smol::prelude::*;
|
||||
use std::{
|
||||
collections::{HashMap, HashSet},
|
||||
fmt::Debug,
|
||||
path::{Path, PathBuf},
|
||||
pin::Pin,
|
||||
sync::Arc,
|
||||
};
|
||||
|
||||
pub trait Item
|
||||
where
|
||||
Self: Sized,
|
||||
{
|
||||
type View: ItemView;
|
||||
fn build_view(
|
||||
handle: ModelHandle<Self>,
|
||||
settings: watch::Receiver<Settings>,
|
||||
ctx: &mut ViewContext<Self::View>,
|
||||
) -> Self::View;
|
||||
}
|
||||
|
||||
pub trait ItemHandle: Debug + Send + Sync {
|
||||
fn add_view(
|
||||
&self,
|
||||
window_id: usize,
|
||||
settings: watch::Receiver<Settings>,
|
||||
app: &mut MutableAppContext,
|
||||
) -> Box<dyn ItemViewHandle>;
|
||||
fn id(&self) -> usize;
|
||||
fn boxed_clone(&self) -> Box<dyn ItemHandle>;
|
||||
}
|
||||
|
||||
impl<T: 'static + Item> ItemHandle for ModelHandle<T> {
|
||||
fn add_view(
|
||||
&self,
|
||||
window_id: usize,
|
||||
settings: watch::Receiver<Settings>,
|
||||
app: &mut MutableAppContext,
|
||||
) -> Box<dyn ItemViewHandle> {
|
||||
Box::new(app.add_view(window_id, |ctx| T::build_view(self.clone(), settings, ctx)))
|
||||
}
|
||||
|
||||
fn id(&self) -> usize {
|
||||
Handle::id(self)
|
||||
}
|
||||
|
||||
fn boxed_clone(&self) -> Box<dyn ItemHandle> {
|
||||
Box::new(self.clone())
|
||||
}
|
||||
}
|
||||
|
||||
impl Clone for Box<dyn ItemHandle> {
|
||||
fn clone(&self) -> Self {
|
||||
self.boxed_clone()
|
||||
}
|
||||
}
|
||||
|
||||
pub type OpenResult = Result<Box<dyn ItemHandle>, Arc<anyhow::Error>>;
|
||||
|
||||
#[derive(Clone)]
|
||||
enum OpenedItem {
|
||||
Loading(watch::Receiver<Option<OpenResult>>),
|
||||
Loaded(Box<dyn ItemHandle>),
|
||||
}
|
||||
|
||||
pub struct Workspace {
|
||||
replica_id: ReplicaId,
|
||||
worktrees: HashSet<ModelHandle<Worktree>>,
|
||||
items: HashMap<(usize, usize), OpenedItem>,
|
||||
}
|
||||
|
||||
impl Workspace {
|
||||
pub fn new(paths: Vec<PathBuf>, ctx: &mut ModelContext<Self>) -> Self {
|
||||
let mut workspace = Self {
|
||||
replica_id: 0,
|
||||
worktrees: HashSet::new(),
|
||||
items: HashMap::new(),
|
||||
};
|
||||
workspace.open_paths(&paths, ctx);
|
||||
workspace
|
||||
}
|
||||
|
||||
pub fn worktrees(&self) -> &HashSet<ModelHandle<Worktree>> {
|
||||
&self.worktrees
|
||||
}
|
||||
|
||||
pub fn contains_paths(&self, paths: &[PathBuf], app: &AppContext) -> bool {
|
||||
paths.iter().all(|path| self.contains_path(&path, app))
|
||||
}
|
||||
|
||||
pub fn contains_path(&self, path: &Path, app: &AppContext) -> bool {
|
||||
self.worktrees
|
||||
.iter()
|
||||
.any(|worktree| worktree.as_ref(app).contains_path(path))
|
||||
}
|
||||
|
||||
pub fn open_paths(&mut self, paths: &[PathBuf], ctx: &mut ModelContext<Self>) {
|
||||
for path in paths.iter().cloned() {
|
||||
self.open_path(path, ctx);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn open_path<'a>(&'a mut self, path: PathBuf, ctx: &mut ModelContext<Self>) {
|
||||
for tree in self.worktrees.iter() {
|
||||
if tree.as_ref(ctx).contains_path(&path) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
let worktree = ctx.add_model(|ctx| Worktree::new(ctx.model_id(), path, Some(ctx)));
|
||||
ctx.observe(&worktree, Self::on_worktree_updated);
|
||||
self.worktrees.insert(worktree);
|
||||
ctx.notify();
|
||||
}
|
||||
|
||||
pub fn open_entry(
|
||||
&mut self,
|
||||
entry: (usize, usize),
|
||||
ctx: &mut ModelContext<'_, Self>,
|
||||
) -> anyhow::Result<Pin<Box<dyn Future<Output = OpenResult> + Send>>> {
|
||||
if let Some(item) = self.items.get(&entry).cloned() {
|
||||
return Ok(async move {
|
||||
match item {
|
||||
OpenedItem::Loaded(handle) => {
|
||||
return Ok(handle);
|
||||
}
|
||||
OpenedItem::Loading(rx) => loop {
|
||||
rx.updated().await;
|
||||
|
||||
if let Some(result) = smol::block_on(rx.read()).clone() {
|
||||
return result;
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
.boxed());
|
||||
}
|
||||
|
||||
let worktree = self
|
||||
.worktrees
|
||||
.get(&entry.0)
|
||||
.cloned()
|
||||
.ok_or(anyhow!("worktree {} does not exist", entry.0,))?;
|
||||
|
||||
let replica_id = self.replica_id;
|
||||
let file = worktree.file(entry.1, ctx.app())?;
|
||||
let history = file.load_history(ctx.app());
|
||||
let buffer = async move { Ok(Buffer::from_history(replica_id, file, history.await?)) };
|
||||
|
||||
let (mut tx, rx) = watch::channel(None);
|
||||
self.items.insert(entry, OpenedItem::Loading(rx));
|
||||
let _ = ctx.spawn(
|
||||
buffer,
|
||||
move |me, buffer: anyhow::Result<Buffer>, ctx| match buffer {
|
||||
Ok(buffer) => {
|
||||
let handle = Box::new(ctx.add_model(|_| buffer)) as Box<dyn ItemHandle>;
|
||||
me.items.insert(entry, OpenedItem::Loaded(handle.clone()));
|
||||
let _ = ctx.spawn(
|
||||
async move {
|
||||
tx.update(|value| *value = Some(Ok(handle))).await;
|
||||
},
|
||||
|_, _, _| {},
|
||||
);
|
||||
}
|
||||
Err(error) => {
|
||||
let _ = ctx.spawn(
|
||||
async move {
|
||||
tx.update(|value| *value = Some(Err(Arc::new(error)))).await;
|
||||
},
|
||||
|_, _, _| {},
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
self.open_entry(entry, ctx)
|
||||
}
|
||||
|
||||
fn on_worktree_updated(&mut self, _: ModelHandle<Worktree>, ctx: &mut ModelContext<Self>) {
|
||||
ctx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for Workspace {
|
||||
type Event = ();
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub trait WorkspaceHandle {
|
||||
fn file_entries(&self, app: &App) -> Vec<(usize, usize)>;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
impl WorkspaceHandle for ModelHandle<Workspace> {
|
||||
fn file_entries(&self, app: &App) -> Vec<(usize, usize)> {
|
||||
self.read(&app, |w, app| {
|
||||
w.worktrees()
|
||||
.iter()
|
||||
.flat_map(|tree| {
|
||||
let tree_id = tree.id();
|
||||
tree.as_ref(app)
|
||||
.files()
|
||||
.map(move |file| (tree_id, file.entry_id))
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::test::temp_tree;
|
||||
use gpui::App;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn test_open_entry() -> Result<(), Arc<anyhow::Error>> {
|
||||
App::test(|mut app| async move {
|
||||
let dir = temp_tree(json!({
|
||||
"a": {
|
||||
"aa": "aa contents",
|
||||
"ab": "ab contents",
|
||||
},
|
||||
}));
|
||||
|
||||
let workspace = app.add_model(|ctx| Workspace::new(vec![dir.path().into()], ctx));
|
||||
app.finish_pending_tasks().await; // Open and populate worktree.
|
||||
|
||||
// Get the first file entry.
|
||||
let entry = workspace.read(&app, |w, app| {
|
||||
let tree = w.worktrees.iter().next().unwrap();
|
||||
let entry_id = tree.as_ref(app).files().next().unwrap().entry_id;
|
||||
(tree.id(), entry_id)
|
||||
});
|
||||
|
||||
// Open the same entry twice before it finishes loading.
|
||||
let (future_1, future_2) = workspace.update(&mut app, |w, app| {
|
||||
(
|
||||
w.open_entry(entry, app).unwrap(),
|
||||
w.open_entry(entry, app).unwrap(),
|
||||
)
|
||||
});
|
||||
|
||||
let handle_1 = future_1.await?;
|
||||
let handle_2 = future_2.await?;
|
||||
assert_eq!(handle_1.id(), handle_2.id());
|
||||
|
||||
// Open the same entry again now that it has loaded
|
||||
let handle_3 = workspace
|
||||
.update(&mut app, |w, app| w.open_entry(entry, app).unwrap())
|
||||
.await?;
|
||||
|
||||
assert_eq!(handle_3.id(), handle_1.id());
|
||||
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
}
|
444
zed/src/workspace/workspace_view.rs
Normal file
444
zed/src/workspace/workspace_view.rs
Normal file
|
@ -0,0 +1,444 @@
|
|||
use super::{pane, Pane, PaneGroup, SplitDirection, Workspace};
|
||||
use crate::{settings::Settings, watch};
|
||||
use gpui::{color::rgbu, ChildView};
|
||||
use gpui::{
|
||||
elements::*, AnyViewHandle, AppContext, Entity, ModelHandle, MutableAppContext, View,
|
||||
ViewContext, ViewHandle,
|
||||
};
|
||||
use log::{error, info};
|
||||
use std::{collections::HashSet, path::PathBuf};
|
||||
|
||||
pub trait ItemView: View {
|
||||
fn is_activate_event(event: &Self::Event) -> bool;
|
||||
fn title(&self, app: &AppContext) -> String;
|
||||
fn entry_id(&self, app: &AppContext) -> Option<(usize, usize)>;
|
||||
fn clone_on_split(&self, _: &mut ViewContext<Self>) -> Option<Self>
|
||||
where
|
||||
Self: Sized,
|
||||
{
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
pub trait ItemViewHandle: Send + Sync {
|
||||
fn title(&self, app: &AppContext) -> String;
|
||||
fn entry_id(&self, app: &AppContext) -> Option<(usize, usize)>;
|
||||
fn boxed_clone(&self) -> Box<dyn ItemViewHandle>;
|
||||
fn clone_on_split(&self, app: &mut MutableAppContext) -> Option<Box<dyn ItemViewHandle>>;
|
||||
fn set_parent_pane(&self, pane: &ViewHandle<Pane>, app: &mut MutableAppContext);
|
||||
fn id(&self) -> usize;
|
||||
fn to_any(&self) -> AnyViewHandle;
|
||||
}
|
||||
|
||||
impl<T: ItemView> ItemViewHandle for ViewHandle<T> {
|
||||
fn title(&self, app: &AppContext) -> String {
|
||||
self.as_ref(app).title(app)
|
||||
}
|
||||
|
||||
fn entry_id(&self, app: &AppContext) -> Option<(usize, usize)> {
|
||||
self.as_ref(app).entry_id(app)
|
||||
}
|
||||
|
||||
fn boxed_clone(&self) -> Box<dyn ItemViewHandle> {
|
||||
Box::new(self.clone())
|
||||
}
|
||||
|
||||
fn clone_on_split(&self, app: &mut MutableAppContext) -> Option<Box<dyn ItemViewHandle>> {
|
||||
self.update(app, |item, ctx| {
|
||||
ctx.add_option_view(|ctx| item.clone_on_split(ctx))
|
||||
})
|
||||
.map(|handle| Box::new(handle) as Box<dyn ItemViewHandle>)
|
||||
}
|
||||
|
||||
fn set_parent_pane(&self, pane: &ViewHandle<Pane>, app: &mut MutableAppContext) {
|
||||
pane.update(app, |_, ctx| {
|
||||
ctx.subscribe_to_view(self, |pane, item, event, ctx| {
|
||||
if T::is_activate_event(event) {
|
||||
if let Some(ix) = pane.item_index(&item) {
|
||||
pane.activate_item(ix, ctx);
|
||||
pane.activate(ctx);
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn id(&self) -> usize {
|
||||
self.id()
|
||||
}
|
||||
|
||||
fn to_any(&self) -> AnyViewHandle {
|
||||
self.into()
|
||||
}
|
||||
}
|
||||
|
||||
impl Clone for Box<dyn ItemViewHandle> {
|
||||
fn clone(&self) -> Box<dyn ItemViewHandle> {
|
||||
self.boxed_clone()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct State {
|
||||
pub modal: Option<usize>,
|
||||
pub center: PaneGroup,
|
||||
}
|
||||
|
||||
pub struct WorkspaceView {
|
||||
pub workspace: ModelHandle<Workspace>,
|
||||
pub settings: watch::Receiver<Settings>,
|
||||
modal: Option<AnyViewHandle>,
|
||||
center: PaneGroup,
|
||||
panes: Vec<ViewHandle<Pane>>,
|
||||
active_pane: ViewHandle<Pane>,
|
||||
loading_entries: HashSet<(usize, usize)>,
|
||||
}
|
||||
|
||||
impl WorkspaceView {
|
||||
pub fn new(
|
||||
workspace: ModelHandle<Workspace>,
|
||||
settings: watch::Receiver<Settings>,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) -> Self {
|
||||
ctx.observe(&workspace, Self::workspace_updated);
|
||||
|
||||
let pane = ctx.add_view(|_| Pane::new(settings.clone()));
|
||||
let pane_id = pane.id();
|
||||
ctx.subscribe_to_view(&pane, move |me, _, event, ctx| {
|
||||
me.handle_pane_event(pane_id, event, ctx)
|
||||
});
|
||||
ctx.focus(&pane);
|
||||
|
||||
WorkspaceView {
|
||||
workspace,
|
||||
modal: None,
|
||||
center: PaneGroup::new(pane.id()),
|
||||
panes: vec![pane.clone()],
|
||||
active_pane: pane.clone(),
|
||||
loading_entries: HashSet::new(),
|
||||
settings,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn contains_paths(&self, paths: &[PathBuf], app: &AppContext) -> bool {
|
||||
self.workspace.as_ref(app).contains_paths(paths, app)
|
||||
}
|
||||
|
||||
pub fn open_paths(&self, paths: &[PathBuf], app: &mut MutableAppContext) {
|
||||
self.workspace
|
||||
.update(app, |workspace, ctx| workspace.open_paths(paths, ctx));
|
||||
}
|
||||
|
||||
pub fn toggle_modal<V, F>(&mut self, ctx: &mut ViewContext<Self>, add_view: F)
|
||||
where
|
||||
V: 'static + View,
|
||||
F: FnOnce(&mut ViewContext<Self>, &mut Self) -> ViewHandle<V>,
|
||||
{
|
||||
if self.modal.as_ref().map_or(false, |modal| modal.is::<V>()) {
|
||||
self.modal.take();
|
||||
ctx.focus_self();
|
||||
} else {
|
||||
let modal = add_view(ctx, self);
|
||||
ctx.focus(&modal);
|
||||
self.modal = Some(modal.into());
|
||||
}
|
||||
ctx.notify();
|
||||
}
|
||||
|
||||
pub fn modal(&self) -> Option<&AnyViewHandle> {
|
||||
self.modal.as_ref()
|
||||
}
|
||||
|
||||
pub fn dismiss_modal(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
if self.modal.take().is_some() {
|
||||
ctx.focus(&self.active_pane);
|
||||
ctx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn open_entry(&mut self, entry: (usize, usize), ctx: &mut ViewContext<Self>) {
|
||||
if self.loading_entries.contains(&entry) {
|
||||
return;
|
||||
}
|
||||
|
||||
if self
|
||||
.active_pane()
|
||||
.update(ctx, |pane, ctx| pane.activate_entry(entry, ctx))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
self.loading_entries.insert(entry);
|
||||
|
||||
match self
|
||||
.workspace
|
||||
.update(ctx, |workspace, ctx| workspace.open_entry(entry, ctx))
|
||||
{
|
||||
Err(error) => error!("{}", error),
|
||||
Ok(item) => {
|
||||
let settings = self.settings.clone();
|
||||
let _ = ctx.spawn(item, move |me, item, ctx| {
|
||||
me.loading_entries.remove(&entry);
|
||||
match item {
|
||||
Ok(item) => {
|
||||
let item_view = item.add_view(ctx.window_id(), settings, ctx.app_mut());
|
||||
me.add_item(item_view, ctx);
|
||||
}
|
||||
Err(error) => {
|
||||
error!("{}", error);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn open_example_entry(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
if let Some(tree) = self.workspace.as_ref(ctx).worktrees().iter().next() {
|
||||
if let Some(file) = tree.as_ref(ctx).files().next() {
|
||||
info!("open_entry ({}, {})", tree.id(), file.entry_id);
|
||||
self.open_entry((tree.id(), file.entry_id), ctx);
|
||||
} else {
|
||||
error!("No example file found for worktree {}", tree.id());
|
||||
}
|
||||
} else {
|
||||
error!("No worktree found while opening example entry");
|
||||
}
|
||||
}
|
||||
|
||||
fn workspace_updated(&mut self, _: ModelHandle<Workspace>, ctx: &mut ViewContext<Self>) {
|
||||
ctx.notify();
|
||||
}
|
||||
|
||||
fn add_pane(&mut self, ctx: &mut ViewContext<Self>) -> ViewHandle<Pane> {
|
||||
let pane = ctx.add_view(|_| Pane::new(self.settings.clone()));
|
||||
let pane_id = pane.id();
|
||||
ctx.subscribe_to_view(&pane, move |me, _, event, ctx| {
|
||||
me.handle_pane_event(pane_id, event, ctx)
|
||||
});
|
||||
self.panes.push(pane.clone());
|
||||
self.activate_pane(pane.clone(), ctx);
|
||||
pane
|
||||
}
|
||||
|
||||
fn activate_pane(&mut self, pane: ViewHandle<Pane>, ctx: &mut ViewContext<Self>) {
|
||||
self.active_pane = pane;
|
||||
ctx.focus(&self.active_pane);
|
||||
ctx.notify();
|
||||
}
|
||||
|
||||
fn handle_pane_event(
|
||||
&mut self,
|
||||
pane_id: usize,
|
||||
event: &pane::Event,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) {
|
||||
if let Some(pane) = self.pane(pane_id) {
|
||||
match event {
|
||||
pane::Event::Split(direction) => {
|
||||
self.split_pane(pane, *direction, ctx);
|
||||
}
|
||||
pane::Event::Remove => {
|
||||
self.remove_pane(pane, ctx);
|
||||
}
|
||||
pane::Event::Activate => {
|
||||
self.activate_pane(pane, ctx);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
error!("pane {} not found", pane_id);
|
||||
}
|
||||
}
|
||||
|
||||
fn split_pane(
|
||||
&mut self,
|
||||
pane: ViewHandle<Pane>,
|
||||
direction: SplitDirection,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) -> ViewHandle<Pane> {
|
||||
let new_pane = self.add_pane(ctx);
|
||||
self.activate_pane(new_pane.clone(), ctx);
|
||||
if let Some(item) = pane.as_ref(ctx).active_item() {
|
||||
if let Some(clone) = item.clone_on_split(ctx.app_mut()) {
|
||||
self.add_item(clone, ctx);
|
||||
}
|
||||
}
|
||||
self.center
|
||||
.split(pane.id(), new_pane.id(), direction)
|
||||
.unwrap();
|
||||
ctx.notify();
|
||||
new_pane
|
||||
}
|
||||
|
||||
fn remove_pane(&mut self, pane: ViewHandle<Pane>, ctx: &mut ViewContext<Self>) {
|
||||
if self.center.remove(pane.id()).unwrap() {
|
||||
self.panes.retain(|p| p != &pane);
|
||||
self.activate_pane(self.panes.last().unwrap().clone(), ctx);
|
||||
}
|
||||
}
|
||||
|
||||
fn pane(&self, pane_id: usize) -> Option<ViewHandle<Pane>> {
|
||||
self.panes.iter().find(|pane| pane.id() == pane_id).cloned()
|
||||
}
|
||||
|
||||
pub fn active_pane(&self) -> &ViewHandle<Pane> {
|
||||
&self.active_pane
|
||||
}
|
||||
|
||||
fn add_item(&self, item: Box<dyn ItemViewHandle>, ctx: &mut ViewContext<Self>) {
|
||||
let active_pane = self.active_pane();
|
||||
item.set_parent_pane(&active_pane, ctx.app_mut());
|
||||
active_pane.update(ctx, |pane, ctx| {
|
||||
let item_idx = pane.add_item(item, ctx);
|
||||
pane.activate_item(item_idx, ctx);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for WorkspaceView {
|
||||
type Event = ();
|
||||
}
|
||||
|
||||
impl View for WorkspaceView {
|
||||
fn ui_name() -> &'static str {
|
||||
"Workspace"
|
||||
}
|
||||
|
||||
fn render(&self, _: &AppContext) -> Box<dyn Element> {
|
||||
Container::new(
|
||||
// self.center.render(bump)
|
||||
Stack::new()
|
||||
.with_child(self.center.render())
|
||||
.with_children(self.modal.as_ref().map(|m| ChildView::new(m.id()).boxed()))
|
||||
.boxed(),
|
||||
)
|
||||
.with_background_color(rgbu(0xea, 0xea, 0xeb))
|
||||
.boxed()
|
||||
}
|
||||
|
||||
fn on_focus(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
ctx.focus(&self.active_pane);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{pane, Workspace, WorkspaceView};
|
||||
use crate::{settings, test::temp_tree, workspace::WorkspaceHandle as _};
|
||||
use anyhow::Result;
|
||||
use gpui::{App, FontCache};
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn test_open_entry() -> Result<()> {
|
||||
App::test(|mut app| async move {
|
||||
let dir = temp_tree(json!({
|
||||
"a": {
|
||||
"aa": "aa contents",
|
||||
"ab": "ab contents",
|
||||
"ac": "ab contents",
|
||||
},
|
||||
}));
|
||||
|
||||
let settings = settings::channel(&FontCache::new()).unwrap().1;
|
||||
let workspace = app.add_model(|ctx| Workspace::new(vec![dir.path().into()], ctx));
|
||||
app.finish_pending_tasks().await; // Open and populate worktree.
|
||||
let entries = workspace.file_entries(&app);
|
||||
|
||||
let (_, workspace_view) =
|
||||
app.add_window(|ctx| WorkspaceView::new(workspace.clone(), settings, ctx));
|
||||
|
||||
// Open the first entry
|
||||
workspace_view.update(&mut app, |w, ctx| w.open_entry(entries[0], ctx));
|
||||
app.finish_pending_tasks().await;
|
||||
|
||||
workspace_view.read(&app, |w, app| {
|
||||
assert_eq!(w.active_pane().as_ref(app).items().len(), 1);
|
||||
});
|
||||
|
||||
// Open the second entry
|
||||
workspace_view.update(&mut app, |w, ctx| w.open_entry(entries[1], ctx));
|
||||
app.finish_pending_tasks().await;
|
||||
|
||||
workspace_view.read(&app, |w, app| {
|
||||
let active_pane = w.active_pane().as_ref(app);
|
||||
assert_eq!(active_pane.items().len(), 2);
|
||||
assert_eq!(
|
||||
active_pane.active_item().unwrap().entry_id(app),
|
||||
Some(entries[1])
|
||||
);
|
||||
});
|
||||
|
||||
// Open the first entry again
|
||||
workspace_view.update(&mut app, |w, ctx| w.open_entry(entries[0], ctx));
|
||||
app.finish_pending_tasks().await;
|
||||
|
||||
workspace_view.read(&app, |w, app| {
|
||||
let active_pane = w.active_pane().as_ref(app);
|
||||
assert_eq!(active_pane.items().len(), 2);
|
||||
assert_eq!(
|
||||
active_pane.active_item().unwrap().entry_id(app),
|
||||
Some(entries[0])
|
||||
);
|
||||
});
|
||||
|
||||
// Open the third entry twice concurrently
|
||||
workspace_view.update(&mut app, |w, ctx| {
|
||||
w.open_entry(entries[2], ctx);
|
||||
w.open_entry(entries[2], ctx);
|
||||
});
|
||||
app.finish_pending_tasks().await;
|
||||
|
||||
workspace_view.read(&app, |w, app| {
|
||||
assert_eq!(w.active_pane().as_ref(app).items().len(), 3);
|
||||
});
|
||||
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_pane_actions() -> Result<()> {
|
||||
App::test(|mut app| async move {
|
||||
pane::init(&mut app);
|
||||
|
||||
let dir = temp_tree(json!({
|
||||
"a": {
|
||||
"aa": "aa contents",
|
||||
"ab": "ab contents",
|
||||
"ac": "ab contents",
|
||||
},
|
||||
}));
|
||||
|
||||
let settings = settings::channel(&FontCache::new()).unwrap().1;
|
||||
let workspace = app.add_model(|ctx| Workspace::new(vec![dir.path().into()], ctx));
|
||||
app.finish_pending_tasks().await; // Open and populate worktree.
|
||||
let entries = workspace.file_entries(&app);
|
||||
|
||||
let (window_id, workspace_view) =
|
||||
app.add_window(|ctx| WorkspaceView::new(workspace.clone(), settings, ctx));
|
||||
|
||||
workspace_view.update(&mut app, |w, ctx| w.open_entry(entries[0], ctx));
|
||||
app.finish_pending_tasks().await;
|
||||
|
||||
let pane_1 = workspace_view.read(&app, |w, _| w.active_pane().clone());
|
||||
|
||||
app.dispatch_action(window_id, vec![pane_1.id()], "pane:split_right", ());
|
||||
let pane_2 = workspace_view.read(&app, |w, _| w.active_pane().clone());
|
||||
assert_ne!(pane_1, pane_2);
|
||||
|
||||
pane_2.read(&app, |p, app| {
|
||||
assert_eq!(p.active_item().unwrap().entry_id(app), Some(entries[0]));
|
||||
});
|
||||
|
||||
app.dispatch_action(window_id, vec![pane_2.id()], "pane:close_active_item", ());
|
||||
|
||||
workspace_view.read(&app, |w, _| {
|
||||
assert_eq!(w.panes.len(), 1);
|
||||
assert_eq!(w.active_pane(), &pane_1)
|
||||
});
|
||||
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
}
|
|
@ -2,4 +2,4 @@ mod char_bag;
|
|||
mod fuzzy;
|
||||
mod worktree;
|
||||
|
||||
pub use worktree::{match_paths, FileHandle, PathMatch, Worktree};
|
||||
pub use worktree::{match_paths, FileHandle, PathMatch, Worktree, WorktreeHandle};
|
||||
|
|
Loading…
Reference in a new issue