Add pkg/util/dirtree
Update dir_tree tool to use it Update filepicker bubble to use it
This commit is contained in:
@@ -3,12 +3,11 @@ package toolbox
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
toolutil "git.mlow.ca/mlow/lmcli/pkg/agents/toolbox/util"
|
||||
"git.mlow.ca/mlow/lmcli/pkg/api"
|
||||
"git.mlow.ca/mlow/lmcli/pkg/util/dirtree"
|
||||
)
|
||||
|
||||
const TREE_DESCRIPTION = `Retrieve a tree-like view of a directory's contents.
|
||||
@@ -85,58 +84,25 @@ func tree(path string, depth int) api.CallResult {
|
||||
return api.CallResult{Message: reason}
|
||||
}
|
||||
|
||||
var treeOutput strings.Builder
|
||||
treeOutput.WriteString(path + "\n")
|
||||
err := buildTree(&treeOutput, path, "", depth)
|
||||
tree := dirtree.NewTree(path)
|
||||
err := tree.Root.LoadChildren(true, depth, nil)
|
||||
if err != nil {
|
||||
return api.CallResult{
|
||||
Message: err.Error(),
|
||||
}
|
||||
}
|
||||
|
||||
return api.CallResult{Result: treeOutput.String()}
|
||||
}
|
||||
|
||||
func buildTree(output *strings.Builder, path string, prefix string, depth int) error {
|
||||
files, err := os.ReadDir(path)
|
||||
nodes := dirtree.FlattenTree(tree.Root, true)
|
||||
rendered := dirtree.RenderNodes(nodes, dirtree.Standard, -1, func(n *dirtree.Node) string {
|
||||
if n.IsDir() {
|
||||
return fmt.Sprintf("%s/", n.Name)
|
||||
} else {
|
||||
info, err := os.Stat(n.Path)
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Sprintf("%s (ERR: %s bytes)", n.Name, err.Error())
|
||||
}
|
||||
|
||||
for i, file := range files {
|
||||
if strings.HasPrefix(file.Name(), ".") {
|
||||
// Skip hidden files and directories
|
||||
continue
|
||||
return fmt.Sprintf("%s (%d bytes)", n.Name, info.Size())
|
||||
}
|
||||
|
||||
isLast := i == len(files)-1
|
||||
var branch string
|
||||
if isLast {
|
||||
branch = "└── "
|
||||
} else {
|
||||
branch = "├── "
|
||||
}
|
||||
|
||||
info, _ := file.Info()
|
||||
size := info.Size()
|
||||
sizeStr := fmt.Sprintf(" (%d bytes)", size)
|
||||
|
||||
output.WriteString(prefix + branch + file.Name())
|
||||
if file.IsDir() {
|
||||
output.WriteString("/\n")
|
||||
if depth > 0 {
|
||||
var nextPrefix string
|
||||
if isLast {
|
||||
nextPrefix = prefix + " "
|
||||
} else {
|
||||
nextPrefix = prefix + "│ "
|
||||
}
|
||||
buildTree(output, filepath.Join(path, file.Name()), nextPrefix, depth-1)
|
||||
}
|
||||
} else {
|
||||
output.WriteString(sizeStr + "\n")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
return api.CallResult{Result: rendered}
|
||||
}
|
||||
|
||||
113
pkg/tui/bubbles/filepicker/filepicker.go
Normal file
113
pkg/tui/bubbles/filepicker/filepicker.go
Normal file
@@ -0,0 +1,113 @@
|
||||
package filepicker
|
||||
|
||||
import (
|
||||
"git.mlow.ca/mlow/lmcli/pkg/util/dirtree"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
type Model struct {
|
||||
tree *dirtree.Tree
|
||||
cursor int
|
||||
displayNodes []*dirtree.Node // Flattened view of visible nodes
|
||||
}
|
||||
|
||||
func New(path string) Model {
|
||||
m := Model{
|
||||
tree: dirtree.NewTree(path),
|
||||
}
|
||||
|
||||
m.tree.Root.LoadChildren(false, 0, nil)
|
||||
m.updateRenderTree()
|
||||
return m
|
||||
}
|
||||
|
||||
func (m *Model) updateRenderTree() {
|
||||
m.displayNodes = dirtree.FlattenTree(m.tree.Root, false)
|
||||
}
|
||||
|
||||
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.KeyMsg:
|
||||
switch msg.String() {
|
||||
case "j":
|
||||
if m.cursor < len(m.displayNodes)-1 {
|
||||
m.cursor++
|
||||
}
|
||||
case "k":
|
||||
if m.cursor > 0 {
|
||||
m.cursor--
|
||||
}
|
||||
case "l", "right":
|
||||
current := m.displayNodes[m.cursor]
|
||||
if current.IsDir() {
|
||||
current.Expanded = true
|
||||
current.LoadChildren(false, 0, nil)
|
||||
m.updateRenderTree()
|
||||
}
|
||||
case "h", "left":
|
||||
current := m.displayNodes[m.cursor]
|
||||
if current.IsDir() && current.Expanded {
|
||||
current.Expanded = false
|
||||
m.updateRenderTree()
|
||||
} else {
|
||||
// Find and collapse parent
|
||||
if !current.IsRoot() {
|
||||
current.Parent().Expanded = false
|
||||
m.updateRenderTree()
|
||||
// Move cursor to parent
|
||||
for i, node := range m.displayNodes {
|
||||
if node == current.Parent() {
|
||||
m.cursor = i
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
case " ":
|
||||
current := m.displayNodes[m.cursor]
|
||||
if current.IsSelected() {
|
||||
current.Deselect(true)
|
||||
} else {
|
||||
current.Select(true, 0)
|
||||
}
|
||||
m.updateRenderTree()
|
||||
case "H":
|
||||
m.tree.ShowHidden = !m.tree.ShowHidden
|
||||
m.tree.Root.LoadChildren(false, 0, nil)
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m Model) Init() tea.Cmd {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m Model) View() string {
|
||||
// Define styles
|
||||
dirStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("12")) // Blue for directories
|
||||
fileStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("7")) // White for files
|
||||
selectedStyle := lipgloss.NewStyle().Bold(true)
|
||||
|
||||
renderNode := func(node *dirtree.Node) string {
|
||||
var nodeName string
|
||||
if node.IsDir() {
|
||||
caret := "▶ "
|
||||
if node.Expanded {
|
||||
caret = "▼ "
|
||||
}
|
||||
nodeName = caret + node.Name + "/"
|
||||
nodeName = dirStyle.Render(nodeName)
|
||||
} else {
|
||||
nodeName = fileStyle.Render(node.Name)
|
||||
}
|
||||
|
||||
if node.IsSelected() {
|
||||
nodeName = selectedStyle.Render(nodeName)
|
||||
}
|
||||
return nodeName
|
||||
}
|
||||
|
||||
return dirtree.RenderNodes(m.displayNodes, dirtree.Compact, m.cursor, renderNode)
|
||||
}
|
||||
@@ -1,299 +0,0 @@
|
||||
package filepicker
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
type Node struct {
|
||||
path string
|
||||
name string
|
||||
isDir bool
|
||||
expanded bool
|
||||
children []*Node
|
||||
}
|
||||
|
||||
type Model struct {
|
||||
root *Node
|
||||
cursor int
|
||||
showHidden bool
|
||||
displayLines []*Node // Flattened view of visible nodes
|
||||
rootPath string
|
||||
|
||||
SelectedPaths []string
|
||||
expandedPaths map[string]bool // Track expanded paths
|
||||
}
|
||||
|
||||
func New(path string) Model {
|
||||
root := &Node{
|
||||
path: path,
|
||||
name: filepath.Base(path),
|
||||
isDir: true,
|
||||
expanded: true,
|
||||
}
|
||||
|
||||
m := Model{
|
||||
root: root,
|
||||
rootPath: path,
|
||||
SelectedPaths: make([]string, 0),
|
||||
showHidden: false,
|
||||
expandedPaths: make(map[string]bool),
|
||||
}
|
||||
|
||||
m.loadDirectory(root)
|
||||
m.updateDisplayLines()
|
||||
return m
|
||||
}
|
||||
|
||||
func (m *Model) loadDirectory(node *Node) error {
|
||||
// Save current expanded state of children
|
||||
for _, child := range node.children {
|
||||
if child.expanded {
|
||||
m.expandedPaths[child.path] = true
|
||||
}
|
||||
}
|
||||
|
||||
entries, err := os.ReadDir(node.path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
node.children = make([]*Node, 0)
|
||||
for _, entry := range entries {
|
||||
if !m.showHidden && strings.HasPrefix(entry.Name(), ".") {
|
||||
continue
|
||||
}
|
||||
|
||||
childPath := filepath.Join(node.path, entry.Name())
|
||||
child := &Node{
|
||||
path: childPath,
|
||||
name: entry.Name(),
|
||||
isDir: entry.IsDir(),
|
||||
expanded: m.expandedPaths[childPath], // Restore expanded state
|
||||
}
|
||||
node.children = append(node.children, child)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Model) updateDisplayLines() {
|
||||
m.displayLines = make([]*Node, 0)
|
||||
m.flattenTree(m.root, 0)
|
||||
}
|
||||
|
||||
func (m *Model) flattenTree(node *Node, depth int) {
|
||||
m.displayLines = append(m.displayLines, node)
|
||||
if node.isDir && node.expanded {
|
||||
for _, child := range node.children {
|
||||
m.flattenTree(child, depth+1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.KeyMsg:
|
||||
switch msg.String() {
|
||||
case "j":
|
||||
if m.cursor < len(m.displayLines)-1 {
|
||||
m.cursor++
|
||||
}
|
||||
case "k":
|
||||
if m.cursor > 0 {
|
||||
m.cursor--
|
||||
}
|
||||
case "l":
|
||||
current := m.displayLines[m.cursor]
|
||||
if current.isDir {
|
||||
current.expanded = true
|
||||
m.expandedPaths[current.path] = true
|
||||
m.loadDirectory(current)
|
||||
m.updateDisplayLines()
|
||||
}
|
||||
case "h":
|
||||
current := m.displayLines[m.cursor]
|
||||
if current.isDir && current.expanded {
|
||||
current.expanded = false
|
||||
delete(m.expandedPaths, current.path)
|
||||
m.updateDisplayLines()
|
||||
} else {
|
||||
// Find and collapse parent
|
||||
parent := m.findParent(current)
|
||||
if parent != nil && parent.path != m.rootPath {
|
||||
parent.expanded = false
|
||||
delete(m.expandedPaths, parent.path)
|
||||
m.updateDisplayLines()
|
||||
// Move cursor to parent
|
||||
for i, node := range m.displayLines {
|
||||
if node == parent {
|
||||
m.cursor = i
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
case " ":
|
||||
current := m.displayLines[m.cursor]
|
||||
if m.isSelected(current.path) {
|
||||
m.SelectedPaths = removeString(m.SelectedPaths, current.path)
|
||||
} else {
|
||||
m.SelectedPaths = append(m.SelectedPaths, current.path)
|
||||
}
|
||||
case "H":
|
||||
m.showHidden = !m.showHidden
|
||||
m.reloadTree()
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m Model) Init() tea.Cmd {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m Model) View() string {
|
||||
var sb strings.Builder
|
||||
|
||||
// Define styles
|
||||
dirStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("12")) // Blue for directories
|
||||
fileStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("7")) // White for files
|
||||
cursorStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("yellow"))
|
||||
selectedStyle := lipgloss.NewStyle().Bold(true)
|
||||
|
||||
// Track the parent-child relationships for proper indentation
|
||||
levelIsLast := make(map[int]bool)
|
||||
|
||||
for i, node := range m.displayLines {
|
||||
// Calculate depth and prefix
|
||||
depth := 0
|
||||
if node != m.root {
|
||||
depth = strings.Count(node.path[len(m.rootPath):], string(os.PathSeparator))
|
||||
}
|
||||
|
||||
// Build the prefix (tree structure)
|
||||
var prefix strings.Builder
|
||||
|
||||
if node != m.root {
|
||||
// For each level up to current depth, add the appropriate prefix
|
||||
for d := 0; d < depth; d++ {
|
||||
if levelIsLast[d] {
|
||||
prefix.WriteString(" ")
|
||||
} else {
|
||||
prefix.WriteString("│ ")
|
||||
}
|
||||
}
|
||||
|
||||
// Find if this node is the last among its siblings
|
||||
parent := m.findParent(node)
|
||||
if parent != nil {
|
||||
isLast := parent.children[len(parent.children)-1] == node
|
||||
levelIsLast[depth] = isLast
|
||||
|
||||
if isLast {
|
||||
prefix.WriteString("└── ")
|
||||
} else {
|
||||
prefix.WriteString("├── ")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Cursor indicator
|
||||
cursor := " "
|
||||
if i == m.cursor {
|
||||
cursor = cursorStyle.Render(">")
|
||||
}
|
||||
|
||||
// Node name with optional caret for directories
|
||||
var nodeName string
|
||||
if node.isDir {
|
||||
caret := "▶ "
|
||||
if node.expanded {
|
||||
caret = "▼ "
|
||||
}
|
||||
nodeName = caret + node.name
|
||||
if node != m.root {
|
||||
nodeName += "/"
|
||||
}
|
||||
nodeName = dirStyle.Render(nodeName)
|
||||
} else {
|
||||
nodeName = fileStyle.Render(node.name)
|
||||
}
|
||||
|
||||
// Apply bold style if selected
|
||||
if m.isSelected(node.path) {
|
||||
nodeName = selectedStyle.Render(nodeName)
|
||||
}
|
||||
|
||||
// Build the line
|
||||
line := fmt.Sprintf("%s %s%s\n",
|
||||
cursor,
|
||||
prefix.String(),
|
||||
nodeName)
|
||||
|
||||
sb.WriteString(line)
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func (m *Model) reloadTree() {
|
||||
// Save expanded state before reloading
|
||||
m.saveExpandedState(m.root)
|
||||
|
||||
m.root.children = nil
|
||||
m.loadDirectory(m.root)
|
||||
m.updateDisplayLines()
|
||||
}
|
||||
|
||||
func (m *Model) saveExpandedState(node *Node) {
|
||||
if node.isDir && node.expanded {
|
||||
m.expandedPaths[node.path] = true
|
||||
for _, child := range node.children {
|
||||
m.saveExpandedState(child)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Model) findParent(node *Node) *Node {
|
||||
var findParentRec func(*Node) *Node
|
||||
|
||||
findParentRec = func(current *Node) *Node {
|
||||
if current == nil {
|
||||
return nil
|
||||
}
|
||||
for _, child := range current.children {
|
||||
if child == node {
|
||||
return current
|
||||
}
|
||||
if result := findParentRec(child); result != nil {
|
||||
return result
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
return findParentRec(m.root)
|
||||
}
|
||||
|
||||
func (m Model) isSelected(path string) bool {
|
||||
for _, selected := range m.SelectedPaths {
|
||||
if selected == path {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func removeString(slice []string, s string) []string {
|
||||
for i, v := range slice {
|
||||
if v == s {
|
||||
return append(slice[:i], slice[i+1:]...)
|
||||
}
|
||||
}
|
||||
return slice
|
||||
}
|
||||
82
pkg/util/dirtree/render.go
Normal file
82
pkg/util/dirtree/render.go
Normal file
@@ -0,0 +1,82 @@
|
||||
package dirtree
|
||||
|
||||
import "strings"
|
||||
|
||||
// FlattenTree returns []*Node consistent with each node's `Expanded` state
|
||||
func FlattenTree(node *Node, expandAll bool) []*Node {
|
||||
lines := make([]*Node, 0)
|
||||
var flatten func(n *Node)
|
||||
flatten = func(node *Node) {
|
||||
lines = append(lines, node)
|
||||
if node.isDir && (node.Expanded || expandAll) {
|
||||
for _, child := range node.children {
|
||||
flatten(child)
|
||||
}
|
||||
}
|
||||
}
|
||||
flatten(node)
|
||||
return lines
|
||||
}
|
||||
|
||||
type TreeFormat struct {
|
||||
Space string
|
||||
Span string
|
||||
Node string
|
||||
NodeLast string
|
||||
}
|
||||
|
||||
var (
|
||||
Standard = TreeFormat{
|
||||
Space: " ",
|
||||
Span: "│ ",
|
||||
Node: "├── ",
|
||||
NodeLast: "└── ",
|
||||
}
|
||||
Compact = TreeFormat{
|
||||
Space: " ",
|
||||
Span: "│ ",
|
||||
Node: "├ ",
|
||||
NodeLast: "└ ",
|
||||
}
|
||||
)
|
||||
|
||||
func RenderNodes(displayNodes []*Node, format TreeFormat, cursor int, renderNode func(*Node) string) string {
|
||||
var sb strings.Builder
|
||||
levelIsLast := make(map[int]bool)
|
||||
for i, node := range displayNodes {
|
||||
// Cursor indicator
|
||||
if cursor >= 0 {
|
||||
if i == cursor {
|
||||
sb.WriteString("> ")
|
||||
} else {
|
||||
sb.WriteString(" ")
|
||||
}
|
||||
}
|
||||
|
||||
// Tree structure
|
||||
if node.depth > 0 {
|
||||
for d := 1; d < node.depth; d++ {
|
||||
if levelIsLast[d] {
|
||||
sb.WriteString(format.Space)
|
||||
} else {
|
||||
sb.WriteString(format.Span)
|
||||
}
|
||||
}
|
||||
|
||||
// Find if this node is the last among its siblings
|
||||
isLast := node.parent.children[len(node.parent.children)-1] == node
|
||||
levelIsLast[node.depth] = isLast
|
||||
|
||||
if isLast {
|
||||
sb.WriteString(format.NodeLast)
|
||||
} else {
|
||||
sb.WriteString(format.Node)
|
||||
}
|
||||
}
|
||||
|
||||
// Render the node
|
||||
sb.WriteString(renderNode(node))
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
132
pkg/util/dirtree/tree.go
Normal file
132
pkg/util/dirtree/tree.go
Normal file
@@ -0,0 +1,132 @@
|
||||
package dirtree
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Tree struct {
|
||||
Root *Node
|
||||
ShowHidden bool
|
||||
}
|
||||
|
||||
type Node struct {
|
||||
tree *Tree
|
||||
parent *Node
|
||||
children []*Node
|
||||
depth int
|
||||
Path string
|
||||
Name string
|
||||
Expanded bool
|
||||
isDir bool
|
||||
selected bool
|
||||
}
|
||||
|
||||
func NewTree(path string) *Tree {
|
||||
tree := &Tree{
|
||||
ShowHidden: false,
|
||||
}
|
||||
tree.Root = &Node{
|
||||
tree: tree,
|
||||
parent: nil,
|
||||
Path: path,
|
||||
Name: filepath.Base(path),
|
||||
isDir: true,
|
||||
Expanded: true,
|
||||
}
|
||||
return tree
|
||||
}
|
||||
|
||||
func (node *Node) IsRoot() bool {
|
||||
return node.tree.Root == node
|
||||
}
|
||||
|
||||
func (node *Node) Parent() *Node {
|
||||
return node.parent
|
||||
}
|
||||
|
||||
func (node *Node) IsSelected() bool {
|
||||
return node.selected
|
||||
}
|
||||
|
||||
func (node *Node) IsDir() bool {
|
||||
return node.isDir
|
||||
}
|
||||
|
||||
func (node *Node) Select(recurse bool, maxDepth int) {
|
||||
node.selected = true
|
||||
if node.isDir && recurse {
|
||||
node.LoadChildren(recurse, maxDepth, func (n *Node) {
|
||||
n.selected = true
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (node *Node) Deselect(recurse bool) {
|
||||
node.selected = false
|
||||
if node.isDir && recurse {
|
||||
for _, child := range node.children {
|
||||
child.Deselect(recurse)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (node *Node) LoadChildren(recurse bool, maxDepth int, visitFunc func (node *Node)) error {
|
||||
startDepth := node.depth
|
||||
var loadChildren func(node *Node) error
|
||||
loadChildren = func(node *Node) error {
|
||||
// Load new entries
|
||||
entries, err := os.ReadDir(node.Path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Save current children before loading
|
||||
oldChildren := make(map[string]*Node)
|
||||
for _, child := range node.children {
|
||||
oldChildren[child.Path] = child
|
||||
}
|
||||
|
||||
node.children = make([]*Node, 0)
|
||||
for _, entry := range entries {
|
||||
if !node.tree.ShowHidden && strings.HasPrefix(entry.Name(), ".") {
|
||||
continue
|
||||
}
|
||||
|
||||
child := &Node{
|
||||
tree: node.tree,
|
||||
parent: node,
|
||||
depth: node.depth + 1,
|
||||
Name: entry.Name(),
|
||||
isDir: entry.IsDir(),
|
||||
Path: filepath.Join(node.Path, entry.Name()),
|
||||
}
|
||||
|
||||
oldChild, exists := oldChildren[child.Path]
|
||||
if child.isDir {
|
||||
// Preserve child's grandchildren and expanded/selected state
|
||||
if exists && (oldChild.Expanded || len(oldChild.children) > 0) {
|
||||
child.Expanded = oldChild.Expanded
|
||||
child.selected = oldChild.selected
|
||||
child.children = oldChild.children
|
||||
for i := range child.children {
|
||||
child.children[i].parent = child
|
||||
}
|
||||
loadChildren(child)
|
||||
} else if recurse && (maxDepth <= 0 || node.depth - startDepth < maxDepth) {
|
||||
loadChildren(child)
|
||||
}
|
||||
} else if exists {
|
||||
child.selected = oldChild.selected
|
||||
}
|
||||
|
||||
node.children = append(node.children, child)
|
||||
if visitFunc != nil {
|
||||
visitFunc(child)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return loadChildren(node)
|
||||
}
|
||||
Reference in New Issue
Block a user