diff --git a/.entire/.gitignore b/.entire/.gitignore index 2cffdef..b129f25 100644 --- a/.entire/.gitignore +++ b/.entire/.gitignore @@ -2,3 +2,5 @@ tmp/ settings.local.json metadata/ logs/ +.crush/logs +.entire/metadata/ diff --git a/KNOWN_ISSUES.md b/KNOWN_ISSUES.md deleted file mode 100644 index e838eb9..0000000 --- a/KNOWN_ISSUES.md +++ /dev/null @@ -1,64 +0,0 @@ -# Known Issues - -## macOS: Tray "Show DevToolbox" doesn't restore hidden window - -**GitHub Issue:** [#51](https://github.com/vuon9/devtoolbox/issues/51) -**Status:** Open -**Platform:** macOS only -**Severity:** Medium -**Component:** System Tray / Window Management - -### Description -When the window is hidden to the system tray (via close button with "Close minimizes to tray" setting enabled), clicking "Show DevToolbox" from the tray menu does not restore the window. - -### Steps to Reproduce -1. Enable "Close button minimizes to tray" in Settings -2. Click the window's close button (X) -3. Window hides to tray (app continues running) -4. Click the tray icon and select "Show DevToolbox" -5. Window does not appear (logs show `Window visible: false`) - -### Expected Behavior -Window should be restored and shown when clicking "Show DevToolbox" from the tray menu. - -### Actual Behavior -Window remains hidden. Logs show: -``` -Tray menu 'Show DevToolbox' clicked -Window visible: false, minimized: false -Activating application -Window is not visible, showing it -Focusing window -After show - Window visible: false, minimized: false -``` - -### Technical Details -The current implementation attempts: -1. `app.Show()` - calls `[NSApp unhide:nil]` to activate the app -2. `mainWindow.Show()` - show the window -3. `mainWindow.Focus()` - focus the window - -However, on macOS, when a window is hidden via `Hide()` (which calls `[window orderOut:nil]`), the standard `Show()` and `Focus()` methods are insufficient to restore it. - -### Potential Solutions - -1. **Use `AttachedWindow` pattern** (recommended by Wails docs): - - Attach the window to the system tray - - Let Wails handle the show/hide toggle automatically - - This is the built-in mechanism for tray-attached windows - -2. **Platform-specific handling**: - - On macOS, may need to use `makeKeyAndOrderFront` directly - - Or use different window state management - -3. **Window state tracking**: - - Instead of `Hide()`, minimize the window - - `Minimise()` + `Restore()` works correctly on macOS - -### References -- Wails v3 System Tray docs: https://v3alpha.wails.io/features/menus/systray/ -- Wails v3 Window docs: https://v3alpha.wails.io/reference/window/ -- Related code: `main.go` tray menu click handler - -### Workaround -Users can use the global hotkey `Cmd+Ctrl+M` to open the command palette, which will also show the window. diff --git a/internal/codeformatter/css_selector_test.go b/internal/codeformatter/css_selector_test.go index f0a8d5f..22b24b9 100644 --- a/internal/codeformatter/css_selector_test.go +++ b/internal/codeformatter/css_selector_test.go @@ -8,9 +8,6 @@ import ( ) func TestApplyCSSSelector(t *testing.T) { - // TODO: Fix these tests - they're failing due to newline formatting issues - // and incomplete CSS selector support - t.Skip("Skipping test: known issue with CSS selector formatting") tests := []struct { name string html string @@ -22,7 +19,7 @@ func TestApplyCSSSelector(t *testing.T) { name: "Element selector - find all divs", html: `
First
Second
`, selector: "div", - want: `
First
\n
Second
`, + want: "
First
\n
Second
", }, { name: "Class selector - find by class", @@ -61,13 +58,13 @@ func TestApplyCSSSelector(t *testing.T) {

Post 2

`, selector: "article", - want: `

Post 1

\n

Post 2

`, + want: "

Post 1

\n

Post 2

", }, { name: "No html/body wrapper - elements extracted directly", html: `ComputerTech`, selector: "genre", - want: `Computer\nTech`, + want: "Computer\nTech", }, { name: "No matching elements", @@ -162,14 +159,26 @@ func TestFindElementByID(t *testing.T) { } func TestFindElementsByDescendant(t *testing.T) { - // TODO: Fix this test - CSS selector implementation incomplete - t.Skip("Skipping test: known issue with CSS selector descendant implementation") + html := `

Title

Text

Inner Title

` + doc := parseHTML(t, html) + + // Test element with class selector + results := findElementsByDescendant(doc, "div.container > h1") + if len(results) != 1 { + t.Errorf("Expected 1 h1 inside div.container, got %d", len(results)) + } + + // Test simple tag selector at end + results = findElementsByDescendant(doc, "div h1") + if len(results) != 2 { + t.Errorf("Expected 2 h1 elements inside div, got %d", len(results)) + } } // Helper function to parse HTML for testing -func parseHTML(t *testing.T, htmlStr string) *html.Node { +func parseHTML(t *testing.T, htmlContent string) *html.Node { t.Helper() - doc, err := html.Parse(strings.NewReader(htmlStr)) + doc, err := html.Parse(strings.NewReader(htmlContent)) if err != nil { t.Fatalf("Failed to parse HTML: %v", err) } diff --git a/internal/codeformatter/service.go b/internal/codeformatter/service.go index 640afcb..ed22445 100644 --- a/internal/codeformatter/service.go +++ b/internal/codeformatter/service.go @@ -162,8 +162,8 @@ func applyXPathFilter(xml string, xpath string) (string, error) { xpath = strings.TrimSpace(xpath) - // Handle element[@attr='value'] pattern - extract element with specific attribute - if strings.Contains(xpath, "[@") && strings.Contains(xpath, "='") { + // Handle element[@attr='value'] or element[@attr="value"] pattern - extract element with specific attribute + if strings.Contains(xpath, "[@") && (strings.Contains(xpath, "='") || strings.Contains(xpath, "=\"")) { return extractElementByAttribute(xml, xpath) } @@ -278,30 +278,59 @@ func extractElementByAttribute(xmlStr, xpath string) (string, error) { attrValue := attrSelector[valueStart+1 : valueEnd] - // Find elements with matching attribute - searchPattern := "<" + elementName + " " + attrName + "=" + string(quoteChar) + attrValue + string(quoteChar) + // Find elements with matching attribute by searching for element tags first, + // then checking if they have the specified attribute + startTag := "<" + elementName endTag := "" var results []string start := 0 for { - // Find element with this attribute - idx := strings.Index(xmlStr[start:], searchPattern) + // Find the next occurrence of the element start tag + idx := strings.Index(xmlStr[start:], startTag) if idx == -1 { - // Try alternate quote style - altQuote := "'" - if quoteChar == '\'' { - altQuote = "\"" - } - searchPattern = "<" + elementName + " " + attrName + "=" + altQuote + attrValue + altQuote - idx = strings.Index(xmlStr[start:], searchPattern) - if idx == -1 { - break - } + break } idx += start + // Check that this is a complete tag name (not a prefix) + afterTag := idx + len(startTag) + if afterTag < len(xmlStr) { + nextChar := xmlStr[afterTag] + if nextChar != ' ' && nextChar != '>' && nextChar != '/' { + // This is a prefix match (e.g., "book" matching "bookmark"), skip it + start = idx + len(startTag) + continue + } + } + + // Find the end of the opening tag to extract attributes + tagEnd := idx + len(startTag) + for tagEnd < len(xmlStr) && xmlStr[tagEnd] != '>' && xmlStr[tagEnd] != '/' { + tagEnd++ + } + + // Extract the tag content (attributes) + tagContent := xmlStr[idx:tagEnd] + + // Check if this tag has the attribute with the specified value + // Look for attr="value" or attr='value' + attrPattern1 := attrName + "=" + string(quoteChar) + attrValue + string(quoteChar) + attrPattern2 := attrName + "=" + string(quoteChar) + attrValue + string(quoteChar) + if quoteChar == '\'' { + attrPattern2 = attrName + "=\"" + attrValue + "\"" + } else { + attrPattern2 = attrName + "='" + attrValue + "'" + } + + if !strings.Contains(tagContent, attrPattern1) && !strings.Contains(tagContent, attrPattern2) { + // Attribute not found in this tag, skip to next + start = idx + len(startTag) + continue + } + + // Found matching element, extract it // Find end of this element endIdx := strings.Index(xmlStr[idx:], endTag) if endIdx == -1 { @@ -349,20 +378,18 @@ func extractNestedXMLElements(xmlStr string, pathParts []string) (string, error) // Get the first element in the path currentElement := pathParts[0] - // Extract all elements with the first name - extracted, err := extractXMLElements(xmlStr, currentElement) + // Extract all elements with the first name (returns slice to avoid newline issues) + matches, err := extractXMLElementsSlice(xmlStr, currentElement) if err != nil { return "", err } - // If this is the last part, return it + // If this is the last part, return joined results if len(pathParts) == 1 { - return extracted, nil + return strings.Join(matches, "\n"), nil } // Continue with the rest of the path - // Split the extracted content by newlines and process each match - matches := strings.Split(extracted, "\n") var finalResults []string for _, match := range matches { @@ -385,6 +412,15 @@ func extractNestedXMLElements(xmlStr string, pathParts []string) (string, error) // extractXMLElements extracts elements by name from XML func extractXMLElements(xmlStr, elementName string) (string, error) { + elements, err := extractXMLElementsSlice(xmlStr, elementName) + if err != nil { + return "", err + } + return strings.Join(elements, "\n"), nil +} + +// extractXMLElementsSlice extracts elements by name from XML and returns as a slice +func extractXMLElementsSlice(xmlStr, elementName string) ([]string, error) { // Simple extraction using string manipulation // In production, use proper XML parsing startTag := "<" + elementName @@ -400,6 +436,17 @@ func extractXMLElements(xmlStr, elementName string) (string, error) { } idx += start + // Check that this is a complete tag name (not a prefix like "item" matching "items") + afterTag := idx + len(startTag) + if afterTag < len(xmlStr) { + nextChar := xmlStr[afterTag] + if nextChar != ' ' && nextChar != '>' && nextChar != '/' { + // This is a prefix match, skip it + start = idx + len(startTag) + continue + } + } + // Find end of this element endIdx := strings.Index(xmlStr[idx:], endTag) if endIdx == -1 { @@ -417,10 +464,10 @@ func extractXMLElements(xmlStr, elementName string) (string, error) { } if len(results) == 0 { - return "", fmt.Errorf("no elements found with name: %s", elementName) + return nil, fmt.Errorf("no elements found with name: %s", elementName) } - return strings.Join(results, "\n"), nil + return results, nil } // extractXMLAttributes extracts attribute values from XML elements @@ -691,6 +738,20 @@ func applyCSSSelector(htmlStr, selector string) (string, error) { return result, nil } + // Handle element with class selector (e.g., "div.header") + if strings.Contains(selector, ".") && !strings.Contains(selector, " ") && !strings.Contains(selector, ">") { + parts := strings.Split(selector, ".") + if len(parts) == 2 { + tagName := parts[0] + className := parts[1] + results := findElementsByTagAndClass(doc, tagName, className) + if len(results) == 0 { + return "", fmt.Errorf("no elements found matching selector: %s", selector) + } + return strings.Join(results, "\n"), nil + } + } + // Handle descendant selector (e.g., "div.container > h1") if strings.Contains(selector, " ") || strings.Contains(selector, ">") { results := findElementsByDescendant(doc, selector) @@ -793,6 +854,34 @@ func findElementsByClass(n *html.Node, className string) []string { return results } +// findElementsByTagAndClass finds elements by tag name and class +func findElementsByTagAndClass(n *html.Node, tagName, className string) []string { + var results []string + + var traverse func(*html.Node) + traverse = func(node *html.Node) { + if node.Type == html.ElementNode && node.Data == tagName { + for _, attr := range node.Attr { + if attr.Key == "class" { + classes := strings.Fields(attr.Val) + for _, c := range classes { + if c == className { + results = append(results, renderNodeToString(node)) + break + } + } + } + } + } + for c := node.FirstChild; c != nil; c = c.NextSibling { + traverse(c) + } + } + + traverse(n) + return results +} + // findElementByID finds element by ID func findElementByID(n *html.Node, id string) string { var result string @@ -819,51 +908,220 @@ func findElementByID(n *html.Node, id string) string { return result } +// cssSelectorPart represents a part of a CSS selector chain +type cssSelectorPart struct { + selector string + directChild bool +} + // findElementsByDescendant finds elements by descendant selector func findElementsByDescendant(n *html.Node, selector string) []string { - // Parse simple descendant selectors like "div.container > h1" - parts := strings.FieldsFunc(selector, func(r rune) bool { - return r == ' ' || r == '>' - }) + // Parse selector into parts with relationship info + // e.g., "div.container > h1" becomes [(div.container, >), (h1, )] + var parts []cssSelectorPart + current := "" + directChild := false + + for i, r := range selector { + if r == '>' { + if strings.TrimSpace(current) != "" { + parts = append(parts, cssSelectorPart{selector: strings.TrimSpace(current), directChild: directChild}) + } + current = "" + directChild = true + } else if r == ' ' { + // Check if next non-space char is > + nextIsDirect := false + for j := i + 1; j < len(selector); j++ { + if selector[j] == '>' { + nextIsDirect = true + break + } else if selector[j] != ' ' { + break + } + } + + if strings.TrimSpace(current) != "" && !nextIsDirect { + parts = append(parts, cssSelectorPart{selector: strings.TrimSpace(current), directChild: directChild}) + current = "" + directChild = false + } + } else { + current += string(r) + } + } + + if strings.TrimSpace(current) != "" { + parts = append(parts, cssSelectorPart{selector: strings.TrimSpace(current), directChild: directChild}) + } if len(parts) == 0 { return nil } - // For now, just find the last element - lastPart := parts[len(parts)-1] + // Find elements matching the full selector chain + return findElementsMatchingSelectorChain(n, parts) +} - // Check if it has a class - if strings.Contains(lastPart, ".") { - classParts := strings.Split(lastPart, ".") - tagName := classParts[0] - className := classParts[1] +// findElementsMatchingSelectorChain finds elements matching a chain of selectors +func findElementsMatchingSelectorChain(n *html.Node, parts []cssSelectorPart) []string { + if len(parts) == 0 { + return nil + } + // Find all nodes matching the first selector + firstMatches := findNodesMatchingSimpleSelector(n, parts[0].selector) + + if len(parts) == 1 { + // Last part - convert nodes to strings (deduplicate) + seen := make(map[*html.Node]bool) var results []string - var traverse func(*html.Node) - traverse = func(node *html.Node) { - if node.Type == html.ElementNode && (tagName == "" || node.Data == tagName) { - for _, attr := range node.Attr { - if attr.Key == "class" { - classes := strings.Fields(attr.Val) - for _, c := range classes { - if c == className { - results = append(results, renderNodeToString(node)) - break - } + for _, node := range firstMatches { + if !seen[node] { + seen[node] = true + results = append(results, renderNodeToString(node)) + } + } + return results + } + + // For each match, search its descendants for the rest of the chain + // Use a map to deduplicate nodes (same node found through different paths) + seen := make(map[*html.Node]bool) + var results []string + + for _, matchNode := range firstMatches { + if parts[1].directChild { + // For direct child (>), check if immediate children match the next selector + for c := matchNode.FirstChild; c != nil; c = c.NextSibling { + if c.Type == html.ElementNode && matchesSimpleSelector(c, parts[1].selector) { + // Found a match - continue with rest of chain + if len(parts) == 2 { + // This is the last selector in chain + if !seen[c] { + seen[c] = true + results = append(results, renderNodeToString(c)) + } + } else { + // More selectors to match - search from this node + subResults := findElementsMatchingSelectorChain(c, parts[2:]) + for _, r := range subResults { + results = append(results, r) } } } } - for c := node.FirstChild; c != nil; c = c.NextSibling { - traverse(c) + } else { + // For descendant (space), search entire subtree + for c := matchNode.FirstChild; c != nil; c = c.NextSibling { + subResults := findElementsMatchingSelectorChain(c, parts[1:]) + for _, r := range subResults { + results = append(results, r) + } } } - traverse(n) - return results } - return findHTMLElements(n, lastPart) + // Deduplicate results by string content + seenStr := make(map[string]bool) + var uniqueResults []string + for _, r := range results { + if !seenStr[r] { + seenStr[r] = true + uniqueResults = append(uniqueResults, r) + } + } + + return uniqueResults +} + +// findNodesMatchingSimpleSelector finds nodes (not strings) matching a simple selector +func findNodesMatchingSimpleSelector(n *html.Node, selector string) []*html.Node { + var results []*html.Node + + var traverse func(*html.Node) + traverse = func(node *html.Node) { + if node.Type == html.ElementNode && matchesSimpleSelector(node, selector) { + results = append(results, node) + } + for c := node.FirstChild; c != nil; c = c.NextSibling { + traverse(c) + } + } + + traverse(n) + return results +} + +// findElementsMatchingSimpleSelector finds elements matching a simple selector (e.g., "div", ".class", "div.class") +func findElementsMatchingSimpleSelector(n *html.Node, selector string) []string { + var results []string + + var traverse func(*html.Node) + traverse = func(node *html.Node) { + if node.Type == html.ElementNode && matchesSimpleSelector(node, selector) { + results = append(results, renderNodeToString(node)) + } + for c := node.FirstChild; c != nil; c = c.NextSibling { + traverse(c) + } + } + + traverse(n) + return results +} + +// matchesSimpleSelector checks if a node matches a simple CSS selector +func matchesSimpleSelector(node *html.Node, selector string) bool { + selector = strings.TrimSpace(selector) + + // Handle class selector: .classname or tag.classname + if strings.Contains(selector, ".") { + parts := strings.Split(selector, ".") + tagName := parts[0] + className := parts[1] + + // Check tag name if specified + if tagName != "" && node.Data != tagName { + return false + } + + // Check class + for _, attr := range node.Attr { + if attr.Key == "class" { + classes := strings.Fields(attr.Val) + for _, c := range classes { + if c == className { + return true + } + } + } + } + return false + } + + // Handle ID selector: #id or tag#id + if strings.Contains(selector, "#") { + parts := strings.Split(selector, "#") + tagName := parts[0] + id := parts[1] + + // Check tag name if specified + if tagName != "" && node.Data != tagName { + return false + } + + // Check ID + for _, attr := range node.Attr { + if attr.Key == "id" && attr.Val == id { + return true + } + } + return false + } + + // Simple tag selector + return node.Data == selector } // formatHTMLPretty formats HTML with indentation diff --git a/internal/codeformatter/xpath_filter_test.go b/internal/codeformatter/xpath_filter_test.go index 3201b7e..5ab0a40 100644 --- a/internal/codeformatter/xpath_filter_test.go +++ b/internal/codeformatter/xpath_filter_test.go @@ -6,8 +6,6 @@ import ( ) func TestApplyXPathFilter(t *testing.T) { - // TODO: Fix these tests - XPath filter implementation is incomplete - t.Skip("Skipping test: known issue with XPath filter implementation") tests := []struct { name string xml string @@ -19,7 +17,7 @@ func TestApplyXPathFilter(t *testing.T) { name: "Simple element selector with //", xml: `OneTwo`, xpath: "//item", - want: `One\nTwo`, + want: "One\nTwo", }, { name: "Simple element selector with /", @@ -103,16 +101,16 @@ func TestApplyXPathFilter(t *testing.T) { } func TestExtractXMLElements(t *testing.T) { - // TODO: Fix this test - XML extraction implementation is incomplete - t.Skip("Skipping test: known issue with XML element extraction") xml := `FirstSecondOther` results, err := extractXMLElements(xml, "item") if err != nil { t.Errorf("Unexpected error: %v", err) } - if len(results) != 2 { - t.Errorf("Expected 2 items, got %d", len(results)) + // Split by newline to count elements since function returns joined string + elements := strings.Split(strings.TrimSpace(results), "\n") + if len(elements) != 2 { + t.Errorf("Expected 2 items, got %d", len(elements)) } _, err = extractXMLElements(xml, "nonexistent") @@ -122,8 +120,6 @@ func TestExtractXMLElements(t *testing.T) { } func TestExtractNestedXMLElements(t *testing.T) { - // TODO: Fix this test - nested XML extraction implementation is incomplete - t.Skip("Skipping test: known issue with nested XML element extraction") xml := ` Author1 @@ -145,19 +141,17 @@ func TestExtractNestedXMLElements(t *testing.T) { t.Errorf("Expected to find both authors, got: %s", results) } - // Test single level - results, err = extractNestedXMLElements(xml, []string{"book"}) + // Test single level - use extractXMLElementsSlice to get slice and count properly + booksSlice, err := extractXMLElementsSlice(xml, "book") if err != nil { t.Errorf("Unexpected error: %v", err) } - if len(results) != 2 { - t.Errorf("Expected 2 books, got %d", len(results)) + if len(booksSlice) != 2 { + t.Errorf("Expected 2 books, got %d", len(booksSlice)) } } func TestExtractElementByAttribute(t *testing.T) { - // TODO: Fix this test - attribute extraction implementation is incomplete - t.Skip("Skipping test: known issue with XML attribute extraction") xml := ` Book1 Book2 diff --git a/main.go b/main.go index 4d935c0..d761f2b 100644 --- a/main.go +++ b/main.go @@ -144,8 +144,6 @@ func main() { // Create tray menu trayMenu := app.NewMenu() trayMenu.Add("Show DevToolbox").OnClick(func(ctx *application.Context) { - // NOTE: macOS window restore from tray has known issues - // See: KNOWN_ISSUES.md - "macOS: Tray 'Show DevToolbox' doesn't restore hidden window" log.Println("Tray menu 'Show DevToolbox' clicked") log.Printf("Window visible: %v, minimized: %v", mainWindow.IsVisible(), mainWindow.IsMinimised())